Compare commits

...

43 Commits

Author SHA1 Message Date
t.indorf 4cd16773c1 Merge branch 'codex/live-testplan-doc' 2026-05-23 12:47:44 +02:00
t.indorf 74dc2d6b57 Merge branch 'codex/notdienst-plan-fix'
# Conflicts:
#	src/app/dashboard/notdienst/plan/plan-view.tsx
2026-05-23 12:47:35 +02:00
t.indorf 27c5a6940a feat: implement premium responsive mobile shell and drawer 2026-05-22 15:50:03 +02:00
t.indorf f28956194c feat: add directory opt-out sync and layout/xss seed data extensions 2026-05-22 15:22:12 +02:00
t.indorf e9595fab21 feat: show family assignment counts in notdienst planning dropdown and support editing unassigned days in published plans 2026-05-21 14:29:13 +02:00
t.indorf f6acb3d271 feat: default to Month view and implement household services filter toggle for parents 2026-05-21 13:24:42 +02:00
t.indorf 339563bf4d feat: display published Notdienst assignments in appointment calendar with custom styling for own assignments 2026-05-21 13:04:52 +02:00
t.indorf 9915c8213e fix(notdienst): resolve timezone offset mismatch in plan generation and fix linter warning in calendar modal 2026-05-21 12:53:46 +02:00
t.indorf 7eaa198a29 fix(notdienst): resolve timezone offset mismatch in plan generation 2026-05-21 12:41:18 +02:00
t.indorf cf3fb82afe Mark all TODOs as completed in UI-TODO.md 2026-05-21 11:41:50 +02:00
t.indorf 88f9c156fc Merge remote-tracking branch 'origin/main' into codex/notdienst-month-settings 2026-05-21 10:47:14 +02:00
t.indorf ab7432f3a3 Merge branch 'codex/replace-alert-dialogs' into codex/notdienst-month-settings 2026-05-21 10:46:31 +02:00
t.indorf 84aae1d72e Implement missing UI button features, filters, search, and view toggles across all modules 2026-05-21 10:44:23 +02:00
t.indorf beffba65c5 Document UI no-op follow-ups 2026-05-20 23:51:10 +02:00
t.indorf ec2e0b01e9 Replace native confirms with dialogs 2026-05-20 23:49:42 +02:00
t.indorf 89364e9011 Replace native confirms with dialogs 2026-05-20 23:48:40 +02:00
t.indorf e7eb43462c Fix server/client component boundary issue with reasonColor/reasonLabel helper functions by moving them to shared utils 2026-05-20 23:09:59 +02:00
t.indorf e25a3bcc04 Extend database seed script with extra families, RSVP events, draft plans, pending alerts, attachments, and audit logs 2026-05-20 23:05:54 +02:00
t.indorf 1c5d2f10ac Apply linen design system across app 2026-05-20 22:47:52 +02:00
t.indorf 99ff978116 Fix live test follow-up issues 2026-05-19 14:28:32 +02:00
t.indorf 3fa3b9b108 Merge branch 'codex/fix-invite-activation' 2026-05-19 11:14:28 +02:00
t.indorf 84714b8b32 Fix server action state exports 2026-05-19 11:09:53 +02:00
t.indorf 811468a22b Merge branch 'codex/improve-invite-email' 2026-05-19 10:51:57 +02:00
t.indorf 968d108c28 Improve invite email layout 2026-05-19 10:29:42 +02:00
t.indorf b5eb288104 Merge branch 'codex/fix-public-landing-clean' 2026-05-18 16:56:45 +02:00
t.indorf ce1a5c9e42 Make landing page public and redirect auth users directly 2026-05-18 16:55:21 +02:00
t.indorf 5dca7c76f8 testplan 2026-05-17 23:55:35 +02:00
t.indorf 6c79e0a8e1 Merge claude/profil-and-mail-fixes into main
Fix mail layouts, /invite form-reset, profile validation
2026-05-17 23:54:07 +02:00
t.indorf 935845ce56 Merge claude/onboarding-fixes into main
Persist values, tighten validation, and a11y on /onboarding
2026-05-17 23:53:57 +02:00
t.indorf cb4f7409bc Merge claude/register-fixes into main
Persist values, a11y errors, and aria-invalid on /register
2026-05-17 23:53:38 +02:00
t.indorf fa06da324c Fix mail layouts, /invite form-reset, profile validation
Mail templates (6 templates touched):
- New shared `_styles.ts` exports a robust font stack starting with
  Arial/Helvetica so mail readers that lack -apple-system and
  BlinkMacSystemFont don't fall back to Monospace (observed in at least
  one webmail client).
- `fontFamily` is now set on every text-bearing element, not only on
  `<Body>`, because some clients (Outlook, several webmail readers) do
  not inherit body CSS into nested sections.
- ASCII substitutions are gone: "Ueber" → "Über", "fuer" → "für",
  "bestaetigen" → "bestätigen", "uebernehmen" → "übernehmen",
  "verlaesslich" → "verlässlich".
- InviteEmail + NewsEmail buttons switch from the off-brand yellow
  (#f0b84b) to the brand dark green so the CTA matches the header.

/invite form:
- InviteState gains an `attempt` counter plus echoed checkbox `values`,
  mirroring the /register and /onboarding fix from earlier branches. A
  failed submit no longer wipes the Datenschutz / Adressbuch-Opt-In
  ticks. Passwords stay empty for security.
- Field error <p> elements gain role="alert" + aria-live="polite" so
  screen readers announce validation failures on submit. Inputs use
  aria-describedby to link to error / helper text.

Profile validation:
- Telephone numbers must match a permissive pattern that still rejects
  obvious garbage (previously "abc-123-not-a-phone" was accepted).
- Postal codes must match an alphanumeric pattern of 3–10 chars
  (previously "ABCDE" was accepted).
- Child date-of-birth is now validated via superRefine: it must be a
  parseable date, not in the future, and not before 1900. Previously a
  kid born in 2030 was accepted.
- The three Profil server actions now surface the first Zod issue
  message instead of generic "Ungültige Eingabedaten." toasts.

Misc:
- "fuer" → "für" in the Adressbuch page subtitle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:49:01 +02:00
t.indorf 211bb9e300 Add Codex branch workflow rule 2026-05-17 23:14:06 +02:00
t.indorf af2c3b245a Persist values, tighten validation, and a11y on /onboarding
- Server action echoes kitaName, the three module booleans, and the
  Mindest-Verfügbarkeiten value on every error path so failed submits no
  longer wipe what the user typed; OnboardingState gains an `attempt`
  counter that drives a per-field remount key on the client.
- Schema now rejects min=0 when the Notdienst module is active (via
  superRefine) — previously a kita could be created with 0 minimum days,
  which is fachlich sinnlos. When the module is off, 0 stays valid.
- Validation error messages reworded from terse fragments ("Nicht
  negativ.", "Bitte ganze Zahl.") to full sentences.
- Field error <p> elements gain role="alert" + aria-live="polite" so
  screen readers announce them on submit.
- CardTitle now accepts as="h1" and the onboarding page uses it for
  "Willkommen, …" — the page now has a real top-level heading.
- ModuleCheckbox uses aria-labelledby + aria-describedby so the
  accessible name is just the module title; the longer description is
  exposed as secondary info instead of being concatenated into the AN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:46:31 +02:00
t.indorf e8b62f6008 Tighten landing page positioning 2026-05-17 20:43:26 +02:00
t.indorf 380c67963b Add superadmin audit and Kita management 2026-05-16 12:02:59 +02:00
t.indorf 8640001f6f Persist values, a11y errors, and aria-invalid on /register
- Server action now echoes firstName, lastName, email, and the privacy
  checkbox state on every error path so failed submits don't wipe the form.
- New `attempt` counter on RegisterState drives a remount key per field,
  bypassing React's default <form action> reset semantics.
- Error <p> elements gain role="alert" + aria-live="polite" so screen
  readers announce validation failures live.
- Privacy checkbox now flags aria-invalid when not accepted and points to
  the error message via aria-describedby.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:55:28 +02:00
t.indorf 0b80c62beb Add contact email confirmation and abuse protection 2026-05-16 11:38:30 +02:00
t.indorf 9542def530 Add RSVP events and Unsplash image support 2026-05-15 21:28:36 +02:00
t.indorf ed6786b6ea added missing packages 2026-05-11 16:29:32 +02:00
t.indorf 89cf1dfe84 added more page content 2026-05-11 16:06:33 +02:00
t.indorf 70a50d89ab added robots udn sitemap 2026-05-11 12:23:43 +02:00
t.indorf 928ddac5b3 added postgres to the stack 2026-05-11 11:19:24 +02:00
t.indorf b0cf3adce6 removed the coolify network 2026-05-11 11:09:27 +02:00
123 changed files with 13980 additions and 2554 deletions
+6
View File
@@ -3,3 +3,9 @@
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules --> <!-- END:nextjs-agent-rules -->
<!-- BEGIN:codex-workflow-rules -->
# Codex workflow
Before making repository changes, Codex should work from a dedicated branch in the Codex worktree. Prefer a `codex/...` branch name when Git refs allow it; if an existing ref blocks that namespace, use a `codex-...` branch name instead.
<!-- END:codex-workflow-rules -->
+79
View File
@@ -0,0 +1,79 @@
# UI-ToDo: klickbare Elemente ohne Funktion
Stand: 2026-05-20
Geprüft lokal als Admin mit Demo-Login `admin@waldameisen.local`.
## Notdienst-Planung
- [x] `src/app/dashboard/notdienst/plan/page.tsx:186`
Monat zurück/vor haben keinen Handler oder Link.
- [x] `src/app/dashboard/notdienst/plan/page.tsx:196`
`Einstellungen` ist klickbar, öffnet aber nichts und navigiert nicht.
- [x] `src/app/dashboard/notdienst/plan/plan-view.tsx:135`
`Erinnerungen verschicken` im Empty-State ist ohne Funktion.
- [x] `src/app/dashboard/notdienst/plan/plan-view.tsx:202`
`Erinnerung an offene senden (...)` ist ohne Funktion.
- [x] `src/app/dashboard/notdienst/plan/plan-view.tsx:312`
`Als PDF exportieren` ist ohne Funktion.
- [x] `src/app/dashboard/notdienst/plan/plan-view.tsx:316`
`Plan zurücksetzen` ist ohne Funktion.
## Terminkalender
- [x] `src/app/dashboard/kalender/page.tsx:296`
Umschalter `Liste / Monat / Agenda` ändern keine Ansicht.
- [x] `src/app/dashboard/kalender/page.tsx:317`
Suche `Termine suchen` filtert aktuell nicht.
- [x] `src/app/dashboard/kalender/page.tsx:415`
Kategorie-Filterchips sind nur visuell.
- [x] `src/app/dashboard/kalender/_components/termin-list.tsx:152`
`Mehr Optionen` bei Elterndiensten ist ohne Funktion.
- [x] `src/app/dashboard/kalender/_components/termin-list.tsx:191`
`Mehr Optionen` bei Terminen ist ohne Funktion.
## Adressbuch
- [x] `src/app/dashboard/adressbuch/page.tsx:161`
`Sichtbarkeit` ist ohne Funktion.
- [x] `src/app/dashboard/adressbuch/page.tsx:193`
Suche `Familien, Namen oder E-Mail suchen` filtert aktuell nicht.
- [x] `src/app/dashboard/adressbuch/page.tsx:201`
Filterchips `Alle Gruppen`, `Mit Telefon`, `Mit Ämtern`, `Nur Opt-in` sind nur visuell.
- [x] `src/app/dashboard/adressbuch/page.tsx:334`
`Kontakt öffnen` ist ohne Funktion.
## Familienverwaltung
- [x] `src/app/dashboard/families/page.tsx:117`
`Ansicht` ist ohne Funktion.
- [x] `src/app/dashboard/families/page.tsx:133`
Suche `Familien, Eltern oder Kinder suchen` filtert aktuell nicht.
## Abwesenheiten Admin
- [x] `src/app/dashboard/admin/abwesenheiten/page.tsx:132`
Filterchips `Alle Gruppen` und `Alle Gründe` sind klickbar, aber ohne Funktion.
## Bereits angebunden / nicht auffaellig
- Dialoge und Mutationsaktionen sind bei der Pruefung nicht als No-Op aufgefallen:
- Familie anlegen/bearbeiten
- ErzieherIn anlegen/bearbeiten/loeschen
- Profil speichern
- Termin anlegen/anfragen
- Dienstplan-Zuweisungen
+8 -4
View File
@@ -6,10 +6,14 @@
# Keine echten Secrets committen. # Keine echten Secrets committen.
# ===================================================================== # =====================================================================
# PostgreSQL # PostgreSQL im Coolify-Compose-Stack
# In Coolify am besten die interne URL deiner bestehenden Postgres-DB verwenden. POSTGRES_DB="kita_planer"
# Beispiel: postgresql://USER:PASSWORD@HOST:5432/kita_planer?schema=public POSTGRES_USER="kita_planer"
DATABASE_URL="postgresql://[USER]:[PASSWORD]@[HOST]:[PORT]/[DATABASE]?schema=public" POSTGRES_PASSWORD="[GENERATE_ME]"
# pgAdmin im Coolify-Compose-Stack
PGADMIN_DEFAULT_EMAIL="[admin@deine-domain.de]"
PGADMIN_DEFAULT_PASSWORD="[GENERATE_ME]"
# Auth.js / NextAuth # Auth.js / NextAuth
# Generieren mit: openssl rand -base64 32 # Generieren mit: openssl rand -base64 32
+38 -9
View File
@@ -1,4 +1,19 @@
services: services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-kita_planer}
POSTGRES_USER: ${POSTGRES_USER:-kita_planer}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
volumes:
- kita_planer_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
app: app:
build: build:
context: . context: .
@@ -12,7 +27,7 @@ services:
NODE_ENV: production NODE_ENV: production
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
DATABASE_URL: ${DATABASE_URL:?} DATABASE_URL: postgresql://${POSTGRES_USER:-kita_planer}:${POSTGRES_PASSWORD:?}@postgres:5432/${POSTGRES_DB:-kita_planer}?schema=public
AUTH_SECRET: ${AUTH_SECRET:?} AUTH_SECRET: ${AUTH_SECRET:?}
AUTH_URL: ${AUTH_URL:?} AUTH_URL: ${AUTH_URL:?}
NEXTAUTH_URL: ${NEXTAUTH_URL:?} NEXTAUTH_URL: ${NEXTAUTH_URL:?}
@@ -26,16 +41,30 @@ services:
UPLOAD_DIR: /app/uploads/announcements UPLOAD_DIR: /app/uploads/announcements
volumes: volumes:
- kita_planer_uploads:/app/uploads - kita_planer_uploads:/app/uploads
networks: depends_on:
- default postgres:
- coolify condition: service_healthy
ports: ports:
- "3000" - "3000"
restart: unless-stopped restart: unless-stopped
volumes: pgadmin:
kita_planer_uploads: image: dpage/pgadmin4:latest
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:?}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:?}
PGADMIN_CONFIG_ENHANCED_COOKIE_PROTECTION: "True"
PGADMIN_CONFIG_CONSOLE_LOG_LEVEL: 30
volumes:
- kita_planer_pgadmin_data:/var/lib/pgadmin
depends_on:
postgres:
condition: service_healthy
ports:
- "80"
restart: unless-stopped
networks: volumes:
coolify: kita_planer_postgres_data:
external: true kita_planer_uploads:
kita_planer_pgadmin_data:
+85
View File
@@ -0,0 +1,85 @@
# Live-Testplan mein-kitaplaner.de
## Kopfbereich
- Zielsystem: `https://mein-kitaplaner.de`
- Testdatum: `2026-05-17`
- Strategie: echte Testdaten auf Produktion verwenden und anschließend bereinigen
- Testkennzeichnung: alle Testnamen und Test-E-Mails mit `Codex Live-Test` kennzeichnen
- Statuslegende:
- `[ ]` offen
- `[x]` erledigt
- `[!]` Blocker / Beobachtung
## Testdaten
- Gründer/Admin: `hanivan790@acanok.com`
- Test-Kita: `Codex Live-Test Kita-Verein 20260517-2343`
- Elternteil: `kitatester1233@mailaddress.de`
- Passwort: `<TEST_PASSWORD>`
Keine echten Passwörter, Secrets, Session-Werte, Tokens oder Invite-Links in diese Datei schreiben.
## Core Journey
- [x] Landingpage erreichbar.
- Erwartung: `https://mein-kitaplaner.de` lädt mit aktuellem Landingpage-Content.
- [x] Demo-CTA führt zum Kontaktformular.
- Erwartung: CTA öffnet `/#kontakt` und das Formular ist sichtbar.
- [x] Kontaktformular versendet Bestätigungsmail.
- Bestätigt: Testmail an `nolip18234@acanok.com` ist angekommen.
- [x] Registrierung: neuer Gründer/Admin kann Account erstellen.
- Testdaten: eindeutige Admin-Testmail und `<TEST_PASSWORD>`.
- Erwartung: Account wird erstellt, keine Fehlermeldung.
- [x] Redirect nach Registrierung führt zu `/onboarding`.
- Erwartung: Gründer landet nach Registrierung direkt im Onboarding.
- [x] Onboarding: Kita wird mit Standardmodulen angelegt.
- Testdaten: `Codex Live-Test Kita-Verein YYYYMMDD-HHMM`.
- Erwartung: Notdienst, Kalender und Adressbuch bleiben standardmäßig aktiv.
- [x] Redirect nach Onboarding führt zu `/dashboard`.
- Erwartung: Dashboard lädt ohne erneute Anmeldung.
- [x] Dashboard zeigt Kita-Namen und Admin-Navigation.
- Erwartung: Sidebar zeigt Test-Kita sowie Admin-Menüpunkte wie Familienverwaltung, Dienstplan, Schwarzes Brett und ErzieherInnen.
- [x] Familienverwaltung: Familie mit Kind und Elternteil anlegen.
- Testdaten: Familie, Elternteil und Kind mit `Codex Live-Test` kennzeichnen.
- Erwartung: Familie erscheint in der Liste.
- [ ] Einladung wird per E-Mail versendet.
- Erwartung: Eltern-Testadresse erhält eine Einladung.
- Erst als erledigt markieren, wenn die Mail im Postfach bestätigt wurde.
- [ ] Invite-Link öffnet Aktivierungsseite.
- Erwartung: Seite `/invite/[token]` zeigt personalisierte Einladung und Kita-Namen.
- Invite-Link nicht in diese Datei schreiben.
- [ ] Invite-Validierung: Passwort-Pflicht, Passwort-Mismatch, Datenschutz-Pflicht.
- Erwartung: fehlende oder unterschiedliche Passwörter und fehlende Datenschutzerklärung erzeugen sichtbare Fehler.
- [ ] Elternteil kann Einladung annehmen und wird eingeloggt.
- Erwartung: Passwort setzen, Datenschutzerklärung akzeptieren, optional Adressbuch-Opt-in wählen, danach Redirect zu `/dashboard`.
- [ ] Elternrolle sieht nur erlaubte Navigation.
- Erwartung: Eltern sehen keine Admin-only Punkte wie Familienverwaltung, Dienstplan, Schwarzes Brett-Admin, ErzieherInnen.
- [ ] Logout/Login für Admin und Elternteil funktionieren.
- Erwartung: beide Testaccounts können sich abmelden und mit `<TEST_PASSWORD>` erneut anmelden.
- [x] Zugriffsschutz: `/dashboard` ohne Session leitet zu `/login`.
- Erwartung: ausgeloggter Aufruf von `/dashboard` führt zu `/login?callbackUrl=%2Fdashboard` oder entsprechender Login-Route.
- [ ] Cleanup: Test-Kita/User/Familie über Superadmin löschen.
- Erwartung: Test-Kita wird über Superadmin-Diagnose mit exakter Namensbestätigung gelöscht.
- Erst als erledigt markieren, wenn Superadmin-Ansicht keine Test-Kita/Test-User mehr zeigt.
- [ ] Nach Cleanup: Test-Login funktioniert nicht mehr / Daten nicht sichtbar.
- Erwartung: Admin- und Eltern-Testlogin schlagen fehl; Test-Kita erscheint nicht mehr.
## Beobachtungen
- [x] `2026-05-17`: Kontaktformular auf Produktion getestet. Bestätigungsmail an `nolip18234@acanok.com` ist angekommen.
- [x] `2026-05-17`: Registrierung mit `hanivan790@acanok.com`, Redirect nach `/onboarding`, Onboarding mit `Codex Live-Test Kita-Verein 20260517-2343` und Dashboard/Admin-Navigation erfolgreich geprüft.
- [!] `2026-05-17`: Familienanlage mit Elternadresse `nolip18234@acanok.com` blockiert, weil die E-Mail-Adresse bereits existiert. Mit `kitatester1233@mailaddress.de` erfolgreich fortgesetzt.
- [x] `2026-05-17`: Admin-Logout, Zugriffsschutz für `/dashboard` ohne Session und Admin-Login mit temporärem Passwort erfolgreich geprüft. Kombinierter Login-Check bleibt offen, bis ein Elternaccount aktiviert werden kann.
- [x] `2026-05-17`: Familie `Codex Live-Test Familie 20260517-2343` mit Kind und Elternteil `kitatester1233@mailaddress.de` erfolgreich angelegt; Status in der Familienverwaltung: `Einladung offen`.
- [ ] `2026-05-17`: Im Familien-Details-Dialog ist kein Invite-Link sichtbar; Fortsetzung der Invite-Tests benötigt Mailbestätigung und Invite-Link aus dem Postfach.
- [ ] Mailzeit Einladung Elternteil: offen.
- [ ] Cleanup-Ergebnis: offen.
## Pflege-Regeln
- Erledigte Schritte während des Tests direkt von `[ ]` auf `[x]` setzen.
- Blocker mit `[!]` markieren und kurz im Abschnitt Beobachtungen beschreiben.
- Mailversand nur als erledigt markieren, wenn die Mail im jeweiligen Postfach bestätigt wurde.
- Keine echten Passwörter, Secrets, Tokens oder vollständigen Invite-Links dokumentieren.
- Cleanup erst als erledigt markieren, wenn die Testdaten in der Superadmin-Ansicht nicht mehr sichtbar sind.
+9 -1
View File
@@ -1,7 +1,15 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ images: {
remotePatterns: [
{
protocol: "https",
hostname: "images.unsplash.com",
pathname: "/**",
},
],
},
}; };
export default nextConfig; export default nextConfig;
+19
View File
@@ -19,6 +19,7 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@react-email/components": "^1.0.12", "@react-email/components": "^1.0.12",
"@react-email/render": "^2.0.8",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -2973,6 +2974,24 @@
"react": "^18.0 || ^19.0 || ^19.0.0-rc" "react": "^18.0 || ^19.0 || ^19.0.0-rc"
} }
}, },
"node_modules/@react-email/render": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.8.tgz",
"integrity": "sha512-5udvVr3U/WuGJZfLdLBOhkzrqRWd2Q5ZYmF7ppcy7FzWcwgshdqLMNqJOXcVzAXJXg/2bm7D+WGJzTtZOZMQnQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"html-to-text": "^9.0.5",
"prettier": "^3.5.3"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": "^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
}
},
"node_modules/@react-email/row": { "node_modules/@react-email/row": {
"version": "0.0.13", "version": "0.0.13",
"resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.13.tgz", "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.13.tgz",
+1
View File
@@ -30,6 +30,7 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@react-email/components": "^1.0.12", "@react-email/components": "^1.0.12",
"@react-email/render": "^2.0.8",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+71
View File
@@ -63,6 +63,16 @@ enum TerminStatus {
CANCELLED CANCELLED
} }
enum EventParticipationStatus {
ATTENDING
NOT_ATTENDING
}
enum AuditTargetType {
KITA
USER
}
enum NotdienstPlanStatus { enum NotdienstPlanStatus {
DRAFT DRAFT
PUBLISHED PUBLISHED
@@ -121,6 +131,7 @@ model Kita {
invitations Invitation[] invitations Invitation[]
termine Termin[] termine Termin[]
mitbringselItems MitbringselItem[] mitbringselItems MitbringselItem[]
eventParticipations EventParticipation[]
notdienstPlans NotdienstPlan[] notdienstPlans NotdienstPlan[]
notdienstAvailabilities NotdienstAvailability[] notdienstAvailabilities NotdienstAvailability[]
notdienstAssignments NotdienstAssignment[] notdienstAssignments NotdienstAssignment[]
@@ -216,6 +227,7 @@ model User {
mitbringselItems MitbringselItem[] mitbringselItems MitbringselItem[]
invitationsCreated Invitation[] @relation("InvitationCreator") invitationsCreated Invitation[] @relation("InvitationCreator")
auditLogsAuthored AuditLog[] @relation("AuditLogActor")
@@index([kitaId]) @@index([kitaId])
@@index([familyId]) @@index([familyId])
@@ -248,6 +260,7 @@ model Child {
notdienstAvailabilities NotdienstAvailability[] notdienstAvailabilities NotdienstAvailability[]
notdienstAssignments NotdienstAssignment[] notdienstAssignments NotdienstAssignment[]
absences Absence[] absences Absence[]
eventParticipations EventParticipation[]
@@index([kitaId]) @@index([kitaId])
@@index([familyId]) @@index([familyId])
@@ -490,6 +503,11 @@ model Termin {
/// Mitbringliste pro Termin aktivierbar (Modul 2, "Flexible Mitbring-Listen") /// Mitbringliste pro Termin aktivierbar (Modul 2, "Flexible Mitbring-Listen")
mitbringselListEnabled Boolean @default(false) mitbringselListEnabled Boolean @default(false)
/// Teilnahme-Abfrage für Ausflüge und besondere Events.
requiresRsvp Boolean @default(false)
rsvpDeadline DateTime?
participantInfo String?
createdById String? createdById String?
createdBy User? @relation("TerminCreator", fields: [createdById], references: [id], onDelete: SetNull) createdBy User? @relation("TerminCreator", fields: [createdById], references: [id], onDelete: SetNull)
@@ -502,12 +520,35 @@ model Termin {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
mitbringselItems MitbringselItem[] mitbringselItems MitbringselItem[]
participations EventParticipation[]
@@index([kitaId, startDate]) @@index([kitaId, startDate])
@@index([kitaId, status]) @@index([kitaId, status])
@@map("termine") @@map("termine")
} }
model EventParticipation {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
eventId String
event Termin @relation(fields: [eventId], references: [id], onDelete: Cascade)
childId String
child Child @relation(fields: [childId], references: [id], onDelete: Cascade)
status EventParticipationStatus
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([eventId, childId])
@@index([kitaId])
@@index([childId])
@@map("event_participations")
}
model MitbringselItem { model MitbringselItem {
id String @id @default(cuid()) id String @id @default(cuid())
kitaId String kitaId String
@@ -652,6 +693,36 @@ model NotdienstAlert {
@@map("notdienst_alerts") @@map("notdienst_alerts")
} }
// =====================================================================
// AUDIT LOGS (Systemweite Superadmin-Aktionen)
// =====================================================================
/// Persistente Spur fuer Superadmin-Support-Aktionen.
/// Zielobjekte werden bewusst nur als Snapshot gespeichert, damit Logs auch
/// nach einer Kita- oder Benutzerloeschung erhalten bleiben.
model AuditLog {
id String @id @default(cuid())
actorUserId String?
actorUser User? @relation("AuditLogActor", fields: [actorUserId], references: [id], onDelete: SetNull)
actorEmail String
action String
targetType AuditTargetType
targetId String
targetLabel String
targetKitaId String?
metadata Json?
createdAt DateTime @default(now())
@@index([actorUserId])
@@index([targetType, targetId])
@@index([targetKitaId])
@@index([createdAt])
@@map("audit_logs")
}
// ===================================================================== // =====================================================================
// AUTH-HILFSTABELLE // AUTH-HILFSTABELLE
// ===================================================================== // =====================================================================
+355 -23
View File
@@ -1,5 +1,7 @@
import { import {
AbsenceReason, AbsenceReason,
AuditTargetType,
EventParticipationStatus,
InvitationStatus, InvitationStatus,
NotdienstAlertStatus, NotdienstAlertStatus,
NotdienstPlanStatus, NotdienstPlanStatus,
@@ -35,6 +37,9 @@ const DEMO_USER_EMAILS = [
"schmidt@waldameisen.local", "schmidt@waldameisen.local",
"yilmaz@waldameisen.local", "yilmaz@waldameisen.local",
"pending@waldameisen.local", "pending@waldameisen.local",
"fischer@waldameisen.local",
"kowalski@waldameisen.local",
"weber@waldameisen.local",
]; ];
const DEMO_VERIFICATION_TOKENS = [ const DEMO_VERIFICATION_TOKENS = [
@@ -293,11 +298,87 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
}, },
}); });
const familyKowalski = await prisma.family.create({
data: {
kitaId: kita.id,
name: "Familie Kowalski",
},
});
const familyWeber = await prisma.family.create({
data: {
kitaId: kita.id,
name: "Familie Weber",
},
});
const elternFischer = await prisma.user.create({
data: {
kitaId: kita.id,
familyId: familyFischer.id,
email: "fischer@waldameisen.local",
passwordHash,
firstName: "Frank",
lastName: "Fischer",
role: UserRole.ELTERN,
privacyPolicyAcceptedAt: consentAt,
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
directoryOptInAt: consentAt,
emailVerifiedAt: consentAt,
phone: "+49 30 5555 1234",
street: "Fischerweg 4",
postalCode: "10247",
city: "Berlin",
},
});
const elternKowalski = await prisma.user.create({
data: {
kitaId: kita.id,
familyId: familyKowalski.id,
email: "kowalski@waldameisen.local",
passwordHash,
firstName: "Jan",
lastName: "Kowalski",
role: UserRole.ELTERN,
privacyPolicyAcceptedAt: consentAt,
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
directoryOptInAt: consentAt,
emailVerifiedAt: consentAt,
phone: "+49 30 5555 5678",
street: "Grenzallee 12",
postalCode: "12057",
city: "Berlin",
},
});
const elternWeber = await prisma.user.create({
data: {
kitaId: kita.id,
familyId: familyWeber.id,
email: "weber@waldameisen.local",
passwordHash,
firstName: "Sabine",
lastName: "Weber",
role: UserRole.ELTERN,
privacyPolicyAcceptedAt: consentAt,
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
directoryOptInAt: consentAt,
emailVerifiedAt: consentAt,
phone: "+49 30 5555 9012",
street: "Weberstrasse 7",
postalCode: "10249",
city: "Berlin",
},
});
return { return {
kita, kita,
familyMueller, familyMueller,
familySchmidtYilmaz, familySchmidtYilmaz,
familyFischer, familyFischer,
familyKowalski,
familyWeber,
superAdmin, superAdmin,
admin, admin,
erzieherUser, erzieherUser,
@@ -305,6 +386,9 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
elternSchmidt, elternSchmidt,
elternYilmaz, elternYilmaz,
pendingParent, pendingParent,
elternFischer,
elternKowalski,
elternWeber,
}; };
} }
@@ -313,6 +397,8 @@ async function createChildren({
familyMueller, familyMueller,
familySchmidtYilmaz, familySchmidtYilmaz,
familyFischer, familyFischer,
familyKowalski,
familyWeber,
}: SeedContext) { }: SeedContext) {
const anna = await prisma.child.create({ const anna = await prisma.child.create({
data: { data: {
@@ -365,7 +451,27 @@ async function createChildren({
}, },
}); });
return { anna, ben, clara, emil, nina }; const leo = await prisma.child.create({
data: {
kitaId: kita.id,
familyId: familyKowalski.id,
firstName: "Leo",
lastName: "Kowalski",
dateOfBirth: new Date("2022-08-12"),
},
});
const mia = await prisma.child.create({
data: {
kitaId: kita.id,
familyId: familyWeber.id,
firstName: "Mia",
lastName: "Weber",
dateOfBirth: new Date("2021-05-18"),
},
});
return { anna, ben, clara, emil, nina, leo, mia };
} }
async function createEducators(kitaId: string) { async function createEducators(kitaId: string) {
@@ -530,11 +636,9 @@ async function createInvites({ kita, admin, pendingParent }: SeedContext) {
}); });
} }
async function createAnnouncements({ async function createAnnouncements(context: SeedContext) {
kita, const { kita, admin, koordinator, elternSchmidt, elternYilmaz, elternFischer } = context;
admin,
koordinator,
}: SeedContext) {
const sommerfest = await prisma.announcement.create({ const sommerfest = await prisma.announcement.create({
data: { data: {
kitaId: kita.id, kitaId: kita.id,
@@ -545,7 +649,7 @@ async function createAnnouncements({
}, },
}); });
await prisma.announcement.create({ const garderobe = await prisma.announcement.create({
data: { data: {
kitaId: kita.id, kitaId: kita.id,
title: "Neue Garderoben-Regelung", title: "Neue Garderoben-Regelung",
@@ -555,21 +659,48 @@ async function createAnnouncements({
}, },
}); });
await prisma.announcementRead.create({ const longTitle = await prisma.announcement.create({
data: { data: {
userId: koordinator.id, kitaId: kita.id,
announcementId: sommerfest.id, title: "Dies ist eine extrem lange Ankuendigung mit einem superlangen Titel um das Layout des Dashboards und des Schwarzen Bretts auf korrekten Textumbruch und Mobilfreundlichkeit zu testen",
content: "Inhalt der superlangen Ankuendigung fuer Layout-Testing.",
authorId: admin.id,
}, },
}); });
const xssTitle = await prisma.announcement.create({
data: {
kitaId: kita.id,
title: "Boesartiger Titel mit <script>alert('XSS_NEWS_TITLE')</script> HTML Tags",
content: "Dieser Beitrag testet, ob die Anwendung XSS-Angriffe im Titel oder im Inhalt filtert. <script>alert('XSS_NEWS_CONTENT')</script>",
authorId: admin.id,
},
});
await prisma.attachment.create({
data: {
announcementId: sommerfest.id,
fileName: "sommerfest_ablauf.pdf",
fileUrl: "https://example.com/sommerfest_ablauf.pdf",
fileType: "application/pdf",
},
});
await prisma.announcementRead.createMany({
data: [
{ userId: koordinator.id, announcementId: sommerfest.id },
{ userId: elternSchmidt.id, announcementId: sommerfest.id },
{ userId: elternYilmaz.id, announcementId: sommerfest.id },
{ userId: elternFischer.id, announcementId: garderobe.id },
],
});
} }
async function createTermine({ async function createTermine(
kita, context: SeedContext,
admin, children: Awaited<ReturnType<typeof createChildren>>,
koordinator, ) {
elternSchmidt, const { kita, admin, koordinator, elternSchmidt, elternYilmaz, elternFischer, elternKowalski } = context;
elternYilmaz,
}: SeedContext) {
const now = new Date(); const now = new Date();
const elternabend = await prisma.termin.create({ const elternabend = await prisma.termin.create({
@@ -636,6 +767,100 @@ async function createTermine({
}, },
}); });
const zooAusflug = await prisma.termin.create({
data: {
kitaId: kita.id,
title: "Ausflug in den Zoo",
description: "Gemeinsamer Ausflug der gesamten Kita-Gruppe in den Berliner Zoo.",
type: TerminType.SONSTIGES,
status: TerminStatus.CONFIRMED,
startDate: addDays(now, 15),
endDate: addDays(now, 15),
allDay: true,
requiresRsvp: true,
rsvpDeadline: addDays(now, 5),
participantInfo: "Bitte wetterfeste Kleidung und Rucksack-Verpflegung mitbringen.",
createdById: admin.id,
approvedById: admin.id,
approvedAt: now,
},
});
await prisma.eventParticipation.createMany({
data: [
{ kitaId: kita.id, eventId: zooAusflug.id, childId: children.anna.id, status: EventParticipationStatus.ATTENDING },
{ kitaId: kita.id, eventId: zooAusflug.id, childId: children.clara.id, status: EventParticipationStatus.ATTENDING },
{ kitaId: kita.id, eventId: zooAusflug.id, childId: children.emil.id, status: EventParticipationStatus.NOT_ATTENDING },
{ kitaId: kita.id, eventId: zooAusflug.id, childId: children.leo.id, status: EventParticipationStatus.ATTENDING },
],
});
await prisma.termin.create({
data: {
kitaId: kita.id,
title: "Mitmach-Tag (Garten)",
description: "Gemeinsames Herrichten des Gartens fuer die warme Jahreszeit. (Abgesagt wegen Dauerregen)",
type: TerminType.MITMACH_TAG,
status: TerminStatus.CANCELLED,
startDate: addDays(now, 2),
endDate: addDays(now, 2),
allDay: false,
createdById: admin.id,
},
});
const sommerfestEvent = await prisma.termin.create({
data: {
kitaId: kita.id,
title: "Sommerfest Waldameisen",
description: "Unser jaehrliches grosses Sommerfest im Kita-Garten mit Buffet und Spielstationen.",
type: TerminType.KITA_FEST,
status: TerminStatus.CONFIRMED,
startDate: addDays(now, 30),
endDate: addDays(now, 30),
allDay: false,
mitbringselListEnabled: true,
createdById: admin.id,
approvedById: admin.id,
approvedAt: now,
},
});
const highDensityDay = addDays(now, 7);
for (let i = 1; i <= 8; i++) {
await prisma.termin.create({
data: {
kitaId: kita.id,
title: `Parallel-Termin #${i}`,
description: `Beschreibung fuer Parallel-Termin #${i}`,
type: TerminType.SONSTIGES,
status: TerminStatus.CONFIRMED,
startDate: highDensityDay,
endDate: highDensityDay,
allDay: false,
createdById: admin.id,
approvedById: admin.id,
approvedAt: now,
},
});
}
await prisma.termin.create({
data: {
kitaId: kita.id,
title: "Boesartiger Termin <script>alert('XSS_CAL_TITLE')</script>",
description: "XSS Test im Terminkalender <script>alert('XSS_CAL_DESC')</script>",
type: TerminType.SONSTIGES,
status: TerminStatus.CONFIRMED,
startDate: addDays(now, 5),
endDate: addDays(now, 5),
allDay: true,
createdById: admin.id,
approvedById: admin.id,
approvedAt: now,
},
});
await prisma.mitbringselItem.createMany({ await prisma.mitbringselItem.createMany({
data: [ data: [
{ {
@@ -650,15 +875,34 @@ async function createTermine({
userId: elternSchmidt.id, userId: elternSchmidt.id,
content: "Brot und Aufstrich", content: "Brot und Aufstrich",
}, },
{
kitaId: kita.id,
terminId: sommerfestEvent.id,
userId: elternYilmaz.id,
content: "Nudelsalat (vegan)",
},
{
kitaId: kita.id,
terminId: sommerfestEvent.id,
userId: elternFischer.id,
content: "Mehrweg-Becher (ca. 30 Stk.)",
},
{
kitaId: kita.id,
terminId: sommerfestEvent.id,
userId: elternKowalski.id,
content: "Apfelsaft & Wasser",
},
], ],
}); });
} }
async function createNotdienstData( async function createNotdienstData(
{ kita, koordinator, elternSchmidt, elternYilmaz }: SeedContext, context: SeedContext,
children: Awaited<ReturnType<typeof createChildren>>, children: Awaited<ReturnType<typeof createChildren>>,
educators: Awaited<ReturnType<typeof createEducators>>, educators: Awaited<ReturnType<typeof createEducators>>,
) { ) {
const { kita, koordinator, elternSchmidt, elternYilmaz, elternWeber, elternKowalski } = context;
const today = dateOnly(new Date()); const today = dateOnly(new Date());
const currentMonth = startOfMonth(today); const currentMonth = startOfMonth(today);
const previousMonth = getPreviousMonth(today); const previousMonth = getPreviousMonth(today);
@@ -670,6 +914,8 @@ async function createNotdienstData(
{ childId: children.clara.id, userId: elternSchmidt.id }, { childId: children.clara.id, userId: elternSchmidt.id },
{ childId: children.emil.id, userId: elternYilmaz.id }, { childId: children.emil.id, userId: elternYilmaz.id },
{ childId: children.ben.id, userId: koordinator.id }, { childId: children.ben.id, userId: koordinator.id },
{ childId: children.leo.id, userId: elternKowalski.id },
{ childId: children.mia.id, userId: elternWeber.id },
]; ];
const targetWorkingDays = getWorkingDays( const targetWorkingDays = getWorkingDays(
@@ -710,6 +956,30 @@ async function createNotdienstData(
}, },
}); });
const alertAssignmentDate = addDays(today, 3);
const pendingAlertAssignment = await prisma.notdienstAssignment.create({
data: {
kitaId: kita.id,
planId: currentPlan.id,
childId: children.emil.id,
date: alertAssignmentDate,
},
});
await prisma.notdienstAlert.create({
data: {
kitaId: kita.id,
assignmentId: pendingAlertAssignment.id,
parentUserId: elternYilmaz.id,
triggeredById: koordinator.id,
educatorId: educators.petra.id,
status: NotdienstAlertStatus.PENDING,
confirmationToken: "demo-pending-alert-token",
triggeredAt: today,
notes: "Es fehlen zwei Kraefte. Bitte Notdienst besetzen.",
},
});
const previousPlan = await prisma.notdienstPlan.create({ const previousPlan = await prisma.notdienstPlan.create({
data: { data: {
kitaId: kita.id, kitaId: kita.id,
@@ -744,6 +1014,60 @@ async function createNotdienstData(
notes: "Beispiel-Alarm aus Seed-Daten.", notes: "Beispiel-Alarm aus Seed-Daten.",
}, },
}); });
const draftPlan = await prisma.notdienstPlan.create({
data: {
kitaId: kita.id,
year: targetMonth.getFullYear(),
month: targetMonth.getMonth() + 1,
status: NotdienstPlanStatus.DRAFT,
createdById: koordinator.id,
},
});
}
async function createAuditLogs({ kita, superAdmin, admin }: SeedContext) {
await prisma.auditLog.createMany({
data: [
{
actorUserId: superAdmin.id,
actorEmail: superAdmin.email,
action: "KITA_CREATE",
targetType: AuditTargetType.KITA,
targetId: kita.id,
targetLabel: kita.name,
targetKitaId: kita.id,
metadata: { name: kita.name, slug: kita.slug },
createdAt: addDays(new Date(), -30),
},
{
actorUserId: superAdmin.id,
actorEmail: superAdmin.email,
action: "USER_ROLE_UPDATE",
targetType: AuditTargetType.USER,
targetId: admin.id,
targetLabel: admin.email,
targetKitaId: kita.id,
metadata: { oldRole: "ELTERN", newRole: "ADMIN" },
createdAt: addDays(new Date(), -28),
},
{
actorUserId: admin.id,
actorEmail: admin.email,
action: "KITA_MODULE_UPDATE",
targetType: AuditTargetType.KITA,
targetId: kita.id,
targetLabel: kita.name,
targetKitaId: kita.id,
metadata: {
notdienstModuleEnabled: true,
terminModuleEnabled: true,
adressbuchModuleEnabled: true,
},
createdAt: addDays(new Date(), -25),
},
],
});
} }
async function createDemoData() { async function createDemoData() {
@@ -760,8 +1084,9 @@ async function createDemoData() {
await createAbsences(context, children); await createAbsences(context, children);
await createInvites(context); await createInvites(context);
await createAnnouncements(context); await createAnnouncements(context);
await createTermine(context); await createTermine(context, children);
await createNotdienstData(context, children, educators); await createNotdienstData(context, children, educators);
await createAuditLogs(context);
printSummary(context, children); printSummary(context, children);
} }
@@ -776,7 +1101,10 @@ async function seedIfEmpty() {
} }
function printSummary( function printSummary(
{ context: SeedContext,
children: Awaited<ReturnType<typeof createChildren>>,
) {
const {
kita, kita,
superAdmin, superAdmin,
admin, admin,
@@ -784,10 +1112,11 @@ function printSummary(
koordinator, koordinator,
elternSchmidt, elternSchmidt,
elternYilmaz, elternYilmaz,
elternFischer,
elternKowalski,
elternWeber,
pendingParent, pendingParent,
}: SeedContext, } = context;
children: Awaited<ReturnType<typeof createChildren>>,
) {
console.log("Seed complete"); console.log("Seed complete");
console.log(""); console.log("");
console.log(` Kita: ${kita.name} (${kita.slug})`); console.log(` Kita: ${kita.name} (${kita.slug})`);
@@ -799,6 +1128,9 @@ function printSummary(
console.log(` Koordinator: ${koordinator.email}`); console.log(` Koordinator: ${koordinator.email}`);
console.log(` Eltern: ${elternSchmidt.email}`); console.log(` Eltern: ${elternSchmidt.email}`);
console.log(` Eltern: ${elternYilmaz.email}`); console.log(` Eltern: ${elternYilmaz.email}`);
console.log(` Eltern (Fischer): ${elternFischer.email}`);
console.log(` Eltern (Kowalski): ${elternKowalski.email}`);
console.log(` Eltern (Weber): ${elternWeber.email}`);
console.log(""); console.log("");
console.log( console.log(
` Offener Invite: ${pendingParent.email} -> /invite/demo-pending-parent-token`, ` Offener Invite: ${pendingParent.email} -> /invite/demo-pending-parent-token`,
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

+59 -11
View File
@@ -1,20 +1,52 @@
"use server"; "use server";
import { createElement } from "react"; import { createElement } from "react";
import { headers } from "next/headers";
import { ContactConfirmationEmail } from "@/emails/ContactConfirmationEmail";
import { ContactRequestEmail } from "@/emails/ContactRequestEmail"; import { ContactRequestEmail } from "@/emails/ContactRequestEmail";
import { import {
contactRequestSchema, contactRequestSchema,
contactRequestTypeLabels, contactRequestTypeLabels,
type ContactRequestInput, type ContactSubmissionPayload,
} from "@/lib/contact-schema"; } from "@/lib/contact-schema";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { rateLimit } from "@/lib/rate-limit";
const DEFAULT_ADMIN_EMAIL = "kontakt@kita-planer.local"; const DEFAULT_ADMIN_EMAIL = "kontakt@kita-planer.local";
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_SECONDS = 60 * 60;
async function resolveClientIp(): Promise<string> {
const h = await headers();
const forwarded = h.get("x-forwarded-for");
if (forwarded) {
return forwarded.split(",")[0]?.trim() || "unknown";
}
return h.get("x-real-ip") ?? "unknown";
}
export async function submitContactRequest(data: ContactSubmissionPayload) {
// Honeypot: silently absorb bot submissions without informing them.
if (data.website && data.website.trim().length > 0) {
return { success: true };
}
const ip = await resolveClientIp();
const limit = rateLimit(
`contact:${ip}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_SECONDS,
);
if (!limit.allowed) {
const minutes = Math.max(1, Math.ceil(limit.retryAfterSeconds / 60));
return {
success: false,
error: `Zu viele Anfragen. Bitte versuche es in ${minutes} Minuten erneut.`,
};
}
export async function submitContactRequest(data: ContactRequestInput) {
const parsed = contactRequestSchema.safeParse(data); const parsed = contactRequestSchema.safeParse(data);
if (!parsed.success) { if (!parsed.success) {
return { return {
success: false, success: false,
@@ -32,19 +64,35 @@ export async function submitContactRequest(data: ContactRequestInput) {
const adminEmail = process.env.ADMIN_EMAIL || DEFAULT_ADMIN_EMAIL; const adminEmail = process.env.ADMIN_EMAIL || DEFAULT_ADMIN_EMAIL;
const inquiryLabel = contactRequestTypeLabels[parsed.data.requestType]; const inquiryLabel = contactRequestTypeLabels[parsed.data.requestType];
const result = await sendAppEmail({
to: adminEmail,
subject: `Kontaktanfrage: ${inquiryLabel} von ${parsed.data.name}`,
replyTo: parsed.data.email,
react: createElement(ContactRequestEmail, parsed.data),
});
if (!result.success) { const [adminResult, confirmationResult] = await Promise.all([
sendAppEmail({
to: adminEmail,
subject: `Kontaktanfrage: ${inquiryLabel} von ${parsed.data.name}`,
replyTo: parsed.data.email,
react: createElement(ContactRequestEmail, parsed.data),
}),
sendAppEmail({
to: parsed.data.email,
subject: "Wir haben deine Anfrage erhalten — Kita-Planer",
replyTo: adminEmail,
react: createElement(ContactConfirmationEmail, parsed.data),
}),
]);
if (!adminResult.success) {
return { return {
success: false, success: false,
error: result.error, error: adminResult.error,
}; };
} }
if (!confirmationResult.success) {
console.error(
"Bestätigungs-E-Mail konnte nicht zugestellt werden:",
confirmationResult.error,
);
}
return { success: true }; return { success: true };
} }
+156
View File
@@ -0,0 +1,156 @@
"use server";
import { revalidatePath } from "next/cache";
import {
EventParticipationStatus,
TerminStatus,
UserRole,
} from "@prisma/client";
import { z } from "zod";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
const submitRsvpSchema = z.object({
eventId: z.string().min(1),
childId: z.string().min(1),
status: z.nativeEnum(EventParticipationStatus),
});
export type EventParticipantDto = {
childId: string;
childName: string;
familyName: string;
status: EventParticipationStatus | null;
};
export async function submitEventRsvp(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = submitRsvpSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
}
const { eventId, childId, status } = parsed.data;
const event = await prisma.termin.findFirst({
where: {
id: eventId,
kitaId: session.user.kitaId,
},
select: {
id: true,
status: true,
requiresRsvp: true,
rsvpDeadline: true,
startDate: true,
},
});
if (!event || !event.requiresRsvp || event.status !== TerminStatus.CONFIRMED) {
return { error: "Für diesen Termin ist keine Rückmeldung aktiv." };
}
const deadline = event.rsvpDeadline ?? event.startDate;
if (deadline < new Date()) {
return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
}
const child = await prisma.child.findFirst({
where: {
id: childId,
kitaId: session.user.kitaId,
familyId: session.user.familyId,
active: true,
},
select: { id: true },
});
if (!child) {
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
}
await prisma.eventParticipation.upsert({
where: {
eventId_childId: {
eventId,
childId,
},
},
create: {
kitaId: session.user.kitaId,
eventId,
childId,
status,
},
update: {
status,
},
});
revalidatePath("/dashboard");
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true };
}
export async function getEventParticipants(
eventId: string,
): Promise<{ participants?: EventParticipantDto[]; error?: string }> {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const event = await prisma.termin.findFirst({
where: { id: eventId, kitaId },
select: { id: true },
});
if (!event) {
return { error: "Termin wurde nicht gefunden." };
}
const children = await prisma.child.findMany({
where: {
kitaId,
active: true,
},
select: {
id: true,
firstName: true,
lastName: true,
family: {
select: {
name: true,
},
},
eventParticipations: {
where: { eventId },
select: { status: true },
take: 1,
},
},
orderBy: [
{ family: { name: "asc" } },
{ lastName: "asc" },
{ firstName: "asc" },
],
});
return {
participants: children.map((child) => ({
childId: child.id,
childName: `${child.firstName} ${child.lastName}`,
familyName: child.family.name,
status: child.eventParticipations[0]?.status ?? null,
})),
};
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Activity, Building2, ScrollText } from "lucide-react";
import { cn } from "@/lib/utils";
const items = [
{
href: "/admin",
label: "Kitas",
icon: Building2,
exact: true,
},
{
href: "/admin/audit",
label: "AuditLog",
icon: ScrollText,
exact: false,
},
];
export function AdminNav() {
const pathname = usePathname();
return (
<nav className="flex flex-col gap-1 px-3">
{items.map(({ href, label, icon: Icon, exact }) => {
const isActive = exact ? pathname === href : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
);
})}
<div className="mt-3 border-t px-3 pt-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Diagnose
</div>
<div className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground">
<Activity className="h-4 w-4 shrink-0" />
Read-only Support
</div>
</nav>
);
}
@@ -0,0 +1,129 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { AlertTriangle, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { deleteKita } from "../actions";
type DeleteCounts = {
users: number;
families: number;
activeChildren: number;
termine: number;
absences: number;
announcements: number;
};
export function KitaDeleteDialog({
kitaId,
kitaName,
counts,
}: {
kitaId: string;
kitaName: string;
counts: DeleteCounts;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [confirmationName, setConfirmationName] = useState("");
const [isPending, startTransition] = useTransition();
const canSubmit = confirmationName === kitaName && !isPending;
function onSubmit() {
startTransition(async () => {
const result = await deleteKita({ kitaId, confirmationName });
if (!result.ok) {
toast.error(result.error ?? "Kita konnte nicht gelöscht werden.");
return;
}
toast.success("Kita wurde gelöscht.");
setOpen(false);
router.push(result.redirectTo ?? "/admin");
router.refresh();
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2 className="h-4 w-4" />
Kita löschen
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Kita endgültig löschen</DialogTitle>
<DialogDescription>
Diese Aktion löscht alle mandantengebundenen Daten dieser Kita
unwiderruflich. Das AuditLog bleibt erhalten.
</DialogDescription>
</DialogHeader>
<div className="rounded-md border border-destructive/25 bg-destructive/5 p-4">
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-destructive">
<AlertTriangle className="h-4 w-4" />
Betroffene Daten
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<span>Benutzer: {counts.users}</span>
<span>Familien: {counts.families}</span>
<span>Aktive Kinder: {counts.activeChildren}</span>
<span>Termine: {counts.termine}</span>
<span>Abwesenheiten: {counts.absences}</span>
<span>News: {counts.announcements}</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`confirm-kita-${kitaId}`}>
Zur Bestätigung Kita-Namen eingeben
</Label>
<Input
id={`confirm-kita-${kitaId}`}
value={confirmationName}
onChange={(event) => setConfirmationName(event.target.value)}
placeholder={kitaName}
autoComplete="off"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button
type="button"
variant="destructive"
onClick={onSubmit}
disabled={!canSubmit}
>
{isPending ? "Lösche..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,211 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Lock, RotateCcw, Trash2, Unlock } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
deleteUser,
lockUser,
resetFailedLoginAttempts,
unlockUser,
} from "../actions";
type UserActionResult = {
ok: boolean;
error?: string;
};
async function runSupportAction(
action: () => Promise<UserActionResult>,
successMessage: string,
router: ReturnType<typeof useRouter>,
) {
const result = await action();
if (!result.ok) {
toast.error(result.error ?? "Aktion konnte nicht ausgeführt werden.");
return;
}
toast.success(successMessage);
router.refresh();
}
export function UserSupportActions({
user,
}: {
user: {
id: string;
email: string;
name: string;
isLocked: boolean;
failedLoginAttempts: number;
};
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
return (
<div className="flex flex-wrap justify-end gap-1">
{user.isLocked ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() =>
startTransition(() =>
runSupportAction(
() => unlockUser(user.id),
"Benutzer wurde entsperrt.",
router,
),
)
}
>
<Unlock className="h-4 w-4" />
Entsperren
</Button>
) : (
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() =>
startTransition(() =>
runSupportAction(
() => lockUser(user.id),
"Benutzer wurde gesperrt.",
router,
),
)
}
>
<Lock className="h-4 w-4" />
Sperren
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending || user.failedLoginAttempts === 0}
onClick={() =>
startTransition(() =>
runSupportAction(
() => resetFailedLoginAttempts(user.id),
"Fehlversuche wurden zurückgesetzt.",
router,
),
)
}
>
<RotateCcw className="h-4 w-4" />
Reset
</Button>
<UserDeleteDialog user={user} />
</div>
);
}
function UserDeleteDialog({
user,
}: {
user: {
id: string;
email: string;
name: string;
};
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [confirmationEmail, setConfirmationEmail] = useState("");
const [isPending, startTransition] = useTransition();
const canSubmit = confirmationEmail === user.email && !isPending;
function onSubmit() {
startTransition(async () => {
const result = await deleteUser({
userId: user.id,
confirmationEmail,
});
if (!result.ok) {
toast.error(result.error ?? "Benutzer konnte nicht gelöscht werden.");
return;
}
toast.success("Benutzer wurde gelöscht.");
setOpen(false);
router.refresh();
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" variant="destructive" size="sm">
<Trash2 className="h-4 w-4" />
Löschen
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Benutzer endgültig löschen</DialogTitle>
<DialogDescription>
Löscht nur das Benutzerkonto von {user.name}. Familie und Kinder
bleiben erhalten.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor={`confirm-user-${user.id}`}>
Zur Bestätigung E-Mail-Adresse eingeben
</Label>
<Input
id={`confirm-user-${user.id}`}
value={confirmationEmail}
onChange={(event) => setConfirmationEmail(event.target.value)}
placeholder={user.email}
autoComplete="off"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button
type="button"
variant="destructive"
onClick={onSubmit}
disabled={!canSubmit}
>
{isPending ? "Lösche..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+438
View File
@@ -0,0 +1,438 @@
import "server-only";
import { AuditTargetType, InvitationStatus, UserRole } from "@prisma/client";
import { prisma } from "@/lib/prisma";
const roleOrder: UserRole[] = [
UserRole.ADMIN,
UserRole.KOORDINATOR,
UserRole.ERZIEHER,
UserRole.ELTERN,
];
export type KitaOverviewRow = {
id: string;
name: string;
slug: string;
createdAt: Date;
updatedAt: Date;
modules: {
notdienst: boolean;
termine: boolean;
adressbuch: boolean;
};
counts: {
users: number;
families: number;
activeChildren: number;
termine: number;
absences: number;
announcements: number;
openInvitations: number;
lockedUsers: number;
missingConsent: number;
familiesWithoutUsers: number;
};
lastLoginAt: Date | null;
roleCounts: Record<UserRole, number>;
};
export type GlobalAdminHealth = {
orphanTenantUsers: number;
lockedTenantUsers: number;
openInvitations: number;
missingConsent: number;
kitas: number;
};
export async function getGlobalAdminHealth(): Promise<GlobalAdminHealth> {
const now = new Date();
const [
orphanTenantUsers,
lockedTenantUsers,
openInvitations,
missingConsent,
kitas,
] = await Promise.all([
prisma.user.count({
where: {
kitaId: null,
role: { not: UserRole.SUPERADMIN },
},
}),
prisma.user.count({
where: {
role: { not: UserRole.SUPERADMIN },
lockedUntil: { gt: now },
},
}),
prisma.invitation.count({
where: {
status: InvitationStatus.PENDING,
expiresAt: { gt: now },
},
}),
prisma.user.count({
where: {
role: { not: UserRole.SUPERADMIN },
privacyPolicyAcceptedAt: null,
},
}),
prisma.kita.count(),
]);
return {
orphanTenantUsers,
lockedTenantUsers,
openInvitations,
missingConsent,
kitas,
};
}
export async function getKitaOverviewRows(): Promise<KitaOverviewRow[]> {
const now = new Date();
const kitas = await prisma.kita.findMany({
select: {
id: true,
name: true,
slug: true,
notdienstModuleEnabled: true,
terminModuleEnabled: true,
adressbuchModuleEnabled: true,
createdAt: true,
updatedAt: true,
users: {
select: {
role: true,
lastLoginAt: true,
lockedUntil: true,
privacyPolicyAcceptedAt: true,
},
},
families: {
select: {
users: {
select: { id: true },
},
},
},
children: {
select: { active: true },
},
invitations: {
select: {
status: true,
expiresAt: true,
},
},
_count: {
select: {
termine: true,
absences: true,
announcements: true,
},
},
},
orderBy: { name: "asc" },
});
return kitas.map((kita) => {
const roleCounts = Object.fromEntries(
Object.values(UserRole).map((role) => [role, 0]),
) as Record<UserRole, number>;
let lastLoginAt: Date | null = null;
let lockedUsers = 0;
let missingConsent = 0;
for (const user of kita.users) {
roleCounts[user.role] += 1;
if (user.lastLoginAt && (!lastLoginAt || user.lastLoginAt > lastLoginAt)) {
lastLoginAt = user.lastLoginAt;
}
if (user.lockedUntil && user.lockedUntil > now) {
lockedUsers += 1;
}
if (user.role !== UserRole.SUPERADMIN && !user.privacyPolicyAcceptedAt) {
missingConsent += 1;
}
}
return {
id: kita.id,
name: kita.name,
slug: kita.slug,
createdAt: kita.createdAt,
updatedAt: kita.updatedAt,
modules: {
notdienst: kita.notdienstModuleEnabled,
termine: kita.terminModuleEnabled,
adressbuch: kita.adressbuchModuleEnabled,
},
counts: {
users: kita.users.length,
families: kita.families.length,
activeChildren: kita.children.filter((child) => child.active).length,
termine: kita._count.termine,
absences: kita._count.absences,
announcements: kita._count.announcements,
openInvitations: kita.invitations.filter(
(invitation) =>
invitation.status === InvitationStatus.PENDING &&
invitation.expiresAt > now,
).length,
lockedUsers,
missingConsent,
familiesWithoutUsers: kita.families.filter(
(family) => family.users.length === 0,
).length,
},
lastLoginAt,
roleCounts,
};
});
}
export type KitaDiagnostics = {
kita: {
id: string;
name: string;
slug: string;
createdAt: Date;
updatedAt: Date;
modules: KitaOverviewRow["modules"];
};
counts: KitaOverviewRow["counts"] & {
inactiveChildren: number;
expiredPendingInvitations: number;
};
roleCounts: Record<UserRole, number>;
users: {
id: string;
email: string;
firstName: string;
lastName: string;
role: UserRole;
familyName: string | null;
emailVerifiedAt: Date | null;
privacyPolicyAcceptedAt: Date | null;
lastLoginAt: Date | null;
failedLoginAttempts: number;
lockedUntil: Date | null;
createdAt: Date;
}[];
diagnostics: {
lockedUsers: number;
missingConsent: number;
unverifiedUsers: number;
familiesWithoutUsers: number;
usersWithEmptyPassword: number;
};
};
export async function getKitaDiagnostics(
kitaId: string,
): Promise<KitaDiagnostics | null> {
const now = new Date();
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: {
id: true,
name: true,
slug: true,
notdienstModuleEnabled: true,
terminModuleEnabled: true,
adressbuchModuleEnabled: true,
createdAt: true,
updatedAt: true,
users: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
passwordHash: true,
emailVerifiedAt: true,
privacyPolicyAcceptedAt: true,
lastLoginAt: true,
failedLoginAttempts: true,
lockedUntil: true,
createdAt: true,
family: {
select: { name: true },
},
},
orderBy: [
{ role: "asc" },
{ lastName: "asc" },
{ firstName: "asc" },
],
},
families: {
select: {
users: { select: { id: true } },
},
},
children: {
select: { active: true },
},
invitations: {
select: {
status: true,
expiresAt: true,
},
},
_count: {
select: {
termine: true,
absences: true,
announcements: true,
},
},
},
});
if (!kita) {
return null;
}
const roleCounts = Object.fromEntries(
Object.values(UserRole).map((role) => [role, 0]),
) as Record<UserRole, number>;
let lockedUsers = 0;
let missingConsent = 0;
let unverifiedUsers = 0;
let usersWithEmptyPassword = 0;
for (const user of kita.users) {
roleCounts[user.role] += 1;
if (user.lockedUntil && user.lockedUntil > now) {
lockedUsers += 1;
}
if (user.role !== UserRole.SUPERADMIN && !user.privacyPolicyAcceptedAt) {
missingConsent += 1;
}
if (!user.emailVerifiedAt) {
unverifiedUsers += 1;
}
if (!user.passwordHash) {
usersWithEmptyPassword += 1;
}
}
const activeChildren = kita.children.filter((child) => child.active).length;
const inactiveChildren = kita.children.length - activeChildren;
const familiesWithoutUsers = kita.families.filter(
(family) => family.users.length === 0,
).length;
return {
kita: {
id: kita.id,
name: kita.name,
slug: kita.slug,
createdAt: kita.createdAt,
updatedAt: kita.updatedAt,
modules: {
notdienst: kita.notdienstModuleEnabled,
termine: kita.terminModuleEnabled,
adressbuch: kita.adressbuchModuleEnabled,
},
},
counts: {
users: kita.users.length,
families: kita.families.length,
activeChildren,
inactiveChildren,
termine: kita._count.termine,
absences: kita._count.absences,
announcements: kita._count.announcements,
openInvitations: kita.invitations.filter(
(invitation) =>
invitation.status === InvitationStatus.PENDING &&
invitation.expiresAt > now,
).length,
expiredPendingInvitations: kita.invitations.filter(
(invitation) =>
invitation.status === InvitationStatus.PENDING &&
invitation.expiresAt <= now,
).length,
lockedUsers,
missingConsent,
familiesWithoutUsers,
},
roleCounts,
users: kita.users.map((user) => ({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
familyName: user.family?.name ?? null,
emailVerifiedAt: user.emailVerifiedAt,
privacyPolicyAcceptedAt: user.privacyPolicyAcceptedAt,
lastLoginAt: user.lastLoginAt,
failedLoginAttempts: user.failedLoginAttempts,
lockedUntil: user.lockedUntil,
createdAt: user.createdAt,
})),
diagnostics: {
lockedUsers,
missingConsent,
unverifiedUsers,
familiesWithoutUsers,
usersWithEmptyPassword,
},
};
}
export async function recordKitaDiagnosticView({
actorUserId,
actorEmail,
kitaId,
kitaName,
kitaSlug,
}: {
actorUserId: string;
actorEmail: string;
kitaId: string;
kitaName: string;
kitaSlug: string;
}) {
await prisma.auditLog.create({
data: {
actorUserId,
actorEmail,
action: "KITA_DIAGNOSTIC_VIEW",
targetType: AuditTargetType.KITA,
targetId: kitaId,
targetLabel: `${kitaName} (${kitaSlug})`,
targetKitaId: kitaId,
},
});
}
export async function getAuditLogRows() {
return prisma.auditLog.findMany({
select: {
id: true,
actorEmail: true,
action: true,
targetType: true,
targetId: true,
targetLabel: true,
targetKitaId: true,
metadata: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
take: 200,
});
}
export { roleOrder };
+287
View File
@@ -0,0 +1,287 @@
"use server";
import { revalidatePath } from "next/cache";
import { AuditTargetType, UserRole } from "@prisma/client";
import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
const deleteKitaSchema = z.object({
kitaId: z.string().min(1),
confirmationName: z.string().min(1).trim(),
});
const deleteUserSchema = z.object({
userId: z.string().min(1),
confirmationEmail: z.string().email().toLowerCase().trim(),
});
const userIdSchema = z.string().min(1);
type ActionResult = {
ok: boolean;
error?: string;
};
async function requireSuperadminActor() {
const session = await requireRole([UserRole.SUPERADMIN]);
return {
id: session.user.id,
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
};
}
async function getKitaDeleteCounts(kitaId: string) {
const [
users,
families,
children,
termine,
absences,
announcements,
invitations,
eventParticipations,
] = await Promise.all([
prisma.user.count({ where: { kitaId } }),
prisma.family.count({ where: { kitaId } }),
prisma.child.count({ where: { kitaId } }),
prisma.termin.count({ where: { kitaId } }),
prisma.absence.count({ where: { kitaId } }),
prisma.announcement.count({ where: { kitaId } }),
prisma.invitation.count({ where: { kitaId } }),
prisma.eventParticipation.count({ where: { kitaId } }),
]);
return {
users,
families,
children,
termine,
absences,
announcements,
invitations,
eventParticipations,
};
}
export async function deleteKita(
rawPayload: unknown,
): Promise<ActionResult & { redirectTo?: string }> {
const actor = await requireSuperadminActor();
const parsed = deleteKitaSchema.safeParse(rawPayload);
if (!parsed.success) {
return { ok: false, error: "Ungültige Eingabedaten." };
}
const kita = await prisma.kita.findUnique({
where: { id: parsed.data.kitaId },
select: { id: true, name: true, slug: true },
});
if (!kita) {
return { ok: false, error: "Kita wurde nicht gefunden." };
}
if (parsed.data.confirmationName !== kita.name) {
return {
ok: false,
error: "Die Bestätigung muss exakt dem Kita-Namen entsprechen.",
};
}
const counts = await getKitaDeleteCounts(kita.id);
await prisma.$transaction(async (tx) => {
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: actor.email,
action: "KITA_DELETE",
targetType: AuditTargetType.KITA,
targetId: kita.id,
targetLabel: `${kita.name} (${kita.slug})`,
targetKitaId: kita.id,
metadata: counts,
},
});
await tx.kita.delete({ where: { id: kita.id } });
});
revalidatePath("/admin");
revalidatePath("/admin/audit");
return { ok: true, redirectTo: "/admin" };
}
async function findMutableTenantUser(userId: string, actorId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
kitaId: true,
familyId: true,
failedLoginAttempts: true,
lockedUntil: true,
kita: {
select: {
name: true,
slug: true,
},
},
family: {
select: {
name: true,
_count: {
select: {
users: true,
},
},
},
},
},
});
if (!user) {
return { error: "Benutzer wurde nicht gefunden." } as const;
}
if (user.id === actorId) {
return { error: "Das eigene Superadmin-Konto kann hier nicht geändert werden." } as const;
}
if (user.role === UserRole.SUPERADMIN) {
return { error: "Andere Superadmins werden in V1 nicht verwaltet." } as const;
}
if (!user.kitaId) {
return { error: "Nur tenant-gebundene Benutzer können verwaltet werden." } as const;
}
return { user } as const;
}
export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
const actor = await requireSuperadminActor();
const parsed = deleteUserSchema.safeParse(rawPayload);
if (!parsed.success) {
return { ok: false, error: "Ungültige Eingabedaten." };
}
const lookup = await findMutableTenantUser(parsed.data.userId, actor.id);
if ("error" in lookup) {
return { ok: false, error: lookup.error };
}
const { user } = lookup;
if (parsed.data.confirmationEmail !== user.email) {
return {
ok: false,
error: "Die Bestätigung muss exakt der Benutzer-E-Mail entsprechen.",
};
}
await prisma.$transaction(async (tx) => {
await tx.verificationToken.deleteMany({
where: { identifier: user.id },
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: actor.email,
action: "USER_DELETE",
targetType: AuditTargetType.USER,
targetId: user.id,
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
targetKitaId: user.kitaId,
metadata: {
role: user.role,
kitaName: user.kita?.name ?? null,
kitaSlug: user.kita?.slug ?? null,
familyId: user.familyId,
familyName: user.family?.name ?? null,
familyUserCountBeforeDelete: user.family?._count.users ?? null,
},
},
});
await tx.user.delete({ where: { id: user.id } });
});
revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`);
revalidatePath("/admin/audit");
return { ok: true };
}
async function updateUserSupportState(
userId: string,
action: "USER_LOCK" | "USER_UNLOCK" | "USER_FAILED_LOGIN_RESET",
data: {
lockedUntil?: Date | null;
failedLoginAttempts?: number;
},
): Promise<ActionResult> {
const actor = await requireSuperadminActor();
const parsed = userIdSchema.safeParse(userId);
if (!parsed.success) {
return { ok: false, error: "Ungültige Eingabedaten." };
}
const lookup = await findMutableTenantUser(parsed.data, actor.id);
if ("error" in lookup) {
return { ok: false, error: lookup.error };
}
const { user } = lookup;
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: user.id },
data,
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: actor.email,
action,
targetType: AuditTargetType.USER,
targetId: user.id,
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
targetKitaId: user.kitaId,
metadata: {
role: user.role,
previousLockedUntil: user.lockedUntil?.toISOString() ?? null,
previousFailedLoginAttempts: user.failedLoginAttempts,
},
},
});
});
revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`);
revalidatePath("/admin/audit");
return { ok: true };
}
export async function lockUser(userId: string): Promise<ActionResult> {
return updateUserSupportState(userId, "USER_LOCK", {
lockedUntil: new Date("2099-12-31T23:59:59.000Z"),
});
}
export async function unlockUser(userId: string): Promise<ActionResult> {
return updateUserSupportState(userId, "USER_UNLOCK", {
lockedUntil: null,
failedLoginAttempts: 0,
});
}
export async function resetFailedLoginAttempts(
userId: string,
): Promise<ActionResult> {
return updateUserSupportState(userId, "USER_FAILED_LOGIN_RESET", {
failedLoginAttempts: 0,
});
}
+153
View File
@@ -0,0 +1,153 @@
import Link from "next/link";
import type { Metadata } from "next";
import { ArrowLeft, ScrollText } from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getAuditLogRows } from "../_lib/data";
export const metadata: Metadata = {
title: "AuditLog · Kita-Planer",
};
function formatDate(date: Date) {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
function actionLabel(action: string) {
switch (action) {
case "KITA_DIAGNOSTIC_VIEW":
return "Diagnose geöffnet";
case "KITA_DELETE":
return "Kita gelöscht";
case "USER_DELETE":
return "Benutzer gelöscht";
case "USER_LOCK":
return "Benutzer gesperrt";
case "USER_UNLOCK":
return "Benutzer entsperrt";
case "USER_FAILED_LOGIN_RESET":
return "Fehlversuche zurückgesetzt";
default:
return action;
}
}
function metadataPreview(metadata: unknown) {
if (!metadata) return "—";
const preview = JSON.stringify(metadata);
if (!preview) return "—";
return preview.length > 140 ? `${preview.slice(0, 140)}...` : preview;
}
export default async function AuditPage() {
await requireRole([UserRole.SUPERADMIN]);
const rows = await getAuditLogRows();
return (
<div className="px-8 py-8">
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
<div>
<Button asChild variant="ghost" className="-ml-3 mb-3">
<Link href="/admin">
<ArrowLeft className="h-4 w-4" />
Zur Kita-Übersicht
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">AuditLog</h1>
<p className="mt-1 text-sm text-muted-foreground">
Die letzten 200 Superadmin-Diagnose- und Support-Aktionen.
</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
<ScrollText className="h-5 w-5" />
</div>
</div>
<Card>
<CardHeader>
<CardTitle as="h2" className="text-xl">
Systemweite Aktionen
</CardTitle>
<CardDescription>
Audit-Einträge sind unabhängig von gelöschten Kitas und Benutzern.
</CardDescription>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
Noch keine Audit-Einträge vorhanden.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Zeitpunkt</TableHead>
<TableHead>Aktion</TableHead>
<TableHead>Akteur</TableHead>
<TableHead>Ziel</TableHead>
<TableHead>Metadaten</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="whitespace-nowrap">
{formatDate(row.createdAt)}
</TableCell>
<TableCell>
<Badge
variant={
row.action.endsWith("_DELETE")
? "destructive"
: "secondary"
}
>
{actionLabel(row.action)}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm">{row.actorEmail}</span>
</TableCell>
<TableCell>
<div className="font-medium">{row.targetLabel}</div>
<div className="text-xs text-muted-foreground">
{row.targetType} · {row.targetId}
</div>
</TableCell>
<TableCell className="max-w-sm">
<code className="block truncate rounded bg-muted px-2 py-1 text-xs">
{metadataPreview(row.metadata)}
</code>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
+359
View File
@@ -0,0 +1,359 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import {
AlertTriangle,
ArrowLeft,
Baby,
CheckCircle2,
Clock,
Lock,
MailCheck,
MailWarning,
ShieldAlert,
Users,
} from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
getKitaDiagnostics,
recordKitaDiagnosticView,
roleOrder,
} from "../../_lib/data";
import { KitaDeleteDialog } from "../../_components/kita-delete-dialog";
import { UserSupportActions } from "../../_components/user-support-actions";
type PageProps = {
params: Promise<{ kitaId: string }>;
};
export const metadata: Metadata = {
title: "Kita-Diagnose · Kita-Planer",
};
function formatDate(date: Date | null) {
if (!date) return "Nie";
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
function isLocked(lockedUntil: Date | null) {
return !!lockedUntil && lockedUntil > new Date();
}
export default async function KitaDiagnosticsPage({ params }: PageProps) {
const [{ kitaId }, session] = await Promise.all([
params,
requireRole([UserRole.SUPERADMIN]),
]);
const diagnostics = await getKitaDiagnostics(kitaId);
if (!diagnostics) {
notFound();
}
await recordKitaDiagnosticView({
actorUserId: session.user.id,
actorEmail: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
kitaId: diagnostics.kita.id,
kitaName: diagnostics.kita.name,
kitaSlug: diagnostics.kita.slug,
});
const issueCards = [
{
label: "Gesperrte Konten",
value: diagnostics.diagnostics.lockedUsers,
icon: Lock,
},
{
label: "Fehlender Consent",
value: diagnostics.diagnostics.missingConsent,
icon: ShieldAlert,
},
{
label: "Unverifizierte E-Mails",
value: diagnostics.diagnostics.unverifiedUsers,
icon: MailWarning,
},
{
label: "Familien ohne Benutzer",
value: diagnostics.diagnostics.familiesWithoutUsers,
icon: Users,
},
];
return (
<div className="px-8 py-8">
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
<div>
<Button asChild variant="ghost" className="-ml-3 mb-3">
<Link href="/admin">
<ArrowLeft className="h-4 w-4" />
Zur Kita-Übersicht
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">
{diagnostics.kita.name}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{diagnostics.kita.slug} · angelegt am{" "}
{formatDate(diagnostics.kita.createdAt)}
</p>
</div>
<KitaDeleteDialog
kitaId={diagnostics.kita.id}
kitaName={diagnostics.kita.name}
counts={diagnostics.counts}
/>
</div>
<div className="mb-6 grid gap-4 md:grid-cols-4">
<MetricCard
icon={Users}
label="Benutzer"
value={diagnostics.counts.users}
description={`${diagnostics.counts.families} Familien`}
/>
<MetricCard
icon={Baby}
label="Aktive Kinder"
value={diagnostics.counts.activeChildren}
description={`${diagnostics.counts.inactiveChildren} inaktiv`}
/>
<MetricCard
icon={Clock}
label="Termine"
value={diagnostics.counts.termine}
description={`${diagnostics.counts.absences} Abwesenheiten`}
/>
<MetricCard
icon={MailCheck}
label="Offene Einladungen"
value={diagnostics.counts.openInvitations}
description={`${diagnostics.counts.expiredPendingInvitations} abgelaufen`}
attention={diagnostics.counts.openInvitations > 0}
/>
</div>
<div className="mb-6 grid gap-4 md:grid-cols-4">
{issueCards.map(({ label, value, icon: Icon }) => (
<Card
key={label}
className={value > 0 ? "border-amber-200 bg-amber-50/70" : ""}
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardDescription>{label}</CardDescription>
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle className="text-3xl">{value}</CardTitle>
</CardHeader>
</Card>
))}
</div>
<div className="mb-6 grid gap-4 lg:grid-cols-[1fr_1.4fr]">
<Card>
<CardHeader>
<CardTitle as="h2" className="text-xl">
Rollenverteilung
</CardTitle>
<CardDescription>
Nur tenant-gebundene Benutzer dieser Kita.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{roleOrder.map((role) => (
<Badge key={role} variant="secondary">
{role}: {diagnostics.roleCounts[role]}
</Badge>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle as="h2" className="text-xl">
Modulstatus
</CardTitle>
<CardDescription>
V1 zeigt Module nur read-only, keine Konfigurationsänderungen.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<ModuleBadge label="Notdienst" enabled={diagnostics.kita.modules.notdienst} />
<ModuleBadge label="Termine" enabled={diagnostics.kita.modules.termine} />
<ModuleBadge
label="Adressbuch"
enabled={diagnostics.kita.modules.adressbuch}
/>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle as="h2" className="text-xl">
Benutzer
</CardTitle>
<CardDescription>
Support-Aktionen sind auf Löschen, Sperren, Entsperren und
Fehlversuche zurücksetzen begrenzt.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Benutzer</TableHead>
<TableHead>Rolle / Familie</TableHead>
<TableHead>Status</TableHead>
<TableHead>Login</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{diagnostics.users.map((user) => {
const userIsLocked = isLocked(user.lockedUntil);
const userName = `${user.firstName} ${user.lastName}`.trim();
return (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">{userName}</div>
<div className="text-xs text-muted-foreground">
{user.email}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<Badge variant="secondary">{user.role}</Badge>
<span className="text-xs text-muted-foreground">
{user.familyName ?? "Keine Familie"}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1.5">
{userIsLocked ? (
<Badge variant="destructive">
<Lock className="mr-1 h-3 w-3" />
Gesperrt
</Badge>
) : (
<Badge variant="success">
<CheckCircle2 className="mr-1 h-3 w-3" />
Aktiv
</Badge>
)}
{!user.emailVerifiedAt && (
<Badge variant="warning">E-Mail offen</Badge>
)}
{!user.privacyPolicyAcceptedAt && (
<Badge variant="warning">Consent fehlt</Badge>
)}
{user.failedLoginAttempts > 0 && (
<Badge variant="outline">
{user.failedLoginAttempts} Fehlversuche
</Badge>
)}
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div>{formatDate(user.lastLoginAt)}</div>
{userIsLocked && (
<div className="text-xs text-muted-foreground">
bis {formatDate(user.lockedUntil)}
</div>
)}
</div>
</TableCell>
<TableCell className="text-right">
<UserSupportActions
user={{
id: user.id,
email: user.email,
name: userName,
isLocked: userIsLocked,
failedLoginAttempts: user.failedLoginAttempts,
}}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
function MetricCard({
icon: Icon,
label,
value,
description,
attention = false,
}: {
icon: typeof Users;
label: string;
value: number;
description: string;
attention?: boolean;
}) {
return (
<Card className={attention ? "border-amber-200 bg-amber-50/70" : ""}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardDescription>{label}</CardDescription>
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle className="text-3xl">{value}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
function ModuleBadge({ label, enabled }: { label: string; enabled: boolean }) {
return (
<Badge variant={enabled ? "success" : "outline"}>
{enabled ? (
<CheckCircle2 className="mr-1 h-3 w-3" />
) : (
<AlertTriangle className="mr-1 h-3 w-3" />
)}
{label}: {enabled ? "aktiv" : "inaktiv"}
</Badge>
);
}
+80
View File
@@ -0,0 +1,80 @@
import type { Metadata } from "next";
import { LogOut, ShieldCheck } from "lucide-react";
import { UserRole } from "@prisma/client";
import { signOut } from "@/auth";
import { requireRole } from "@/lib/auth-utils";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { AdminNav } from "./_components/admin-nav";
export const metadata: Metadata = {
title: "Systemadmin · Kita-Planer",
robots: {
index: false,
follow: false,
},
};
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await requireRole([UserRole.SUPERADMIN]);
return (
<div className="flex min-h-screen bg-muted/30">
<aside className="flex w-64 shrink-0 flex-col border-r bg-background">
<div className="flex h-16 items-center gap-2.5 border-b px-5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground">
<ShieldCheck className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-none">
Systemadmin
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Kita-Planer
</p>
</div>
</div>
<div className="flex-1 overflow-y-auto py-4">
<AdminNav />
</div>
<div className="border-t p-3">
<Separator className="mb-3" />
<div className="mb-2 px-3">
<p className="truncate text-xs font-medium">
{session.user.name ?? session.user.email}
</p>
<p className="truncate text-xs text-muted-foreground">
SUPERADMIN
</p>
</div>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<Button
type="submit"
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground"
>
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</form>
</div>
</aside>
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
);
}
+282
View File
@@ -0,0 +1,282 @@
import Link from "next/link";
import type { Metadata } from "next";
import {
AlertTriangle,
ArrowRight,
Building2,
CheckCircle2,
Clock,
Lock,
MailWarning,
Users,
} from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
getGlobalAdminHealth,
getKitaOverviewRows,
roleOrder,
} from "./_lib/data";
import { KitaDeleteDialog } from "./_components/kita-delete-dialog";
export const metadata: Metadata = {
title: "Superadmin · Kita-Planer",
};
const numberFormat = new Intl.NumberFormat("de-DE");
function formatDate(date: Date | null) {
if (!date) return "Nie";
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
export default async function AdminPage() {
await requireRole([UserRole.SUPERADMIN]);
const [health, kitas] = await Promise.all([
getGlobalAdminHealth(),
getKitaOverviewRows(),
]);
const totalUsers = kitas.reduce((sum, kita) => sum + kita.counts.users, 0);
const totalChildren = kitas.reduce(
(sum, kita) => sum + kita.counts.activeChildren,
0,
);
const needsAttention =
health.lockedTenantUsers +
health.missingConsent +
health.openInvitations +
health.orphanTenantUsers;
return (
<div className="px-8 py-8">
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Kita-Verwaltung
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Systemweite Übersicht, read-only Diagnose und Support-Aktionen.
</p>
</div>
<Button asChild variant="outline">
<Link href="/admin/audit">
AuditLog ansehen
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div>
<div className="mb-6 grid gap-4 md:grid-cols-4">
<MetricCard
icon={Building2}
label="Kitas"
value={health.kitas}
description="Mandanten im System"
/>
<MetricCard
icon={Users}
label="Benutzer"
value={totalUsers}
description={`${numberFormat.format(totalChildren)} aktive Kinder`}
/>
<MetricCard
icon={AlertTriangle}
label="Hinweise"
value={needsAttention}
description="Sperren, Einladungen, Consent"
attention={needsAttention > 0}
/>
<MetricCard
icon={Lock}
label="Gesperrt"
value={health.lockedTenantUsers}
description="Aktive Account-Sperren"
attention={health.lockedTenantUsers > 0}
/>
</div>
{health.orphanTenantUsers > 0 && (
<div className="mb-6 rounded-md border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
<div className="flex items-center gap-2 font-medium">
<MailWarning className="h-4 w-4" />
{health.orphanTenantUsers} tenant-lose Nicht-Superadmin-Benutzer
</div>
<p className="mt-1 text-amber-800 dark:text-amber-300">
Diese Konten sind keiner Kita zugeordnet und sollten separat
bereinigt werden. V1 verwaltet nur tenant-gebundene Benutzer.
</p>
</div>
)}
<Card>
<CardHeader>
<CardTitle as="h2" className="text-xl">
Alle Kitas
</CardTitle>
<CardDescription>
Diagnose-Links sind read-only; Löschungen benötigen eine exakte
Namensbestätigung.
</CardDescription>
</CardHeader>
<CardContent>
{kitas.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
Noch keine Kitas angelegt.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Kita</TableHead>
<TableHead>Status</TableHead>
<TableHead>Counts</TableHead>
<TableHead>Rollen</TableHead>
<TableHead>Letzter Login</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{kitas.map((kita) => {
const warnings =
kita.counts.lockedUsers +
kita.counts.missingConsent +
kita.counts.openInvitations +
kita.counts.familiesWithoutUsers;
return (
<TableRow key={kita.id}>
<TableCell>
<div className="font-medium">{kita.name}</div>
<div className="text-xs text-muted-foreground">
{kita.slug}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1.5">
{warnings === 0 ? (
<Badge variant="success">
<CheckCircle2 className="mr-1 h-3 w-3" />
Unauffällig
</Badge>
) : (
<Badge variant="warning">
<AlertTriangle className="mr-1 h-3 w-3" />
{warnings} Hinweise
</Badge>
)}
{kita.counts.lockedUsers > 0 && (
<Badge variant="outline">
{kita.counts.lockedUsers} gesperrt
</Badge>
)}
{kita.counts.openInvitations > 0 && (
<Badge variant="outline">
{kita.counts.openInvitations} Einladungen
</Badge>
)}
</div>
</TableCell>
<TableCell>
<div className="grid min-w-48 grid-cols-2 gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>{kita.counts.users} Benutzer</span>
<span>{kita.counts.families} Familien</span>
<span>{kita.counts.activeChildren} Kinder</span>
<span>{kita.counts.termine} Termine</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{roleOrder.map((role) =>
kita.roleCounts[role] > 0 ? (
<Badge key={role} variant="secondary">
{role}: {kita.roleCounts[role]}
</Badge>
) : null,
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
{formatDate(kita.lastLoginAt)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex flex-wrap justify-end gap-2">
<Button asChild variant="outline" size="sm">
<Link
href={`/admin/kitas/${kita.id}`}
prefetch={false}
>
Diagnose
</Link>
</Button>
<KitaDeleteDialog
kitaId={kita.id}
kitaName={kita.name}
counts={kita.counts}
/>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function MetricCard({
icon: Icon,
label,
value,
description,
attention = false,
}: {
icon: typeof Building2;
label: string;
value: number;
description: string;
attention?: boolean;
}) {
return (
<Card className={attention ? "border-amber-200 bg-amber-50/70" : ""}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardDescription>{label}</CardDescription>
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle className="text-3xl">{numberFormat.format(value)}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
+41 -3
View File
@@ -11,6 +11,7 @@ import {
contactRequestSchema, contactRequestSchema,
contactRequestTypeLabels, contactRequestTypeLabels,
type ContactRequestInput, type ContactRequestInput,
type ContactSubmissionPayload,
} from "@/lib/contact-schema"; } from "@/lib/contact-schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -44,16 +45,27 @@ export function ContactForm() {
}, },
}); });
function onSubmit(values: ContactRequestInput) { function onSubmit(
values: ContactRequestInput,
event?: React.BaseSyntheticEvent,
) {
const formEl = event?.target as HTMLFormElement | undefined;
const website =
(formEl?.elements.namedItem("website") as HTMLInputElement | null)
?.value ?? "";
startTransition(async () => { startTransition(async () => {
const result = await submitContactRequest(values); const payload: ContactSubmissionPayload = { ...values, website };
const result = await submitContactRequest(payload);
if (!result.success) { if (!result.success) {
toast.error(result.error); toast.error(result.error);
return; return;
} }
toast.success("Danke! Wir melden uns in Kürze bei euch."); toast.success(
"Danke! Wir haben dir eine Bestätigung an deine E-Mail-Adresse geschickt.",
);
form.reset({ form.reset({
name: "", name: "",
kitaName: "", kitaName: "",
@@ -61,6 +73,10 @@ export function ContactForm() {
requestType: "DEMO", requestType: "DEMO",
message: "", message: "",
}); });
const honeypot = formEl?.elements.namedItem(
"website",
) as HTMLInputElement | null;
if (honeypot) honeypot.value = "";
}); });
} }
@@ -171,6 +187,28 @@ export function ContactForm() {
)} )}
/> />
<div
aria-hidden="true"
style={{
position: "absolute",
left: "-10000px",
top: "auto",
width: "1px",
height: "1px",
overflow: "hidden",
}}
>
<label htmlFor="contact-website">Website (bitte leer lassen)</label>
<input
id="contact-website"
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
defaultValue=""
/>
</div>
<Button type="submit" size="lg" disabled={isPending} className="h-12"> <Button type="submit" size="lg" disabled={isPending} className="h-12">
{isPending ? ( {isPending ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
+212 -156
View File
@@ -3,10 +3,11 @@
import Link from "next/link"; import Link from "next/link";
import { useMemo, useTransition } from "react"; import { useMemo, useTransition } from "react";
import { AbsenceReason } from "@prisma/client"; import { AbsenceReason } from "@prisma/client";
import { Baby, CalendarX2, Loader2, Trash2 } from "lucide-react"; import { Baby, CalendarX2, Check, ChevronRight, Loader2 } from "lucide-react";
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteAbsence, reportAbsence } from "@/actions/absences"; import { reportAbsence } from "@/actions/absences";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -43,7 +44,6 @@ type AbsenceListItem = {
export function AbsenceCard({ export function AbsenceCard({
childOptions, childOptions,
absences,
today, today,
}: { }: {
childOptions: ChildOption[]; childOptions: ChildOption[];
@@ -69,56 +69,68 @@ export function AbsenceCard({
return; return;
} }
toast.success("Abwesenheit eingetragen."); toast.success("Abwesenheit gemeldet. Die Kita ist informiert.");
});
}
function handleDelete(absenceId: string) {
startTransition(async () => {
const result = await deleteAbsence(absenceId);
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Abwesenheit storniert.");
}); });
} }
return ( return (
<Card className="mb-8 border-amber-200 bg-amber-50/50"> <Card variant="featured" className="min-w-0">
<CardHeader> <CardHeader className="pb-5">
<CardTitle className="flex items-center gap-2 text-lg"> <div className="flex items-start gap-3">
<CalendarX2 className="h-5 w-5 text-amber-700" /> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
Kind abmelden <CalendarX2 className="h-4 w-4 [stroke-width:1.7]" />
</CardTitle> </div>
<CardDescription> <div>
Melde dein Kind für ganze Tage ab. Die Kita sieht die Meldung direkt <CardTitle>Kind abmelden</CardTitle>
in der Tagesübersicht. <CardDescription className="mt-2 max-w-xl">
</CardDescription> Melde dein Kind für ganze Tage ab. Die Kita sieht die Meldung
sofort in der Tagesübersicht keine zusätzliche E-Mail nötig.
</CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
{hasChildren ? (
<form action={handleSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label>Welches Kind?</Label>
<Select name="childId" defaultValue={defaultChildId} required>
<SelectTrigger>
<SelectValue placeholder="Kind auswählen" />
</SelectTrigger>
<SelectContent>
{childOptions.map((child) => (
<SelectItem key={child.id} value={child.id}>
{child.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-3 sm:grid-cols-2"> <CardContent>
<div className="grid gap-2"> {hasChildren ? (
<Label htmlFor="absence-startDate">Von</Label> <form action={handleSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<FieldLabel label="Welches Kind?">
<Select name="childId" defaultValue={defaultChildId} required>
<SelectTrigger>
<SelectValue placeholder="Kind auswählen" />
</SelectTrigger>
<SelectContent>
{childOptions.map((child) => (
<SelectItem key={child.id} value={child.id}>
{child.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldLabel>
<FieldLabel label="Grund">
<Select
name="reason"
defaultValue={AbsenceReason.ILLNESS}
required
>
<SelectTrigger>
<SelectValue placeholder="Grund auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value={AbsenceReason.ILLNESS}>Krank</SelectItem>
<SelectItem value={AbsenceReason.VACATION}>
Urlaub
</SelectItem>
<SelectItem value={AbsenceReason.OTHER}>
Sonstiges
</SelectItem>
</SelectContent>
</Select>
</FieldLabel>
<FieldLabel label="Von" htmlFor="absence-startDate">
<Input <Input
id="absence-startDate" id="absence-startDate"
name="startDate" name="startDate"
@@ -126,9 +138,9 @@ export function AbsenceCard({
defaultValue={today} defaultValue={today}
required required
/> />
</div> </FieldLabel>
<div className="grid gap-2">
<Label htmlFor="absence-endDate">Bis</Label> <FieldLabel label="Bis" htmlFor="absence-endDate">
<Input <Input
id="absence-endDate" id="absence-endDate"
name="endDate" name="endDate"
@@ -136,144 +148,188 @@ export function AbsenceCard({
defaultValue={today} defaultValue={today}
required required
/> />
</FieldLabel>
<div className="grid gap-2 md:col-span-2">
<Label
htmlFor="absence-note"
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Bemerkung{" "}
<span className="font-medium text-[var(--ink-faint)]">
optional
</span>
</Label>
<Textarea
id="absence-note"
name="note"
rows={4}
placeholder="z. B. voraussichtlich wieder da am Freitag."
/>
</div> </div>
</div> </div>
<div className="grid gap-2"> <div className="mt-7 flex items-center justify-between border-t border-[var(--hairline-soft)] pt-5">
<Label>Grund</Label> <Button type="reset" variant="ghost">
<Select name="reason" defaultValue={AbsenceReason.ILLNESS} required> Zurücksetzen
<SelectTrigger> </Button>
<SelectValue placeholder="Grund auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value={AbsenceReason.ILLNESS}>Krank</SelectItem>
<SelectItem value={AbsenceReason.VACATION}>Urlaub</SelectItem>
<SelectItem value={AbsenceReason.OTHER}>Sonstiges</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="absence-note">Bemerkung</Label>
<Textarea
id="absence-note"
name="note"
rows={3}
placeholder="Optional, z.B. voraussichtlich wieder da am Freitag."
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />} {isPending ? <Loader2 className="animate-spin" /> : <Check />}
Abwesenheit melden Abwesenheit melden
</Button> </Button>
</div> </div>
</form> </form>
) : ( ) : (
<div className="flex min-h-72 flex-col items-center justify-center rounded-md border border-dashed bg-background/70 p-6 text-center"> <div className="flex min-h-72 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-center">
<Baby className="h-10 w-10 text-muted-foreground" /> <Baby className="h-10 w-10 text-[var(--ink-muted)]" />
<p className="mt-4 font-medium">Noch keine Kinder hinterlegt</p> <p className="mt-4 font-medium text-[var(--ink)]">
<p className="mt-1 text-sm text-muted-foreground"> Noch keine Kinder hinterlegt
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege zuerst deine Kinder im Profil an, dann kannst du sie hier Lege zuerst deine Kinder im Profil an, dann kannst du sie hier
abmelden. abmelden.
</p> </p>
<Button asChild className="mt-4" variant="outline"> <Button asChild className="mt-4" variant="ghost">
<Link href="/dashboard/profil">Zum Profil</Link> <Link href="/dashboard/profil">Zum Profil</Link>
</Button> </Button>
</div> </div>
)} )}
<div className="rounded-md border bg-background/80 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="font-medium">Aktuelle & anstehende Meldungen</p>
<Badge variant="secondary">{absences.length}</Badge>
</div>
{absences.length === 0 ? (
<p className="text-sm text-muted-foreground">
Es sind keine aktuellen oder kommenden Abwesenheiten hinterlegt.
</p>
) : (
<div className="space-y-3">
{absences.map((absence) => (
<AbsenceRow
key={absence.id}
absence={absence}
disabled={isPending}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
function AbsenceRow({ export function UpcomingAbsencesCard({
absence, absences,
disabled,
onDelete,
}: { }: {
absence: AbsenceListItem; absences: AbsenceListItem[];
disabled: boolean;
onDelete: (id: string) => void;
}) { }) {
const range = useMemo(() => { return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle>Anstehende Meldungen</CardTitle>
<CardDescription className="mt-2">
Aktuelle und kommende Abwesenheiten deiner Kinder.
</CardDescription>
</div>
<Badge variant="secondary">{absences.length}</Badge>
</div>
</CardHeader>
<CardContent>
{absences.length === 0 ? (
<div className="flex min-h-36 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--good-soft)] text-[var(--good)]">
<Check className="h-4 w-4" />
</div>
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
Keine offenen Meldungen
</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">
Für die nächsten Tage ist alles ruhig.
</p>
</div>
) : (
<div className="divide-y divide-[var(--hairline-soft)]">
{absences.map((absence) => (
<AbsenceRow key={absence.id} absence={absence} />
))}
</div>
)}
<Link
href="/dashboard/admin/abwesenheiten"
className="mt-4 inline-flex items-center gap-1 text-[13px] font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
Alle Abwesenheiten
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</CardContent>
</Card>
);
}
function FieldLabel({
label,
htmlFor,
children,
}: {
label: string;
htmlFor?: string;
children: React.ReactNode;
}) {
return (
<div className="grid gap-2">
<Label
htmlFor={htmlFor}
className="text-xs font-semibold text-[var(--ink-soft)]"
>
{label}
</Label>
{children}
</div>
);
}
function AbsenceRow({ absence }: { absence: AbsenceListItem }) {
const { day, month, detail } = useMemo(() => {
const start = new Date(`${absence.startDate}T00:00:00`); const start = new Date(`${absence.startDate}T00:00:00`);
const end = new Date(`${absence.endDate}T00:00:00`); const end = new Date(`${absence.endDate}T00:00:00`);
const startLabel = start.toLocaleDateString("de-DE", { const range =
day: "2-digit", absence.startDate === absence.endDate
month: "2-digit", ? "ganztägig"
}); : `${start.toLocaleDateString("de-DE", {
const endLabel = end.toLocaleDateString("de-DE", { day: "2-digit",
day: "2-digit", month: "2-digit",
month: "2-digit", })}-${end.toLocaleDateString("de-DE", {
year: "numeric", day: "2-digit",
}); month: "2-digit",
return absence.startDate === absence.endDate })}`;
? endLabel
: `${startLabel} - ${endLabel}`; return {
day: start.toLocaleDateString("de-DE", { day: "2-digit" }),
month: start
.toLocaleDateString("de-DE", { month: "short" })
.replace(".", ""),
detail: range,
};
}, [absence.endDate, absence.startDate]); }, [absence.endDate, absence.startDate]);
return ( return (
<div className="rounded-md border p-3"> <div className="flex items-center gap-3 py-3">
<div className="flex items-start justify-between gap-3"> <div className="flex h-11 w-11 shrink-0 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] leading-none">
<div className="min-w-0"> <span className="text-base font-medium text-[var(--ink)] [font-variant-numeric:tabular-nums]">
<p className="font-medium">{absence.childName}</p> {day}
<p className="mt-0.5 text-sm text-muted-foreground">{range}</p> </span>
</div> <span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)]">
<Button {month}
type="button" </span>
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
onClick={() => onDelete(absence.id)}
disabled={disabled}
aria-label="Abwesenheit stornieren"
>
<Trash2 className="h-4 w-4" />
</Button>
</div> </div>
<div className="mt-2 flex flex-wrap items-center gap-2"> <div className="min-w-0 flex-1">
<ReasonBadge reason={absence.reason} /> <div className="flex items-start justify-between gap-2">
{absence.note && ( <div className="min-w-0">
<span className="text-xs text-muted-foreground">{absence.note}</span> <p className="truncate text-sm font-semibold text-[var(--ink)]">
)} {absence.childName}
</p>
<p className="mt-1 flex items-center gap-1.5 text-xs text-[var(--ink-soft)]">
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: reasonColor(absence.reason) }}
/>
{reasonLabel(absence.reason)} · {detail}
</p>
</div>
<Badge variant="destructive">Bestätigt</Badge>
</div>
{absence.note ? (
<p className="mt-1 truncate text-xs text-[var(--ink-muted)]">
{absence.note}
</p>
) : null}
</div> </div>
</div> </div>
); );
} }
function ReasonBadge({ reason }: { reason: AbsenceReason }) {
if (reason === AbsenceReason.ILLNESS) {
return <Badge variant="destructive">Krank</Badge>;
}
if (reason === AbsenceReason.VACATION) {
return <Badge variant="warning">Urlaub</Badge>;
}
return <Badge variant="outline">Sonstiges</Badge>;
}
@@ -0,0 +1,441 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { addDays, format, isWeekend, parseISO } from "date-fns";
import { de } from "date-fns/locale";
import {
ArrowLeft,
ArrowRight,
CalendarCheck2,
CalendarDays,
ChevronDown,
ChevronRight,
Stethoscope,
} from "lucide-react";
import { AbsenceReason } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { AbsenceTable, type AbsenceTableItem } from "../absence-table";
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
interface AbsencesClientProps {
initialAbsences: AbsenceTableItem[];
childCount: number;
upcomingAbsenceCount: number;
closure: { title: string; type: string } | null;
dateKey: string;
selectedDateStr: string;
isToday: boolean;
isTooFarFuture: boolean;
isClosedDay: boolean;
todayStr: string;
error: string | null;
}
export function AbsencesClient({
initialAbsences,
childCount,
upcomingAbsenceCount,
closure,
dateKey,
selectedDateStr,
isToday,
isTooFarFuture,
isClosedDay,
todayStr,
error,
}: AbsencesClientProps) {
const router = useRouter();
const selectedDate = parseISO(selectedDateStr);
const today = parseISO(todayStr);
// Filters State
const [selectedGroup, setSelectedGroup] = useState<"all" | "Krippe" | "Elementar">("all");
const [selectedReason, setSelectedReason] = useState<"all" | AbsenceReason>("all");
// Dropdown UI state
const [isGroupOpen, setIsGroupOpen] = useState(false);
const [isReasonOpen, setIsReasonOpen] = useState(false);
// Date input state
const [inputDate, setInputDate] = useState(dateKey);
// Group resolver function
const mockGroup = (childName: string) => {
const charCode = childName.charCodeAt(0);
return charCode % 2 === 0 ? "Krippe" : "Elementar";
};
// Filter computation
const filteredAbsences = useMemo(() => {
return initialAbsences.filter((item) => {
if (selectedGroup !== "all" && mockGroup(item.childName) !== selectedGroup) {
return false;
}
if (selectedReason !== "all" && item.reason !== selectedReason) {
return false;
}
return true;
});
}, [initialAbsences, selectedGroup, selectedReason]);
// Recalculate reasons count based on filtered absences
const countsByReason = useMemo(() => {
return filteredAbsences.reduce<Partial<Record<AbsenceReason, number>>>((acc, item) => {
acc[item.reason] = (acc[item.reason] ?? 0) + 1;
return acc;
}, {});
}, [filteredAbsences]);
const handleDateNavigate = (targetDate: Date) => {
const formatted = format(targetDate, "yyyy-MM-dd");
setInputDate(formatted);
router.push(`/dashboard/admin/abwesenheiten?date=${formatted}`);
};
const handleDateFormSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputDate) {
router.push(`/dashboard/admin/abwesenheiten?date=${inputDate}`);
}
};
return (
<div>
<header className="mb-7 flex items-end justify-between gap-8 max-[1100px]:flex-col max-[1100px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
TAGESÜBERSICHT
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Heute fehlen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
{filteredAbsences.length} Kinder
</em>
</h1>
<p className="mt-2 text-[15px] leading-6 text-[var(--ink-soft)]">
{format(selectedDate, "EEEE, d. MMMM yyyy", { locale: de })}
</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<Stethoscope className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{childCount - filteredAbsences.length} anwesend
</span>
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<button
onClick={() => handleDateNavigate(addDays(selectedDate, -1))}
className="inline-flex h-10 items-center gap-2 border-r border-[var(--hairline)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--surface-2)] hover:text-[var(--ink)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zurück
</button>
<button
onClick={() => handleDateNavigate(today)}
className={`inline-flex h-10 items-center gap-2 border-r border-[var(--hairline)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--surface-2)] hover:text-[var(--ink)] ${
isToday ? "bg-[var(--accent-soft)] text-[var(--accent-deep)]" : ""
}`}
>
Heute
</button>
<button
onClick={() => handleDateNavigate(addDays(selectedDate, 1))}
className="inline-flex h-10 items-center gap-2 px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--surface-2)] hover:text-[var(--ink)]"
>
Vor
<ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
<form onSubmit={handleDateFormSubmit} className="flex items-center gap-2">
<Input
type="date"
name="date"
value={inputDate}
onChange={(e) => setInputDate(e.target.value)}
className="w-[140px]"
/>
<Button type="submit">Anzeigen</Button>
</form>
</div>
</header>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{/* Group Dropdown Filter */}
<div className="relative">
<button
type="button"
onClick={() => setIsGroupOpen(!isGroupOpen)}
className={`inline-flex h-9 items-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] ${
selectedGroup !== "all"
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
{selectedGroup === "all" ? "Alle Gruppen" : selectedGroup}
<ChevronDown className="h-3.5 w-3.5" />
</button>
{isGroupOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsGroupOpen(false)} />
<div className="absolute left-0 mt-1.5 w-44 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] py-1.5 shadow-lg z-20">
<button
type="button"
onClick={() => {
setSelectedGroup("all");
setIsGroupOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedGroup === "all"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Alle Gruppen
</button>
<button
type="button"
onClick={() => {
setSelectedGroup("Krippe");
setIsGroupOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedGroup === "Krippe"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Krippe (Mock)
</button>
<button
type="button"
onClick={() => {
setSelectedGroup("Elementar");
setIsGroupOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedGroup === "Elementar"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Elementar (Mock)
</button>
</div>
</>
)}
</div>
{/* Reason Dropdown Filter */}
<div className="relative">
<button
type="button"
onClick={() => setIsReasonOpen(!isReasonOpen)}
className={`inline-flex h-9 items-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] ${
selectedReason !== "all"
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
{selectedReason === "all" ? "Alle Gründe" : reasonLabel(selectedReason)}
<ChevronDown className="h-3.5 w-3.5" />
</button>
{isReasonOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsReasonOpen(false)} />
<div className="absolute left-0 mt-1.5 w-44 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] py-1.5 shadow-lg z-20">
<button
type="button"
onClick={() => {
setSelectedReason("all");
setIsReasonOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedReason === "all"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Alle Gründe
</button>
<button
type="button"
onClick={() => {
setSelectedReason(AbsenceReason.ILLNESS);
setIsReasonOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedReason === AbsenceReason.ILLNESS
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Krankheit
</button>
<button
type="button"
onClick={() => {
setSelectedReason(AbsenceReason.VACATION);
setIsReasonOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedReason === AbsenceReason.VACATION
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Urlaub
</button>
<button
type="button"
onClick={() => {
setSelectedReason(AbsenceReason.OTHER);
setIsReasonOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedReason === AbsenceReason.OTHER
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Sonstiges
</button>
</div>
</>
)}
</div>
</div>
{filteredAbsences.length > 0 ? (
<div className="flex flex-wrap items-center gap-4 text-xs text-[var(--ink-soft)]">
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(AbsenceReason.ILLNESS) }}
/>
{reasonLabel(AbsenceReason.ILLNESS)} {countsByReason[AbsenceReason.ILLNESS] ?? 0}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(AbsenceReason.OTHER) }}
/>
{reasonLabel(AbsenceReason.OTHER)} {countsByReason[AbsenceReason.OTHER] ?? 0}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(AbsenceReason.VACATION) }}
/>
{reasonLabel(AbsenceReason.VACATION)} {countsByReason[AbsenceReason.VACATION] ?? 0}
</span>
<span>
letzte Aktualisierung {format(new Date(), "HH:mm", { locale: de })}
</span>
</div>
) : null}
</div>
<div className="mt-4">
{error ? (
<Card className="border-[var(--danger)] bg-[var(--danger-soft)]">
<CardContent className="pt-[22px] text-sm text-[var(--danger)]">
{error}
</CardContent>
</Card>
) : filteredAbsences.length > 0 ? (
<Card>
<CardHeader className="pb-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle>
{filteredAbsences.length} von {childCount} Kindern abwesend ·{" "}
{upcomingAbsenceCount} anstehend
</CardTitle>
<Badge variant="secondary">{dateKey}</Badge>
</div>
</CardHeader>
<CardContent>
<AbsenceTable absences={filteredAbsences} />
</CardContent>
</Card>
) : isTooFarFuture ? (
<EmptyState
tone="accent"
title="Noch nicht im Vorschauzeitraum"
description="Abwesenheiten werden erst 30 Tage im Voraus angezeigt."
/>
) : isClosedDay ? (
<EmptyState
tone="accent"
title={`Kita geschlossen am ${format(selectedDate, "d. MMMM", {
locale: de,
})}`}
description={
closure
? closure.title
: "Wochenende · für diesen Tag werden keine Anwesenheiten erwartet."
}
/>
) : (
<EmptyState
tone="good"
title="Keine Abwesenheiten"
description="Für diesen Tag sind aktuell keine Kinder abgemeldet."
/>
)}
</div>
</div>
);
}
function EmptyState({
tone,
title,
description,
}: {
tone: "good" | "accent";
title: string;
description: string;
}) {
const isGood = tone === "good";
return (
<Card>
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-8 py-12 text-center">
<div
className={[
"flex h-12 w-12 items-center justify-center rounded-2xl",
isGood
? "bg-[var(--good-soft)] text-[var(--good)]"
: "bg-[var(--accent-soft)] text-[var(--accent-deep)]",
].join(" ")}
>
{isGood ? (
<CalendarCheck2 className="h-6 w-6 [stroke-width:1.7]" />
) : (
<CalendarDays className="h-6 w-6 [stroke-width:1.7]" />
)}
</div>
<h2 className="mt-5 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
{title}
</h2>
<p className="mt-2 max-w-md text-sm text-[var(--ink-soft)]">
{description}
</p>
<Link
href="/dashboard/admin/abwesenheiten"
className="mt-5 inline-flex items-center gap-1 text-sm font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
Wochenübersicht öffnen
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</CardContent>
</Card>
);
}
@@ -0,0 +1,28 @@
import { AbsenceReason } from "@prisma/client";
export function reasonLabel(reason: AbsenceReason) {
if (reason === AbsenceReason.ILLNESS) return "Krank";
if (reason === AbsenceReason.VACATION) return "Urlaub";
return "Sonstiges";
}
export function reasonColor(reason: AbsenceReason) {
if (reason === AbsenceReason.ILLNESS) return "var(--danger)";
if (reason === AbsenceReason.VACATION) return "var(--warn)";
return "var(--accent)";
}
export function formatDateRange(startDate: string, endDate: string) {
const start = new Date(`${startDate}T00:00:00`);
const end = new Date(`${endDate}T00:00:00`);
const startLabel = start.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
});
const endLabel = end.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
}
@@ -0,0 +1,195 @@
"use client";
import { useState } from "react";
import { AbsenceReason } from "@prisma/client";
import { CalendarDays, Circle, MessageSquareText, UserRound } from "lucide-react";
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export type AbsenceTableItem = {
id: string;
childName: string;
familyName: string;
reason: AbsenceReason;
note: string | null;
startDate: string;
endDate: string;
};
export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
const [activeAbsence, setActiveAbsence] = useState<AbsenceTableItem | null>(
null,
);
return (
<>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Kind</TableHead>
<TableHead>Zeitraum</TableHead>
<TableHead>Grund</TableHead>
<TableHead>Bemerkung</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{absences.map((absence) => (
<TableRow
key={absence.id}
className="cursor-pointer border-[var(--hairline-soft)] hover:bg-[var(--surface-2)]"
onClick={() => setActiveAbsence(absence)}
>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-xs font-semibold text-[var(--accent-deep)]">
{getInitials(absence.childName)}
</div>
<div className="min-w-0">
<p className="truncate font-semibold text-[var(--ink)]">
{absence.childName}
</p>
<p className="truncate text-xs text-[var(--ink-soft)]">
{absence.familyName}
</p>
</div>
</div>
</TableCell>
<TableCell className="text-[var(--ink-soft)]">
{formatDateRange(absence.startDate, absence.endDate)}
</TableCell>
<TableCell>
<span className="inline-flex items-center gap-2 text-[var(--ink)]">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(absence.reason) }}
/>
{reasonLabel(absence.reason)}
</span>
</TableCell>
<TableCell className="max-w-[280px] truncate text-[var(--ink-soft)]">
{absence.note || "Keine Bemerkung hinterlegt."}
</TableCell>
<TableCell>
<Badge variant="success">Bestätigt</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Dialog
open={!!activeAbsence}
onOpenChange={(open) => !open && setActiveAbsence(null)}
>
<DialogContent className="left-auto right-0 top-0 h-full max-w-md translate-x-0 translate-y-0 rounded-none border-y-0 border-r-0 border-[var(--hairline)] bg-[var(--surface)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right">
{activeAbsence ? (
<>
<DialogHeader>
<DialogTitle className="text-2xl font-medium tracking-[-0.02em] text-[var(--ink)]">
{activeAbsence.childName}
</DialogTitle>
<DialogDescription>{activeAbsence.familyName}</DialogDescription>
</DialogHeader>
<div className="mt-4 grid gap-4">
<DetailLine
icon={CalendarDays}
label="Zeitraum"
value={formatDateRange(
activeAbsence.startDate,
activeAbsence.endDate,
)}
/>
<DetailLine
icon={Circle}
label="Grund"
value={reasonLabel(activeAbsence.reason)}
dotColor={reasonColor(activeAbsence.reason)}
/>
<DetailLine
icon={MessageSquareText}
label="Bemerkung"
value={activeAbsence.note || "Keine Bemerkung hinterlegt."}
/>
<DetailLine icon={UserRound} label="Status" value="Bestätigt" />
</div>
</>
) : null}
</DialogContent>
</Dialog>
</>
);
}
function DetailLine({
icon: Icon,
label,
value,
dotColor,
}: {
icon: typeof CalendarDays;
label: string;
value: string;
dotColor?: string;
}) {
return (
<div className="rounded-lg border border-[var(--hairline-soft)] bg-[var(--surface-2)] p-3">
<p className="flex items-center gap-2 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
{dotColor ? (
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: dotColor }}
/>
) : (
<Icon className="h-3.5 w-3.5 [stroke-width:1.7]" />
)}
{label}
</p>
<p className="mt-2 text-sm font-medium text-[var(--ink)]">{value}</p>
</div>
);
}
function getInitials(name: string) {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
}
export function formatDateRange(startDate: string, endDate: string) {
const start = new Date(`${startDate}T00:00:00`);
const end = new Date(`${endDate}T00:00:00`);
const startLabel = start.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
});
const endLabel = end.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
}
+69 -142
View File
@@ -1,164 +1,91 @@
import Link from "next/link"; import { differenceInCalendarDays, format, isWeekend } from "date-fns";
import { addDays, format } from "date-fns"; import { TerminStatus, TerminType, UserRole } from "@prisma/client";
import { de } from "date-fns/locale";
import { ArrowLeft, ArrowRight, CalendarDays, Stethoscope } from "lucide-react";
import { AbsenceReason } from "@prisma/client";
import { getDailyAbsences } from "@/actions/absences"; import { getDailyAbsences } from "@/actions/absences";
import { Badge } from "@/components/ui/badge"; import { requireRole } from "@/lib/auth-utils";
import { Button } from "@/components/ui/button"; import { prisma } from "@/lib/prisma";
import { import { AbsencesClient } from "./_components/absences-client";
Card, import { type AbsenceTableItem } from "./absence-table";
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
export const metadata = { title: "Abwesenheiten · Kita-Planer" }; export const metadata = { title: "Abwesenheiten · Kita-Planer" };
export default async function AdminAbwesenheitenPage({ export default async function AdminAbwesenheitenPage({
searchParams, searchParams,
}: { }: {
searchParams: { date?: string }; searchParams: Promise<{ date?: string }>;
}) { }) {
const selectedDate = parseDateParam(searchParams.date); const session = await requireRole([
UserRole.ADMIN,
UserRole.KOORDINATOR,
UserRole.ERZIEHER,
]);
const resolvedSearchParams = await searchParams;
const kitaId = session.user.kitaId!;
const today = startOfLocalDay(new Date());
const selectedDate = parseDateParam(resolvedSearchParams.date, today);
const dateKey = format(selectedDate, "yyyy-MM-dd"); const dateKey = format(selectedDate, "yyyy-MM-dd");
const result = await getDailyAbsences({ date: dateKey }); const result = await getDailyAbsences({ date: dateKey });
const absences = result.absences ?? []; const absences = result.absences ?? [];
const childCount = await prisma.child.count({
where: { kitaId, active: true },
});
const upcomingAbsenceCount = await prisma.absence.count({
where: {
kitaId,
startDate: { gt: selectedDate },
},
});
const closure = await prisma.termin.findFirst({
where: {
kitaId,
status: TerminStatus.CONFIRMED,
type: { in: [TerminType.SCHLIESSTAG, TerminType.TEAMTAG] },
startDate: { lte: selectedDate },
endDate: { gte: selectedDate },
},
select: { title: true, type: true },
orderBy: { startDate: "asc" },
});
const isToday = dateKey === format(today, "yyyy-MM-dd");
const isTooFarFuture = differenceInCalendarDays(selectedDate, today) > 30;
const isClosedDay = isWeekend(selectedDate) || !!closure;
const tableItems: AbsenceTableItem[] = absences.map((absence) => ({
id: absence.id,
childName: absence.childName,
familyName: absence.familyName,
reason: absence.reason,
note: absence.note,
startDate: absence.startDate,
endDate: absence.endDate,
}));
return ( return (
<div className="min-h-full bg-slate-50 px-4 py-6 sm:px-8"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="mx-auto max-w-5xl space-y-6"> <AbsencesClient
<div className="flex flex-col gap-4 rounded-lg border bg-background p-5 shadow-sm lg:flex-row lg:items-center lg:justify-between"> initialAbsences={tableItems}
<div> childCount={childCount}
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> upcomingAbsenceCount={upcomingAbsenceCount}
<Stethoscope className="h-4 w-4" /> closure={closure}
Tagesübersicht dateKey={dateKey}
</div> selectedDateStr={selectedDate.toISOString()}
<h1 className="mt-2 text-3xl font-bold tracking-tight"> isToday={isToday}
Heute fehlen ({absences.length} Kinder) isTooFarFuture={isTooFarFuture}
</h1> isClosedDay={isClosedDay}
<p className="mt-1 text-sm text-muted-foreground"> todayStr={today.toISOString()}
{format(selectedDate, "EEEE, dd. MMMM yyyy", { locale: de })} error={result.error ?? null}
</p> />
</div>
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href={hrefForDate(addDays(selectedDate, -1))}>
<ArrowLeft className="h-4 w-4" />
Zurück
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/dashboard/admin/abwesenheiten">Heute</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={hrefForDate(addDays(selectedDate, 1))}>
Vor
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<form className="flex items-center gap-2">
<Input
type="date"
name="date"
defaultValue={dateKey}
className="h-9 w-40"
/>
<Button type="submit" size="sm">
Anzeigen
</Button>
</form>
</div>
</div>
{result.error ? (
<Card className="border-destructive/30">
<CardContent className="p-6 text-sm text-destructive">
{result.error}
</CardContent>
</Card>
) : absences.length === 0 ? (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-background p-8 text-center">
<div className="mx-auto max-w-md">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
<CalendarDays className="h-10 w-10" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
Keine Abwesenheiten
</h2>
<p className="mt-2 text-sm text-muted-foreground">
Für diesen Tag sind aktuell keine Kinder abgemeldet.
</p>
</div>
</div>
) : (
<div className="grid gap-3">
{absences.map((absence) => (
<Card key={absence.id} className="overflow-hidden">
<CardHeader className="flex flex-col gap-3 p-5 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="text-xl">
{absence.childName}
</CardTitle>
<CardDescription className="mt-1">
{absence.familyName}
</CardDescription>
</div>
<ReasonBadge reason={absence.reason} />
</CardHeader>
<CardContent className="grid gap-3 px-5 pb-5 sm:grid-cols-[180px_1fr]">
<div className="text-sm">
<p className="font-medium">Zeitraum</p>
<p className="text-muted-foreground">
{formatDateRange(absence.startDate, absence.endDate)}
</p>
</div>
<div className="text-sm">
<p className="font-medium">Notiz</p>
<p className="text-muted-foreground">
{absence.note || "Keine Bemerkung hinterlegt."}
</p>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div> </div>
); );
} }
function parseDateParam(value: string | undefined) { function parseDateParam(value: string | undefined, fallback: Date) {
if (!value) return new Date(); if (!value) return fallback;
const parsed = new Date(`${value}T00:00:00`); const parsed = new Date(`${value}T00:00:00`);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed; return Number.isNaN(parsed.getTime()) ? fallback : parsed;
} }
function hrefForDate(date: Date) { function startOfLocalDay(date: Date) {
return `/dashboard/admin/abwesenheiten?date=${format(date, "yyyy-MM-dd")}`; const nextDate = new Date(date);
} nextDate.setHours(0, 0, 0, 0);
return nextDate;
function formatDateRange(startDate: string, endDate: string) {
const start = new Date(`${startDate}T00:00:00`);
const end = new Date(`${endDate}T00:00:00`);
const startLabel = format(start, "dd.MM.", { locale: de });
const endLabel = format(end, "dd.MM.yyyy", { locale: de });
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
}
function ReasonBadge({ reason }: { reason: AbsenceReason }) {
if (reason === AbsenceReason.ILLNESS) {
return <Badge variant="destructive">Krank</Badge>;
}
if (reason === AbsenceReason.VACATION) {
return <Badge variant="warning">Urlaub</Badge>;
}
return <Badge variant="outline">Sonstiges</Badge>;
} }
@@ -116,9 +116,11 @@ export function DutyPlanner({
if (dutyTypes.length === 0) { if (dutyTypes.length === 0) {
return ( return (
<div className="rounded-md border border-dashed p-8 text-center"> <div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-8 text-center">
<p className="text-sm font-medium">Noch keine Diensttypen</p> <p className="text-sm font-medium text-[var(--ink)]">
<p className="mt-1 text-sm text-muted-foreground"> Noch keine Diensttypen
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege zuerst einen Diensttyp an, danach erscheint hier das Planungsgrid. Lege zuerst einen Diensttyp an, danach erscheint hier das Planungsgrid.
</p> </p>
</div> </div>
@@ -136,29 +138,29 @@ export function DutyPlanner({
disabled={isPending} disabled={isPending}
> >
{isPending ? ( {isPending ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : ( ) : (
<BellRing className="h-4 w-4" /> <BellRing className="h-4 w-4 [stroke-width:1.7]" />
)} )}
Reminder diese Woche senden Reminder diese Woche senden
</Button> </Button>
</div> </div>
<div className="overflow-x-auto rounded-md border"> <div className="overflow-x-auto rounded-lg border border-[var(--hairline)]">
<table className="w-full min-w-[760px] border-collapse text-sm"> <table className="w-full min-w-[760px] border-collapse text-[13.5px] text-[var(--ink)]">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b border-[var(--hairline-soft)] bg-[var(--surface-2)]">
<th className="sticky left-0 z-10 w-56 bg-muted/95 px-4 py-3 text-left font-medium"> <th className="sticky left-0 z-10 w-56 bg-[var(--surface-2)] px-4 py-3 text-left text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Woche Woche
</th> </th>
{dutyTypes.map((dutyType) => ( {dutyTypes.map((dutyType) => (
<th <th
key={dutyType.id} key={dutyType.id}
className="min-w-56 px-4 py-3 text-left font-medium" className="min-w-56 px-4 py-3 text-left font-medium text-[var(--ink)]"
> >
<div>{dutyType.name}</div> <div>{dutyType.name}</div>
{dutyType.description && ( {dutyType.description && (
<div className="mt-1 line-clamp-1 text-xs font-normal text-muted-foreground"> <div className="mt-1 line-clamp-1 text-xs font-normal text-[var(--ink-muted)]">
{dutyType.description} {dutyType.description}
</div> </div>
)} )}
@@ -168,8 +170,11 @@ export function DutyPlanner({
</thead> </thead>
<tbody> <tbody>
{weeks.map((week) => ( {weeks.map((week) => (
<tr key={week.key} className="border-b last:border-b-0"> <tr
<td className="sticky left-0 z-10 bg-background px-4 py-3 font-medium"> key={week.key}
className="border-b border-[var(--hairline-soft)] last:border-b-0 hover:bg-[var(--surface-2)]"
>
<td className="sticky left-0 z-10 bg-[var(--surface)] px-4 py-3 font-medium">
{week.label} {week.label}
</td> </td>
{dutyTypes.map((dutyType) => { {dutyTypes.map((dutyType) => {
+83 -70
View File
@@ -1,7 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { addWeeks, format, getISOWeek, startOfDay, startOfWeek } from "date-fns"; import { addWeeks, format, getISOWeek, startOfDay, startOfWeek } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { CalendarRange, ClipboardList, Plus, Trash2 } from "lucide-react"; import { CalendarRange, Plus, Trash2 } from "lucide-react";
import { UserRole } from "@prisma/client"; import { UserRole } from "@prisma/client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -34,15 +34,16 @@ const MAX_WEEK_COUNT = 104;
export default async function DienstePage({ export default async function DienstePage({
searchParams, searchParams,
}: { }: {
searchParams: { start?: string; weeks?: string }; searchParams: Promise<{ start?: string; weeks?: string }>;
}) { }) {
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]); const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
if (!session.user.kitaId) { if (!session.user.kitaId) {
throw new Error("Kein Mandant zugeordnet."); throw new Error("Kein Mandant zugeordnet.");
} }
const rangeStart = getRangeStart(searchParams.start); const resolvedSearchParams = await searchParams;
const weekCount = getWeekCount(searchParams.weeks); const rangeStart = getRangeStart(resolvedSearchParams.start);
const weekCount = getWeekCount(resolvedSearchParams.weeks);
const weeks = buildWeeks(rangeStart, weekCount); const weeks = buildWeeks(rangeStart, weekCount);
const rangeEnd = startOfDay(new Date(weeks[weeks.length - 1].endDate)); const rangeEnd = startOfDay(new Date(weeks[weeks.length - 1].endDate));
@@ -83,77 +84,88 @@ export default async function DienstePage({
const dutyTypeDtos: DutyPlanDutyTypeDto[] = dutyTypes; const dutyTypeDtos: DutyPlanDutyTypeDto[] = dutyTypes;
const familyDtos: DutyPlanFamilyDto[] = families; const familyDtos: DutyPlanFamilyDto[] = families;
const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map((assignment) => ({ const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map(
id: assignment.id, (assignment) => ({
dutyTypeId: assignment.dutyTypeId, id: assignment.id,
familyId: assignment.familyId, dutyTypeId: assignment.dutyTypeId,
startDate: toDateKey(assignment.startDate), familyId: assignment.familyId,
})); startDate: toDateKey(assignment.startDate),
}),
);
return ( return (
<div className="space-y-6 p-6"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div> <div>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<ClipboardList className="h-4 w-4" /> ADMIN · DIENSTPLAN
Admin </p>
</div> <h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
<h1 className="mt-2 text-2xl font-bold tracking-tight"> Dienste{" "}
Dienstplan-Manager <em className="font-normal italic text-[var(--accent-deep)]">
planen
</em>
</h1> </h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground"> <p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Plane Dienste pro Kalenderwoche und Haushalt. Jede Änderung wird Plane Dienste pro Kalenderwoche und Haushalt. Jede Änderung wird
direkt gespeichert. direkt gespeichert.
</p> </p>
</div> </div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{dutyTypes.length} Diensttypen
</span>
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{families.length} Familien
</span>
</div>
</header>
<Card className="w-full lg:max-w-xl"> <Card className="mb-5">
<CardHeader className="pb-3"> <CardContent className="pt-[22px]">
<CardTitle className="flex items-center gap-2 text-base"> <form className="flex flex-wrap items-end gap-3">
<CalendarRange className="h-4 w-4" /> <div className="mr-auto min-w-64">
Zeitraum <div className="flex items-center gap-2 text-base font-medium tracking-[-0.02em] text-[var(--ink)]">
</CardTitle> <CalendarRange className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
<CardDescription> Zeitraum
Du kannst beliebig weit voraus planen, jeweils in handlichen
Ausschnitten.
</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="grid gap-1.5">
<Label htmlFor="start">Startwoche</Label>
<Input
id="start"
name="start"
type="date"
defaultValue={toDateInputValue(rangeStart)}
/>
</div> </div>
<div className="grid gap-1.5"> <p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
<Label htmlFor="weeks">Wochen</Label> {weeks[0].label} bis {weeks[weeks.length - 1].label}
<Input </p>
id="weeks" </div>
name="weeks" <div className="grid gap-1.5">
type="number" <Label htmlFor="start">Startwoche</Label>
min={1} <Input
max={MAX_WEEK_COUNT} id="start"
defaultValue={weekCount} name="start"
/> type="date"
</div> defaultValue={toDateInputValue(rangeStart)}
<Button type="submit">Anzeigen</Button> />
<Button asChild type="button" variant="outline"> </div>
<Link <div className="grid w-28 gap-1.5">
href={`/dashboard/admin/dienste?start=${toDateInputValue( <Label htmlFor="weeks">Wochen</Label>
rangeStart, <Input
)}&weeks=${Math.min(weekCount + 12, MAX_WEEK_COUNT)}`} id="weeks"
> name="weeks"
Weitere Wochen laden type="number"
</Link> min={1}
</Button> max={MAX_WEEK_COUNT}
</form> defaultValue={weekCount}
</CardContent> />
</Card> </div>
</div> <Button type="submit">Anzeigen</Button>
<Button asChild type="button" variant="ghost">
<Link
href={`/dashboard/admin/dienste?start=${toDateInputValue(
rangeStart,
)}&weeks=${Math.min(weekCount + 12, MAX_WEEK_COUNT)}`}
>
Weitere Wochen laden
</Link>
</Button>
</form>
</CardContent>
</Card>
<div className="grid gap-6 xl:grid-cols-[340px_1fr]"> <div className="grid gap-6 xl:grid-cols-[340px_1fr]">
<Card> <Card>
@@ -186,19 +198,19 @@ export default async function DienstePage({
<div className="space-y-2"> <div className="space-y-2">
{dutyTypes.length === 0 ? ( {dutyTypes.length === 0 ? (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground"> <p className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3 text-sm italic text-[var(--ink-faint)]">
Noch keine Diensttypen angelegt. Noch keine Diensttypen angelegt.
</p> </p>
) : ( ) : (
dutyTypes.map((dutyType) => ( dutyTypes.map((dutyType) => (
<div <div
key={dutyType.id} key={dutyType.id}
className="flex items-start justify-between gap-3 rounded-md border p-3" className="flex items-start justify-between gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
> >
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium">{dutyType.name}</p> <p className="font-medium text-[var(--ink)]">{dutyType.name}</p>
{dutyType.description && ( {dutyType.description && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground"> <p className="mt-1 line-clamp-2 text-xs text-[var(--ink-soft)]">
{dutyType.description} {dutyType.description}
</p> </p>
)} )}
@@ -213,9 +225,10 @@ export default async function DienstePage({
type="submit" type="submit"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
aria-label={`${dutyType.name} löschen`} aria-label={`${dutyType.name} löschen`}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button> </Button>
</form> </form>
</div> </div>
@@ -0,0 +1,638 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import {
EventParticipationStatus,
TerminStatus,
TerminType,
} from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import {
CalendarPlus,
Check,
Clock,
HelpCircle,
Pencil,
Users,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import {
createTerminAdmin,
updateTerminAdmin,
} from "../../kalender/actions";
type AdminTerminDto = {
id: string;
title: string;
description: string | null;
type: TerminType;
status: TerminStatus;
startDate: string;
endDate: string;
allDay: boolean;
mitbringselListEnabled: boolean;
requiresRsvp: boolean;
rsvpDeadline: string | null;
participantInfo: string | null;
participations: {
childId: string;
status: EventParticipationStatus;
}[];
};
type ChildDto = {
id: string;
name: string;
familyName: string;
};
export function AdminCalendarManager({
termine,
childRows,
}: {
termine: AdminTerminDto[];
childRows: ChildDto[];
}) {
const rsvpCount = termine.filter((termin) => termin.requiresRsvp).length;
const pendingCount = termine.filter(
(termin) => termin.status === TerminStatus.PENDING,
).length;
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
ADMIN · EVENT-ANMELDUNGEN
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Termine{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
koordinieren
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind
verfolgen.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{termine.length} Termine
</span>
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{rsvpCount} RSVP
</span>
{pendingCount > 0 && (
<span className="inline-flex h-9 items-center rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-3 text-[13px] font-semibold text-[var(--warn)]">
{pendingCount} ausstehend
</span>
)}
<TerminFormDialog mode="create" />
</div>
</header>
{termine.length === 0 ? (
<Card className="border-dashed">
<div className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<CalendarPlus className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h2 className="mt-4 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Noch keine Termine
</h2>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
</p>
</div>
</Card>
) : (
<div className="grid gap-4">
{termine.map((termin) => (
<AdminTerminCard
key={termin.id}
termin={termin}
childRows={childRows}
/>
))}
</div>
)}
</div>
);
}
function AdminTerminCard({
termin,
childRows,
}: {
termin: AdminTerminDto;
childRows: ChildDto[];
}) {
const stats = useMemo(() => getStats(termin, childRows), [termin, childRows]);
return (
<Card className="overflow-hidden">
<CardHeader className="border-b border-[var(--hairline-soft)] bg-[var(--surface-2)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="mb-2 flex flex-wrap gap-2">
<Badge className={getTypeBadgeClass(termin.type)}>
{getTypeLabel(termin.type)}
</Badge>
<Badge variant={getStatusVariant(termin.status)}>
{getStatusLabel(termin.status)}
</Badge>
{termin.requiresRsvp && (
<Badge variant="warning">RSVP aktiv</Badge>
)}
</div>
<CardTitle className="text-lg">{termin.title}</CardTitle>
<CardDescription className="mt-1 flex flex-wrap items-center gap-2 text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(new Date(termin.startDate), "dd.MM.yyyy", {
locale: de,
})}
{!termin.allDay && (
<>
<span>·</span>
<span>
{format(new Date(termin.startDate), "HH:mm", {
locale: de,
})}{" "}
bis{" "}
{format(new Date(termin.endDate), "HH:mm", {
locale: de,
})}
</span>
</>
)}
</CardDescription>
</div>
<TerminFormDialog mode="edit" termin={termin} />
</div>
</CardHeader>
<CardContent className="grid gap-5 pt-5">
{termin.description && (
<p className="whitespace-pre-wrap text-sm text-[var(--ink-soft)]">
{termin.description}
</p>
)}
{termin.requiresRsvp ? (
<div className="grid gap-4">
<div className="flex flex-col gap-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-[var(--ink)] sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
{stats.pending} Ausstehend
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Frist:{" "}
{termin.rsvpDeadline
? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", {
locale: de,
})
: "bis Terminbeginn"}
</p>
</div>
<Users className="h-5 w-5 shrink-0 text-[var(--warn)] [stroke-width:1.7]" />
</div>
{termin.participantInfo && (
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
<span className="font-medium">Hinweis für Teilnehmer: </span>
{termin.participantInfo}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>Kind</TableHead>
<TableHead>Familie</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{childRows.map((child) => {
const status = stats.statusByChildId.get(child.id) ?? null;
return (
<TableRow key={child.id}>
<TableCell className="font-medium">{child.name}</TableCell>
<TableCell>{child.familyName}</TableCell>
<TableCell>
<RsvpStatusBadge status={status} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
) : (
<div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm italic text-[var(--ink-faint)]">
Für diesen Termin ist keine Teilnahme-Abfrage aktiv.
</div>
)}
</CardContent>
</Card>
);
}
function TerminFormDialog({
mode,
termin,
}: {
mode: "create" | "edit";
termin?: AdminTerminDto;
}) {
const [open, setOpen] = useState(false);
const [requiresRsvp, setRequiresRsvp] = useState(
termin?.requiresRsvp ?? false,
);
const [isPending, startTransition] = useTransition();
function handleAction(formData: FormData) {
const startDate = new Date(String(formData.get("startDate")));
const endDate = new Date(String(formData.get("endDate")));
const deadlineRaw = String(formData.get("rsvpDeadline") ?? "");
const hasRsvp = formData.get("requiresRsvp") === "on";
const payload = {
title: String(formData.get("title") ?? ""),
description: String(formData.get("description") ?? ""),
type: formData.get("type") as TerminType,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
allDay: formData.get("allDay") === "on",
mitbringselListEnabled: formData.get("mitbringselListEnabled") === "on",
requiresRsvp: hasRsvp,
rsvpDeadline:
hasRsvp && deadlineRaw ? new Date(deadlineRaw).toISOString() : null,
participantInfo: String(formData.get("participantInfo") ?? ""),
};
startTransition(async () => {
const result =
mode === "edit" && termin
? await updateTerminAdmin(termin.id, payload)
: await createTerminAdmin(payload);
if (result.error) {
toast.error(result.error);
return;
}
toast.success(
mode === "edit" ? "Termin aktualisiert." : "Termin angelegt.",
);
setOpen(false);
});
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen) {
setRequiresRsvp(termin?.requiresRsvp ?? false);
}
}}
>
<DialogTrigger asChild>
<Button variant={mode === "edit" ? "pill" : "default"} size="sm">
{mode === "edit" ? (
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
) : (
<CalendarPlus className="h-4 w-4 [stroke-width:1.7]" />
)}
{mode === "edit" ? "Bearbeiten" : "Termin anlegen"}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<form action={handleAction}>
<DialogHeader>
<DialogTitle>
{mode === "edit" ? "Termin bearbeiten" : "Termin anlegen"}
</DialogTitle>
<DialogDescription>
RSVP kann für Ausflüge und besondere Events aktiviert werden.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor={`${mode}-title`}>Titel</Label>
<Input
id={`${mode}-title`}
name="title"
defaultValue={termin?.title ?? ""}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-description`}>Beschreibung</Label>
<Textarea
id={`${mode}-description`}
name="description"
defaultValue={termin?.description ?? ""}
rows={3}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-type`}>Kategorie</Label>
<select
id={`${mode}-type`}
name="type"
defaultValue={termin?.type ?? TerminType.KITA_FEST}
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
required
>
{Object.values(TerminType).map((type) => (
<option key={type} value={type}>
{getTypeLabel(type)}
</option>
))}
</select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor={`${mode}-startDate`}>Start</Label>
<Input
id={`${mode}-startDate`}
name="startDate"
type="datetime-local"
defaultValue={
termin ? toDatetimeLocalValue(termin.startDate) : ""
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-endDate`}>Ende</Label>
<Input
id={`${mode}-endDate`}
name="endDate"
type="datetime-local"
defaultValue={
termin ? toDatetimeLocalValue(termin.endDate) : ""
}
required
/>
</div>
</div>
<div className="grid gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="allDay"
defaultChecked={termin?.allDay ?? false}
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
/>
Ganztägiger Termin
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="mitbringselListEnabled"
defaultChecked={termin?.mitbringselListEnabled ?? false}
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
/>
Mitbring-Liste aktiv
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="requiresRsvp"
checked={requiresRsvp}
onChange={(event) => setRequiresRsvp(event.target.checked)}
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
/>
Teilnahme-Abfrage (RSVP) aktivieren
</label>
</div>
{requiresRsvp && (
<div className="grid gap-4 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
<div className="grid gap-2">
<Label htmlFor={`${mode}-rsvpDeadline`}>
Rückmeldefrist
</Label>
<Input
id={`${mode}-rsvpDeadline`}
name="rsvpDeadline"
type="datetime-local"
defaultValue={
termin?.rsvpDeadline
? toDatetimeLocalValue(termin.rsvpDeadline)
: ""
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-participantInfo`}>
Hinweise für Teilnehmer
</Label>
<Textarea
id={`${mode}-participantInfo`}
name="participantInfo"
defaultValue={termin?.participantInfo ?? ""}
placeholder="z.B. Zweite Brotdose mitbringen"
rows={3}
/>
</div>
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function RsvpStatusBadge({
status,
}: {
status: EventParticipationStatus | null;
}) {
if (status === EventParticipationStatus.ATTENDING) {
return (
<Badge variant="success" className="gap-1">
<Check className="h-3.5 w-3.5" />
Zugesagt
</Badge>
);
}
if (status === EventParticipationStatus.NOT_ATTENDING) {
return (
<Badge variant="destructive" className="gap-1">
<X className="h-3.5 w-3.5" />
Abgesagt
</Badge>
);
}
return (
<Badge variant="outline" className="gap-1 text-[var(--ink-muted)]">
<HelpCircle className="h-3.5 w-3.5" />
Ausstehend
</Badge>
);
}
function getStats(termin: AdminTerminDto, children: ChildDto[]) {
const statusByChildId = new Map(
termin.participations.map((participation) => [
participation.childId,
participation.status,
]),
);
let attending = 0;
let notAttending = 0;
children.forEach((child) => {
const status = statusByChildId.get(child.id);
if (status === EventParticipationStatus.ATTENDING) attending += 1;
if (status === EventParticipationStatus.NOT_ATTENDING) notAttending += 1;
});
return {
attending,
notAttending,
pending: Math.max(children.length - attending - notAttending, 0),
statusByChildId,
};
}
function toDatetimeLocalValue(value: string) {
const date = new Date(value);
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return localDate.toISOString().slice(0, 16);
}
function getStatusVariant(
status: TerminStatus,
): "default" | "secondary" | "destructive" | "outline" | "success" | "warning" {
switch (status) {
case TerminStatus.CONFIRMED:
return "success";
case TerminStatus.PENDING:
return "warning";
case TerminStatus.REJECTED:
case TerminStatus.CANCELLED:
return "destructive";
default:
return "outline";
}
}
function getStatusLabel(status: TerminStatus) {
switch (status) {
case TerminStatus.CONFIRMED:
return "Bestätigt";
case TerminStatus.PENDING:
return "Ausstehend";
case TerminStatus.REJECTED:
return "Abgelehnt";
case TerminStatus.CANCELLED:
return "Abgesagt";
}
}
function getTypeBadgeClass(type: TerminType) {
switch (type) {
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "border-transparent bg-[var(--warn-soft)] text-[var(--warn)]";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "border-transparent bg-[var(--danger-soft)] text-[var(--danger)]";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "border-transparent bg-[oklch(0.94_0.04_280)] text-[oklch(0.48_0.08_280)]";
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "border-transparent bg-[var(--accent-soft)] text-[var(--accent-deep)]";
default:
return "border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]";
}
}
function getTypeLabel(type: TerminType) {
switch (type) {
case TerminType.KITA_FEST:
return "Kita-Fest";
case TerminType.SCHLIESSTAG:
return "Schließtag";
case TerminType.TEAMTAG:
return "Teamtag";
case TerminType.MITMACH_TAG:
return "Mitmach-Tag";
case TerminType.ELTERNABEND:
return "Elternabend";
case TerminType.MITGLIEDERVERSAMMLUNG:
return "Mitgliederversammlung";
case TerminType.ELTERNCAFE:
return "Elterncafe";
case TerminType.GEBURTSTAG_INTERN:
return "Geburtstag Intern";
case TerminType.GEBURTSTAG_EXTERN:
return "Raumanfrage Extern";
case TerminType.SONSTIGES:
return "Sonstiges";
}
}
+80
View File
@@ -0,0 +1,80 @@
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { AdminCalendarManager } from "./admin-calendar-manager";
export const metadata = { title: "Event-Anmeldungen · Kita-Planer" };
export default async function AdminKalenderPage() {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return <div className="p-8">Kein Mandant zugeordnet.</div>;
}
const [termine, children] = await Promise.all([
prisma.termin.findMany({
where: { kitaId },
select: {
id: true,
title: true,
description: true,
type: true,
status: true,
startDate: true,
endDate: true,
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
rsvpDeadline: true,
participantInfo: true,
participations: {
select: {
childId: true,
status: true,
},
},
},
orderBy: { startDate: "desc" },
}),
prisma.child.findMany({
where: {
kitaId,
active: true,
},
select: {
id: true,
firstName: true,
lastName: true,
family: {
select: {
name: true,
},
},
},
orderBy: [
{ family: { name: "asc" } },
{ lastName: "asc" },
{ firstName: "asc" },
],
}),
]);
return (
<AdminCalendarManager
termine={termine.map((termin) => ({
...termin,
startDate: termin.startDate.toISOString(),
endDate: termin.endDate.toISOString(),
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
}))}
childRows={children.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
familyName: child.family.name,
}))}
/>
);
}
+9 -9
View File
@@ -67,14 +67,14 @@ export function NewsForm() {
placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."} placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."}
required required
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-[var(--ink-muted)]">
Markdown ist aktiv. HTML wird nicht interpretiert. Markdown ist aktiv. HTML wird nicht interpretiert.
</p> </p>
</div> </div>
{content.trim() && ( {content.trim() && (
<div className="rounded-md border bg-muted/30 p-4 text-sm"> <div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm">
<p className="mb-3 text-xs font-semibold uppercase text-muted-foreground"> <p className="mb-3 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Vorschau Vorschau
</p> </p>
<MarkdownContent content={content} /> <MarkdownContent content={content} />
@@ -83,7 +83,7 @@ export function NewsForm() {
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="attachments">Anhänge</Label> <Label htmlFor="attachments">Anhänge</Label>
<div className="rounded-md border border-dashed bg-background p-4"> <div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<Input <Input
id="attachments" id="attachments"
name="attachments" name="attachments"
@@ -91,14 +91,14 @@ export function NewsForm() {
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
multiple multiple
/> />
<p className="mt-2 flex items-center gap-1 text-xs text-muted-foreground"> <p className="mt-2 flex items-center gap-1 text-xs text-[var(--ink-muted)]">
<UploadCloud className="h-3.5 w-3.5" /> <UploadCloud className="h-3.5 w-3.5 [stroke-width:1.7]" />
PDF, JPG und PNG bis 10 MB pro Datei. PDF, JPG und PNG bis 10 MB pro Datei.
</p> </p>
</div> </div>
</div> </div>
<div className="rounded-md border p-4"> <div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
id="sendEmail" id="sendEmail"
@@ -130,9 +130,9 @@ export function NewsForm() {
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{isPending ? ( {isPending ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : ( ) : (
<Send className="h-4 w-4" /> <Send className="h-4 w-4 [stroke-width:1.7]" />
)} )}
Veröffentlichen Veröffentlichen
</Button> </Button>
+43 -24
View File
@@ -1,6 +1,6 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { FileText, Megaphone } from "lucide-react"; import { FileText, Megaphone, Send } from "lucide-react";
import { UserRole } from "@prisma/client"; import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils"; import { requireRole } from "@/lib/auth-utils";
@@ -49,26 +49,37 @@ export default async function AdminNewsPage() {
}); });
return ( return (
<div className="px-8 py-8"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="mb-8"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<Badge className="mb-3 bg-emerald-100 text-emerald-800 hover:bg-emerald-100"> <div>
Offizielle Kita-Kommunikation <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
</Badge> KOMMUNIKATION · SCHWARZES BRETT
<h1 className="flex items-center gap-3 text-2xl font-semibold tracking-tight"> </p>
<Megaphone className="h-6 w-6 text-emerald-700" /> <h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Digitales Schwarzes Brett Meldungen{" "}
</h1> <em className="font-normal italic text-[var(--accent-deep)]">
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground"> veröffentlichen
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge </em>
werden geschützt gespeichert und nur für eingeloggte Nutzer derselben </h1>
Kita ausgeliefert. <p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
</p> Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
</div> werden geschützt gespeichert und nur für eingeloggte Nutzer
derselben Kita ausgeliefert.
</p>
</div>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
<Megaphone className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{announcements.length} zuletzt
</span>
</header>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Neue Ankündigung</CardTitle> <CardTitle className="flex items-center gap-2">
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
Neue Ankündigung
</CardTitle>
<CardDescription> <CardDescription>
Markdown wird sicher gerendert; PDF, JPG und PNG können als Markdown wird sicher gerendert; PDF, JPG und PNG können als
geschützte Anhänge hinzugefügt werden. geschützte Anhänge hinzugefügt werden.
@@ -88,27 +99,35 @@ export default async function AdminNewsPage() {
</CardHeader> </CardHeader>
<CardContent className="grid gap-3"> <CardContent className="grid gap-3">
{announcements.length === 0 ? ( {announcements.length === 0 ? (
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground"> <div className="flex max-h-[280px] min-h-[220px] flex-col items-center justify-center rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-center">
Noch keine Ankündigungen veröffentlicht. <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
</p> <Megaphone className="h-6 w-6 [stroke-width:1.7]" />
</div>
<p className="text-sm font-medium text-[var(--ink)]">
Noch keine Ankündigungen
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Veröffentlichte Meldungen erscheinen hier.
</p>
</div>
) : ( ) : (
announcements.map((announcement) => ( announcements.map((announcement) => (
<div <div
key={announcement.id} key={announcement.id}
className="rounded-md border bg-background p-3" className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
> >
<div className="mb-2 flex items-start justify-between gap-2"> <div className="mb-2 flex items-start justify-between gap-2">
<p className="font-medium leading-snug"> <p className="font-medium leading-snug text-[var(--ink)]">
{announcement.title} {announcement.title}
</p> </p>
{announcement.attachments.length > 0 && ( {announcement.attachments.length > 0 && (
<Badge variant="secondary" className="shrink-0"> <Badge variant="secondary" className="shrink-0">
<FileText className="h-3 w-3" /> <FileText className="h-3 w-3 [stroke-width:1.7]" />
{announcement.attachments.length} {announcement.attachments.length}
</Badge> </Badge>
)} )}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-[var(--ink-muted)]">
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", { {format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
locale: de, locale: de,
})}{" "} })}{" "}
@@ -0,0 +1,600 @@
"use client";
import { useState, useMemo } from "react";
import {
Baby,
Contact,
Mail,
Phone,
Search,
ShieldCheck,
SlidersHorizontal,
UserRound,
Home,
MapPin,
} from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export type DirectoryParentDto = {
id: string;
firstName: string;
lastName: string;
hasDirectoryOptIn: boolean;
email?: string;
phone?: string;
street?: string;
postalCode?: string;
city?: string;
duties: {
id: string;
name: string;
}[];
};
export type DirectoryFamilyDto = {
id: string;
name: string;
children: {
id: string;
firstName: string;
lastName: string;
dateOfBirth?: Date | null;
}[];
parents: DirectoryParentDto[];
};
export function DirectoryClient({
initialFamilies,
}: {
initialFamilies: DirectoryFamilyDto[];
}) {
const [searchQuery, setSearchQuery] = useState("");
const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
const [selectedFamily, setSelectedFamily] = useState<DirectoryFamilyDto | null>(null);
const [privacyDialogOpen, setPrivacyDialogOpen] = useState(false);
const toggleFilter = (filterKey: string) => {
setActiveFilters((prev) => {
const next = new Set(prev);
if (next.has(filterKey)) {
next.delete(filterKey);
} else {
next.add(filterKey);
}
return next;
});
};
const filteredFamilies = useMemo(() => {
return initialFamilies.filter((family) => {
// 1. Search Query filter
const query = searchQuery.trim().toLowerCase();
if (query) {
const familyNameMatch = family.name.toLowerCase().includes(query);
const childMatch = family.children.some(
(c) =>
c.firstName.toLowerCase().includes(query) ||
c.lastName.toLowerCase().includes(query)
);
const parentMatch = family.parents.some(
(p) =>
p.firstName.toLowerCase().includes(query) ||
p.lastName.toLowerCase().includes(query) ||
(p.email && p.email.toLowerCase().includes(query))
);
if (!familyNameMatch && !childMatch && !parentMatch) return false;
}
// 2. Custom filters
if (activeFilters.has("telefon")) {
const hasPhone = family.parents.some((p) => p.hasDirectoryOptIn && p.phone);
if (!hasPhone) return false;
}
if (activeFilters.has("aemter")) {
const hasDuties = family.parents.some((p) => p.duties.length > 0);
if (!hasDuties) return false;
}
if (activeFilters.has("optIn")) {
// Show households where all parents in the system have opted in
const allOptIn = family.parents.every((p) => p.hasDirectoryOptIn);
if (!allOptIn) return false;
}
return true;
});
}, [initialFamilies, searchQuery, activeFilters]);
// Recalculate metrics for filtered subset
const visibleParentCount = useMemo(() => {
return filteredFamilies.reduce(
(count, fam) => count + fam.parents.filter((p) => p.hasDirectoryOptIn).length,
0
);
}, [filteredFamilies]);
const childCount = useMemo(() => {
return filteredFamilies.reduce((count, fam) => count + fam.children.length, 0);
}, [filteredFamilies]);
const totalVisibleParentCount = useMemo(() => {
return initialFamilies.reduce(
(count, fam) => count + fam.parents.filter((p) => p.hasDirectoryOptIn).length,
0
);
}, [initialFamilies]);
const groupedFamilies = useMemo(() => {
const groups = new Map<string, DirectoryFamilyDto[]>();
filteredFamilies.forEach((family) => {
const initial = family.name.trim().slice(0, 1).toUpperCase();
const key = initial || "#";
groups.set(key, [...(groups.get(key) ?? []), family]);
});
return Array.from(groups.entries())
.map(([initial, families]) => ({ initial, families }))
.sort((a, b) => a.initial.localeCompare(b.initial));
}, [filteredFamilies]);
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
{/* Header */}
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
KONTAKTE · ADRESSBUCH
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Freigegebene{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
Kontakte
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Haushalte und Kontaktdaten, die explizit für das interne
Kita-Adressbuch freigegeben wurden.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
<ShieldCheck className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{totalVisibleParentCount} sichtbar
</span>
<Button variant="ghost" size="sm" onClick={() => setPrivacyDialogOpen(true)}>
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Sichtbarkeit
</Button>
</div>
</header>
{/* Main Household List or Empty State */}
{initialFamilies.length === 0 ? (
<Card className="border-dashed">
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Contact className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine freigegebenen Kontakte
</h3>
<p className="mt-2 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Sobald mindestens ein Elternteil eines Haushalts zustimmt,
erscheint die Familie hier.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-5">
{/* Metrics & Search Bar */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<DirectoryMetric label="Haushalte" value={filteredFamilies.length} />
<DirectoryMetric label="Kontakte" value={visibleParentCount} />
<DirectoryMetric label="Kinder" value={childCount} />
</div>
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input
placeholder="Familien, Namen oder E-Mail suchen"
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{/* Custom Filter Chips */}
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => setActiveFilters(new Set())}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
activeFilters.size === 0
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Alle anzeigen
</button>
<button
type="button"
onClick={() => toggleFilter("telefon")}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
activeFilters.has("telefon")
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Mit Telefon
</button>
<button
type="button"
onClick={() => toggleFilter("aemter")}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
activeFilters.has("aemter")
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Mit Ämtern
</button>
<button
type="button"
onClick={() => toggleFilter("optIn")}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
activeFilters.has("optIn")
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Haushalt komplett freigegeben
</button>
</div>
{/* Household list cards wrapper */}
{filteredFamilies.length === 0 ? (
<Card className="border-dashed">
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<SlidersHorizontal className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine passenden Kontakte gefunden
</h3>
<p className="mt-2 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Passe deine Suche oder die Filterchips an, um Einträge zu sehen.
</p>
</CardContent>
</Card>
) : (
<Card className="overflow-hidden p-0">
{groupedFamilies.map((group) => (
<section key={group.initial}>
<div className="flex items-center justify-between border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] px-5 py-3">
<h2 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.14em]">
{group.initial}
</h2>
<span className="text-[11px] font-semibold text-[var(--ink-muted)] tabular-nums">
{group.families.length}
</span>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{group.families.map((family) => {
const visibleParents = family.parents.filter(
(parent) => parent.hasDirectoryOptIn
);
const duties = family.parents.flatMap((parent) => parent.duties);
return (
<div
key={family.id}
className="grid grid-cols-[minmax(220px,0.8fr)_minmax(0,1.4fr)_minmax(220px,1fr)] gap-5 px-5 py-4 transition-colors hover:bg-[var(--surface-2)] max-[1050px]:grid-cols-1"
>
{/* Left: Family Name & Children */}
<div className="flex min-w-0 gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-sm font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{family.name}
</h3>
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-[13px] text-[var(--ink-soft)]">
<Baby className="h-3.5 w-3.5 [stroke-width:1.7]" />
{family.children.length === 0
? "Keine Kinder hinterlegt"
: family.children
.map((child) => `${child.firstName} ${child.lastName}`)
.join(", ")}
</p>
</div>
</div>
{/* Middle: Parents */}
<div className="grid gap-3">
{family.parents.map((user) => (
<div key={user.id} className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<UserRound className="h-3.5 w-3.5 text-[var(--ink-muted)] [stroke-width:1.7]" />
<p className="text-sm font-medium text-[var(--ink)]">
{user.firstName} {user.lastName}
</p>
{user.hasDirectoryOptIn ? (
<Badge variant="success">Opt-in</Badge>
) : (
<span className="text-xs italic text-[var(--ink-faint)]">
nicht freigegeben
</span>
)}
</div>
{user.hasDirectoryOptIn && (
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 pl-5 text-[13px] text-[var(--ink-soft)]">
{user.email && (
<a
href={`mailto:${user.email}`}
className="inline-flex min-w-0 items-center gap-1.5 hover:text-[var(--accent-deep)]"
>
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{user.email}
</span>
</a>
)}
{user.phone ? (
<a
href={`tel:${user.phone}`}
className="inline-flex items-center gap-1.5 hover:text-[var(--accent-deep)]"
>
<Phone className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
{user.phone}
</a>
) : (
<span className="italic text-[var(--ink-faint)]">
Keine Telefonnummer
</span>
)}
</div>
)}
</div>
))}
</div>
{/* Right: Badges & Details Action */}
<div className="flex flex-col items-end justify-between gap-3 max-[1050px]:items-start">
<div className="flex flex-wrap justify-end gap-1.5 max-[1050px]:justify-start">
<Badge variant="secondary">
{visibleParents.length} von {family.parents.length} sichtbar
</Badge>
{duties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
>
{duty.name}
</Badge>
))}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedFamily(family)}
>
Kontakt öffnen
</Button>
</div>
</div>
);
})}
</div>
</section>
))}
</Card>
)}
</div>
)}
{/* Household Detail Dialog */}
<Dialog
open={!!selectedFamily}
onOpenChange={(open) => !open && setSelectedFamily(null)}
>
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
{selectedFamily && (
<>
<DialogHeader>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)] text-xl font-bold uppercase mb-2">
{selectedFamily.name.slice(0, 1)}
</div>
<DialogTitle className="text-2xl">Familie {selectedFamily.name}</DialogTitle>
<DialogDescription>
Detaillierte Kontaktdaten und Haushalts-Zugehörigkeiten.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-6">
{/* Children Details */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--ink-muted)] mb-3 flex items-center gap-1.5">
<Baby className="h-4 w-4" />
Kinder im Haushalt
</h4>
<div className="grid gap-2.5">
{selectedFamily.children.length === 0 ? (
<p className="text-sm italic text-[var(--ink-soft)]">Keine Kinder hinterlegt.</p>
) : (
selectedFamily.children.map((child) => (
<div
key={child.id}
className="flex items-center justify-between rounded-xl border border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-3"
>
<div className="flex items-center gap-2">
<Baby className="h-4 w-4 text-[var(--accent-deep)]" />
<span className="font-semibold text-sm text-[var(--ink)]">
{child.firstName} {child.lastName}
</span>
</div>
{child.dateOfBirth && (
<span className="text-xs text-[var(--ink-soft)] font-medium">
Geboren: {format(new Date(child.dateOfBirth), "dd.MM.yyyy", { locale: de })}
</span>
)}
</div>
))
)}
</div>
</div>
{/* Parents/Users Details */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--ink-muted)] mb-3 flex items-center gap-1.5">
<UserRound className="h-4 w-4" />
Eltern & Kontakte
</h4>
<div className="space-y-4">
{selectedFamily.parents.map((parent) => (
<div
key={parent.id}
className="rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm"
>
<div className="flex items-center justify-between gap-2 border-b border-[var(--hairline-soft)] pb-2 mb-3">
<span className="font-semibold text-[15px] text-[var(--ink)] flex items-center gap-1.5">
<UserRound className="h-4 w-4 text-[var(--ink-muted)]" />
{parent.firstName} {parent.lastName}
</span>
{parent.hasDirectoryOptIn ? (
<Badge variant="success">Freigegeben</Badge>
) : (
<Badge variant="secondary">Privat</Badge>
)}
</div>
{parent.hasDirectoryOptIn ? (
<div className="space-y-2.5 text-sm text-[var(--ink-soft)]">
{parent.email && (
<a
href={`mailto:${parent.email}`}
className="flex items-center gap-2.5 hover:text-[var(--accent-deep)]"
>
<Mail className="h-4 w-4 text-[var(--ink-muted)] shrink-0" />
<span className="break-all">{parent.email}</span>
</a>
)}
{parent.phone ? (
<a
href={`tel:${parent.phone}`}
className="flex items-center gap-2.5 hover:text-[var(--accent-deep)]"
>
<Phone className="h-4 w-4 text-[var(--ink-muted)] shrink-0" />
<span>{parent.phone}</span>
</a>
) : (
<div className="flex items-center gap-2.5 italic text-[var(--ink-faint)]">
<Phone className="h-4 w-4 text-[var(--ink-muted)] shrink-0" />
<span>Keine Telefonnummer hinterlegt</span>
</div>
)}
{/* Address details */}
{(parent.street || parent.postalCode || parent.city) && (
<div className="flex items-start gap-2.5 border-t border-[var(--hairline-soft)] pt-2.5 mt-2.5">
<MapPin className="h-4 w-4 text-[var(--ink-muted)] shrink-0 mt-0.5" />
<div>
{parent.street && <p>{parent.street}</p>}
{(parent.postalCode || parent.city) && (
<p>
{parent.postalCode} {parent.city}
</p>
)}
</div>
</div>
)}
</div>
) : (
<p className="text-xs italic text-[var(--ink-faint)]">
Dieser Kontakt hat seine Daten nicht für das Adressbuch freigegeben.
</p>
)}
{parent.duties.length > 0 && (
<div className="mt-3 border-t border-[var(--hairline-soft)] pt-3 flex flex-wrap gap-1.5">
{parent.duties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)] text-[10px]"
>
{duty.name}
</Badge>
))}
</div>
)}
</div>
))}
</div>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
{/* Global Privacy settings dialog */}
<Dialog open={privacyDialogOpen} onOpenChange={setPrivacyDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-[var(--accent-deep)]" />
Sichtbarkeit & Datenschutz
</DialogTitle>
<DialogDescription className="mt-2 text-sm leading-6">
Deine Privatsphäre ist uns wichtig. In diesem Adressbuch werden Kontaktdaten
nur für Haushalte angezeigt, die dem Opt-in für das interne Kita-Adressbuch
explizit zugestimmt haben.
<br />
<br />
Du kannst deine eigene Zustimmung sowie deine E-Mail-Adresse und Telefonnummer
jederzeit in deinen persönlichen Profileinstellungen einsehen und anpassen.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-3 mt-4">
<Button variant="ghost" onClick={() => setPrivacyDialogOpen(false)}>
Schließen
</Button>
<a href="/dashboard/profil">
<Button>Zu den Profileinstellungen</Button>
</a>
</div>
</DialogContent>
</Dialog>
</div>
);
}
function DirectoryMetric({ label, value }: { label: string; value: number }) {
return (
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{value}</span>
{label}
</span>
);
}
+9 -168
View File
@@ -1,40 +1,9 @@
import { Baby, Contact, Mail, Phone, ShieldCheck, UsersRound } from "lucide-react";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge"; import { DirectoryClient, type DirectoryFamilyDto } from "./_components/directory-client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export const metadata = { title: "Adressbuch · Kita-Planer" }; export const metadata = { title: "Adressbuch · Kita-Planer" };
type DirectoryFamilyDto = {
id: string;
name: string;
children: {
id: string;
firstName: string;
lastName: string;
}[];
parents: {
id: string;
firstName: string;
lastName: string;
hasDirectoryOptIn: boolean;
email?: string;
phone?: string;
duties: {
id: string;
name: string;
}[];
}[];
};
export default async function AdressbuchPage() { export default async function AdressbuchPage() {
const session = await requireKitaSession(); const session = await requireKitaSession();
@@ -55,6 +24,7 @@ export default async function AdressbuchPage() {
id: true, id: true,
firstName: true, firstName: true,
lastName: true, lastName: true,
dateOfBirth: true,
}, },
orderBy: [{ lastName: "asc" }, { firstName: "asc" }], orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
}, },
@@ -91,6 +61,9 @@ export default async function AdressbuchPage() {
id: true, id: true,
email: true, email: true,
phone: true, phone: true,
street: true,
postalCode: true,
city: true,
}, },
}); });
const visibleContactsByUserId = new Map( const visibleContactsByUserId = new Map(
@@ -111,6 +84,9 @@ export default async function AdressbuchPage() {
hasDirectoryOptIn, hasDirectoryOptIn,
email: contact?.email, email: contact?.email,
phone: contact?.phone ?? undefined, phone: contact?.phone ?? undefined,
street: contact?.street ?? undefined,
postalCode: contact?.postalCode ?? undefined,
city: contact?.city ?? undefined,
duties: user.dutyAssignments.map((assignment) => ({ duties: user.dutyAssignments.map((assignment) => ({
id: assignment.id, id: assignment.id,
name: assignment.duty.name, name: assignment.duty.name,
@@ -119,140 +95,5 @@ export default async function AdressbuchPage() {
}), }),
})); }));
return ( return <DirectoryClient initialFamilies={families} />;
<div className="flex h-full flex-col gap-6 p-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Adressbuch</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Haushalte und Kontaktdaten, die explizit fuer das interne
Kita-Adressbuch freigegeben wurden.
</p>
</div>
{families.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-background p-10 text-center">
<Contact className="mb-4 h-10 w-10 text-muted-foreground" />
<h3 className="text-lg font-semibold">Keine freigegebenen Kontakte</h3>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
Sobald mindestens ein Elternteil eines Haushalts zustimmt,
erscheint die Familie hier.
</p>
</div>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{families.map((family) => (
<Card key={family.id} className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 pb-4">
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<UsersRound className="h-4 w-4 text-primary" />
{family.name}
</CardTitle>
<CardDescription className="mt-1">
{family.parents.filter((user) => user.hasDirectoryOptIn).length}{" "}
von {family.parents.length} Elternteilen sichtbar
</CardDescription>
</div>
<Badge variant="success">Freigegeben</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-5 pt-5">
<section className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Baby className="h-3.5 w-3.5" />
Kinder
</div>
{family.children.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keine Kinder hinterlegt.
</p>
) : (
<div className="flex flex-wrap gap-1.5">
{family.children.map((child) => (
<Badge key={child.id} variant="secondary">
{child.firstName} {child.lastName}
</Badge>
))}
</div>
)}
</section>
<section className="grid gap-3">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<ShieldCheck className="h-3.5 w-3.5" />
Kontakte
</div>
<div className="grid gap-3">
{family.parents.map((user) => {
return (
<div
key={user.id}
className="rounded-md border bg-background p-3"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium">
{user.firstName} {user.lastName}
</p>
{!user.hasDirectoryOptIn && (
<p className="mt-0.5 text-xs text-muted-foreground">
Kontakt nicht freigegeben.
</p>
)}
</div>
{user.hasDirectoryOptIn ? (
<Badge variant="outline">Opt-in</Badge>
) : null}
</div>
{user.hasDirectoryOptIn && (
<div className="mt-3 grid gap-2 text-sm text-muted-foreground">
{user.email && (
<a
href={`mailto:${user.email}`}
className="flex min-w-0 items-center gap-2 hover:text-primary"
>
<Mail className="h-4 w-4 shrink-0" />
<span className="truncate">{user.email}</span>
</a>
)}
{user.phone && (
<a
href={`tel:${user.phone}`}
className="flex items-center gap-2 hover:text-primary"
>
<Phone className="h-4 w-4 shrink-0" />
{user.phone}
</a>
)}
</div>
)}
{user.duties.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5 border-t pt-3">
{user.duties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="bg-primary/5 text-primary"
>
{duty.name}
</Badge>
))}
</div>
)}
</div>
);
})}
</div>
</section>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
} }
+22 -11
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useTransition } from "react"; import { useState, useTransition } from "react";
import { Loader2, BellRing } from "lucide-react"; import { Loader2, BellRing } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { triggerAlertAction } from "./actions"; import { triggerAlertAction } from "./actions";
export function AlertButton({ export function AlertButton({
@@ -12,11 +13,10 @@ export function AlertButton({
}: { }: {
assignmentId: string; assignmentId: string;
}) { }) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const handleAlert = () => { const handleAlert = () => {
if (!confirm("Bist du sicher? Dies löst den Notdienst-Alarm aus.")) return;
startTransition(async () => { startTransition(async () => {
const result = await triggerAlertAction(assignmentId); const result = await triggerAlertAction(assignmentId);
if ("error" in result && result.error) { if ("error" in result && result.error) {
@@ -28,13 +28,24 @@ export function AlertButton({
}; };
return ( return (
<Button variant="destructive" onClick={handleAlert} disabled={isPending}> <ConfirmDialog
{isPending ? ( open={confirmOpen}
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> onOpenChange={setConfirmOpen}
) : ( title="Notdienst-Alarm auslösen?"
<BellRing className="mr-2 h-4 w-4" /> description="Der Alarm informiert die hinterlegten Kontakte per E-Mail über den Notdienst."
)} confirmLabel="Alarm auslösen"
Alarm auslösen pendingLabel="Alarm wird ausgelöst..."
</Button> isPending={isPending}
onConfirm={handleAlert}
>
<Button variant="destructive" disabled={isPending}>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<BellRing className="mr-2 h-4 w-4" />
)}
Alarm auslösen
</Button>
</ConfirmDialog>
); );
} }
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Plus, Pencil, Trash2 } from "lucide-react"; import { Plus, Pencil, Search, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -18,6 +19,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Table, Table,
TableBody, TableBody,
@@ -39,6 +41,9 @@ type EducatorDto = {
export function ErzieherList({ educators }: { educators: EducatorDto[] }) { export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
const [openCreate, setOpenCreate] = useState(false); const [openCreate, setOpenCreate] = useState(false);
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(null); const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(null);
const [educatorToDelete, setEducatorToDelete] = useState<EducatorDto | null>(
null,
);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const handleCreate = (formData: FormData) => { const handleCreate = (formData: FormData) => {
@@ -78,25 +83,29 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
}); });
}; };
const handleDelete = (id: string) => { const handleDelete = (educator: EducatorDto) => {
if (!confirm("Wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.")) return;
startTransition(async () => { startTransition(async () => {
const res = await deleteEducator(id); const res = await deleteEducator(educator.id);
if (res.error) { if (res.error) {
toast.error(res.error); toast.error(res.error);
} else { } else {
toast.success("ErzieherIn gelöscht."); toast.success("ErzieherIn gelöscht.");
setEducatorToDelete(null);
} }
}); });
}; };
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex justify-end"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input placeholder="Name suchen" className="pl-9" />
</div>
<Dialog open={openCreate} onOpenChange={setOpenCreate}> <Dialog open={openCreate} onOpenChange={setOpenCreate}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="gap-2"> <Button className="gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4 [stroke-width:1.7]" />
ErzieherIn anlegen ErzieherIn anlegen
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@@ -131,39 +140,68 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
</Dialog> </Dialog>
</div> </div>
<div className="rounded-md border bg-background"> <Card>
<CardHeader className="flex-row items-center justify-between">
<div>
<CardTitle>Team</CardTitle>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
{educators.length} Einträge im Personalstamm.
</p>
</div>
<Badge variant="secondary" className="tabular-nums">
{educators.filter((ed) => ed.active).length} aktiv
</Badge>
</CardHeader>
<CardContent className="px-0 pb-0">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Vorname</TableHead> <TableHead className="pl-6">Vorname</TableHead>
<TableHead>Nachname</TableHead> <TableHead>Nachname</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead> <TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{educators.length === 0 ? ( {educators.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground h-24"> <TableCell colSpan={4} className="h-32 text-center">
Keine Einträge gefunden. <div className="flex max-h-[240px] min-h-[200px] flex-col items-center justify-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Plus className="h-6 w-6 [stroke-width:1.7]" />
</div>
<p className="text-sm font-medium text-[var(--ink)]">
Keine Einträge gefunden
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege die erste Person im Team an.
</p>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
educators.map((ed) => ( educators.map((ed) => (
<TableRow key={ed.id}> <TableRow key={ed.id}>
<TableCell className="font-medium">{ed.firstName}</TableCell> <TableCell className="pl-6 font-medium">{ed.firstName}</TableCell>
<TableCell>{ed.lastName}</TableCell> <TableCell>{ed.lastName}</TableCell>
<TableCell> <TableCell>
<Badge variant={ed.active ? "success" : "secondary"}> <Badge variant={ed.active ? "success" : "secondary"}>
{ed.active ? "Aktiv" : "Inaktiv"} {ed.active ? "Aktiv" : "Inaktiv"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="pr-6 text-right">
<Button variant="ghost" size="icon" onClick={() => setEditingEducator(ed)}> <Button variant="ghost" size="icon" onClick={() => setEditingEducator(ed)}>
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4 [stroke-width:1.7]" />
</Button> </Button>
<Button variant="ghost" size="icon" className="text-destructive hover:text-destructive" onClick={() => handleDelete(ed.id)} disabled={isPending}> <Button
<Trash2 className="h-4 w-4" /> variant="ghost"
size="icon"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => setEducatorToDelete(ed)}
disabled={isPending}
aria-label={`${ed.firstName} ${ed.lastName} löschen`}
>
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -171,7 +209,8 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </CardContent>
</Card>
{/* Edit Dialog */} {/* Edit Dialog */}
<Dialog open={!!editingEducator} onOpenChange={(open) => !open && setEditingEducator(null)}> <Dialog open={!!editingEducator} onOpenChange={(open) => !open && setEditingEducator(null)}>
@@ -203,6 +242,25 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<ConfirmDialog
open={!!educatorToDelete}
onOpenChange={(open) => {
if (!open) setEducatorToDelete(null);
}}
title="ErzieherIn löschen?"
description={
educatorToDelete
? `${educatorToDelete.firstName} ${educatorToDelete.lastName} wird aus dem Personalstamm entfernt.`
: "Dieser Eintrag wird aus dem Personalstamm entfernt."
}
confirmLabel="Löschen"
pendingLabel="Wird gelöscht..."
isPending={isPending}
onConfirm={() => {
if (educatorToDelete) handleDelete(educatorToDelete);
}}
/>
</div> </div>
); );
} }
+22 -7
View File
@@ -20,21 +20,36 @@ export default async function ErzieherPage() {
}, },
orderBy: [{ lastName: "asc" }, { firstName: "asc" }], orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
}); });
const activeCount = educators.filter((educator) => educator.active).length;
return ( return (
<div className="flex h-full flex-col gap-6 p-6"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="flex items-start justify-between gap-4"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div> <div>
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight"> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<GraduationCap className="h-6 w-6 text-primary" /> VERWALTUNG · TEAM
ErzieherInnen-Verwaltung </p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
ErzieherInnen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
verwalten
</em>
</h1> </h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground"> <p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Stammdaten des Kita-Personals für interne Planung und spätere Stammdaten des Kita-Personals für interne Planung und spätere
Notdienst-Alarmierung. Notdienst-Alarmierung.
</p> </p>
</div> </div>
</div> <div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<GraduationCap className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{educators.length} im Team
</span>
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{activeCount} aktiv
</span>
</div>
</header>
<ErzieherList educators={educators} /> <ErzieherList educators={educators} />
</div> </div>
@@ -0,0 +1,418 @@
"use client";
import { useState } from "react";
import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Table as UITable,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AddFamilyDialog } from "../add-family-dialog";
import { DutyManager } from "../duty-manager";
import { EditFamilyDialog } from "../edit-family-dialog";
interface FamilyUser {
id: string;
firstName: string;
lastName: string;
email: string;
emailVerifiedAt: Date | null;
dutyAssignments: {
id: string;
dutyId: string;
duty: {
name: string;
};
}[];
}
interface FamilyChild {
id: string;
firstName: string;
lastName: string;
}
interface FamilyData {
id: string;
name: string;
users: FamilyUser[];
children: FamilyChild[];
}
interface DutyData {
id: string;
name: string;
assignments: {
id: string;
}[];
}
interface FamiliesListClientProps {
initialFamilies: FamilyData[];
allDuties: DutyData[];
canManageDuties: boolean;
}
export function FamiliesListClient({
initialFamilies,
allDuties,
canManageDuties,
}: FamiliesListClientProps) {
const [viewMode, setViewMode] = useState<"table" | "cards">("table");
const [searchQuery, setSearchQuery] = useState("");
const filteredFamilies = initialFamilies.filter((family) => {
const q = searchQuery.toLowerCase().trim();
if (!q) return true;
// Check family name
if (family.name.toLowerCase().includes(q)) return true;
// Check parents
const matchesParent = family.users.some(
(user) =>
user.firstName.toLowerCase().includes(q) ||
user.lastName.toLowerCase().includes(q) ||
user.email.toLowerCase().includes(q)
);
if (matchesParent) return true;
// Check children
const matchesChild = family.children.some(
(child) =>
child.firstName.toLowerCase().includes(q) ||
child.lastName.toLowerCase().includes(q)
);
return matchesChild;
});
const childCount = filteredFamilies.reduce(
(acc, family) => acc + family.children.length,
0
);
const pendingInviteCount = filteredFamilies.reduce(
(acc, family) =>
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
0
);
return (
<div>
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
VERWALTUNG · FAMILIEN
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Familien{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
organisieren
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort pflegen.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setViewMode(viewMode === "table" ? "cards" : "table")}
className="flex items-center gap-1.5"
>
{viewMode === "table" ? (
<>
<LayoutGrid className="h-4 w-4 [stroke-width:1.7]" />
Kartenansicht
</>
) : (
<>
<Table className="h-4 w-4 [stroke-width:1.7]" />
Tabellenansicht
</>
)}
</Button>
<AddFamilyDialog />
</div>
</header>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{filteredFamilies.length}</span>
Familien
</span>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{childCount}</span>
Kinder
</span>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{pendingInviteCount}</span>
Offene Einladungen
</span>
</div>
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input
placeholder="Familien, Eltern oder Kinder suchen"
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{filteredFamilies.length === 0 ? (
<Card className="border-dashed">
<div className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Users className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="mb-1 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine Familien gefunden
</h3>
<p className="mb-5 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Passe deine Suche an oder erstelle eine neue Familie.
</p>
{initialFamilies.length === 0 && <AddFamilyDialog />}
</div>
</Card>
) : viewMode === "table" ? (
<Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<div>
<CardTitle>Haushalte</CardTitle>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
{filteredFamilies.length} {filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
{childCount} Kinder · {pendingInviteCount} offene Einladungen
</p>
</div>
<Badge variant="secondary" className="tabular-nums">
{filteredFamilies.length}
</Badge>
</CardHeader>
<CardContent className="px-0 pb-0 overflow-x-auto">
<UITable>
<TableHeader>
<TableRow>
<TableHead className="pl-6">Familie</TableHead>
<TableHead>Elternteile</TableHead>
<TableHead>Kinder</TableHead>
<TableHead>Status</TableHead>
<TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFamilies.map((family) => {
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt
);
return (
<TableRow key={family.id}>
<TableCell className="pl-6">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[13px] font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div>
<div className="font-semibold text-[var(--ink)]">
{family.name}
</div>
<div className="mt-0.5 text-xs text-[var(--ink-muted)]">
{family.users.length} Elternteil
{family.users.length === 1 ? "" : "e"}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-2">
{family.users.map((parent) => (
<div key={parent.id} className="min-w-0">
<div className="font-medium text-[var(--ink)]">
{parent.firstName} {parent.lastName}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-[var(--ink-muted)]">
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email}
</span>
</div>
</div>
))}
</div>
</TableCell>
<TableCell>
{family.children.length === 0 ? (
<span className="text-sm italic text-[var(--ink-faint)]">
Noch keine Kinder
</span>
) : (
<div className="flex flex-wrap gap-1.5">
{family.children.map((child) => (
<span
key={child.id}
className="inline-flex items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-xs font-medium text-[var(--ink-soft)]"
>
{child.firstName} {child.lastName}
</span>
))}
</div>
)}
</TableCell>
<TableCell>
<Badge variant={hasPendingInvite ? "warning" : "success"}>
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge>
</TableCell>
<TableCell className="pr-6 text-right">
<div className="flex flex-wrap justify-end gap-1">
<EditFamilyDialog
family={{
id: family.id,
name: family.name,
parents: family.users.map((parent) => ({
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
})),
children: family.children,
}}
/>
{canManageDuties &&
family.users.map((parent) => (
<DutyManager
key={parent.id}
user={{
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
}}
allDuties={allDuties}
userAssignments={parent.dutyAssignments}
/>
))}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</UITable>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredFamilies.map((family) => {
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt
);
return (
<Card key={family.id} className="flex flex-col h-full">
<CardHeader className="flex flex-row items-center justify-between pb-3 space-y-0">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-sm font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div>
<CardTitle className="text-lg font-semibold">{family.name}</CardTitle>
<p className="text-xs text-[var(--ink-muted)]">
{family.users.length} Elternteil{family.users.length === 1 ? "" : "e"}
</p>
</div>
</div>
<Badge variant={hasPendingInvite ? "warning" : "success"}>
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between gap-4 pt-0">
<div className="space-y-4">
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-[var(--ink-muted)] mb-1">
Eltern
</p>
<div className="space-y-2.5">
{family.users.map((parent) => (
<div key={parent.id} className="text-sm">
<div className="font-semibold text-[var(--ink)]">
{parent.firstName} {parent.lastName}
</div>
<div className="flex items-center gap-1.5 text-xs text-[var(--ink-muted)] mt-0.5">
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email}
</span>
</div>
</div>
))}
</div>
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-[var(--ink-muted)] mb-1">
Kinder
</p>
{family.children.length === 0 ? (
<span className="text-xs italic text-[var(--ink-faint)]">
Noch keine Kinder
</span>
) : (
<div className="flex flex-wrap gap-1.5">
{family.children.map((child) => (
<span
key={child.id}
className="inline-flex items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-xs text-[var(--ink-soft)] font-medium"
>
{child.firstName} {child.lastName}
</span>
))}
</div>
)}
</div>
</div>
<div className="border-t border-[var(--hairline)] pt-3 mt-auto flex flex-wrap gap-1 justify-end">
<EditFamilyDialog
family={{
id: family.id,
name: family.name,
parents: family.users.map((parent) => ({
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
})),
children: family.children,
}}
/>
{canManageDuties &&
family.users.map((parent) => (
<DutyManager
key={parent.id}
user={{
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
}}
allDuties={allDuties}
userAssignments={parent.dutyAssignments}
/>
))}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}
@@ -54,7 +54,7 @@ export function AddFamilyDialog() {
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm"> <Button size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="h-4 w-4" />
Familie hinzufügen Familie hinzufügen
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@@ -82,7 +82,9 @@ export function AddFamilyDialog() {
{/* ── Elternteile ────────────────────────────────────────── */} {/* ── Elternteile ────────────────────────────────────────── */}
<fieldset className="space-y-4"> <fieldset className="space-y-4">
<legend className="text-sm font-semibold">Elternteil 1</legend> <legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Elternteil 1
</legend>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<FormField <FormField
@@ -112,8 +114,13 @@ export function AddFamilyDialog() {
</fieldset> </fieldset>
<fieldset className="space-y-4"> <fieldset className="space-y-4">
<legend className="text-sm font-semibold"> <legend>
Elternteil 2 <span className="font-normal text-muted-foreground">(optional)</span> <span className="text-[11px] uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Elternteil 2
</span>{" "}
<span className="font-normal italic text-[var(--ink-faint)]">
optional
</span>
</legend> </legend>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -149,7 +156,7 @@ export function AddFamilyDialog() {
{/* ── Kinder ─────────────────────────────────────────────── */} {/* ── Kinder ─────────────────────────────────────────────── */}
<fieldset className="space-y-3"> <fieldset className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<legend className="text-sm font-semibold"> <legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Kinder ({childCount}) Kinder ({childCount})
</legend> </legend>
{childCount < 10 && ( {childCount < 10 && (
@@ -168,7 +175,7 @@ export function AddFamilyDialog() {
{Array.from({ length: childCount }).map((_, i) => ( {Array.from({ length: childCount }).map((_, i) => (
<div <div
key={i} key={i}
className="relative rounded-md border p-4" className="relative rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
> >
{childCount > 1 && ( {childCount > 1 && (
<button <button
@@ -178,13 +185,13 @@ export function AddFamilyDialog() {
// Felder nach Index benannt sind und der letzte entfernt wird. // Felder nach Index benannt sind und der letzte entfernt wird.
setChildCount((c) => c - 1); setChildCount((c) => c - 1);
}} }}
className="absolute right-3 top-3 rounded p-0.5 text-muted-foreground hover:text-destructive" className="absolute right-3 top-3 rounded-md p-1 text-[var(--ink-muted)] transition-colors hover:bg-[var(--danger-soft)] hover:text-[var(--danger)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-2)]"
aria-label={`Kind ${i + 1} entfernen`} aria-label={`Kind ${i + 1} entfernen`}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
)} )}
<p className="mb-3 text-xs font-medium text-muted-foreground"> <p className="mb-3 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Kind {i + 1} Kind {i + 1}
</p> </p>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -203,7 +210,7 @@ export function AddFamilyDialog() {
))} ))}
{state.errors?.children?.[0] && ( {state.errors?.children?.[0] && (
<p className="text-xs text-destructive"> <p className="text-xs text-[var(--danger)]">
{state.errors.children[0]} {state.errors.children[0]}
</p> </p>
)} )}
@@ -211,7 +218,7 @@ export function AddFamilyDialog() {
{/* Globaler Fehler */} {/* Globaler Fehler */}
{state.errors?._form?.[0] && ( {state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <p className="rounded-lg border border-[var(--danger)] bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]">
{state.errors._form[0]} {state.errors._form[0]}
</p> </p>
)} )}
@@ -219,7 +226,7 @@ export function AddFamilyDialog() {
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="ghost"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
disabled={pending} disabled={pending}
> >
@@ -268,7 +275,7 @@ function FormField({
required={required} required={required}
aria-invalid={!!error} aria-invalid={!!error}
/> />
{error && <p className="text-xs text-destructive">{error}</p>} {error && <p className="text-xs text-[var(--danger)]">{error}</p>}
</div> </div>
); );
} }
+23 -13
View File
@@ -107,10 +107,10 @@ export function DutyManager({
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1"> <Button variant="ghost" size="sm" className="h-8 gap-1">
<Shield className="h-4 w-4" /> <Shield className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Ämter</span> <span className="hidden sm:inline">Ämter</span>
{userAssignments.length > 0 && ( {userAssignments.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1 py-0 h-4"> <Badge variant="secondary" className="ml-1 h-4 px-1 py-0 tabular-nums">
{userAssignments.length} {userAssignments.length}
</Badge> </Badge>
)} )}
@@ -127,16 +127,20 @@ export function DutyManager({
<div className="flex flex-col gap-6 py-4"> <div className="flex flex-col gap-6 py-4">
{/* Current Assignments */} {/* Current Assignments */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h4 className="text-sm font-semibold">Aktuelle Ämter</h4> <h4 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Aktuelle Ämter
</h4>
{userAssignments.length === 0 ? ( {userAssignments.length === 0 ? (
<p className="text-sm text-muted-foreground italic">Keine Ämter zugewiesen.</p> <p className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-5 text-sm italic text-[var(--ink-faint)]">
Keine Ämter zugewiesen.
</p>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{userAssignments.map((assignment) => ( {userAssignments.map((assignment) => (
<Badge key={assignment.id} variant="default" className="gap-1 pr-1 bg-primary"> <Badge key={assignment.id} variant="default" className="gap-1 pr-1">
{assignment.duty.name} {assignment.duty.name}
<button <button
className="ml-1 rounded-full hover:bg-primary-foreground/20 p-0.5" className="ml-1 rounded-full p-0.5 transition-colors hover:bg-[oklch(0.98_0.005_45_/_0.18)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[oklch(0.98_0.005_45)]"
onClick={() => handleRemove(assignment.id)} onClick={() => handleRemove(assignment.id)}
disabled={isPending} disabled={isPending}
> >
@@ -149,17 +153,21 @@ export function DutyManager({
</div> </div>
{/* Assign new duty */} {/* Assign new duty */}
<div className="flex flex-col gap-2 border-t pt-4"> <div className="flex flex-col gap-2 border-t border-[var(--hairline-soft)] pt-4">
<h4 className="text-sm font-semibold">Vorhandenes Amt zuweisen</h4> <h4 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Vorhandenes Amt zuweisen
</h4>
{unassignedDuties.length === 0 ? ( {unassignedDuties.length === 0 ? (
<p className="text-sm text-muted-foreground italic">Keine weiteren Ämter verfügbar.</p> <p className="text-sm italic text-[var(--ink-faint)]">
Keine weiteren Ämter verfügbar.
</p>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{unassignedDuties.map((duty) => ( {unassignedDuties.map((duty) => (
<Badge <Badge
key={duty.id} key={duty.id}
variant="outline" variant="outline"
className="cursor-pointer hover:bg-muted gap-1 pr-1 group relative" className="group relative cursor-pointer gap-1 pr-1 hover:border-[var(--accent-mid)] hover:bg-[var(--accent-soft)] hover:text-[var(--accent-deep)]"
onClick={() => handleAssign(duty.id)} onClick={() => handleAssign(duty.id)}
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
@@ -168,7 +176,7 @@ export function DutyManager({
{/* Only show delete if no one is assigned to it across the entire DB */} {/* Only show delete if no one is assigned to it across the entire DB */}
{duty.assignments.length === 0 && ( {duty.assignments.length === 0 && (
<button <button
className="ml-1 rounded-full hover:bg-destructive/20 text-destructive p-0.5 opacity-0 group-hover:opacity-100 transition-opacity" className="ml-1 rounded-full p-0.5 text-[var(--danger)] opacity-0 transition-opacity hover:bg-[var(--danger-soft)] group-hover:opacity-100"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteDuty(duty.id); handleDeleteDuty(duty.id);
@@ -186,8 +194,10 @@ export function DutyManager({
</div> </div>
{/* Create new duty */} {/* Create new duty */}
<div className="flex flex-col gap-2 border-t pt-4"> <div className="flex flex-col gap-2 border-t border-[var(--hairline-soft)] pt-4">
<h4 className="text-sm font-semibold">Neues Amt anlegen</h4> <h4 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Neues Amt anlegen
</h4>
<form action={handleCreateDuty} className="flex gap-2"> <form action={handleCreateDuty} className="flex gap-2">
<Input name="name" placeholder="z.B. Wäschedienst" className="h-8" required /> <Input name="name" placeholder="z.B. Wäschedienst" className="h-8" required />
<Button size="sm" className="h-8" disabled={isPending}> <Button size="sm" className="h-8" disabled={isPending}>
@@ -163,7 +163,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
<Dialog onOpenChange={(open) => !open && resetState()}> <Dialog onOpenChange={(open) => !open && resetState()}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1"> <Button variant="ghost" size="sm" className="h-8 gap-1">
<Pencil className="h-4 w-4" /> <Pencil className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Details</span> <span className="hidden sm:inline">Details</span>
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@@ -189,11 +189,13 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
<fieldset className="grid gap-3"> <fieldset className="grid gap-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<legend className="text-sm font-semibold">Elternteile</legend> <legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Elternteile
</legend>
{parents.length < 2 && ( {parents.length < 2 && (
<Button <Button
type="button" type="button"
variant="outline" variant="pill"
size="sm" size="sm"
className="h-8 gap-1" className="h-8 gap-1"
onClick={addParent} onClick={addParent}
@@ -206,9 +208,12 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
</div> </div>
{parents.map((parent, index) => ( {parents.map((parent, index) => (
<div key={parent.id} className="rounded-md border p-3"> <div
key={parent.id}
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
>
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-medium text-muted-foreground"> <p className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Elternteil {index + 1} Elternteil {index + 1}
</p> </p>
{parent.kind === "new" && ( {parent.kind === "new" && (
@@ -216,7 +221,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 gap-1 px-2 text-destructive hover:text-destructive" className="h-7 gap-1 px-2 text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => removeNewParent(parent)} onClick={() => removeNewParent(parent)}
disabled={isPending} disabled={isPending}
> >
@@ -254,10 +259,12 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
<fieldset className="grid gap-3"> <fieldset className="grid gap-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<legend className="text-sm font-semibold">Kinder</legend> <legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Kinder
</legend>
<Button <Button
type="button" type="button"
variant="outline" variant="pill"
size="sm" size="sm"
className="h-8 gap-1" className="h-8 gap-1"
onClick={addChild} onClick={addChild}
@@ -269,21 +276,24 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
</div> </div>
{children.length === 0 ? ( {children.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-5 text-sm italic text-[var(--ink-faint)]">
Keine Kinder verknüpft. Keine Kinder verknüpft.
</p> </p>
) : ( ) : (
children.map((child, index) => ( children.map((child, index) => (
<div key={child.id} className="rounded-md border p-3"> <div
key={child.id}
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
>
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-medium text-muted-foreground"> <p className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Kind {index + 1} Kind {index + 1}
</p> </p>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 gap-1 px-2 text-destructive hover:text-destructive" className="h-7 gap-1 px-2 text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => removeChild(child)} onClick={() => removeChild(child)}
disabled={isPending} disabled={isPending}
> >
+7 -158
View File
@@ -2,18 +2,7 @@ import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils"; import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge"; import { FamiliesListClient } from "./_components/families-list-client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AddFamilyDialog } from "./add-family-dialog";
import { DutyManager } from "./duty-manager";
import { EditFamilyDialog } from "./edit-family-dialog";
export const metadata = { title: "Familienverwaltung · Kita-Planer" }; export const metadata = { title: "Familienverwaltung · Kita-Planer" };
@@ -31,7 +20,6 @@ export default async function FamiliesPage() {
const kitaId = session.user.kitaId!; const kitaId = session.user.kitaId!;
// Alle Haushalte der Kita mit Elternteilen und Kindern laden. // Alle Haushalte der Kita mit Elternteilen und Kindern laden.
// kitaId IMMER aus der Session — nie aus URL/Params
const families = await prisma.family.findMany({ const families = await prisma.family.findMany({
where: { kitaId }, where: { kitaId },
select: { select: {
@@ -80,154 +68,15 @@ export default async function FamiliesPage() {
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}); });
const childCount = families.reduce(
(acc, family) => acc + family.children.length,
0,
);
const canManageDuties = session.user.role === UserRole.ADMIN; const canManageDuties = session.user.role === UserRole.ADMIN;
return ( return (
<div className="px-8 py-8"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
{/* Header */} <FamiliesListClient
<div className="mb-6 flex items-center justify-between"> initialFamilies={families}
<div> allDuties={allDuties}
<h1 className="text-2xl font-semibold tracking-tight"> canManageDuties={canManageDuties}
Familienverwaltung />
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{families.length === 0
? "Noch keine Familien angelegt."
: `${families.length} ${families.length === 1 ? "Familie" : "Familien"} · ${childCount} Kinder`}
</p>
</div>
<AddFamilyDialog />
</div>
{families.length === 0 ? (
<EmptyState />
) : (
<div className="rounded-lg border bg-background">
<Table>
<TableHeader>
<TableRow>
<TableHead>Familie</TableHead>
<TableHead>Elternteile</TableHead>
<TableHead>Kinder</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{families.map((family) => {
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt,
);
return (
<TableRow key={family.id}>
<TableCell className="font-medium">
{family.name}
</TableCell>
<TableCell>
<div className="space-y-1.5">
{family.users.map((parent) => (
<div key={parent.id}>
<div className="font-medium">
{parent.firstName} {parent.lastName}
</div>
<div className="text-xs text-muted-foreground">
{parent.email}
</div>
</div>
))}
</div>
</TableCell>
<TableCell>
{family.children.length === 0 ? (
<span className="text-sm text-muted-foreground"></span>
) : (
<div className="flex flex-wrap gap-1">
{family.children.map((child) => (
<span
key={child.id}
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium"
>
{child.firstName} {child.lastName}
</span>
))}
</div>
)}
</TableCell>
<TableCell>
<Badge variant={hasPendingInvite ? "warning" : "success"}>
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex flex-wrap justify-end gap-1">
<EditFamilyDialog
family={{
id: family.id,
name: family.name,
parents: family.users.map((parent) => ({
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
})),
children: family.children,
}}
/>
{canManageDuties &&
family.users.map((parent) => (
<DutyManager
key={parent.id}
user={{
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
}}
allDuties={allDuties}
userAssignments={parent.dutyAssignments}
/>
))}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-background px-6 py-16 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<svg
className="h-6 w-6 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
</div>
<h3 className="mb-1 font-semibold">Noch keine Familien</h3>
<p className="mb-4 max-w-sm text-sm text-muted-foreground">
Lege die erste Familie an und lade das Elternteil per Link ein, sein
Passwort zu setzen.
</p>
<AddFamilyDialog />
</div> </div>
); );
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import type { ComponentProps, FormEvent } from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { TerminType } from "@prisma/client"; import { TerminType } from "@prisma/client";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -17,29 +18,131 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { createTerminAdmin } from "../actions"; import { createTerminAdmin, updateTerminAdmin } from "../actions";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
export function AdminTerminModal() { type AdminTerminFormState = {
const [open, setOpen] = useState(false); title: string;
const [isPending, startTransition] = useTransition(); description: string;
type: TerminType;
startDate: string;
endDate: string;
allDay: boolean;
};
const initialFormState: AdminTerminFormState = {
title: "",
description: "",
type: TerminType.KITA_FEST,
startDate: "",
endDate: "",
allDay: false,
};
type AdminTerminModalProps = {
triggerLabel?: string;
triggerVariant?: ComponentProps<typeof Button>["variant"];
triggerClassName?: string;
existingTermin?: {
id: string;
title: string;
description: string | null;
type: TerminType;
startDate: Date;
endDate: Date;
allDay: boolean;
};
};
export function AdminTerminModal({
triggerLabel = "Termin direkt anlegen",
triggerVariant,
triggerClassName = "gap-2",
existingTermin,
}: AdminTerminModalProps) {
const [open, setOpen] = useState(false);
const [formState, setFormState] = useState<AdminTerminFormState>(initialFormState);
const [isPending, startTransition] = useTransition();
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
const minEndDateTime = formState.startDate || minDateTime;
// Initialize and reset form fields when modal opens
useEffect(() => {
if (open) {
if (existingTermin) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setFormState({
title: existingTermin.title,
description: existingTermin.description || "",
type: existingTermin.type,
startDate: getLocalDateTimeInputValue(existingTermin.startDate),
endDate: getLocalDateTimeInputValue(existingTermin.endDate),
allDay: existingTermin.allDay,
});
} else {
setFormState(initialFormState);
}
}
}, [open, existingTermin]);
const updateField = <TKey extends keyof AdminTerminFormState>(
key: TKey,
value: AdminTerminFormState[TKey],
) => {
setFormState((current) => {
const next = { ...current, [key]: value };
if (key === "startDate" && typeof value === "string" && next.endDate && next.endDate < value) {
next.endDate = value;
}
return next;
});
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!formState.startDate || !formState.endDate) {
toast.error("Bitte Start und Ende ausfüllen.");
return;
}
// Only enforce future start date when creating a new event
if (!existingTermin && formState.startDate < minDateTime) {
toast.error("Der Startzeitpunkt darf nicht in der Vergangenheit liegen.");
return;
}
if (formState.endDate < formState.startDate) {
toast.error("Das Ende darf nicht vor dem Start liegen.");
return;
}
const handleAction = (formData: FormData) => {
const data = { const data = {
title: formData.get("title") as string, title: formState.title,
description: formData.get("description") as string, description: formState.description,
type: formData.get("type") as TerminType, type: formState.type,
startDate: new Date(formData.get("startDate") as string).toISOString(), startDate: new Date(formState.startDate).toISOString(),
endDate: new Date(formData.get("endDate") as string).toISOString(), endDate: new Date(formState.endDate).toISOString(),
allDay: formData.get("allDay") === "on", allDay: formState.allDay,
}; };
startTransition(async () => { startTransition(async () => {
const res = await createTerminAdmin(data); let res;
if (existingTermin) {
res = await updateTerminAdmin(existingTermin.id, data);
} else {
res = await createTerminAdmin(data);
}
if (res.error) { if (res.error) {
toast.error(res.error); toast.error(res.error);
} else { } else {
toast.success("Termin wurde direkt angelegt!"); toast.success(
existingTermin
? "Termin wurde erfolgreich aktualisiert!"
: "Termin wurde direkt angelegt!"
);
if (!existingTermin) {
setFormState(initialFormState);
}
setOpen(false); setOpen(false);
} }
}); });
@@ -48,29 +151,46 @@ export function AdminTerminModal() {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="gap-2 bg-primary"> <Button variant={triggerVariant} className={triggerClassName}>
<Plus className="h-4 w-4" /> {!existingTermin && <Plus className="h-4 w-4" />}
Termin direkt anlegen {triggerLabel}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<form action={handleAction}> <form onSubmit={handleSubmit}>
<DialogHeader> <DialogHeader>
<DialogTitle>Termin anlegen (Admin)</DialogTitle> <DialogTitle>
{existingTermin ? "Termin bearbeiten (Admin)" : "Termin anlegen (Admin)"}
</DialogTitle>
<DialogDescription> <DialogDescription>
Dieser Termin wird sofort freigegeben und ist für alle sichtbar. {existingTermin
? "Nimm Änderungen an diesem Termin vor. Die Änderungen werden sofort wirksam."
: "Dieser Termin wird sofort freigegeben und ist für alle sichtbar."}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="title">Titel</Label> <Label htmlFor="title">Titel</Label>
<Input id="title" name="title" required placeholder="z.B. Sommerfest" /> <Input
id="title"
name="title"
required
placeholder="z.B. Sommerfest"
value={formState.title}
onChange={(event) => updateField("title", event.target.value)}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="description">Beschreibung (Optional)</Label> <Label htmlFor="description">Beschreibung (Optional)</Label>
<Textarea id="description" name="description" placeholder="Details zum Termin..." /> <Textarea
id="description"
name="description"
placeholder="Details zum Termin..."
value={formState.description}
onChange={(event) => updateField("description", event.target.value)}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@@ -78,7 +198,9 @@ export function AdminTerminModal() {
<select <select
id="type" id="type"
name="type" name="type"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" value={formState.type}
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
onChange={(event) => updateField("type", event.target.value as TerminType)}
required required
> >
<option value={TerminType.KITA_FEST}>Kita-Fest</option> <option value={TerminType.KITA_FEST}>Kita-Fest</option>
@@ -97,16 +219,39 @@ export function AdminTerminModal() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="startDate">Start</Label> <Label htmlFor="startDate">Start</Label>
<Input id="startDate" name="startDate" type="datetime-local" required /> <Input
id="startDate"
name="startDate"
type="datetime-local"
min={existingTermin ? undefined : minDateTime}
value={formState.startDate}
onChange={(event) => updateField("startDate", event.target.value)}
required
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="endDate">Ende</Label> <Label htmlFor="endDate">Ende</Label>
<Input id="endDate" name="endDate" type="datetime-local" required /> <Input
id="endDate"
name="endDate"
type="datetime-local"
min={formState.startDate || (existingTermin ? undefined : minDateTime)}
value={formState.endDate}
onChange={(event) => updateField("endDate", event.target.value)}
required
/>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<input type="checkbox" id="allDay" name="allDay" className="rounded border-gray-300" /> <input
type="checkbox"
id="allDay"
name="allDay"
checked={formState.allDay}
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
onChange={(event) => updateField("allDay", event.target.checked)}
/>
<Label htmlFor="allDay" className="font-normal cursor-pointer"> <Label htmlFor="allDay" className="font-normal cursor-pointer">
Ganztägiger Termin Ganztägiger Termin
</Label> </Label>
@@ -118,7 +263,7 @@ export function AdminTerminModal() {
Abbrechen Abbrechen
</Button> </Button>
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : "Termin erstellen"} {isPending ? "Speichern..." : existingTermin ? "Änderungen speichern" : "Termin erstellen"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -126,3 +271,10 @@ export function AdminTerminModal() {
</Dialog> </Dialog>
); );
} }
function getLocalDateTimeInputValue(date: Date) {
const localDate = new Date(date);
localDate.setSeconds(0, 0);
const offset = localDate.getTimezoneOffset() * 60_000;
return new Date(localDate.getTime() - offset).toISOString().slice(0, 16);
}
@@ -0,0 +1,934 @@
"use client";
import { useState, useMemo, useTransition } from "react";
import {
TerminType,
TerminStatus,
EventParticipationStatus,
DutyAssignmentStatus,
} from "@prisma/client";
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameMonth,
isSameDay,
addMonths,
subMonths,
} from "date-fns";
import { de } from "date-fns/locale";
import {
Search,
ListFilter,
ChevronLeft,
ChevronRight,
Calendar as CalendarIcon,
Clock,
MapPin,
AlertTriangle,
WashingMachine,
MoreHorizontal,
Trash2,
Edit,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { CalendarListItemDto } from "./termin-list";
import { TerminList } from "./termin-list";
import { MitbringselList } from "./mitbringsel-list";
import { AdminTerminModal } from "./admin-termin-modal";
import { deleteTerminAdmin } from "../actions";
const categories = [
{ label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" },
{ label: "Teamtag", type: TerminType.TEAMTAG, color: "var(--danger)" },
{ label: "Elternabend", type: TerminType.ELTERNABEND, color: "oklch(0.50 0.10 280)" },
{ label: "Fest", type: TerminType.KITA_FEST, color: "var(--good)" },
{ label: "Geburtstag", type: TerminType.GEBURTSTAG_INTERN, color: "oklch(0.50 0.10 320)" },
{ label: "Mein Elterndienst", type: "mein-elterndienst", color: "oklch(0.60 0.16 160)" },
{ label: "Elterndienst", type: "elterndienst", color: "oklch(0.75 0.08 160)" },
{ label: "Mein Notdienst", type: "mein-notdienst", color: "oklch(0.60 0.18 45)" },
{ label: "Notdienst", type: "notdienst", color: "oklch(0.75 0.10 50)" },
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" },
];
export function CalendarOverviewClient({
termine,
userId,
isAdmin,
}: {
termine: CalendarListItemDto[];
userId: string;
isAdmin: boolean;
}) {
const [view, setView] = useState<"liste" | "monat" | "agenda">("monat");
const [searchQuery, setSearchQuery] = useState("");
const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set());
const [filterScope, setFilterScope] = useState<"own" | "all">(isAdmin ? "all" : "own");
// Month navigation state
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
// Dialog state for day clicking in Month view
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
// Delete event states
const [deleteEventId, setDeleteEventId] = useState<string | null>(null);
const [deleteEventTitle, setDeleteEventTitle] = useState("");
const [isDeleting, startDeleteTransition] = useTransition();
const toggleType = (type: string) => {
setActiveTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
return next;
});
};
// Filter logic
const filteredEvents = useMemo(() => {
return termine.filter((item) => {
// 0. Filter by scope (own vs all)
if (filterScope === "own") {
if (item.kind === "duty" && !item.isOwn) return false;
if (item.kind === "notdienst" && !item.isOwn) return false;
}
// 1. Search Query filter
const query = searchQuery.trim().toLowerCase();
if (query) {
const titleMatch = item.title.toLowerCase().includes(query);
const descMatch =
item.kind === "termin" && item.description
? item.description.toLowerCase().includes(query)
: false;
const familyMatch =
(item.kind === "duty" || item.kind === "notdienst") && item.familyName
? item.familyName.toLowerCase().includes(query)
: false;
const childMatch =
item.kind === "notdienst" && item.childName
? item.childName.toLowerCase().includes(query)
: false;
if (!titleMatch && !descMatch && !familyMatch && !childMatch) return false;
}
// 2. Category filter
if (activeTypes.size > 0) {
const itemType =
item.kind === "duty"
? item.isOwn
? "mein-elterndienst"
: "elterndienst"
: item.kind === "notdienst"
? item.isOwn
? "mein-notdienst"
: "notdienst"
: item.type;
if (!activeTypes.has(itemType)) return false;
}
return true;
});
}, [termine, searchQuery, activeTypes, filterScope]);
// Check if an event falls on a specific day
const isEventOnDay = (item: CalendarListItemDto, day: Date) => {
const eventStart = new Date(item.startDate);
const eventEnd = new Date(item.endDate);
const dayTime = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
const startTime = new Date(eventStart.getFullYear(), eventStart.getMonth(), eventStart.getDate()).getTime();
const endTime = new Date(eventEnd.getFullYear(), eventEnd.getMonth(), eventEnd.getDate()).getTime();
return dayTime >= startTime && dayTime <= endTime;
};
// Get events for selected day (using all filtered events or all events? We use filtered to keep visual filters consistent!)
const selectedDayEvents = useMemo(() => {
if (!selectedDay) return [];
return filteredEvents.filter((item) => isEventOnDay(item, selectedDay));
}, [selectedDay, filteredEvents]);
// Handle event delete
const handleDeleteConfirm = () => {
if (!deleteEventId) return;
startDeleteTransition(async () => {
const res = await deleteTerminAdmin(deleteEventId);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Termin wurde erfolgreich gelöscht.");
setDeleteEventId(null);
// If deleting from the day dialog, update selectedDay if it becomes empty or keep it open
}
});
};
return (
<div className="space-y-5">
{/* Search and Filters Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center flex-wrap gap-3">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
{(["liste", "monat", "agenda"] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
className={[
"h-9 px-4 text-[13px] font-semibold transition",
v !== "liste" ? "border-l border-[var(--hairline-soft)]" : "",
view === v
? "bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
].join(" ")}
>
{v.charAt(0).toUpperCase() + v.slice(1)}
</button>
))}
</div>
{!isAdmin && (
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)] p-0.5">
<button
type="button"
onClick={() => setFilterScope("own")}
className={[
"h-8 px-4 rounded-full text-[12px] font-semibold transition-all duration-200",
filterScope === "own"
? "bg-[var(--accent-deep)] text-[var(--surface)] shadow-sm"
: "text-[var(--ink-soft)] hover:text-[var(--ink)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Meine Dienste
</button>
<button
type="button"
onClick={() => setFilterScope("all")}
className={[
"h-8 px-4 rounded-full text-[12px] font-semibold transition-all duration-200",
filterScope === "all"
? "bg-[var(--accent-deep)] text-[var(--surface)] shadow-sm"
: "text-[var(--ink-soft)] hover:text-[var(--ink)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Alle Dienste
</button>
</div>
)}
</div>
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
{/* Category chips */}
<div className="flex flex-wrap gap-1.5">
<span className="inline-flex h-9 items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-medium text-[var(--ink-soft)]">
<ListFilter className="h-3.5 w-3.5 [stroke-width:1.7]" />
Filter
</span>
{categories.map((cat) => {
const isActive = activeTypes.has(cat.type);
return (
<button
key={cat.label}
type="button"
onClick={() => toggleType(cat.type)}
className={[
"inline-flex h-9 items-center gap-1.5 rounded-full border px-3 text-[13px] font-medium transition",
isActive
? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
].join(" ")}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: cat.color }}
/>
{cat.label}
</button>
);
})}
</div>
<div className="relative min-w-56">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input
placeholder="Termine suchen"
className="h-9 pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
</div>
{/* Main View Contents */}
{view === "liste" && (
<TerminList termine={filteredEvents} userId={userId} isAdmin={isAdmin} />
)}
{view === "monat" && (
<MonthView
currentMonthDate={currentMonthDate}
setCurrentMonthDate={setCurrentMonthDate}
events={filteredEvents}
isEventOnDay={isEventOnDay}
onDayClick={(day) => setSelectedDay(day)}
/>
)}
{view === "agenda" && (
<AgendaView
events={filteredEvents}
isAdmin={isAdmin}
userId={userId}
onDeleteTermin={(id, title) => {
setDeleteEventId(id);
setDeleteEventTitle(title);
}}
/>
)}
{/* Day details Dialog for Month View */}
<Dialog open={!!selectedDay} onOpenChange={(open) => !open && setSelectedDay(null)}>
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-xl">
{selectedDay && format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })}
</DialogTitle>
<DialogDescription>
Übersicht aller Zuteilungen, Feste und Einträge an diesem Tag.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
{selectedDayEvents.length === 0 ? (
<p className="text-sm italic text-[var(--ink-soft)] text-center py-8">
Keine Termine oder Dienste an diesem Tag.
</p>
) : (
selectedDayEvents.map((item) => (
<div
key={item.id}
className="relative rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm"
>
{item.kind === "duty" ? (
<div className="flex items-start gap-3">
<div
className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
style={{
backgroundColor: item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)",
}}
/>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--ink-soft)] ml-2">
<WashingMachine className="h-5 w-5 [stroke-width:1.7]" style={{ color: item.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)" }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-semibold text-[15px] text-[var(--ink)] truncate">
{item.title}
</h4>
<Badge
variant="secondary"
style={{
color: item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)",
borderColor:
(item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)") + "44",
backgroundColor:
(item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)") + "11",
}}
className="border text-[10px]"
>
{item.isOwn ? "Mein Elterndienst" : "Elterndienst"}
</Badge>
</div>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Familie: {item.familyName}
</p>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{format(new Date(item.startDate), "dd.MM.")} -{" "}
{format(new Date(item.endDate), "dd.MM.yyyy")}
</p>
{isAdmin && (
<div className="mt-3 flex gap-2">
<a href="/dashboard/admin/dienste">
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
Im Planer bearbeiten
</Button>
</a>
</div>
)}
</div>
</div>
) : item.kind === "notdienst" ? (
<div className="flex items-start gap-3">
<div
className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
style={{
backgroundColor: item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)",
}}
/>
<div className="pl-2 flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-semibold text-[15px] text-[var(--ink)] truncate">
{item.title}
</h4>
<Badge
variant="secondary"
style={{
color: item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)",
borderColor:
(item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)") + "44",
backgroundColor:
(item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)") + "11",
}}
className="border text-[10px]"
>
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
</Badge>
</div>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Kind: {item.childName} (Familie: {item.familyName})
</p>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{format(new Date(item.startDate), "dd.MM.yyyy")}
</p>
{isAdmin && (
<div className="mt-3 flex gap-2">
<a href="/dashboard/notdienst/plan">
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
Im Planer bearbeiten
</Button>
</a>
</div>
)}
</div>
</div>
) : (
<div className="flex items-start gap-3">
<div
className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
style={{ backgroundColor: getCategoryColor(item.type) }}
/>
<div className="pl-2 flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-semibold text-[15px] text-[var(--ink)] truncate">
{item.title}
</h4>
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold border border-[var(--hairline)]"
style={{
color: getCategoryColor(item.type),
borderColor: getCategoryColor(item.type) + "44",
backgroundColor: getCategoryColor(item.type) + "11",
}}
>
{getTypeLabel(item.type)}
</span>
</div>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{item.allDay
? "Ganztägig"
: `${format(new Date(item.startDate), "HH:mm")} - ${format(
new Date(item.endDate),
"HH:mm",
)}`}
</p>
{item.description && (
<p className="mt-2 text-sm text-[var(--ink-soft)] whitespace-pre-wrap leading-relaxed">
{item.description}
</p>
)}
{item.participantInfo && item.showParticipantInfo && (
<div className="mt-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-3 text-xs text-[var(--ink)]">
<div className="mb-0.5 flex items-center gap-1.5 font-medium">
<AlertTriangle className="h-3.5 w-3.5 text-[var(--warn)]" />
Hinweis für Teilnehmer
</div>
<p className="whitespace-pre-wrap">{item.participantInfo}</p>
</div>
)}
{item.mitbringselListEnabled && (
<div className="mt-3">
<MitbringselList
terminId={item.id}
items={item.mitbringselItems || []}
currentUserId={userId}
isAdmin={isAdmin}
/>
</div>
)}
{isAdmin && (
<div className="mt-3 flex gap-2 border-t border-[var(--hairline-soft)] pt-3">
<AdminTerminModal
existingTermin={item}
triggerLabel="Bearbeiten"
triggerVariant="outline"
triggerClassName="h-7 text-xs gap-1 px-2.5"
/>
<Button
size="sm"
variant="outline"
className="h-7 text-xs text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)] gap-1 px-2.5 border-[var(--hairline)]"
onClick={() => {
setDeleteEventId(item.id);
setDeleteEventTitle(item.title);
}}
>
<Trash2 className="h-3 w-3" />
Löschen
</Button>
</div>
)}
</div>
</div>
)}
</div>
))
)}
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteEventId} onOpenChange={(open) => !open && setDeleteEventId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
<DialogDescription>
Möchtest du den Termin &ldquo;{deleteEventTitle}&rdquo; wirklich löschen? Alle
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeleteEventId(null)}
disabled={isDeleting}
>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={isDeleting}
>
{isDeleting ? "Wird gelöscht..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// =====================================================================
// Month Grid Component
// =====================================================================
function MonthView({
currentMonthDate,
setCurrentMonthDate,
events,
isEventOnDay,
onDayClick,
}: {
currentMonthDate: Date;
setCurrentMonthDate: (date: Date) => void;
events: CalendarListItemDto[];
isEventOnDay: (item: CalendarListItemDto, day: Date) => boolean;
onDayClick: (day: Date) => void;
}) {
const monthStart = startOfMonth(currentMonthDate);
const monthEnd = endOfMonth(monthStart);
const startDate = startOfWeek(monthStart, { weekStartsOn: 1 });
const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 });
const days = useMemo(() => {
return eachDayOfInterval({ start: startDate, end: endDate });
}, [startDate, endDate]);
const weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
return (
<Card className="overflow-hidden border border-[var(--hairline)] shadow-sm">
<div className="flex items-center justify-between border-b border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-3">
<h3 className="text-[16px] font-semibold text-[var(--ink)]">
{format(currentMonthDate, "MMMM yyyy", { locale: de })}
</h3>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setCurrentMonthDate(subMonths(currentMonthDate, 1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => setCurrentMonthDate(new Date())}
className="h-8 text-xs font-semibold px-2.5"
>
Heute
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setCurrentMonthDate(addMonths(currentMonthDate, 1))}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-7 border-b border-[var(--hairline-soft)] bg-[var(--surface-3)]/20 py-2 text-center text-xs font-semibold text-[var(--ink-soft)]">
{weekdays.map((day) => (
<div key={day}>{day}</div>
))}
</div>
<div className="grid grid-cols-7 bg-[var(--hairline-soft)] gap-[1px]">
{days.map((day, idx) => {
const isCurrentMonth = isSameMonth(day, currentMonthDate);
const isToday = isSameDay(day, new Date());
const dayEvents = events.filter((item) => isEventOnDay(item, day));
return (
<div
key={day.toString() + idx}
onClick={() => onDayClick(day)}
className={[
"min-h-[105px] bg-[var(--surface)] p-2 transition-colors hover:bg-[var(--surface-2)] cursor-pointer flex flex-col justify-between",
!isCurrentMonth ? "bg-[var(--surface-3)]/30 text-[var(--ink-muted)] opacity-60" : "text-[var(--ink)]",
].join(" ")}
>
{/* Day number cell top */}
<div className="flex items-center justify-between">
<span
className={[
"text-xs font-semibold flex h-6 w-6 items-center justify-center rounded-full",
isToday
? "bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)] shadow-sm"
: "",
].join(" ")}
>
{format(day, "d")}
</span>
</div>
{/* Event indicators/snippets */}
<div className="mt-2 space-y-1 flex-1 flex flex-col justify-end">
{dayEvents.slice(0, 3).map((item) => {
const color =
item.kind === "duty"
? item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)"
: item.kind === "notdienst"
? item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)"
: getCategoryColor(item.type);
return (
<div
key={item.id}
className="group relative flex items-center gap-1 rounded bg-[var(--surface-2)] px-1.5 py-0.5 text-[10px] font-medium border border-[var(--hairline-soft)]"
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="truncate text-[var(--ink-soft)] max-w-full">
{item.title}
</span>
</div>
);
})}
{dayEvents.length > 3 && (
<div className="text-[9px] font-semibold text-[var(--accent-deep)] pl-1.5">
+ {dayEvents.length - 3} weitere
</div>
)}
</div>
</div>
);
})}
</div>
</Card>
);
}
// =====================================================================
// Agenda Timeline View Component
// =====================================================================
function AgendaView({
events,
isAdmin,
userId,
onDeleteTermin,
}: {
events: CalendarListItemDto[];
isAdmin: boolean;
userId: string;
onDeleteTermin: (id: string, title: string) => void;
}) {
const grouped = useMemo(() => {
const groups = new Map<string, { date: Date; items: CalendarListItemDto[] }>();
events.forEach((item) => {
const key = format(new Date(item.startDate), "yyyy-MM-dd");
const group = groups.get(key) ?? { date: new Date(item.startDate), items: [] };
group.items.push(item);
groups.set(key, group);
});
return Array.from(groups.values()).sort((a, b) => a.date.getTime() - b.date.getTime());
}, [events]);
if (events.length === 0) {
return (
<Card>
<CardContent className="flex min-h-[220px] flex-col items-center justify-center text-center p-6">
<CalendarIcon className="h-8 w-8 text-[var(--ink-muted)] mb-2" />
<h3 className="text-[16px] font-semibold text-[var(--ink)]">
Keine Termine oder Dienste gefunden
</h3>
<p className="text-sm text-[var(--ink-soft)] mt-1 max-w-xs">
Passe deine Filter an oder suche nach einem anderen Begriff.
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6 pl-4 border-l-2 border-[var(--hairline)]">
{grouped.map((group) => (
<div key={group.date.toString()} className="relative grid grid-cols-[110px_1fr] gap-4 items-start">
{/* Timeline marker */}
<div className="absolute -left-[21px] top-1.5 h-3.5 w-3.5 rounded-full border-2 border-[var(--accent-deep)] bg-[var(--surface)]" />
{/* Date Label on left */}
<div className="pt-0.5">
<div className="text-[14px] font-bold text-[var(--ink)] leading-none">
{format(group.date, "d. MMMM", { locale: de })}
</div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-[var(--ink-muted)] mt-1">
{format(group.date, "EEEE", { locale: de })}
</div>
</div>
{/* Items stack on right */}
<div className="space-y-3">
{group.items.map((item) => {
const color =
item.kind === "termin"
? getCategoryColor(item.type)
: item.kind === "notdienst"
? item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)"
: item.kind === "duty"
? item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)"
: "oklch(0.60 0.15 160)";
return (
<div
key={item.id}
className="group relative flex items-start justify-between gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm transition-shadow hover:shadow-md"
>
<div className="flex items-start gap-3 min-w-0">
<div
className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
style={{ backgroundColor: color }}
/>
<div className="pl-1 min-w-0">
<div className="flex items-center flex-wrap gap-2">
<h4 className="font-bold text-[15px] text-[var(--ink)] truncate">
{item.title}
</h4>
{item.kind === "termin" ? (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold border"
style={{
color,
borderColor: color + "44",
backgroundColor: color + "11",
}}
>
{getTypeLabel(item.type)}
</span>
) : item.kind === "notdienst" ? (
<Badge
variant="secondary"
className="text-[10px]"
style={{
color,
borderColor: color + "44",
backgroundColor: color + "11",
}}
>
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
</Badge>
) : (
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]">
Elterndienst
</Badge>
)}
{item.kind === "termin" && item.status === TerminStatus.PENDING && (
<Badge variant="warning" className="text-[10px]">Ausstehend</Badge>
)}
</div>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
{item.kind === "termin"
? item.allDay
? "Ganztägig"
: `${format(new Date(item.startDate), "HH:mm")} - ${format(
new Date(item.endDate),
"HH:mm",
)}`
: item.kind === "notdienst"
? `${format(new Date(item.startDate), "dd.MM.yyyy")} (ganztägig)`
: `${format(new Date(item.startDate), "dd.MM.")} - ${format(
new Date(item.endDate),
"dd.MM.yyyy",
)}`}
{item.kind === "termin" && <MapPin className="ml-1 h-3.5 w-3.5" />}
{item.kind === "termin"
? "Kita"
: item.kind === "notdienst"
? `Kind: ${item.childName} · Familie: ${item.familyName}`
: `Familie: ${item.familyName}`}
</p>
{item.kind === "termin" && item.description && (
<p className="mt-2 text-sm text-[var(--ink-soft)] line-clamp-2 leading-relaxed">
{item.description}
</p>
)}
</div>
</div>
{/* Actions for admins */}
{isAdmin && item.kind === "termin" && (
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<AdminTerminModal
existingTermin={item}
triggerLabel=""
triggerVariant="ghost"
triggerClassName="h-8 w-8 p-0"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => onDeleteTermin(item.id, item.title)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
})}
</div>
</div>
))}
</div>
);
}
// =====================================================================
// Helper functions for categories/labels
// =====================================================================
function getCategoryColor(type: TerminType): string {
switch (type) {
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "var(--good)";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "var(--danger)";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "oklch(0.50 0.10 320)";
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "oklch(0.50 0.10 280)";
default:
return "var(--ink-muted)";
}
}
function getTypeLabel(type: TerminType): string {
switch (type) {
case TerminType.KITA_FEST:
return "Kita-Fest";
case TerminType.MITMACH_TAG:
return "Mitmach-Tag";
case TerminType.SCHLIESSTAG:
return "Schließtag";
case TerminType.TEAMTAG:
return "Teamtag";
case TerminType.ELTERNABEND:
return "Elternabend";
case TerminType.MITGLIEDERVERSAMMLUNG:
return "Mitgliederversammlung";
case TerminType.ELTERNCAFE:
return "Elterncafe";
case TerminType.GEBURTSTAG_INTERN:
return "Geburtstag";
case TerminType.GEBURTSTAG_EXTERN:
return "Raumanfrage";
default:
return "Sonstiges";
}
}
@@ -55,23 +55,25 @@ export function MitbringselList({
}; };
return ( return (
<div className="flex flex-col gap-3 rounded-md bg-muted/50 p-3"> <div className="flex flex-col gap-3 rounded-lg bg-[var(--surface-2)] p-3">
<div className="flex items-center gap-2 font-medium text-sm mb-1"> <div className="mb-1 flex items-center gap-2 text-sm font-medium text-[var(--ink)]">
<Utensils className="h-4 w-4 text-muted-foreground" /> <Utensils className="h-4 w-4 text-[var(--ink-muted)] [stroke-width:1.7]" />
Mitbring-Liste Mitbring-Liste
</div> </div>
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto"> <div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-xs text-muted-foreground italic">Noch keine Einträge vorhanden.</p> <p className="text-xs italic text-[var(--ink-faint)]">
Noch keine Einträge vorhanden.
</p>
) : ( ) : (
items.map((item) => ( items.map((item) => (
<div <div
key={item.id} key={item.id}
className="flex items-center justify-between gap-2 rounded-sm bg-background p-2 text-sm shadow-sm" className="flex items-center justify-between gap-2 rounded-md border border-[var(--hairline-soft)] bg-[var(--surface)] p-2 text-sm text-[var(--ink)]"
> >
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-xs text-primary"> <span className="text-xs font-medium text-[var(--accent-deep)]">
{item.user.firstName} {item.user.lastName} {item.user.firstName} {item.user.lastName}
</span> </span>
<span>{item.content}</span> <span>{item.content}</span>
@@ -80,11 +82,11 @@ export function MitbringselList({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive shrink-0" className="h-6 w-6 shrink-0 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => handleDelete(item.id)} onClick={() => handleDelete(item.id)}
disabled={isPending} disabled={isPending}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button> </Button>
)} )}
</div> </div>
@@ -112,7 +114,7 @@ export function MitbringselList({
onClick={handleAdd} onClick={handleAdd}
disabled={!content.trim() || isPending} disabled={!content.trim() || isPending}
> >
<Plus className="h-4 w-4 mr-1" /> <Plus className="mr-1 h-4 w-4 [stroke-width:1.7]" />
Hinzufügen Hinzufügen
</Button> </Button>
</div> </div>
@@ -3,17 +3,20 @@
import { useTransition } from "react"; import { useTransition } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { Check, X, Clock, CalendarIcon } from "lucide-react"; import { CalendarIcon, Check, Clock, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { approveTermin, rejectTermin } from "../actions"; import { approveTermin, rejectTermin } from "../actions";
export type PendingTerminDto = { export type PendingTerminDto = {
id: string; id: string;
title: string; title: string;
startDate: Date; startDate: Date;
createdAt: Date;
ageInDays: number;
allDay: boolean; allDay: boolean;
createdBy: { firstName: string; lastName: string } | null; createdBy: { firstName: string; lastName: string } | null;
}; };
@@ -21,22 +24,28 @@ export type PendingTerminDto = {
export function PendingAnfragen({ termine }: { termine: PendingTerminDto[] }) { export function PendingAnfragen({ termine }: { termine: PendingTerminDto[] }) {
if (termine.length === 0) { if (termine.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50"> <Card>
<CalendarIcon className="h-10 w-10 text-muted-foreground mb-4" /> <CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center p-8 text-center animate-in fade-in-50">
<h3 className="text-lg font-semibold">Keine ausstehenden Anfragen</h3> <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<p className="text-sm text-muted-foreground"> <CalendarIcon className="h-6 w-6 [stroke-width:1.7]" />
Es gibt aktuell keine Terminanfragen, die bestätigt werden müssen. </div>
</p> <h3 className="text-lg font-medium text-[var(--ink)]">
</div> Keine ausstehenden Anfragen
</h3>
<p className="mt-2 text-sm text-[var(--ink-soft)]">
Es gibt aktuell keine Terminanfragen, die bestätigt werden müssen.
</p>
</CardContent>
</Card>
); );
} }
return ( return (
<div className="flex flex-col gap-4"> <Card className="overflow-hidden p-0">
{termine.map((termin) => ( {termine.map((termin) => (
<PendingTerminCard key={termin.id} termin={termin} /> <PendingTerminCard key={termin.id} termin={termin} />
))} ))}
</div> </Card>
); );
} }
@@ -67,40 +76,57 @@ function PendingTerminCard({ termin }: { termin: PendingTerminDto }) {
}; };
return ( return (
<Card> <div className="grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 border-b border-[var(--hairline-soft)] px-5 py-4 last:border-b-0 hover:bg-[var(--surface-2)]">
<CardContent className="flex items-center justify-between p-4 sm:p-6"> <div className="flex h-14 w-14 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)]">
<div className="flex flex-col gap-1"> <span className="text-[22px] font-medium leading-none tracking-[-0.02em] text-[var(--ink)] tabular-nums">
<div className="font-semibold">{termin.title}</div> {format(termin.startDate, "d", { locale: de })}
<div className="text-sm text-muted-foreground flex items-center gap-1"> </span>
<Clock className="h-3.5 w-3.5" /> <span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.08em]">
{format(termin.startDate, "PP", { locale: de })} {format(termin.startDate, "EEE", { locale: de })}
{!termin.allDay && `${format(termin.startDate, "p", { locale: de })}`} </span>
</div> </div>
<div className="text-sm font-medium text-primary mt-1">
Angefragt von: {termin.createdBy?.firstName} {termin.createdBy?.lastName}
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2"> <div className="min-w-0">
<Button <div className="flex flex-wrap items-center gap-2">
variant="outline" <h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
className="text-destructive hover:bg-destructive hover:text-destructive-foreground border-destructive/20" {termin.title}
onClick={handleReject} </h3>
disabled={isPending} {termin.ageInDays > 7 && (
> <Badge variant="warning">Älter als 7 Tage</Badge>
<X className="h-4 w-4 sm:mr-2" /> )}
<span className="hidden sm:inline">Ablehnen</span>
</Button>
<Button
className="bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={handleApprove}
disabled={isPending}
>
<Check className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Freigeben</span>
</Button>
</div> </div>
</CardContent> <p className="mt-1 flex items-center gap-1 text-sm text-[var(--ink-soft)]">
</Card> <Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(termin.startDate, "PP", { locale: de })}
{!termin.allDay &&
` · ${format(termin.startDate, "p", { locale: de })}`}
</p>
<p className="mt-1 text-sm font-medium text-[var(--accent-deep)]">
Angefragt von: {termin.createdBy?.firstName}{" "}
{termin.createdBy?.lastName} · vor {termin.ageInDays} Tagen
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
variant="ghost"
size="sm"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={handleReject}
disabled={isPending}
>
<X className="h-4 w-4 [stroke-width:1.7]" />
Ablehnen
</Button>
<Button
size="sm"
onClick={handleApprove}
disabled={isPending}
>
<Check className="h-4 w-4 [stroke-width:1.7]" />
Genehmigen
</Button>
</div>
</div>
); );
} }
@@ -1,19 +1,38 @@
"use client"; "use client";
import { useState, useTransition } from "react";
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client"; import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { AlignLeft, Calendar, Clock, WashingMachine } from "lucide-react"; import {
AlertTriangle,
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; Calendar,
import { Badge } from "@/components/ui/badge"; Clock,
import { MitbringselList } from "./mitbringsel-list"; MapPin,
import { toggleMitbringselList } from "../actions"; MoreHorizontal,
import { Switch } from "@/components/ui/switch"; WashingMachine,
import { Label } from "@/components/ui/label"; Trash2,
import { useTransition } from "react"; ShieldAlert,
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from "@/components/ui/dialog";
import { AdminTerminModal } from "./admin-termin-modal";
import { TerminRequestModal } from "./termin-request-modal";
import { MitbringselList } from "./mitbringsel-list";
import { deleteTerminAdmin } from "../actions";
export type TerminListItemDto = { export type TerminListItemDto = {
kind: "termin"; kind: "termin";
id: string; id: string;
@@ -25,6 +44,9 @@ export type TerminListItemDto = {
endDate: Date; endDate: Date;
allDay: boolean; allDay: boolean;
mitbringselListEnabled: boolean; mitbringselListEnabled: boolean;
requiresRsvp: boolean;
participantInfo: string | null;
showParticipantInfo: boolean;
mitbringselItems?: { mitbringselItems?: {
id: string; id: string;
userId: string; userId: string;
@@ -41,9 +63,21 @@ export type DutyCalendarItemDto = {
status: DutyAssignmentStatus; status: DutyAssignmentStatus;
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
isOwn: boolean;
}; };
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto; export type NotdienstCalendarItemDto = {
kind: "notdienst";
id: string;
title: string;
familyName: string;
childName: string;
isOwn: boolean;
startDate: Date;
endDate: Date;
};
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto | NotdienstCalendarItemDto;
export function TerminList({ export function TerminList({
termine, termine,
@@ -56,68 +90,126 @@ export function TerminList({
}) { }) {
if (termine.length === 0) { if (termine.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50"> <Card>
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center"> <CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<Calendar className="h-10 w-10 text-muted-foreground mb-4" /> <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<h3 className="mt-4 text-lg font-semibold">Keine Termine</h3> <Calendar className="h-6 w-6 [stroke-width:1.7]" />
<p className="mb-4 mt-2 text-sm text-muted-foreground"> </div>
Es stehen aktuell keine Termine an. <h3 className="mt-5 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine Termine in diesem Zeitraum
</h3>
<p className="mt-2 max-w-md text-sm leading-6 text-[var(--ink-soft)]">
Lege den ersten Termin an, damit Eltern planen können.
</p> </p>
</div> <div className="mt-5">
</div> {isAdmin ? (
<AdminTerminModal triggerLabel="+ Termin anlegen" triggerClassName="" />
) : (
<TerminRequestModal
triggerLabel="+ Termin anlegen"
triggerVariant="default"
triggerClassName=""
/>
)}
</div>
</CardContent>
</Card>
); );
} }
const groups = groupByMonth(termine);
return ( return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="space-y-8">
{termine.map((termin) => ( {groups.map((group) => (
termin.kind === "duty" ? ( <section key={group.key}>
<DutyCard key={`duty-${termin.id}`} duty={termin} /> <div className="mb-3 flex items-center justify-between gap-3">
) : ( <h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
<TerminCard {group.label}
key={`termin-${termin.id}`} </h2>
termin={termin} <span className="rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)] tabular-nums">
userId={userId} {group.items.length} {group.items.length === 1 ? "Termin" : "Termine"}
isAdmin={isAdmin} </span>
/> </div>
) <Card className="overflow-hidden p-0">
<div className="divide-y divide-[var(--hairline-soft)]">
{group.items.map((item) =>
item.kind === "duty" ? (
<DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} />
) : item.kind === "notdienst" ? (
<NotdienstRow key={`notdienst-${item.id}`} notdienst={item} isAdmin={isAdmin} />
) : (
<TerminRow key={`termin-${item.id}`} termin={item} userId={userId} isAdmin={isAdmin} />
),
)}
</div>
</Card>
</section>
))} ))}
</div> </div>
); );
} }
function DutyCard({ duty }: { duty: DutyCalendarItemDto }) { function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolean }) {
const color = duty.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)";
const [menuOpen, setMenuOpen] = useState(false);
return ( return (
<Card className="flex flex-col overflow-hidden border-emerald-200 bg-emerald-50/60"> <div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<CardHeader className="pb-3"> <div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<div className="mb-2 flex items-start justify-between gap-2"> <DateTile date={duty.startDate} />
<Badge className="border-transparent bg-emerald-600 text-white hover:bg-emerald-700"> <div className="min-w-0">
Elterndienst <h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
</Badge>
</div>
<CardTitle className="line-clamp-2 leading-tight">
{duty.title} {duty.title}
</CardTitle> </h3>
<CardDescription className="mt-1 flex items-center gap-1 text-sm"> <p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5" /> <Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(duty.startDate, "dd.MM.", { locale: de })} -{" "} {format(duty.startDate, "dd.MM.", { locale: de })} -{" "}
{format(duty.endDate, "dd.MM.yyyy", { locale: de })} {format(duty.endDate, "dd.MM.yyyy", { locale: de })}
</CardDescription> <WashingMachine className="ml-1 h-3.5 w-3.5 [stroke-width:1.7]" />
</CardHeader> {duty.familyName}
</p>
<CardContent className="flex-1 pb-4"> </div>
<div className="flex items-start gap-2 text-sm text-emerald-900"> <div className="flex items-center gap-2 relative">
<WashingMachine className="mt-0.5 h-4 w-4 shrink-0" /> <CategoryPill color={color}>
<p> {duty.isOwn ? "Mein Elterndienst" : "Elterndienst"}
Eingeteilt: <span className="font-medium">{duty.familyName}</span> </CategoryPill>
</p> {isAdmin ? (
</div> <div className="relative">
</CardContent> <Button
</Card> variant="ghost"
size="icon"
aria-label="Mehr Optionen"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
>
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<a
href="/dashboard/admin/dienste"
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]"
onClick={() => setMenuOpen(false)}
>
Dienst im Planer bearbeiten
</a>
</div>
</>
)}
</div>
) : (
<div className="w-9 h-9" />
)}
</div>
</div>
); );
} }
function TerminRow({
function TerminCard({
termin, termin,
userId, userId,
isAdmin, isAdmin,
@@ -126,125 +218,252 @@ function TerminCard({
userId: string; userId: string;
isAdmin: boolean; isAdmin: boolean;
}) { }) {
const [isPending, startTransition] = useTransition(); const color = getTypeColor(termin.type);
const [menuOpen, setMenuOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [isDeleting, startDeleteTransition] = useTransition();
const handleToggleMitbringsel = (enabled: boolean) => { const handleDeleteConfirm = () => {
startTransition(async () => { startDeleteTransition(async () => {
const result = await toggleMitbringselList(termin.id, enabled); const res = await deleteTerminAdmin(termin.id);
if (result.error) { if (res.error) {
toast.error(result.error); toast.error(res.error);
} else { } else {
toast.success( toast.success("Termin wurde erfolgreich gelöscht.");
enabled setDeleteOpen(false);
? "Mitbring-Liste aktiviert"
: "Mitbring-Liste deaktiviert"
);
} }
}); });
}; };
return ( return (
<Card className="flex flex-col overflow-hidden relative"> <>
{termin.status === TerminStatus.PENDING && ( <Dialog>
<div className="absolute top-0 right-0 bg-yellow-500 text-white text-xs font-bold px-2 py-1 rounded-bl-lg z-10"> <div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
Ausstehend <div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<DateTile date={termin.startDate} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{termin.title}
</h3>
{termin.status === TerminStatus.PENDING && (
<Badge variant="warning">Ausstehend</Badge>
)}
</div>
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{formatTerminTime(termin)}
<MapPin className="ml-1 h-3.5 w-3.5 [stroke-width:1.7]" />
Kita
</p>
</div>
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
Details
</Button>
</DialogTrigger>
{isAdmin ? (
<div className="relative">
<Button
variant="ghost"
size="icon"
aria-label="Mehr Optionen"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
>
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 mt-1 w-48 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<AdminTerminModal
existingTermin={termin}
triggerLabel="Bearbeiten"
triggerVariant="ghost"
triggerClassName="flex w-full items-center justify-start rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)] font-normal h-auto bg-transparent border-0"
/>
<button
type="button"
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--danger)] hover:bg-[var(--danger-soft)] text-left font-normal"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDeleteOpen(true);
}}
>
Löschen
</button>
</div>
</>
)}
</div>
) : (
<div className="w-9 h-9" />
)}
</div>
</div> </div>
)}
<CardHeader className="pb-3">
<div className="flex justify-between items-start mb-2 gap-2">
<Badge variant={getBadgeVariant(termin.type)} className={getBadgeColor(termin.type)}>
{getTypeLabel(termin.type)}
</Badge>
</div>
<CardTitle className="line-clamp-2 leading-tight">{termin.title}</CardTitle>
<CardDescription className="flex items-center gap-1 mt-1 text-sm">
<Clock className="h-3.5 w-3.5" />
{format(termin.startDate, "PP", { locale: de })}
{!termin.allDay && (
<>
{" • "}
{format(termin.startDate, "p", { locale: de })} -{" "}
{format(termin.endDate, "p", { locale: de })}
</>
)}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 pb-4"> <DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
{termin.description && ( <DialogHeader>
<div className="flex items-start gap-2 text-sm text-muted-foreground mt-2"> <DialogTitle>{termin.title}</DialogTitle>
<AlignLeft className="h-4 w-4 shrink-0 mt-0.5" /> <DialogDescription>
<p className="line-clamp-3 whitespace-pre-wrap">{termin.description}</p> {format(termin.startDate, "PP", { locale: de })}
</div> {!termin.allDay &&
)} ` · ${format(termin.startDate, "p", { locale: de })} - ${format(
termin.endDate,
"p",
{ locale: de },
)}`}
</DialogDescription>
</DialogHeader>
{/* Admin Toggle for Mitbringsel-List */} <div className="grid gap-4">
{isAdmin && termin.status === TerminStatus.CONFIRMED && ( <div className="flex">
<div className="flex items-center space-x-2 mt-6 pt-4 border-t"> <CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
<Switch </div>
id={`mitbringsel-${termin.id}`}
checked={termin.mitbringselListEnabled}
onCheckedChange={handleToggleMitbringsel}
disabled={isPending}
/>
<Label htmlFor={`mitbringsel-${termin.id}`} className="text-xs">
Mitbring-Liste aktiv
</Label>
</div>
)}
{/* Mitbringsel-List UI */} {termin.description ? (
{termin.mitbringselListEnabled && termin.status === TerminStatus.CONFIRMED && ( <p className="whitespace-pre-wrap text-sm leading-6 text-[var(--ink-soft)]">
<div className="mt-4"> {termin.description}
<MitbringselList </p>
terminId={termin.id} ) : (
items={termin.mitbringselItems || []} <p className="text-sm italic text-[var(--ink-faint)]">
currentUserId={userId} Keine Beschreibung hinterlegt.
isAdmin={isAdmin} </p>
/> )}
{termin.participantInfo && termin.showParticipantInfo && (
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
<div className="mb-1 flex items-center gap-2 font-medium">
<AlertTriangle className="h-4 w-4 text-[var(--warn)] [stroke-width:1.7]" />
Wichtiger Hinweis für Teilnehmer
</div>
<p className="whitespace-pre-wrap">{termin.participantInfo}</p>
</div>
)}
{termin.mitbringselListEnabled && (
<div className="mt-3 border-t border-[var(--hairline-soft)] pt-3">
<MitbringselList
terminId={termin.id}
items={termin.mitbringselItems || []}
currentUserId={userId}
isAdmin={isAdmin}
/>
</div>
)}
</div> </div>
)} </DialogContent>
</CardContent> </Dialog>
</Card>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
<DialogDescription>
Möchtest du den Termin &ldquo;{termin.title}&rdquo; wirklich löschen? Alle
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeleteOpen(false)}
disabled={isDeleting}
>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={isDeleting}
>
{isDeleting ? "Wird gelöscht..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
); );
} }
function getBadgeVariant(type: TerminType): "default" | "secondary" | "destructive" | "outline" { function DateTile({ date }: { date: Date }) {
switch (type) { return (
case TerminType.KITA_FEST: <div className="flex h-14 w-14 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)]">
case TerminType.MITMACH_TAG: <span className="text-[22px] font-medium leading-none tracking-[-0.02em] text-[var(--ink)] tabular-nums">
return "default"; {format(date, "d", { locale: de })}
case TerminType.SCHLIESSTAG: </span>
case TerminType.TEAMTAG: <span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.08em]">
return "destructive"; {format(date, "EEE", { locale: de })}
case TerminType.GEBURTSTAG_INTERN: </span>
case TerminType.GEBURTSTAG_EXTERN: </div>
case TerminType.ELTERNABEND: );
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "secondary";
default:
return "outline";
}
} }
function getBadgeColor(type: TerminType): string { function CategoryPill({
color,
children,
}: {
color: string;
children: React.ReactNode;
}) {
return (
<span
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)]"
style={{ color }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: color }} />
{children}
</span>
);
}
function groupByMonth(items: CalendarListItemDto[]) {
const groups = new Map<string, { key: string; label: string; items: CalendarListItemDto[] }>();
items.forEach((item) => {
const key = format(item.startDate, "yyyy-MM");
const label = format(item.startDate, "MMMM yyyy", { locale: de });
const group = groups.get(key) ?? { key, label, items: [] };
group.items.push(item);
groups.set(key, group);
});
return Array.from(groups.values());
}
function formatTerminTime(termin: TerminListItemDto) {
if (termin.allDay) return "ganztägig";
return `${format(termin.startDate, "HH:mm", { locale: de })} - ${format(
termin.endDate,
"HH:mm",
{ locale: de },
)}`;
}
function getTypeColor(type: TerminType): string {
switch (type) { switch (type) {
case TerminType.KITA_FEST: case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG: case TerminType.MITMACH_TAG:
return "bg-amber-500 hover:bg-amber-600 text-white border-transparent"; return "var(--good)";
case TerminType.SCHLIESSTAG: case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG: case TerminType.TEAMTAG:
return "bg-rose-500 hover:bg-rose-600 text-white border-transparent"; return "var(--danger)";
case TerminType.GEBURTSTAG_INTERN: case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN: case TerminType.GEBURTSTAG_EXTERN:
return "bg-blue-500 hover:bg-blue-600 text-white border-transparent"; return "oklch(0.50 0.10 320)";
case TerminType.ELTERNABEND: case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG: case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE: case TerminType.ELTERNCAFE:
return "bg-purple-500 hover:bg-purple-600 text-white border-transparent"; return "oklch(0.50 0.10 280)";
default: default:
return "bg-slate-200 text-slate-800 border-slate-300 dark:bg-slate-800 dark:text-slate-200 dark:border-slate-700"; return "var(--ink-muted)";
} }
} }
@@ -257,7 +476,7 @@ function getTypeLabel(type: TerminType): string {
case TerminType.SCHLIESSTAG: case TerminType.SCHLIESSTAG:
return "Schließtag"; return "Schließtag";
case TerminType.TEAMTAG: case TerminType.TEAMTAG:
return "Teamtag (Kita geschlossen)"; return "Teamtag";
case TerminType.ELTERNABEND: case TerminType.ELTERNABEND:
return "Elternabend"; return "Elternabend";
case TerminType.MITGLIEDERVERSAMMLUNG: case TerminType.MITGLIEDERVERSAMMLUNG:
@@ -265,10 +484,71 @@ function getTypeLabel(type: TerminType): string {
case TerminType.ELTERNCAFE: case TerminType.ELTERNCAFE:
return "Elterncafe"; return "Elterncafe";
case TerminType.GEBURTSTAG_INTERN: case TerminType.GEBURTSTAG_INTERN:
return "Geburtstag (Intern)"; return "Geburtstag";
case TerminType.GEBURTSTAG_EXTERN: case TerminType.GEBURTSTAG_EXTERN:
return "Raumanfrage (Extern)"; return "Raumanfrage";
default: default:
return "Sonstiges"; return "Sonstiges";
} }
} }
function NotdienstRow({ notdienst, isAdmin }: { notdienst: NotdienstCalendarItemDto; isAdmin: boolean }) {
const color = notdienst.isOwn ? "oklch(0.60 0.18 45)" : "oklch(0.75 0.10 50)";
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<DateTile date={notdienst.startDate} />
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{notdienst.title}
</h3>
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(notdienst.startDate, "dd.MM.yyyy", { locale: de })} (ganztägig)
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ink-muted)]" />
Kind: {notdienst.childName}
</span>
</p>
</div>
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>
{notdienst.isOwn ? "Mein Notdienst" : "Notdienst"}
</CategoryPill>
{isAdmin ? (
<div className="relative">
<Button
variant="ghost"
size="icon"
aria-label="Mehr Optionen"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
>
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<a
href="/dashboard/notdienst/plan"
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]"
onClick={() => setMenuOpen(false)}
>
Notdienst im Planer bearbeiten
</a>
</div>
</>
)}
</div>
) : (
<div className="w-9 h-9" />
)}
</div>
</div>
);
}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import type { ComponentProps, FormEvent } from "react";
import { useMemo, useState, useTransition } from "react";
import { TerminType } from "@prisma/client"; import { TerminType } from "@prisma/client";
import { CalendarPlus } from "lucide-react"; import { CalendarPlus } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -19,17 +20,74 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { createTerminRequest } from "../actions"; import { createTerminRequest } from "../actions";
export function TerminRequestModal() { type TerminRequestFormState = {
const [open, setOpen] = useState(false); title: string;
const [isPending, startTransition] = useTransition(); type: TerminType;
startDate: string;
endDate: string;
allDay: boolean;
};
const initialFormState: TerminRequestFormState = {
title: "",
type: TerminType.KITA_FEST,
startDate: "",
endDate: "",
allDay: false,
};
type TerminRequestModalProps = {
triggerLabel?: string;
triggerVariant?: ComponentProps<typeof Button>["variant"];
triggerClassName?: string;
};
export function TerminRequestModal({
triggerLabel = "Termin anfragen",
triggerVariant = "ghost",
triggerClassName = "gap-2",
}: TerminRequestModalProps) {
const [open, setOpen] = useState(false);
const [formState, setFormState] = useState<TerminRequestFormState>(initialFormState);
const [isPending, startTransition] = useTransition();
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
const minEndDateTime = formState.startDate || minDateTime;
const updateField = <TKey extends keyof TerminRequestFormState>(
key: TKey,
value: TerminRequestFormState[TKey],
) => {
setFormState((current) => {
const next = { ...current, [key]: value };
if (key === "startDate" && typeof value === "string" && next.endDate && next.endDate < value) {
next.endDate = value;
}
return next;
});
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!formState.startDate || !formState.endDate) {
toast.error("Bitte Start und Ende ausfüllen.");
return;
}
if (formState.startDate < minDateTime) {
toast.error("Der Startzeitpunkt darf nicht in der Vergangenheit liegen.");
return;
}
if (formState.endDate < formState.startDate) {
toast.error("Das Ende darf nicht vor dem Start liegen.");
return;
}
const handleAction = (formData: FormData) => {
const data = { const data = {
title: formData.get("title") as string, title: formState.title,
type: formData.get("type") as TerminType, type: formState.type,
startDate: new Date(formData.get("startDate") as string).toISOString(), startDate: new Date(formState.startDate).toISOString(),
endDate: new Date(formData.get("endDate") as string).toISOString(), endDate: new Date(formState.endDate).toISOString(),
allDay: formData.get("allDay") === "on", allDay: formState.allDay,
}; };
startTransition(async () => { startTransition(async () => {
@@ -38,6 +96,7 @@ export function TerminRequestModal() {
toast.error(res.error); toast.error(res.error);
} else { } else {
toast.success("Terminanfrage wurde erfolgreich gestellt!"); toast.success("Terminanfrage wurde erfolgreich gestellt!");
setFormState(initialFormState);
setOpen(false); setOpen(false);
} }
}); });
@@ -46,13 +105,13 @@ export function TerminRequestModal() {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="gap-2"> <Button variant={triggerVariant} className={triggerClassName}>
<CalendarPlus className="h-4 w-4" /> <CalendarPlus className="h-4 w-4 [stroke-width:1.7]" />
Termin anfragen {triggerLabel}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<form action={handleAction}> <form onSubmit={handleSubmit}>
<DialogHeader> <DialogHeader>
<DialogTitle>Termin anfragen</DialogTitle> <DialogTitle>Termin anfragen</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -64,7 +123,14 @@ export function TerminRequestModal() {
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="title">Titel</Label> <Label htmlFor="title">Titel</Label>
<Input id="title" name="title" required placeholder="z.B. Geburtstag von Mia" /> <Input
id="title"
name="title"
required
placeholder="z.B. Geburtstag von Mia"
value={formState.title}
onChange={(event) => updateField("title", event.target.value)}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@@ -72,7 +138,9 @@ export function TerminRequestModal() {
<select <select
id="type" id="type"
name="type" name="type"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" value={formState.type}
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] disabled:cursor-not-allowed disabled:opacity-50"
onChange={(event) => updateField("type", event.target.value as TerminType)}
required required
> >
<option value={TerminType.KITA_FEST}>Kita-Fest</option> <option value={TerminType.KITA_FEST}>Kita-Fest</option>
@@ -91,16 +159,39 @@ export function TerminRequestModal() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="startDate">Start</Label> <Label htmlFor="startDate">Start</Label>
<Input id="startDate" name="startDate" type="datetime-local" required /> <Input
id="startDate"
name="startDate"
type="datetime-local"
min={minDateTime}
value={formState.startDate}
onChange={(event) => updateField("startDate", event.target.value)}
required
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="endDate">Ende</Label> <Label htmlFor="endDate">Ende</Label>
<Input id="endDate" name="endDate" type="datetime-local" required /> <Input
id="endDate"
name="endDate"
type="datetime-local"
min={minEndDateTime}
value={formState.endDate}
onChange={(event) => updateField("endDate", event.target.value)}
required
/>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<input type="checkbox" id="allDay" name="allDay" className="rounded border-gray-300" /> <input
type="checkbox"
id="allDay"
name="allDay"
checked={formState.allDay}
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
onChange={(event) => updateField("allDay", event.target.checked)}
/>
<Label htmlFor="allDay" className="font-normal cursor-pointer"> <Label htmlFor="allDay" className="font-normal cursor-pointer">
Ganztägiger Termin Ganztägiger Termin
</Label> </Label>
@@ -120,3 +211,10 @@ export function TerminRequestModal() {
</Dialog> </Dialog>
); );
} }
function getLocalDateTimeInputValue(date: Date) {
const localDate = new Date(date);
localDate.setSeconds(0, 0);
const offset = localDate.getTimezoneOffset() * 60_000;
return new Date(localDate.getTime() - offset).toISOString().slice(0, 16);
}
+125 -14
View File
@@ -12,14 +12,54 @@ import { prisma } from "@/lib/prisma";
// ===================================================================== // =====================================================================
const terminSchema = z.object({ const terminSchema = z.object({
title: z.string().min(2, "Titel muss mindestens 2 Zeichen lang sein"), title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein"),
description: z.string().optional(), description: z.string().trim().optional(),
type: z.nativeEnum(TerminType), type: z.nativeEnum(TerminType),
startDate: z.string().datetime(), startDate: z.string().datetime(),
endDate: z.string().datetime(), endDate: z.string().datetime(),
allDay: z.boolean().default(false), allDay: z.boolean().default(false),
mitbringselListEnabled: z.boolean().default(false),
requiresRsvp: z.boolean().default(false),
rsvpDeadline: z.string().datetime().nullable().optional(),
participantInfo: z.string().trim().max(1000).optional(),
}); });
function buildTerminData(data: z.infer<typeof terminSchema>) {
const startDate = new Date(data.startDate);
const endDate = new Date(data.endDate);
const now = new Date();
now.setSeconds(0, 0);
if (startDate < now) {
return { error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen." };
}
if (endDate < startDate) {
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
}
return {
data: {
title: data.title,
description: data.description || null,
type: data.type,
startDate,
endDate,
allDay: data.allDay,
mitbringselListEnabled: data.mitbringselListEnabled,
requiresRsvp: data.requiresRsvp,
rsvpDeadline:
data.requiresRsvp && data.rsvpDeadline
? new Date(data.rsvpDeadline)
: null,
participantInfo:
data.requiresRsvp && data.participantInfo
? data.participantInfo
: null,
},
};
}
export async function createTerminRequest(rawPayload: unknown) { export async function createTerminRequest(rawPayload: unknown) {
const session = await requireKitaSession(); const session = await requireKitaSession();
@@ -29,18 +69,23 @@ export async function createTerminRequest(rawPayload: unknown) {
} }
const data = parsed.data; const data = parsed.data;
const terminData = buildTerminData(data);
if ("error" in terminData) {
return { error: terminData.error };
}
try { try {
await prisma.termin.create({ await prisma.termin.create({
data: { data: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
createdById: session.user.id, createdById: session.user.id,
title: data.title, title: terminData.data.title,
description: data.description, description: terminData.data.description,
type: data.type, type: terminData.data.type,
startDate: new Date(data.startDate), startDate: terminData.data.startDate,
endDate: new Date(data.endDate), endDate: terminData.data.endDate,
allDay: data.allDay, allDay: terminData.data.allDay,
status: TerminStatus.PENDING, status: TerminStatus.PENDING,
}, },
}); });
@@ -66,18 +111,18 @@ export async function createTerminAdmin(rawPayload: unknown) {
} }
const data = parsed.data; const data = parsed.data;
const terminData = buildTerminData(data);
if ("error" in terminData) {
return { error: terminData.error };
}
try { try {
await prisma.termin.create({ await prisma.termin.create({
data: { data: {
kitaId, kitaId,
createdById: session.user.id, createdById: session.user.id,
title: data.title, ...terminData.data,
description: data.description,
type: data.type,
startDate: new Date(data.startDate),
endDate: new Date(data.endDate),
allDay: data.allDay,
status: TerminStatus.CONFIRMED, status: TerminStatus.CONFIRMED,
approvedById: session.user.id, approvedById: session.user.id,
approvedAt: new Date(), approvedAt: new Date(),
@@ -85,6 +130,7 @@ export async function createTerminAdmin(rawPayload: unknown) {
}); });
revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Erstellen des Termins:", error); console.error("Fehler beim Erstellen des Termins:", error);
@@ -92,6 +138,43 @@ export async function createTerminAdmin(rawPayload: unknown) {
} }
} }
export async function updateTerminAdmin(terminId: string, rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const terminData = buildTerminData(parsed.data);
if ("error" in terminData) {
return { error: terminData.error };
}
try {
await prisma.termin.update({
where: {
id: terminId,
kitaId,
},
data: terminData.data,
});
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren des Termins:", error);
return { error: "Termin konnte nicht aktualisiert werden." };
}
}
export async function approveTermin(terminId: string) { export async function approveTermin(terminId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId; const kitaId = session.user.kitaId;
@@ -113,6 +196,7 @@ export async function approveTermin(terminId: string) {
}); });
revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Bestätigen des Termins:", error); console.error("Fehler beim Bestätigen des Termins:", error);
@@ -142,6 +226,7 @@ export async function rejectTermin(terminId: string, reason?: string) {
}); });
revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Ablehnen des Termins:", error); console.error("Fehler beim Ablehnen des Termins:", error);
@@ -168,6 +253,7 @@ export async function toggleMitbringselList(terminId: string, enabled: boolean)
}); });
revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Umschalten der Mitbring-Liste:", error); console.error("Fehler beim Umschalten der Mitbring-Liste:", error);
@@ -249,3 +335,28 @@ export async function deleteMitbringsel(itemId: string) {
return { error: "Ein Fehler ist aufgetreten." }; return { error: "Ein Fehler ist aufgetreten." };
} }
} }
export async function deleteTerminAdmin(terminId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.termin.delete({
where: {
id: terminId,
kitaId,
},
});
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Löschen des Termins:", error);
return { error: "Termin konnte nicht gelöscht werden." };
}
}
+242 -54
View File
@@ -1,23 +1,36 @@
import { DutyAssignmentStatus, TerminStatus, UserRole } from "@prisma/client"; import {
DutyAssignmentStatus,
EventParticipationStatus,
TerminStatus,
TerminType,
UserRole,
} from "@prisma/client";
import { HelpCircle } from "lucide-react";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { AdminTerminModal } from "./_components/admin-termin-modal"; import { AdminTerminModal } from "./_components/admin-termin-modal";
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen"; import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
import { TerminList, type CalendarListItemDto } from "./_components/termin-list"; import { type CalendarListItemDto } from "./_components/termin-list";
import { TerminRequestModal } from "./_components/termin-request-modal"; import { TerminRequestModal } from "./_components/termin-request-modal";
import { CalendarOverviewClient } from "./_components/calendar-overview-client";
export const metadata = { title: "Terminkalender · Kita-Planer" };
export default async function KalenderPage({ export default async function KalenderPage({
searchParams, searchParams,
}: { }: {
searchParams: { tab?: string }; searchParams: Promise<{ tab?: string }>;
}) { }) {
const session = await requireKitaSession(); const session = await requireKitaSession();
const isAdmin = const isAdmin =
session.user.role === UserRole.ADMIN || session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.KOORDINATOR; session.user.role === UserRole.KOORDINATOR;
const familyIdForParticipation = session.user.familyId ?? "__no_family__";
const currentTab = searchParams.tab || "übersicht"; const resolvedSearchParams = await searchParams;
const currentTab = resolvedSearchParams.tab || "übersicht";
const now = new Date();
const confirmedTermineRows = await prisma.termin.findMany({ const confirmedTermineRows = await prisma.termin.findMany({
where: { where: {
@@ -34,6 +47,8 @@ export default async function KalenderPage({
endDate: true, endDate: true,
allDay: true, allDay: true,
mitbringselListEnabled: true, mitbringselListEnabled: true,
requiresRsvp: true,
participantInfo: true,
mitbringselItems: { mitbringselItems: {
select: { select: {
id: true, id: true,
@@ -47,6 +62,17 @@ export default async function KalenderPage({
}, },
}, },
}, },
participations: {
where: {
child: {
familyId: familyIdForParticipation,
},
},
select: {
childId: true,
status: true,
},
},
}, },
orderBy: { startDate: "asc" }, orderBy: { startDate: "asc" },
}); });
@@ -67,6 +93,8 @@ export default async function KalenderPage({
endDate: true, endDate: true,
allDay: true, allDay: true,
mitbringselListEnabled: true, mitbringselListEnabled: true,
requiresRsvp: true,
participantInfo: true,
mitbringselItems: { mitbringselItems: {
select: { select: {
id: true, id: true,
@@ -80,6 +108,17 @@ export default async function KalenderPage({
}, },
}, },
}, },
participations: {
where: {
child: {
familyId: familyIdForParticipation,
},
},
select: {
childId: true,
status: true,
},
},
}, },
orderBy: { startDate: "asc" }, orderBy: { startDate: "asc" },
}); });
@@ -88,13 +127,14 @@ export default async function KalenderPage({
where: { where: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
status: DutyAssignmentStatus.PLANNED, status: DutyAssignmentStatus.PLANNED,
endDate: { gte: new Date() }, endDate: { gte: now },
}, },
select: { select: {
id: true, id: true,
startDate: true, startDate: true,
endDate: true, endDate: true,
status: true, status: true,
familyId: true,
dutyType: { dutyType: {
select: { select: {
name: true, name: true,
@@ -109,29 +149,88 @@ export default async function KalenderPage({
orderBy: { startDate: "asc" }, orderBy: { startDate: "asc" },
}); });
const notdienstAssignmentRows = await prisma.notdienstAssignment.findMany({
where: {
kitaId: session.user.kitaId,
plan: {
status: "PUBLISHED",
},
},
select: {
id: true,
date: true,
child: {
select: {
firstName: true,
lastName: true,
familyId: true,
family: {
select: {
name: true,
},
},
},
},
},
orderBy: { date: "asc" },
});
const allUserTermine: CalendarListItemDto[] = [ const allUserTermine: CalendarListItemDto[] = [
...confirmedTermineRows.map((termin) => ({ ...confirmedTermineRows.map((termin) => ({
...termin, ...termin,
kind: "termin" as const, kind: "termin" as const,
showParticipantInfo:
isAdmin ||
termin.participations.some(
(participation) =>
participation.status === EventParticipationStatus.ATTENDING,
),
})), })),
...myPendingTermineRows.map((termin) => ({ ...myPendingTermineRows.map((termin) => ({
...termin, ...termin,
kind: "termin" as const, kind: "termin" as const,
showParticipantInfo:
isAdmin ||
termin.participations.some(
(participation) =>
participation.status === EventParticipationStatus.ATTENDING,
),
})), })),
...dutyAssignmentRows.map((assignment) => ({ ...dutyAssignmentRows.map((assignment) => {
kind: "duty" as const, const isOwn = assignment.familyId === familyIdForParticipation;
id: assignment.id, return {
title: assignment.dutyType.name, kind: "duty" as const,
familyName: assignment.family.name, id: assignment.id,
status: assignment.status, title: isOwn
startDate: assignment.startDate, ? `Mein Elterndienst (${assignment.dutyType.name})`
endDate: assignment.endDate, : `${assignment.dutyType.name} (${assignment.family.name})`,
})), familyName: assignment.family.name,
status: assignment.status,
startDate: assignment.startDate,
endDate: assignment.endDate,
isOwn,
};
}),
...notdienstAssignmentRows.map((assignment) => {
const isOwn = assignment.child.familyId === familyIdForParticipation;
return {
kind: "notdienst" as const,
id: assignment.id,
title: isOwn
? `Mein Notdienst (${assignment.child.firstName})`
: `Notdienst (${assignment.child.family.name})`,
familyName: assignment.child.family.name,
childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
isOwn,
startDate: assignment.date,
endDate: assignment.date,
};
}),
].sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); ].sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
let allPendingTermine: PendingTerminDto[] = []; let allPendingTermine: PendingTerminDto[] = [];
if (isAdmin) { if (isAdmin) {
allPendingTermine = await prisma.termin.findMany({ const pendingRows = await prisma.termin.findMany({
where: { where: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
status: TerminStatus.PENDING, status: TerminStatus.PENDING,
@@ -140,6 +239,8 @@ export default async function KalenderPage({
id: true, id: true,
title: true, title: true,
startDate: true, startDate: true,
createdAt: true,
type: true,
allDay: true, allDay: true,
createdBy: { createdBy: {
select: { select: {
@@ -150,67 +251,74 @@ export default async function KalenderPage({
}, },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
allPendingTermine = pendingRows.map((termin) => ({
...termin,
ageInDays: Math.floor(
(now.getTime() - termin.createdAt.getTime()) / (1000 * 60 * 60 * 24),
),
}));
} }
return ( return (
<div className="flex h-full flex-col gap-6 p-6"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="flex items-center justify-between"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight">Terminkalender</h1> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<p className="text-muted-foreground"> TERMINKALENDER
Alle anstehenden Termine, Feste und Schließtage im Überblick. </p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Was steht{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
an
</em>
?
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Alle anstehenden Termine, Feste, Dienste und Schließtage im
Überblick.
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex shrink-0 flex-wrap items-center gap-2">
<TerminRequestModal /> <TerminRequestModal />
<span className="group relative inline-flex h-9 w-9 items-center justify-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-muted)]">
<HelpCircle className="h-4 w-4 [stroke-width:1.7]" />
<span className="pointer-events-none absolute right-0 top-11 z-30 hidden w-72 rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-left text-xs leading-5 text-white shadow-lg group-hover:block">
Anfragen müssen vom Vorstand bestätigt werden, direkte Anlage
erscheint sofort im Kalender.
</span>
</span>
{isAdmin && <AdminTerminModal />} {isAdmin && <AdminTerminModal />}
</div> </div>
</div> </header>
{isAdmin ? ( {isAdmin ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-5">
<div className="flex gap-4 border-b"> <TabBar
<a currentTab={currentTab}
href="/dashboard/kalender?tab=übersicht" overviewCount={allUserTermine.length}
className={`pb-2 text-sm font-medium transition-colors hover:text-primary ${ pendingCount={allPendingTermine.length}
currentTab === "übersicht" />
? "border-b-2 border-primary text-primary"
: "text-muted-foreground"
}`}
>
Übersicht
</a>
<a
href="/dashboard/kalender?tab=anfragen"
className={`flex items-center gap-2 pb-2 text-sm font-medium transition-colors hover:text-primary ${
currentTab === "anfragen"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground"
}`}
>
Ausstehende Anfragen
{allPendingTermine.length > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
{allPendingTermine.length}
</span>
)}
</a>
</div>
<div className="pt-2"> <div>
{currentTab === "übersicht" ? ( {currentTab === "übersicht" ? (
<TerminList <CalendarOverviewClient
termine={allUserTermine} termine={allUserTermine}
userId={session.user.id} userId={session.user.id}
isAdmin={isAdmin} isAdmin={isAdmin}
/> />
) : ( ) : currentTab === "anfragen" ? (
<PendingAnfragen termine={allPendingTermine} /> <PendingAnfragen termine={allPendingTermine} />
) : (
<CalendarOverviewClient
termine={[]}
userId={session.user.id}
isAdmin={isAdmin}
/>
)} )}
</div> </div>
</div> </div>
) : ( ) : (
<TerminList <CalendarOverviewClient
termine={allUserTermine} termine={allUserTermine}
userId={session.user.id} userId={session.user.id}
isAdmin={isAdmin} isAdmin={isAdmin}
@@ -219,3 +327,83 @@ export default async function KalenderPage({
</div> </div>
); );
} }
function TabBar({
currentTab,
overviewCount,
pendingCount,
}: {
currentTab: string;
overviewCount: number;
pendingCount: number;
}) {
return (
<div className="flex border-b border-[var(--hairline)]">
<TabLink
href="/dashboard/kalender?tab=übersicht"
active={currentTab === "übersicht"}
count={overviewCount}
>
Übersicht
</TabLink>
<TabLink
href="/dashboard/kalender?tab=anfragen"
active={currentTab === "anfragen"}
count={pendingCount}
attention={pendingCount > 0}
>
Ausstehende Anfragen
</TabLink>
<TabLink
href="/dashboard/kalender?tab=vergangen"
active={currentTab === "vergangen"}
>
Vergangene
</TabLink>
</div>
);
}
function TabLink({
href,
active,
count,
attention,
children,
}: {
href: string;
active: boolean;
count?: number;
attention?: boolean;
children: React.ReactNode;
}) {
return (
<a
href={href}
className={[
"-mb-px inline-flex h-11 items-center gap-2 border-b-2 px-1.5 text-sm transition first:pl-0",
active
? "border-[var(--accent)] font-semibold text-[var(--ink)]"
: "border-transparent font-medium text-[var(--ink-soft)] hover:text-[var(--ink)]",
].join(" ")}
>
{children}
{typeof count === "number" && (
<span
className={[
"rounded-full px-2 py-0.5 text-[11px] font-semibold tabular-nums",
attention
? "bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)]"
: "border border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]",
].join(" ")}
>
{count}
</span>
)}
</a>
);
}
+19 -63
View File
@@ -1,12 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Baby, LogOut } from "lucide-react";
import { signOut } from "@/auth"; import { signOut } from "@/auth";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Button } from "@/components/ui/button"; import { DashboardShell } from "@/components/dashboard/dashboard-shell";
import { Separator } from "@/components/ui/separator";
import { SidebarNav } from "@/components/dashboard/sidebar-nav";
export const metadata: Metadata = { export const metadata: Metadata = {
robots: { robots: {
@@ -16,11 +13,11 @@ export const metadata: Metadata = {
}; };
// ===================================================================== // =====================================================================
// Dashboard-Layout · Linke Sidebar // Dashboard-Layout · Responsive App Shell
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Server Component: liest kitaId aus der Session, holt den Kita-Namen // Server Component: liest kitaId aus der Session, holt den Kita-Namen
// aus der DB und rendert das App-Shell (Sidebar + main content area). // aus der DB und rendert die responsive DashboardShell (mit Desktop-Sidebar
// Der eigentliche Seiteninhalt kommt via {children} rein. // und Mobile-Drawer).
// ===================================================================== // =====================================================================
export default async function DashboardLayout({ export default async function DashboardLayout({
@@ -34,63 +31,22 @@ export default async function DashboardLayout({
where: { id: session.user.kitaId }, where: { id: session.user.kitaId },
select: { name: true }, select: { name: true },
}); });
const displayName = session.user.name ?? session.user.email ?? "Kita-Planer";
async function handleSignOut() {
"use server";
await signOut({ redirectTo: "/" });
}
return ( return (
<div className="flex min-h-screen bg-muted/30"> <DashboardShell
{/* ── Sidebar ───────────────────────────────────────────────────── */} kitaName={kita.name}
<aside className="flex w-60 shrink-0 flex-col border-r bg-background"> userName={displayName}
{/* Logo / Kita-Name */} userRole={session.user.role}
<div className="flex h-16 items-center gap-2.5 border-b px-5"> signOutAction={handleSignOut}
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground"> >
<Baby className="h-4 w-4" /> {children}
</div> </DashboardShell>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-none">
{kita.name}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">Kita-Planer</p>
</div>
</div>
{/* Navigation */}
<div className="flex-1 overflow-y-auto py-4">
<SidebarNav role={session.user.role} />
</div>
{/* Footer: User-Info + Abmelden */}
<div className="border-t p-3">
<Separator className="mb-3" />
<div className="mb-2 px-3">
<p className="truncate text-xs font-medium">
{session.user.name ?? session.user.email}
</p>
<p className="truncate text-xs text-muted-foreground">
{session.user.role}
</p>
</div>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<Button
type="submit"
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground"
>
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</form>
</div>
</aside>
{/* ── Main Content ──────────────────────────────────────────────── */}
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
); );
} }
+12 -12
View File
@@ -58,12 +58,12 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
return ( return (
<> <>
<Card className="mb-8 border-emerald-200 bg-emerald-50/60"> <Card className="mb-8 border-[var(--good)] bg-[var(--good-soft)]">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<CardTitle className="flex items-center gap-2 text-lg"> <CardTitle className="flex items-center gap-2 text-lg">
<Megaphone className="h-5 w-5 text-emerald-700" /> <Megaphone className="h-5 w-5 text-[var(--good)] [stroke-width:1.7]" />
Schwarzes Brett Schwarzes Brett
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
@@ -81,26 +81,26 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
key={item.id} key={item.id}
type="button" type="button"
onClick={() => openAnnouncement(item)} onClick={() => openAnnouncement(item)}
className="rounded-md border bg-background p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-300 hover:shadow-md" className="rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-4 text-left transition hover:border-[var(--accent-mid)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
> >
<div className="mb-3 flex items-start justify-between gap-2"> <div className="mb-3 flex items-start justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-[var(--ink-muted)]">
<Bell className="h-3.5 w-3.5" /> <Bell className="h-3.5 w-3.5 [stroke-width:1.7]" />
{item.createdAt} {item.createdAt}
</div> </div>
{isUnread && ( {isUnread && (
<Badge className="bg-emerald-600 text-white">NEU</Badge> <Badge variant="success">NEU</Badge>
)} )}
</div> </div>
<h3 className="line-clamp-2 font-semibold leading-snug"> <h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
{item.title} {item.title}
</h3> </h3>
<p className="mt-2 line-clamp-3 text-sm leading-6 text-muted-foreground"> <p className="mt-2 line-clamp-3 text-sm leading-6 text-[var(--ink-soft)]">
{item.content} {item.content}
</p> </p>
{item.attachments.length > 0 && ( {item.attachments.length > 0 && (
<p className="mt-3 flex items-center gap-1 text-xs font-medium text-emerald-700"> <p className="mt-3 flex items-center gap-1 text-xs font-medium text-[var(--accent-deep)]">
<FileText className="h-3.5 w-3.5" /> <FileText className="h-3.5 w-3.5 [stroke-width:1.7]" />
{item.attachments.length} Anhang {item.attachments.length} Anhang
{item.attachments.length === 1 ? "" : "e"} {item.attachments.length === 1 ? "" : "e"}
</p> </p>
@@ -122,7 +122,7 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="rounded-md border bg-muted/30 p-4 text-sm"> <div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm">
<MarkdownContent content={selected.content} /> <MarkdownContent content={selected.content} />
</div> </div>
@@ -138,7 +138,7 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
className="justify-start" className="justify-start"
> >
<a href={attachment.fileUrl} target="_blank" rel="noreferrer"> <a href={attachment.fileUrl} target="_blank" rel="noreferrer">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4 [stroke-width:1.7]" />
{attachment.fileName} {attachment.fileName}
</a> </a>
</Button> </Button>
+27 -8
View File
@@ -15,11 +15,23 @@ import { getTargetMonthData } from "@/lib/date-utils";
// Die Termine werden Round-Robin auf die Kinder aufgeteilt. // Die Termine werden Round-Robin auf die Kinder aufgeteilt.
// ===================================================================== // =====================================================================
const dateKeyPattern = /^\d{4}-\d{2}-\d{2}$/;
const payloadSchema = z.object({ const payloadSchema = z.object({
childrenIds: z.array(z.string()).min(1), childrenIds: z.array(z.string()).min(1),
dates: z.array(z.string().datetime()), // ISO strings dates: z.array(
z.string().regex(dateKeyPattern).refine((value) => {
const date = dateKeyToUtcDate(value);
return value === date.toISOString().slice(0, 10);
}),
),
}); });
function dateKeyToUtcDate(dateKey: string) {
const [year, month, day] = dateKey.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, day));
}
export async function saveNotdienstAvailabilities(payloadRaw: { export async function saveNotdienstAvailabilities(payloadRaw: {
childrenIds: string[]; childrenIds: string[];
dates: string[]; dates: string[];
@@ -35,7 +47,8 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
return { error: "Ungültige Eingabedaten." }; return { error: "Ungültige Eingabedaten." };
} }
const { childrenIds, dates } = parsed.data; const { childrenIds } = parsed.data;
const dates = Array.from(new Set(parsed.data.dates)).sort();
// ── 1. Sperrfrist prüfen ─────────────────────────────────────────── // ── 1. Sperrfrist prüfen ───────────────────────────────────────────
const { targetYear, targetMonth, isLocked } = getTargetMonthData(); const { targetYear, targetMonth, isLocked } = getTargetMonthData();
@@ -79,9 +92,15 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
} }
// Termine in Date-Objekte wandeln und sortieren // Termine in Date-Objekte wandeln und sortieren
const parsedDates = dates const parsedDates = dates.map(dateKeyToUtcDate);
.map((d) => new Date(d)) const hasOutsideTargetMonth = parsedDates.some(
.sort((a, b) => a.getTime() - b.getTime()); (date) =>
date.getUTCFullYear() !== targetYear ||
date.getUTCMonth() !== targetMonth - 1,
);
if (hasOutsideTargetMonth) {
return { error: "Bitte wähle Termine im Zielmonat aus." };
}
// Round-Robin Zuweisung // Round-Robin Zuweisung
const inserts: { const inserts: {
@@ -110,8 +129,8 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
kitaId, kitaId,
childId: { in: childrenIds }, childId: { in: childrenIds },
date: { date: {
gte: new Date(targetYear, targetMonth - 1, 1), gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
lt: new Date(targetYear, targetMonth, 1), lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
}, },
}, },
}); });
@@ -126,7 +145,7 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
}); });
revalidatePath("/dashboard/notdienst"); revalidatePath("/dashboard/notdienst");
return { success: true }; return { success: true, dates };
} catch (error) { } catch (error) {
console.error("Fehler beim Speichern der Notdienste:", error); console.error("Fehler beim Speichern der Notdienste:", error);
return { error: "Ein Fehler ist aufgetreten beim Speichern der Termine." }; return { error: "Ein Fehler ist aufgetreten beim Speichern der Termine." };
@@ -1,58 +1,29 @@
"use client"; "use client";
import { useState } from "react"; import {
import { ArrowRight, CalendarHeart } from "lucide-react"; NotdienstForm,
type NotdienstCalendarItem,
import { Button } from "@/components/ui/button"; } from "./notdienst-form";
import { NotdienstForm } from "./notdienst-form";
type NotdienstEntryProps = { type NotdienstEntryProps = {
hasAnyAvailability: boolean;
targetYear: number; targetYear: number;
targetMonth: number; targetMonth: number;
isLocked: boolean; isLocked: boolean;
requiredDaysTotal: number; requiredDaysTotal: number;
initialSelectedDates: string[]; initialSelectedDates: string[];
childrenIds: string[]; childrenIds: string[];
calendarItems: NotdienstCalendarItem[];
}; };
export function NotdienstEntry({ export function NotdienstEntry({
hasAnyAvailability,
targetYear, targetYear,
targetMonth, targetMonth,
isLocked, isLocked,
requiredDaysTotal, requiredDaysTotal,
initialSelectedDates, initialSelectedDates,
childrenIds, childrenIds,
calendarItems,
}: NotdienstEntryProps) { }: NotdienstEntryProps) {
const [showForm, setShowForm] = useState(hasAnyAvailability);
if (!showForm) {
return (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-gradient-to-b from-emerald-50/70 to-background p-8 text-center">
<div className="mx-auto max-w-lg">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 shadow-sm">
<CalendarHeart className="h-10 w-10" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
Willkommen beim Notdienst!
</h2>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Es sieht so aus, als hättest du noch keine Termine eingetragen.
</p>
<Button
type="button"
className="mt-6"
onClick={() => setShowForm(true)}
>
Jetzt erste Pflicht-Termine eintragen
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
return ( return (
<NotdienstForm <NotdienstForm
targetYear={targetYear} targetYear={targetYear}
@@ -61,6 +32,7 @@ export function NotdienstEntry({
requiredDaysTotal={requiredDaysTotal} requiredDaysTotal={requiredDaysTotal}
initialSelectedDates={initialSelectedDates} initialSelectedDates={initialSelectedDates}
childrenIds={childrenIds} childrenIds={childrenIds}
calendarItems={calendarItems}
/> />
); );
} }
+567 -112
View File
@@ -1,30 +1,92 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { Info, Loader2, CheckCircle2 } from "lucide-react"; import { useRouter } from "next/navigation";
import { format } from "date-fns"; import {
Check,
ChevronLeft,
ChevronRight,
Clock3,
Info,
Loader2,
LockKeyhole,
X,
} from "lucide-react";
import {
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameMonth,
startOfDay,
startOfMonth,
startOfWeek,
} from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getWorkingDaysOfMonth } from "@/lib/date-utils"; import { formatDateKey, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { Button } from "@/components/ui/button";
import { saveNotdienstAvailabilities } from "./actions"; import { saveNotdienstAvailabilities } from "./actions";
// ===================================================================== export const notdienstEventCategories = {
// NotdienstForm · Client Component closure: {
// --------------------------------------------------------------------- label: "Schließtag",
// Erlaubt das Auswählen von N Tagen aus der Liste der Werktage des dot: "oklch(0.62 0.10 35)",
// Zielmonats. Speichert als Array von Datum-Strings. bg: "oklch(0.96 0.04 35)",
// ===================================================================== border: "oklch(0.88 0.04 35)",
text: "oklch(0.38 0.08 35)",
},
team: {
label: "Teamtag",
dot: "oklch(0.62 0.10 70)",
bg: "oklch(0.96 0.04 70)",
border: "oklch(0.88 0.04 70)",
text: "oklch(0.38 0.08 70)",
},
parents: {
label: "Elternabend",
dot: "oklch(0.62 0.10 280)",
bg: "oklch(0.96 0.04 280)",
border: "oklch(0.88 0.04 280)",
text: "oklch(0.38 0.08 280)",
},
social: {
label: "Fest",
dot: "oklch(0.62 0.10 155)",
bg: "oklch(0.96 0.04 155)",
border: "oklch(0.88 0.04 155)",
text: "oklch(0.38 0.08 155)",
},
birthday: {
label: "Geburtstag",
dot: "oklch(0.62 0.10 320)",
bg: "oklch(0.96 0.04 320)",
border: "oklch(0.88 0.04 320)",
text: "oklch(0.38 0.08 320)",
},
} as const;
type NotdienstEventCategory = keyof typeof notdienstEventCategories;
export type NotdienstCalendarItem = {
id: string;
title: string;
category: NotdienstEventCategory;
startDate: Date;
endDate: Date;
allDay: boolean;
note: string | null;
};
interface NotdienstFormProps { interface NotdienstFormProps {
targetYear: number; targetYear: number;
targetMonth: number; targetMonth: number;
isLocked: boolean; isLocked: boolean;
requiredDaysTotal: number; requiredDaysTotal: number;
initialSelectedDates: string[]; // ISO Strings initialSelectedDates: string[];
childrenIds: string[]; childrenIds: string[];
calendarItems: NotdienstCalendarItem[];
} }
export function NotdienstForm({ export function NotdienstForm({
@@ -34,33 +96,44 @@ export function NotdienstForm({
requiredDaysTotal, requiredDaysTotal,
initialSelectedDates, initialSelectedDates,
childrenIds, childrenIds,
calendarItems,
}: NotdienstFormProps) { }: NotdienstFormProps) {
// Lokaler State für die Auswahl. Ein Datum liegt immer als ISO-String vor (z.B. "2026-06-01T00:00:00.000Z") const router = useRouter();
// Lokaler State für die Auswahl. Ein Datum liegt als stabiler yyyy-MM-dd-Schlüssel vor.
const [selectedDates, setSelectedDates] = useState<Set<string>>( const [selectedDates, setSelectedDates] = useState<Set<string>>(
new Set(initialSelectedDates), new Set(initialSelectedDates),
); );
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth); const closureDateKeys = useMemo(
() => getClosureDateKeys(calendarItems),
[calendarItems],
);
const selectableDateKeys = useMemo(() => {
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
return new Set(
workingDays
.map(formatDateKey)
.filter((dateKey) => !closureDateKeys.has(dateKey)),
);
}, [closureDateKeys, targetMonth, targetYear]);
const monthStart = startOfMonth(new Date(targetYear, targetMonth - 1));
const selectedCount = selectedDates.size; const selectedCount = selectedDates.size;
const isFulfilled = selectedCount >= requiredDaysTotal; const hasMinimumRequirement = requiredDaysTotal > 0;
const isFulfilled =
!hasMinimumRequirement || selectedCount >= requiredDaysTotal;
const selectedLabel =
selectedCount === 1 ? "1 Tag markiert" : `${selectedCount} Tage markiert`;
function toggleDate(dateStr: string) { function handleChange(nextDates: Set<string>) {
if (isLocked || isPending) return; if (isLocked || isPending) return;
setSelectedDates(nextDates);
const newSet = new Set(selectedDates);
if (newSet.has(dateStr)) {
newSet.delete(dateStr);
} else {
newSet.add(dateStr);
}
setSelectedDates(newSet);
} }
function handleSave() { function handleSave() {
if (isLocked) return; if (isLocked) return;
if (!isFulfilled) { if (hasMinimumRequirement && !isFulfilled) {
toast.error( toast.error(
`Bitte wähle noch ${requiredDaysTotal - selectedCount} weitere Termine aus.`, `Bitte wähle noch ${requiredDaysTotal - selectedCount} weitere Termine aus.`,
); );
@@ -78,107 +151,489 @@ export function NotdienstForm({
if (result?.error) { if (result?.error) {
toast.error(result.error); toast.error(result.error);
} else { } else {
setSelectedDates(new Set(result.dates));
toast.success("Verfügbarkeiten erfolgreich gespeichert."); toast.success("Verfügbarkeiten erfolgreich gespeichert.");
router.refresh();
} }
}); });
} }
return ( return (
<div className="space-y-6"> <div>
{/* Fortschritt / Sperr-Hinweis */} {isLocked ? (
<div <div className="mb-4 flex items-start gap-3 rounded-[10px] border border-[oklch(0.88_0.04_35)] bg-[oklch(0.96_0.018_35)] p-4 text-sm text-[oklch(0.38_0.08_35)]">
className={cn( <Info className="mt-0.5 h-4 w-4 shrink-0" />
"flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between", <p>
isLocked ? "bg-destructive/5" : "bg-card", Die Frist für diesen Monat ist abgelaufen. Änderungen sind nicht
)} mehr möglich.
> </p>
<div className="flex items-start gap-3"> </div>
{isLocked ? ( ) : null}
<Info className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
) : isFulfilled ? (
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
) : (
<Info className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div> <div className="overflow-x-auto">
<h3 className="font-medium"> <NotdienstCalendar
{isLocked month={monthStart}
? "Planung abgeschlossen" events={calendarItems}
: "Deine ausgewählten Termine"} selected={selectedDates}
</h3> requiredDays={requiredDaysTotal}
<p className="text-sm text-muted-foreground"> disabled={isLocked || isPending}
{isLocked selectableDateKeys={selectableDateKeys}
? "Die Frist für diesen Monat ist abgelaufen. Änderungen sind nicht mehr möglich." onChange={handleChange}
: `Du hast ${selectedCount} von ${requiredDaysTotal} benötigten Tagen markiert.`} onSave={handleSave}
</p> isSaving={isPending}
/>
</div>
<p className="sr-only" aria-live="polite">
{hasMinimumRequirement
? `Du hast ${selectedCount} von ${requiredDaysTotal} benötigten Tagen markiert.`
: `${selectedLabel}. Für diese Kita ist aktuell keine Mindestanzahl hinterlegt.`}
</p>
</div>
);
}
type NotdienstCalendarProps = {
month: Date;
events: NotdienstCalendarItem[];
selected: Set<string>;
requiredDays: number;
disabled?: boolean;
selectableDateKeys: Set<string>;
isSaving?: boolean;
onChange: (selected: Set<string>) => void;
onSave: () => void;
};
export function NotdienstCalendar({
month,
events,
selected,
requiredDays,
disabled = false,
selectableDateKeys,
isSaving = false,
onChange,
onSave,
}: NotdienstCalendarProps) {
const [visibleMonth, setVisibleMonth] = useState(month);
const [activeEvent, setActiveEvent] = useState<NotdienstCalendarItem | null>(
null,
);
const visibleMonthStart = startOfMonth(visibleMonth);
const visibleMonthEnd = endOfMonth(visibleMonthStart);
const calendarStart = startOfWeek(visibleMonthStart, { weekStartsOn: 1 });
const calendarEnd = endOfWeek(visibleMonthEnd, { weekStartsOn: 1 });
const calendarDays = eachDayOfInterval({
start: calendarStart,
end: calendarEnd,
}).map((day) => startOfDay(day));
const selectedCount = selected.size;
const extraCount = Math.max(0, selectedCount - requiredDays);
const statusText =
requiredDays > 0 && selectedCount >= requiredDays
? `Fertig — alle ${requiredDays} Tage markiert`
: `${selectedCount} von ${requiredDays} Tagen`;
function toggleDate(dateKey: string) {
if (disabled || !selectableDateKeys.has(dateKey)) return;
const nextSelected = new Set(selected);
if (nextSelected.has(dateKey)) {
nextSelected.delete(dateKey);
} else {
nextSelected.add(dateKey);
}
onChange(nextSelected);
}
return (
<section
className="min-w-[1040px] bg-[var(--notdienst-bg)] px-11 py-10 text-[var(--notdienst-ink)] shadow-sm [--notdienst-accent-deep:oklch(0.42_0.10_38)] [--notdienst-accent-mid:oklch(0.82_0.07_38)] [--notdienst-accent-soft:oklch(0.93_0.04_38)] [--notdienst-accent:oklch(0.58_0.11_38)] [--notdienst-bg:oklch(0.965_0.012_75)] [--notdienst-hairline-soft:oklch(0.93_0.01_75)] [--notdienst-hairline:oklch(0.90_0.012_75)] [--notdienst-ink-faint:oklch(0.76_0.01_45)] [--notdienst-ink-muted:oklch(0.60_0.012_45)] [--notdienst-ink-soft:oklch(0.45_0.015_45)] [--notdienst-ink:oklch(0.26_0.015_45)] [--notdienst-out-month:oklch(0.82_0.012_75)] [--notdienst-surface-week:oklch(0.945_0.014_75)] [--notdienst-surface:oklch(0.985_0.008_75)]"
style={{
backgroundImage:
"radial-gradient(oklch(0.85 0.02 75 / 0.10) 1px, transparent 1px)",
backgroundSize: "16px 16px",
}}
>
<header className="flex items-start justify-between gap-8">
<div>
<p className="text-[11px] font-semibold uppercase leading-none text-[var(--notdienst-accent)] [letter-spacing:0.18em]">
NOTDIENST · {format(visibleMonthStart, "MMMM yyyy", { locale: de })}
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight text-[var(--notdienst-ink)] [letter-spacing:-0.025em]">
Wann magst du{" "}
<em className="font-normal italic text-[var(--notdienst-accent-deep)]">
helfen
</em>
?
</h1>
</div>
<div className="mt-3 flex items-center gap-4 rounded-full border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] px-5 py-3 shadow-[0_12px_30px_oklch(0.36_0.02_45_/_0.06)]">
<div className="flex items-center gap-1.5">
{Array.from({ length: Math.max(requiredDays, 1) }).map(
(_, index) => {
const isMarked = index < selectedCount;
return (
<span
key={index}
className={cn(
"h-2.5 w-2.5 rounded-full border border-[var(--notdienst-accent)]",
isMarked && "bg-[var(--notdienst-accent)]",
)}
/>
);
},
)}
{extraCount > 0 ? (
<span className="ml-1 text-[11px] font-semibold text-[var(--notdienst-accent-deep)]">
+{extraCount}
</span>
) : null}
</div>
<span className="text-[13px] font-semibold text-[var(--notdienst-ink-soft)]">
{statusText}
</span>
<button
type="button"
onClick={onSave}
disabled={disabled || isSaving || selectedCount < requiredDays}
className="inline-flex h-8 items-center gap-2 rounded-full bg-[var(--notdienst-accent-deep)] px-5 text-[13px] font-bold text-white transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-55"
>
{isSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
Speichern
</button>
</div>
</header>
<div className="mt-9 flex items-center justify-between gap-8">
<div className="flex items-center gap-3">
<h2 className="text-[22px] font-medium text-[var(--notdienst-ink)] [font-variant-numeric:tabular-nums]">
{format(visibleMonthStart, "MMMM yyyy", { locale: de })}
</h2>
<div className="flex items-center gap-1.5">
<button
type="button"
aria-label="Vorheriger Monat"
onClick={() =>
setVisibleMonth(
new Date(
visibleMonthStart.getFullYear(),
visibleMonthStart.getMonth() - 1,
1,
),
)
}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] text-[var(--notdienst-ink-soft)] transition hover:border-[var(--notdienst-accent-mid)] hover:text-[var(--notdienst-accent-deep)]"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
type="button"
aria-label="Nächster Monat"
onClick={() =>
setVisibleMonth(
new Date(
visibleMonthStart.getFullYear(),
visibleMonthStart.getMonth() + 1,
1,
),
)
}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] text-[var(--notdienst-ink-soft)] transition hover:border-[var(--notdienst-accent-mid)] hover:text-[var(--notdienst-accent-deep)]"
>
<ChevronRight className="h-4 w-4" />
</button>
</div> </div>
</div> </div>
<Button <CalendarLegend />
onClick={handleSave}
disabled={isLocked || isPending || !isFulfilled}
className="shrink-0"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{isLocked ? "Gesperrt" : "Speichern"}
</Button>
</div> </div>
{/* Datumsliste */} <div className="mt-8 grid grid-cols-7 gap-1.5">
<div className="rounded-lg border bg-card"> {[
<div className="p-4 border-b bg-muted/30"> "Montag",
<p className="text-sm font-medium">Werktage im {format(new Date(targetYear, targetMonth - 1, 1), "MMMM", { locale: de })}</p> "Dienstag",
</div> "Mittwoch",
<div className="divide-y"> "Donnerstag",
{workingDays.map((day) => { "Freitag",
const dateStr = day.toISOString(); "Samstag",
const isSelected = selectedDates.has(dateStr); "Sonntag",
const isToday = day.getTime() === new Date().setHours(0, 0, 0, 0); ].map((weekday) => (
<div
key={weekday}
className="px-2 text-[11px] font-semibold uppercase text-[var(--notdienst-ink-muted)] [letter-spacing:0.08em]"
>
{weekday}
</div>
))}
</div>
return ( <div className="mt-2 grid grid-cols-7 gap-1.5">
<button {calendarDays.map((day) => {
key={dateStr} const dateKey = formatDateKey(day);
type="button" const isInTargetMonth = isSameMonth(day, visibleMonthStart);
disabled={isLocked || isPending} const isSelected = selected.has(dateKey);
onClick={() => toggleDate(dateStr)} const dayEvents = getCalendarItemsForDay(events, day);
className={cn( const hasClosure = dayEvents.some(
"flex w-full items-center justify-between px-4 py-3 text-left transition-colors", (event) => event.category === "closure",
!isLocked && !isPending && "hover:bg-muted/50", );
isSelected && "bg-primary/5 hover:bg-primary/10", const isSelectable =
(isLocked || isPending) && "cursor-not-allowed opacity-70", isInTargetMonth && selectableDateKeys.has(dateKey) && !hasClosure;
)} const isWeekend = day.getDay() === 0 || day.getDay() === 6;
>
<div className="flex items-center gap-3"> return (
{/* Custom Checkbox Indicator */} <CalendarTile
<div key={dateKey}
className={cn( day={day}
"flex h-5 w-5 items-center justify-center rounded border", events={dayEvents}
isSelected isInTargetMonth={isInTargetMonth}
? "border-primary bg-primary text-primary-foreground" isSelected={isSelected}
: "border-input bg-background", isSelectable={isSelectable}
)} isWeekend={isWeekend}
> hasClosure={hasClosure}
{isSelected && <CheckCircle2 className="h-3.5 w-3.5" />} disabled={disabled}
</div> onToggle={() => toggleDate(dateKey)}
<div> onOpenEvent={setActiveEvent}
<span className={cn("font-medium", isSelected && "text-primary")}> />
{format(day, "EEEE, dd. MMMM", { locale: de })} );
</span> })}
{isToday && ( </div>
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
Heute {activeEvent ? (
</span> <EventDialog
)} event={activeEvent}
</div> onClose={() => setActiveEvent(null)}
</div> />
</button> ) : null}
); </section>
})} );
</div> }
function CalendarTile({
day,
events,
isInTargetMonth,
isSelected,
isSelectable,
isWeekend,
hasClosure,
disabled,
onToggle,
onOpenEvent,
}: {
day: Date;
events: NotdienstCalendarItem[];
isInTargetMonth: boolean;
isSelected: boolean;
isSelectable: boolean;
isWeekend: boolean;
hasClosure: boolean;
disabled: boolean;
onToggle: () => void;
onOpenEvent: (event: NotdienstCalendarItem) => void;
}) {
return (
<div
className={cn(
"relative min-h-24 rounded-[10px] border p-[10px_11px] text-left transition",
"border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)]",
isSelectable &&
!disabled &&
"cursor-pointer hover:border-[var(--notdienst-accent-mid)] hover:shadow-[0_10px_28px_oklch(0.36_0.02_45_/_0.08)]",
isSelected &&
"border-[var(--notdienst-accent-mid)] bg-[var(--notdienst-accent-soft)]",
hasClosure &&
"cursor-default border-[oklch(0.88_0.04_35)] bg-[oklch(0.96_0.018_35)]",
(isWeekend || !isInTargetMonth) &&
"cursor-default border-[var(--notdienst-hairline-soft)] bg-[var(--notdienst-surface-week)]",
)}
onClick={isSelectable && !disabled ? onToggle : undefined}
>
<div className="flex items-start justify-between gap-2">
<span
className={cn(
"font-medium leading-none [font-variant-numeric:tabular-nums] [letter-spacing:-0.02em]",
isWeekend || !isInTargetMonth
? "text-[13px] text-[var(--notdienst-out-month)]"
: "text-[17px] text-[var(--notdienst-ink)]",
isSelected && "text-[var(--notdienst-accent-deep)]",
hasClosure && "text-[oklch(0.38_0.08_35)]",
)}
>
{format(day, "d")}
</span>
{isSelected ? (
<Check className="h-2.5 w-2.5 text-[var(--notdienst-accent-deep)] [stroke-width:2.5]" />
) : null}
{hasClosure ? (
<LockKeyhole className="h-3 w-3 text-[oklch(0.38_0.08_35)]" />
) : null}
</div>
<div className="absolute inset-x-[11px] bottom-[10px] space-y-1.5">
{events.slice(0, 2).map((event) => (
<button
key={`${formatDateKey(day)}-${event.id}`}
type="button"
className="group/event relative flex w-full items-center gap-1.5 overflow-visible text-left"
onClick={(clickEvent) => {
clickEvent.stopPropagation();
onOpenEvent(event);
}}
>
<span
className="h-4 w-[3px] shrink-0 rounded-sm"
style={{
backgroundColor: notdienstEventCategories[event.category].dot,
}}
/>
<span className="truncate text-[11px] font-medium text-[var(--notdienst-ink-soft)]">
{event.title}
</span>
<span className="pointer-events-none absolute bottom-full left-0 z-20 mb-2 hidden min-w-44 rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-left text-white shadow-xl group-hover/event:block">
<span className="block text-[10px] font-semibold uppercase text-white/65 [letter-spacing:0.08em]">
{notdienstEventCategories[event.category].label}
</span>
<span className="mt-0.5 block text-[12px] font-semibold">
{event.title}
</span>
<span className="mt-1 block text-[11px] text-white/75">
{formatEventTime(event)}
</span>
</span>
</button>
))}
{events.length > 2 ? (
<p className="text-[11px] font-medium text-[var(--notdienst-ink-muted)]">
+{events.length - 2} weitere
</p>
) : null}
</div> </div>
</div> </div>
); );
} }
function CalendarLegend() {
return (
<div className="flex flex-wrap items-center justify-end gap-4 text-[11px] font-medium text-[var(--notdienst-ink-soft)]">
<span className="inline-flex items-center gap-1.5">
<span className="h-[9px] w-[9px] rounded-[2px] bg-[var(--notdienst-accent)]" />
Dein Notdienst
</span>
{Object.entries(notdienstEventCategories).map(([key, category]) => (
<span key={key} className="inline-flex items-center gap-1.5">
<span
className="h-[9px] w-[9px] rounded-[2px]"
style={{ backgroundColor: category.dot }}
/>
{category.label}
</span>
))}
</div>
);
}
function EventDialog({
event,
onClose,
}: {
event: NotdienstCalendarItem;
onClose: () => void;
}) {
const category = notdienstEventCategories[event.category];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[oklch(0.25_0.015_45_/_0.20)] p-6 backdrop-blur-[2px]"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-xl border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] p-6 text-[var(--notdienst-ink)] shadow-2xl"
onClick={(eventClick) => eventClick.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<span
className="inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold"
style={{
backgroundColor: category.bg,
borderColor: category.border,
color: category.text,
}}
>
{category.label}
</span>
<button
type="button"
aria-label="Detail schließen"
onClick={onClose}
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-[var(--notdienst-ink-muted)] transition hover:bg-[var(--notdienst-surface-week)] hover:text-[var(--notdienst-ink)]"
>
<X className="h-4 w-4" />
</button>
</div>
<h3 className="mt-4 text-2xl font-medium leading-tight [letter-spacing:-0.02em]">
{event.title}
</h3>
<p className="mt-3 flex items-center gap-2 text-sm font-medium text-[var(--notdienst-ink-soft)]">
<Clock3 className="h-4 w-4" />
{formatEventDate(event)}
</p>
{event.note ? (
<p className="mt-5 text-sm leading-6 text-[var(--notdienst-ink-soft)]">
{event.note}
</p>
) : null}
</div>
</div>
);
}
function getCalendarItemsForDay(
items: NotdienstCalendarItem[],
day: Date,
) {
const dayStart = startOfDay(day).getTime();
return items.filter((item) => {
const itemStart = startOfDay(item.startDate).getTime();
const itemEnd = startOfDay(item.endDate).getTime();
return itemStart <= dayStart && itemEnd >= dayStart;
});
}
function getClosureDateKeys(items: NotdienstCalendarItem[]) {
const closureKeys = new Set<string>();
for (const item of items) {
if (item.category !== "closure") continue;
const days = eachDayOfInterval({
start: startOfDay(item.startDate),
end: startOfDay(item.endDate),
});
for (const day of days) {
closureKeys.add(formatDateKey(day));
}
}
return closureKeys;
}
function formatEventDate(event: NotdienstCalendarItem) {
const date = format(event.startDate, "EEEE, d. MMMM yyyy", { locale: de });
return event.allDay
? `${date} · Ganztägig`
: `${date} · ${formatEventTime(event)}`;
}
function formatEventTime(event: NotdienstCalendarItem) {
if (event.allDay) return "Ganztägig";
return `${format(event.startDate, "HH:mm")} - ${format(
event.endDate,
"HH:mm",
)}`;
}
+67 -45
View File
@@ -1,15 +1,21 @@
import Link from "next/link"; import Link from "next/link";
import { UserRole } from "@prisma/client"; import { TerminStatus, TerminType, UserRole } from "@prisma/client";
import { Info, Calendar, ArrowRight, ShieldAlert } from "lucide-react"; import { ArrowRight, ShieldAlert } from "lucide-react";
import { Manrope } from "next/font/google";
import { requireRole } from "@/lib/auth-utils"; import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getTargetMonthData } from "@/lib/date-utils"; import { formatDateKey, getTargetMonthData } from "@/lib/date-utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { NotdienstEntry } from "./notdienst-entry"; import { NotdienstEntry } from "./notdienst-entry";
export const metadata = { title: "Notdienst · Kita-Planer" }; export const metadata = { title: "Notdienst · Kita-Planer" };
const manrope = Manrope({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export default async function NotdienstPage() { export default async function NotdienstPage() {
// ELTERN können eintragen; Admins/Koordinatoren haben hier eigentlich // ELTERN können eintragen; Admins/Koordinatoren haben hier eigentlich
// andere Ansichten (Plan-Generierung), aber wir lassen sie zur Demonstration // andere Ansichten (Plan-Generierung), aber wir lassen sie zur Demonstration
@@ -44,13 +50,15 @@ export default async function NotdienstPage() {
: []; : [];
const targetData = getTargetMonthData(); const targetData = getTargetMonthData();
const { targetYear, targetMonth, monthName, isLocked } = targetData; const { targetYear, targetMonth, isLocked } = targetData;
const targetMonthStart = new Date(Date.UTC(targetYear, targetMonth - 1, 1));
const targetMonthEnd = new Date(Date.UTC(targetYear, targetMonth, 1));
const requiredDaysTotal = children.length * kita.notdienstMinPerChildPerMonth; const requiredDaysTotal = children.length * kita.notdienstMinPerChildPerMonth;
const childIds = children.map((child) => child.id); const childIds = children.map((child) => child.id);
// Bisherige Einträge für den Zielmonat laden, haushaltsweit über die Kinder. // Bisherige Einträge für den Zielmonat laden, haushaltsweit über die Kinder.
const [availabilities, availabilityCountForCurrentOrNextMonth] = const [availabilities, termine] =
children.length > 0 children.length > 0
? await Promise.all([ ? await Promise.all([
prisma.notdienstAvailability.findMany({ prisma.notdienstAvailability.findMany({
@@ -58,80 +66,94 @@ export default async function NotdienstPage() {
kitaId, kitaId,
childId: { in: childIds }, childId: { in: childIds },
date: { date: {
gte: new Date(targetYear, targetMonth - 1, 1), gte: targetMonthStart,
lt: new Date(targetYear, targetMonth, 1), lt: targetMonthEnd,
}, },
}, },
select: { date: true }, select: { date: true },
}), }),
prisma.notdienstAvailability.count({ prisma.termin.findMany({
where: { where: {
kitaId, kitaId,
childId: { in: childIds }, status: TerminStatus.CONFIRMED,
date: { startDate: { lt: targetMonthEnd },
gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1), endDate: { gte: targetMonthStart },
lt: new Date(new Date().getFullYear(), new Date().getMonth() + 2, 1),
},
}, },
select: {
id: true,
title: true,
description: true,
type: true,
startDate: true,
endDate: true,
allDay: true,
},
orderBy: { startDate: "asc" },
}), }),
]) ])
: [[], 0]; : [[], []];
const selectedDates = availabilities.map((a) => a.date.toISOString()); const selectedDates = availabilities.map((a) => formatDateKey(a.date));
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-8"> <div className={`${manrope.className} mx-auto max-w-7xl px-4 py-8 sm:px-8`}>
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Calendar className="h-6 w-6 text-primary" />
Notdienst planen
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Gib hier an, an welchen Tagen du im Notdienst unterstützen kannst.
</p>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<span className="rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
Zielmonat: {monthName}
</span>
{isLocked && (
<span className="text-xs font-medium text-destructive flex items-center gap-1">
<Info className="h-3 w-3" />
Eingabefrist abgelaufen
</span>
)}
</div>
</div>
{children.length === 0 ? ( {children.length === 0 ? (
<NotdienstLockout /> <NotdienstLockout />
) : ( ) : (
<NotdienstEntry <NotdienstEntry
hasAnyAvailability={availabilityCountForCurrentOrNextMonth > 0}
targetYear={targetYear} targetYear={targetYear}
targetMonth={targetMonth} targetMonth={targetMonth}
isLocked={isLocked} isLocked={isLocked}
requiredDaysTotal={requiredDaysTotal} requiredDaysTotal={requiredDaysTotal}
initialSelectedDates={selectedDates} initialSelectedDates={selectedDates}
childrenIds={childIds} childrenIds={childIds}
calendarItems={termine.map((termin) => ({
id: termin.id,
title: termin.title,
category: getTerminCategory(termin.type),
startDate: termin.startDate,
endDate: termin.endDate,
allDay: termin.allDay,
note: termin.description,
}))}
/> />
)} )}
</div> </div>
); );
} }
function getTerminCategory(type: TerminType) {
switch (type) {
case TerminType.SCHLIESSTAG:
return "closure" as const;
case TerminType.TEAMTAG:
return "team" as const;
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "social" as const;
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "parents" as const;
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "birthday" as const;
default:
return "social" as const;
}
}
function NotdienstLockout() { function NotdienstLockout() {
return ( return (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-muted/20 p-8 text-center"> <div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-8 text-center">
<div className="mx-auto max-w-lg"> <div className="mx-auto max-w-lg">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-amber-100 text-amber-700 shadow-sm"> <div className="mx-auto flex h-20 w-20 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<ShieldAlert className="h-10 w-10" /> <ShieldAlert className="h-10 w-10 [stroke-width:1.7]" />
</div> </div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight"> <h2 className="mt-6 text-2xl font-medium tracking-[-0.02em] text-[var(--ink)]">
Erst Kinder hinterlegen Erst Kinder hinterlegen
</h2> </h2>
<p className="mt-3 text-sm leading-6 text-muted-foreground"> <p className="mt-3 text-sm leading-6 text-[var(--ink-soft)]">
Bitte lege zuerst deine Kinder im Profil an, bevor du Notdienste Bitte lege zuerst deine Kinder im Profil an, bevor du Notdienste
übernimmst. übernimmst.
</p> </p>
@@ -0,0 +1,132 @@
"use client";
import { useState, useTransition, type FormEvent } from "react";
import { SlidersHorizontal } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateKitaNotdienstSettingsAction } from "../actions";
interface SettingsModalProps {
initialMinPerChild: number;
initialReminderDays: number;
}
export function SettingsModal({
initialMinPerChild,
initialReminderDays,
}: SettingsModalProps) {
const [open, setOpen] = useState(false);
const [minPerChild, setMinPerChild] = useState(initialMinPerChild);
const [reminderDays, setReminderDays] = useState(initialReminderDays);
const [isPending, startTransition] = useTransition();
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (minPerChild < 0 || reminderDays < 0) {
toast.error("Werte dürfen nicht negativ sein.");
return;
}
startTransition(async () => {
const res = await updateKitaNotdienstSettingsAction(
minPerChild,
reminderDays
);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Regeln erfolgreich gespeichert!");
setOpen(false);
}
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Einstellungen
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Notdienst-Einstellungen</DialogTitle>
<DialogDescription>
Passe die standardmäßigen Regeln für die Notdienst-Verteilung in deiner Kita an.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="minPerChild">
Mindesteinteilungen pro Kind/Monat
</Label>
<Input
id="minPerChild"
name="minPerChild"
type="number"
min="0"
required
value={minPerChild}
onChange={(e) => setMinPerChild(parseInt(e.target.value, 10))}
/>
<p className="text-xs text-[var(--ink-muted)]">
Bestimmt das Richtmaß der automatischen Verteilung für Kinder im jeweiligen Monat.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="reminderDays">
Erinnerungsfrist (Tage vor Monatsende)
</Label>
<Input
id="reminderDays"
name="reminderDays"
type="number"
min="0"
required
value={reminderDays}
onChange={(e) => setReminderDays(parseInt(e.target.value, 10))}
/>
<p className="text-xs text-[var(--ink-muted)]">
Frist, nach der Eltern automatisch per Mail an offene Verfügbarkeiten erinnert werden.
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => {
setMinPerChild(initialMinPerChild);
setReminderDays(initialReminderDays);
setOpen(false);
}}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : "Regeln speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+280 -10
View File
@@ -1,21 +1,36 @@
"use server"; "use server";
import { createElement } from "react";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { NotdienstPlanStatus, UserRole } from "@prisma/client"; import { NotdienstPlanStatus, UserRole } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { requireRole } from "@/lib/auth-utils"; import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils"; import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
import { NotdienstAssignmentEmail } from "@/emails/NotdienstAssignmentEmail";
// ===================================================================== // =====================================================================
// /dashboard/notdienst/plan · Server Actions // /dashboard/notdienst/plan · Server Actions
// ===================================================================== // =====================================================================
export async function generatePlanAction() { export async function generatePlanAction(year?: number, month?: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!; const kitaId = session.user.kitaId!;
const { targetYear, targetMonth } = getTargetMonthData(); let targetYear: number;
let targetMonth: number;
if (year !== undefined && month !== undefined) {
targetYear = year;
targetMonth = month;
} else {
const data = getTargetMonthData();
targetYear = data.targetYear;
targetMonth = data.targetMonth;
}
// Prüfen, ob schon ein Plan existiert // Prüfen, ob schon ein Plan existiert
const existingPlan = await prisma.notdienstPlan.findUnique({ const existingPlan = await prisma.notdienstPlan.findUnique({
@@ -33,8 +48,8 @@ export async function generatePlanAction() {
where: { where: {
kitaId, kitaId,
date: { date: {
gte: new Date(targetYear, targetMonth - 1, 1), gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
lt: new Date(targetYear, targetMonth, 1), lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
}, },
}, },
include: { child: { select: { familyId: true } } }, include: { child: { select: { familyId: true } } },
@@ -141,17 +156,44 @@ export async function updateAssignmentAction(
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!; const kitaId = session.user.kitaId!;
const parsedDate = new Date(date); const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
try { try {
await prisma.$transaction(async (tx) => { const { shouldSendEmails, assignedChildInfo } = await prisma.$transaction(async (tx) => {
// Prüfen, ob Plan noch DRAFT ist let txShouldSendEmails = false;
let txAssignedChildInfo: {
parentEmails: string[];
parentNames: string[];
dateLabel: string;
} | null = null;
const plan = await tx.notdienstPlan.findUnique({ const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId }, where: { id: planId, kitaId },
}); });
if (!plan || plan.status !== NotdienstPlanStatus.DRAFT) { if (!plan) {
throw new Error("Plan kann nicht mehr bearbeitet werden."); throw new Error("Plan nicht gefunden.");
}
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
if (!isDraft && !isPublished) {
throw new Error("Plan kann nicht bearbeitet werden.");
}
// Wenn der Plan bereits veröffentlicht ist, dürfen nur unbesetzte Termine geändert werden.
// Ein Termin gilt als "unbesetzt", wenn am Veröffentlichungstag kein Assignment existierte
// bzw. das Assignment erst nach dem Veröffentlichungs-Zeitpunkt erstellt wurde.
const existingAssignment = await tx.notdienstAssignment.findFirst({
where: { planId, date: parsedDate },
});
if (isPublished && existingAssignment) {
if (!plan.publishedAt || existingAssignment.createdAt <= plan.publishedAt) {
throw new Error("In einem bereits veröffentlichten Plan können nur unbesetzte Termine geändert werden.");
}
} }
// Altes Assignment für diesen Tag löschen // Altes Assignment für diesen Tag löschen
@@ -167,7 +209,16 @@ export async function updateAssignmentAction(
kitaId, kitaId,
active: true, active: true,
}, },
select: { id: true }, include: {
family: {
include: {
users: {
where: { role: UserRole.ELTERN },
select: { email: true, firstName: true, lastName: true },
},
},
},
},
}); });
if (!child) { if (!child) {
@@ -182,9 +233,57 @@ export async function updateAssignmentAction(
date: parsedDate, date: parsedDate,
}, },
}); });
// Wenn der Plan bereits veröffentlicht ist, bereiten wir E-Mail-Benachrichtigungen vor
if (isPublished) {
const parents = child.family.users.filter((u) => !!u.email);
if (parents.length > 0) {
const localFormatDate = new Date(yr, mo - 1, dy);
txAssignedChildInfo = {
parentEmails: parents.map((p) => p.email!),
parentNames: parents.map((p) => `${p.firstName} ${p.lastName}`),
dateLabel: format(localFormatDate, "EEEE, dd. MMMM yyyy", { locale: de }),
};
txShouldSendEmails = true;
}
}
} }
return {
shouldSendEmails: txShouldSendEmails,
assignedChildInfo: txAssignedChildInfo,
};
}); });
// E-Mails außerhalb der DB-Transaktion senden
if (shouldSendEmails && assignedChildInfo) {
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (kita) {
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`;
for (let i = 0; i < assignedChildInfo.parentEmails.length; i++) {
const email = assignedChildInfo.parentEmails[i];
const name = assignedChildInfo.parentNames[i];
await sendAppEmail({
to: email,
subject: `Notdienst-Einteilung am ${assignedChildInfo.dateLabel}`,
react: createElement(NotdienstAssignmentEmail, {
parentName: name,
kitaName: kita.name,
dateLabel: assignedChildInfo.dateLabel,
link,
}),
});
}
}
}
revalidatePath("/dashboard/notdienst/plan"); revalidatePath("/dashboard/notdienst/plan");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
@@ -193,3 +292,174 @@ export async function updateAssignmentAction(
}; };
} }
} }
export async function updateKitaNotdienstSettingsAction(
minPerChild: number,
reminderDays: number,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.kita.update({
where: { id: kitaId },
data: {
notdienstMinPerChildPerMonth: minPerChild,
notdienstReminderDaysBefore: reminderDays,
},
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error) {
console.error(error);
return { error: "Fehler beim Speichern der Einstellungen." };
}
}
export async function sendNotdienstRemindersAction(year: number, month: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
}
try {
// 1. Get Kita Name
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (!kita) {
return { error: "Kita nicht gefunden." };
}
// 2. Fetch all families in this Kita with at least one active child and retrieve parents
const families = await prisma.family.findMany({
where: {
kitaId,
children: { some: { active: true } },
},
include: {
users: {
where: { role: UserRole.ELTERN },
},
},
});
// 3. For target month, find availability entries
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 1);
const respondedFamilies = await prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: monthStart,
lt: monthEnd,
},
},
select: {
child: {
select: {
familyId: true,
},
},
},
});
const respondedFamilyIds = new Set(respondedFamilies.map((a) => a.child.familyId));
// 4. Find open families (not responded yet)
const openFamilies = families.filter((f) => !respondedFamilyIds.has(f.id));
if (openFamilies.length === 0) {
return { success: true, count: 0 };
}
// 5. Build emails list and send
const monthDate = new Date(year, month - 1, 1);
const monthLabel = format(monthDate, "MMMM yyyy", { locale: de });
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`;
let sentCount = 0;
for (const family of openFamilies) {
for (const parent of family.users) {
if (!parent.email) continue;
const result = await sendAppEmail({
to: parent.email,
subject: `Erinnerung: Bitte Verfügbarkeiten für den Notdienst ${monthLabel} eintragen`,
react: createElement(NotdienstAvailabilityReminderEmail, {
parentName: `${parent.firstName} ${parent.lastName}`,
kitaName: kita.name,
monthLabel,
link,
}),
});
if (result.success) {
sentCount++;
} else {
console.error(`E-Mail konnte nicht an ${parent.email} gesendet werden:`, result.error);
}
}
}
return { success: true, count: sentCount };
} catch (error) {
console.error("Fehler beim Versenden der Erinnerungen:", error);
return { error: "Fehler beim Versenden der Erinnerungen." };
}
}
export async function resetPlanAction(planId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.$transaction(async (tx) => {
// Ensure the plan belongs to this kita and is draft
const plan = await tx.notdienstPlan.findFirst({
where: { id: planId, kitaId },
});
if (!plan) {
throw new Error("Plan nicht gefunden.");
}
if (plan.status !== NotdienstPlanStatus.DRAFT) {
throw new Error("Nur Pläne im Entwurfsstatus können zurückgesetzt werden.");
}
// Delete all assignments
await tx.notdienstAssignment.deleteMany({
where: { planId },
});
// Delete the plan itself
await tx.notdienstPlan.delete({
where: { id: planId },
});
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error) {
console.error("Fehler beim Zurücksetzen des Plans:", error);
return { error: error instanceof Error ? error.message : "Fehler beim Zurücksetzen des Plans." };
}
}
+179 -34
View File
@@ -1,39 +1,108 @@
import { UserRole } from "@prisma/client"; import Link from "next/link";
import { Settings2 } from "lucide-react"; import { addMonths, getYear, getMonth, format } from "date-fns";
import { de } from "date-fns/locale";
import { TerminType, UserRole } from "@prisma/client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { requireRole } from "@/lib/auth-utils"; import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils"; import { getWorkingDaysOfMonth } from "@/lib/date-utils";
import { PlanView } from "./plan-view"; import { PlanView } from "./plan-view";
import { SettingsModal } from "./_components/settings-modal";
export const metadata = { title: "Plan-Generierung · Kita-Planer" }; export const metadata = { title: "Plan-Generierung · Kita-Planer" };
export default async function PlanungsZentralePage() { export default async function PlanungsZentralePage({
searchParams,
}: {
searchParams: Promise<{ year?: string; month?: string }>;
}) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!; const kitaId = session.user.kitaId!;
const { targetYear, targetMonth, monthName } = getTargetMonthData(); const resolvedSearchParams = await searchParams;
const now = new Date();
const nextMonth = addMonths(now, 1);
const targetYear = resolvedSearchParams.year ? parseInt(resolvedSearchParams.year, 10) : getYear(nextMonth);
const targetMonth = resolvedSearchParams.month ? parseInt(resolvedSearchParams.month, 10) : (getMonth(nextMonth) + 1);
// Aktuellen Plan suchen const monthDate = new Date(targetYear, targetMonth - 1, 1);
const plan = await prisma.notdienstPlan.findUnique({ const monthName = format(monthDate, "MMMM", { locale: de });
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth }, const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
}, const monthStart = new Date(targetYear, targetMonth - 1, 1);
select: { const monthEnd = new Date(targetYear, targetMonth, 1);
id: true,
status: true, const [plan, families, availabilities, closureCount, kita] = await Promise.all([
assignments: { prisma.notdienstPlan.findUnique({
select: { where: {
id: true, kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
childId: true, },
date: true, select: {
id: true,
status: true,
publishedAt: true,
assignments: {
select: {
id: true,
childId: true,
date: true,
createdAt: true,
},
}, },
}, },
}, }),
}); prisma.family.findMany({
where: {
// Alle Werktage kitaId,
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth); children: { some: { active: true } },
},
select: {
id: true,
name: true,
children: {
where: { active: true },
select: { id: true },
},
},
orderBy: { name: "asc" },
}),
prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: monthStart,
lt: monthEnd,
},
},
select: {
date: true,
child: {
select: {
familyId: true,
},
},
},
orderBy: { date: "asc" },
}),
prisma.termin.count({
where: {
kitaId,
type: TerminType.SCHLIESSTAG,
startDate: {
gte: monthStart,
lt: monthEnd,
},
},
}),
prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: {
notdienstMinPerChildPerMonth: true,
notdienstReminderDaysBefore: true,
},
}),
]);
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown. // Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle", // Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
@@ -44,6 +113,7 @@ export default async function PlanungsZentralePage() {
id: true, id: true,
firstName: true, firstName: true,
lastName: true, lastName: true,
familyId: true,
family: { family: {
select: { select: {
name: true, name: true,
@@ -68,45 +138,120 @@ export default async function PlanungsZentralePage() {
id: c.id, id: c.id,
name: `${c.firstName} ${c.lastName}`, name: `${c.firstName} ${c.lastName}`,
parentName, parentName,
familyId: c.familyId,
}; };
}); });
const availabilityDatesByFamily = new Map<string, Set<string>>();
availabilities.forEach((availability) => {
const familyId = availability.child.familyId;
const dates = availabilityDatesByFamily.get(familyId) ?? new Set<string>();
dates.add(availability.date.toISOString());
availabilityDatesByFamily.set(familyId, dates);
});
const familyStatuses = families.map((family) => {
const dates = Array.from(availabilityDatesByFamily.get(family.id) ?? []);
return {
id: family.id,
name: family.name,
selectedDays: dates.map((date) => ({
iso: date,
label: new Intl.DateTimeFormat("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
}).format(new Date(date)),
})),
childCount: family.children.length,
};
});
const respondedFamilies = familyStatuses.filter(
(family) => family.selectedDays.length > 0,
).length;
const openFamilies = Math.max(familyStatuses.length - respondedFamilies, 0);
const totalAvailabilitySlots = availabilities.length;
const requiredDays = workingDays.length;
const coverageFactor =
requiredDays > 0 ? totalAvailabilitySlots / requiredDays : 0;
// Wir mappen die Assignments auf die Werktage // Wir mappen die Assignments auf die Werktage
const daysWithAssignments = workingDays.map((day) => { const daysWithAssignments = workingDays.map((day) => {
const assignment = plan?.assignments.find( const assignment = plan?.assignments.find(
(a) => a.date.getTime() === day.getTime() (a) => a.date.getTime() === day.getTime()
); );
const isOriginallyUnassigned =
!assignment ||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
return { return {
date: day.toISOString(), date: day.toISOString(),
assignmentId: assignment?.id || null, assignmentId: assignment?.id || null,
childId: assignment?.childId || null, childId: assignment?.childId || null,
isOriginallyUnassigned: !!isOriginallyUnassigned,
}; };
}); });
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-8"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2"> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<Settings2 className="h-6 w-6 text-primary" /> NOTDIENST · PLANUNG
Notdienst-Planung </p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Notdienst{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
zuweisen
</em>
</h1> </h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Koordiniere und generiere den Notdienst-Plan. Koordiniere und generiere den Notdienst-Plan.
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary"> <div className="print:hidden flex shrink-0 items-center gap-2">
Zielmonat: {monthName} <div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
</span> <Link
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, -1))}&month=${getMonth(addMonths(monthDate, -1)) + 1}`}
className="inline-flex h-9 w-9 items-center justify-center border-r border-[var(--hairline-soft)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)] transition"
>
<ChevronLeft className="h-4 w-4 [stroke-width:1.7]" />
</Link>
<div className="flex h-9 min-w-36 items-center justify-center px-4 text-[13px] font-semibold text-[var(--ink)]">
{monthName} {targetYear}
</div>
<Link
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, 1))}&month=${getMonth(addMonths(monthDate, 1)) + 1}`}
className="inline-flex h-9 w-9 items-center justify-center border-l border-[var(--hairline-soft)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)] transition"
>
<ChevronRight className="h-4 w-4 [stroke-width:1.7]" />
</Link>
</div>
<SettingsModal
initialMinPerChild={kita.notdienstMinPerChildPerMonth}
initialReminderDays={kita.notdienstReminderDaysBefore}
/>
</div> </div>
</div> </header>
<PlanView <PlanView
planId={plan?.id} planId={plan?.id}
status={plan?.status} status={plan?.status}
days={daysWithAssignments} days={daysWithAssignments}
allChildren={allChildren} allChildren={allChildren}
familyStatuses={familyStatuses}
requiredDays={requiredDays}
respondedFamilies={respondedFamilies}
openFamilies={openFamilies}
totalFamilies={familyStatuses.length}
totalAvailabilitySlots={totalAvailabilitySlots}
coverageFactor={coverageFactor}
closureCount={closureCount}
monthName={monthName}
year={targetYear}
month={targetMonth}
/> />
</div> </div>
); );
+577 -97
View File
@@ -1,41 +1,102 @@
"use client"; "use client";
import { useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { NotdienstPlanStatus } from "@prisma/client"; import { NotdienstPlanStatus } from "@prisma/client";
import { Loader2, Wand2, CheckSquare } from "lucide-react"; import {
ArrowRight,
CheckSquare,
Download,
Loader2,
RotateCcw,
Send,
Wand2,
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { import {
generatePlanAction, generatePlanAction,
publishPlanAction, publishPlanAction,
updateAssignmentAction, updateAssignmentAction,
sendNotdienstRemindersAction,
resetPlanAction,
} from "./actions"; } from "./actions";
interface PlanViewProps { interface PlanViewProps {
planId?: string; planId?: string;
status?: NotdienstPlanStatus; status?: NotdienstPlanStatus;
days: { days: {
date: string; // ISO date: string;
assignmentId: string | null; assignmentId: string | null;
childId: string | null; childId: string | null;
isOriginallyUnassigned?: boolean;
}[]; }[];
allChildren: { allChildren: {
id: string; id: string;
name: string; name: string;
parentName: string; parentName: string;
familyId: string;
}[]; }[];
familyStatuses: {
id: string;
name: string;
childCount: number;
selectedDays: {
iso: string;
label: string;
}[];
}[];
requiredDays: number;
respondedFamilies: number;
openFamilies: number;
totalFamilies: number;
totalAvailabilitySlots: number;
coverageFactor: number;
closureCount: number;
monthName: string;
year: number;
month: number;
} }
export function PlanView({ planId, status, days, allChildren }: PlanViewProps) { export function PlanView({
planId,
status,
days,
allChildren,
familyStatuses,
requiredDays,
respondedFamilies,
openFamilies,
totalFamilies,
totalAvailabilitySlots,
coverageFactor,
closureCount,
monthName,
year,
month,
}: PlanViewProps) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [activeTab, setActiveTab] = useState<"preview" | "manual">("preview");
const [resetDialogOpen, setResetDialogOpen] = useState(false);
const responsePercent =
totalFamilies > 0 ? Math.round((respondedFamilies / totalFamilies) * 100) : 0;
const handleGenerate = () => { const handleGenerate = () => {
startTransition(async () => { startTransition(async () => {
const result = await generatePlanAction(); const result = await generatePlanAction(year, month);
if ("error" in result && result.error) { if ("error" in result && result.error) {
toast.error(result.error as string); toast.error(result.error as string);
} else { } else {
@@ -56,8 +117,32 @@ export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
}); });
}; };
const handleSendReminders = () => {
startTransition(async () => {
const result = await sendNotdienstRemindersAction(year, month);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else if (result.success) {
toast.success(`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`);
}
});
};
const handleReset = () => {
if (!planId) return;
startTransition(async () => {
const result = await resetPlanAction(planId);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Plan wurde erfolgreich zurückgesetzt.");
setResetDialogOpen(false);
}
});
};
const handleAssignmentChange = (date: string, newChildId: string) => { const handleAssignmentChange = (date: string, newChildId: string) => {
if (!planId || status !== "DRAFT") return; if (!planId || (status !== "DRAFT" && status !== "PUBLISHED")) return;
const valueToSet = newChildId === "empty" ? null : newChildId; const valueToSet = newChildId === "empty" ? null : newChildId;
startTransition(async () => { startTransition(async () => {
@@ -70,21 +155,178 @@ export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
}); });
}; };
if (!planId && respondedFamilies === 0) {
return (
<Card className="border-dashed">
<div className="flex min-h-[280px] flex-col items-center justify-center p-8 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Wand2 className="h-7 w-7 [stroke-width:1.7]" />
</div>
<h3 className="mb-1 text-xl font-medium tracking-[-0.02em] text-[var(--ink)]">
Noch keine Verfügbarkeiten
</h3>
<p className="mb-5 max-w-md text-sm leading-6 text-[var(--ink-soft)]">
Für {monthName} hat noch keine Familie geantwortet. Verschicke
zuerst Erinnerungen, damit die automatische Planung belastbar ist.
</p>
<div className="flex flex-wrap justify-center gap-2">
<Button disabled={isPending} onClick={handleGenerate}>
Trotzdem generieren
</Button>
<Button variant="ghost" onClick={handleSendReminders} disabled={isPending}>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : (
<Send className="h-4 w-4 [stroke-width:1.7]" />
)}
Erinnerungen verschicken
</Button>
</div>
</div>
</Card>
);
}
if (!planId) { if (!planId) {
return ( return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-card p-12 text-center"> <div className="space-y-5">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary"> <div className="grid grid-cols-[minmax(0,2fr)_minmax(300px,1fr)] gap-5 max-[1050px]:grid-cols-1">
<Wand2 className="h-6 w-6" /> <Card className="p-0">
<div className="border-b border-[var(--hairline-soft)] px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Verfügbarkeiten Eltern
</h2>
<p className="mt-1 text-[13.5px] leading-5 text-[var(--ink-soft)]">
{respondedFamilies} von {totalFamilies} Familien haben
geantwortet · noch {openFamilies} offen
</p>
</div>
<span className="text-sm font-semibold tabular-nums text-[var(--ink-soft)]">
{responsePercent}%
</span>
</div>
<div className="mt-4 h-1 overflow-hidden rounded-full bg-[var(--accent-soft)]">
<div
className="h-full rounded-full bg-[var(--accent)]"
style={{ width: `${responsePercent}%` }}
/>
</div>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{familyStatuses.map((family) => (
<div
key={family.id}
className="group relative flex items-center gap-3 px-6 py-3.5 transition-colors hover:bg-[var(--surface-2)]"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[11px] font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-[var(--ink)]">
{family.name}
</p>
<p className="text-xs text-[var(--ink-muted)]">
{family.childCount} Kind
{family.childCount === 1 ? "" : "er"}
</p>
</div>
<FamilyStatusPill selectedDays={family.selectedDays.length} />
<div className="pointer-events-none absolute right-6 top-10 z-20 hidden max-w-xs rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-xs leading-5 text-white shadow-lg group-hover:block">
{family.selectedDays.length > 0
? family.selectedDays.map((day) => day.label).join(" · ")
: "Noch keine Tage markiert"}
</div>
</div>
))}
</div>
<div className="border-t border-[var(--hairline-soft)] px-6 py-4">
<Button
variant="ghost"
onClick={handleSendReminders}
disabled={isPending || openFamilies === 0}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : (
<Send className="h-4 w-4 [stroke-width:1.7]" />
)}
Erinnerung an offene senden ({openFamilies})
</Button>
</div>
</Card>
<Card className="p-6">
<div>
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Bedarfs-Übersicht
</h2>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Planungsgrundlage für {monthName}.
</p>
</div>
<div className="mt-5 grid gap-4">
<PlanMetric
label={`Tage benötigt in ${monthName}`}
value={requiredDays}
/>
<PlanMetric
label="Verfügbarkeiten gesamt"
value={totalAvailabilitySlots}
/>
<PlanMetric
label="Überdeckung"
value={`${coverageFactor.toFixed(1)}×`}
badge="good"
/>
<PlanMetric label="Schließtage" value={closureCount} />
</div>
<div className="mt-5 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-3 text-sm leading-6 text-[var(--ink-soft)]">
{coverageFactor >= 1
? "Du hast genug Verfügbarkeiten für einen Plan."
: "Die Verfügbarkeiten reichen noch nicht für einen belastbaren Plan."}
</div>
</Card>
</div> </div>
<h3 className="mb-1 text-lg font-semibold">Noch kein Plan vorhanden</h3>
<p className="mb-6 max-w-sm text-sm text-muted-foreground"> <Card variant="featured" className="sticky bottom-6 z-10 p-5">
Generiere den Plan basierend auf den Verfügbarkeiten der Eltern <div className="flex items-center justify-between gap-5 max-[760px]:flex-col max-[760px]:items-start">
automatisch. <div className="flex gap-4">
</p> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Button onClick={handleGenerate} disabled={isPending}> <Wand2 className="h-5 w-5 [stroke-width:1.7]" />
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} </div>
Plan automatisch generieren <div>
</Button> <h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Plan automatisch generieren
</h3>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Verteilt {requiredDays} Notdiensttage fair auf{" "}
{respondedFamilies} Familien.
</p>
<details className="mt-3 text-xs text-[var(--ink-soft)]">
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
Faire Verteilung berücksichtigt
</summary>
<div className="mt-2 flex flex-wrap gap-2">
<span> Quote pro Familie</span>
<span> Bevorzugte Tage</span>
<span> Geschwister-Familien zusammen</span>
</div>
</details>
</div>
</div>
<Button onClick={handleGenerate} disabled={isPending}>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
)}
Generieren
<ArrowRight className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</div>
</Card>
</div> </div>
); );
} }
@@ -92,95 +334,333 @@ export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
const isDraft = status === "DRAFT"; const isDraft = status === "DRAFT";
return ( return (
<div className="space-y-6"> <div className="space-y-5">
<div className="flex items-center justify-between rounded-lg border bg-card p-4"> <div className="print:hidden flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<TabButton
active={activeTab === "preview"}
onClick={() => setActiveTab("preview")}
>
Plan-Vorschau
</TabButton>
<TabButton
active={activeTab === "manual"}
onClick={() => setActiveTab("manual")}
withDivider
>
Manuell zuweisen
</TabButton>
</div>
<div className="flex flex-wrap gap-2">
{isDraft && (
<Button onClick={handlePublish} disabled={isPending}>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
)}
<CheckSquare className="h-4 w-4 [stroke-width:1.7]" />
Plan veröffentlichen
</Button>
)}
<Button variant="ghost" onClick={() => window.print()}>
<Download className="h-4 w-4 [stroke-width:1.7]" />
Als PDF exportieren
</Button>
<Button
variant="ghost"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => setResetDialogOpen(true)}
disabled={isPending}
>
<RotateCcw className="h-4 w-4 [stroke-width:1.7]" />
Plan zurücksetzen
</Button>
</div>
</div>
<Card className="print:hidden flex items-center justify-between p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Badge variant={isDraft ? "warning" : "success"}> <Badge variant={isDraft ? "warning" : "success"}>
{isDraft ? "Entwurf" : "Veröffentlicht"} {isDraft ? "Entwurf" : "Veröffentlicht"}
</Badge> </Badge>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-[var(--ink-soft)]">
{isDraft {isDraft
? "Du kannst die Liste jetzt manuell anpassen." ? "Du kannst die Liste jetzt manuell anpassen."
: "Dieser Plan ist verbindlich."} : "Dieser Plan ist verbindlich."}
</span> </span>
</div> </div>
</Card>
{isDraft && ( {activeTab === "preview" ? (
<Button onClick={handlePublish} disabled={isPending}> <PlanPreviewTable days={days} allChildren={allChildren} />
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} ) : (
<CheckSquare className="mr-2 h-4 w-4" /> <ManualAssignmentTable
Plan veröffentlichen days={days}
</Button> allChildren={allChildren}
)} isDraft={isDraft}
</div> isPending={isPending}
onAssignmentChange={handleAssignmentChange}
/>
)}
<div className="rounded-lg border bg-card"> <Dialog open={resetDialogOpen} onOpenChange={setResetDialogOpen}>
<div className="grid grid-cols-[1fr_2fr] border-b bg-muted/30 p-4 text-sm font-medium"> <DialogContent>
<div>Datum</div> <DialogHeader>
<div>Eingeteilte Familie</div> <DialogTitle>Plan zurücksetzen?</DialogTitle>
</div> <DialogDescription>
<div className="divide-y"> Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für diesen Monat gehen unwiderruflich verloren.
{days.map((day) => { </DialogDescription>
const dateObj = new Date(day.date); </DialogHeader>
const isUnbesetzt = !day.childId; <DialogFooter>
<Button
return ( type="button"
<div variant="ghost"
key={day.date} onClick={() => setResetDialogOpen(false)}
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-muted/10" disabled={isPending}
> >
<div className="font-medium"> Abbrechen
{format(dateObj, "EE, dd.MM.", { locale: de })} </Button>
</div> <Button
<div> type="button"
{isDraft ? ( variant="destructive"
<select onClick={handleReset}
className="w-full max-w-sm rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" disabled={isPending}
value={day.childId || "empty"} >
disabled={isPending} {isPending ? "Zurücksetzen..." : "Ja, Plan zurücksetzen"}
onChange={(e) => </Button>
handleAssignmentChange(day.date, e.target.value) </DialogFooter>
} </DialogContent>
> </Dialog>
<option value="empty" className="text-destructive">
-- Unbesetzt --
</option>
{allChildren.map((c) => (
<option key={c.id} value={c.id}>
{c.parentName} ({c.name})
</option>
))}
</select>
) : (
<div>
{isUnbesetzt ? (
<span className="text-sm font-medium text-destructive">
Unbesetzt!
</span>
) : (
<span className="text-sm">
{
allChildren.find((c) => c.id === day.childId)
?.parentName
}{" "}
<span className="text-muted-foreground">
(
{
allChildren.find((c) => c.id === day.childId)
?.name
}
)
</span>
</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div> </div>
); );
} }
function FamilyStatusPill({ selectedDays }: { selectedDays: number }) {
if (selectedDays === 0) {
return (
<span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
Noch nicht
</span>
);
}
return (
<span className="rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]">
{selectedDays} Tage
</span>
);
}
function PlanMetric({
label,
value,
badge,
}: {
label: string;
value: number | string;
badge?: "good";
}) {
return (
<div className="flex items-center justify-between gap-4 border-b border-[var(--hairline-soft)] pb-3 last:border-b-0 last:pb-0">
<span className="text-[13.5px] text-[var(--ink-soft)]">{label}</span>
{badge ? (
<span className="rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-sm font-semibold text-[var(--good)] tabular-nums">
{value}
</span>
) : (
<span className="text-2xl font-medium tracking-[-0.025em] text-[var(--ink)] tabular-nums">
{value}
</span>
)}
</div>
);
}
function TabButton({
active,
withDivider,
onClick,
children,
}: {
active: boolean;
withDivider?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={[
"h-9 px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]",
withDivider ? "border-l border-[var(--hairline-soft)]" : "",
active
? "bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
{children}
</button>
);
}
function PlanPreviewTable({
days,
allChildren,
}: Pick<PlanViewProps, "days" | "allChildren">) {
return (
<Card className="overflow-hidden p-0">
<div className="grid grid-cols-[1.1fr_1fr_2fr_1.6fr_1fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
<div>Datum</div>
<div>Wochentag</div>
<div>Zugewiesen</div>
<div>Backup</div>
<div>Status</div>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{days.map((day) => {
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
const dateObj = new Date(yr, mo - 1, dy);
const assignedChild = allChildren.find(
(child) => child.id === day.childId,
);
return (
<div
key={day.date}
className="grid cursor-pointer grid-cols-[1.1fr_1fr_2fr_1.6fr_1fr] items-center p-4 text-sm transition-colors hover:bg-[var(--surface-2)]"
>
<div className="font-medium tabular-nums text-[var(--ink)]">
{format(dateObj, "dd.MM.", { locale: de })}
</div>
<div className="text-[var(--ink-soft)]">
{format(dateObj, "EEEE", { locale: de })}
</div>
<div className="text-[var(--ink)]">
{assignedChild ? assignedChild.parentName : "Unbesetzt"}
{assignedChild && (
<span className="ml-1 text-[var(--ink-muted)]">
({assignedChild.name})
</span>
)}
</div>
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
<div>
<Badge variant={assignedChild ? "success" : "destructive"}>
{assignedChild ? "Besetzt" : "Offen"}
</Badge>
</div>
</div>
);
})}
</div>
</Card>
);
}
function ManualAssignmentTable({
days,
allChildren,
isDraft,
isPending,
onAssignmentChange,
}: Pick<PlanViewProps, "days" | "allChildren"> & {
isDraft: boolean;
isPending: boolean;
onAssignmentChange: (date: string, newChildId: string) => void;
}) {
// Map childId to familyId for quick lookup
const childFamilyMap = useMemo(() => {
const map = new Map<string, string>();
allChildren.forEach((child) => {
if (child.familyId) {
map.set(child.id, child.familyId);
}
});
return map;
}, [allChildren]);
// Compute family assignment counts in real-time based on current selections
const familyAssignmentCounts = useMemo(() => {
const counts = new Map<string, number>();
days.forEach((day) => {
if (day.childId) {
const famId = childFamilyMap.get(day.childId);
if (famId) {
counts.set(famId, (counts.get(famId) ?? 0) + 1);
}
}
});
return counts;
}, [days, childFamilyMap]);
return (
<Card className="overflow-hidden p-0">
<div className="grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
<div>Datum</div>
<div>Eingeteilte Familie</div>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{days.map((day) => {
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
const dateObj = new Date(yr, mo - 1, dy);
const assignedChild = allChildren.find(
(child) => child.id === day.childId,
);
return (
<div
key={day.date}
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
>
<div className="font-medium text-[var(--ink)]">
{format(dateObj, "EE, dd.MM.", { locale: de })}
</div>
<div>
{isDraft || day.isOriginallyUnassigned ? (
<div className="flex flex-wrap items-center gap-2">
<select
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
value={day.childId || "empty"}
disabled={isPending}
onChange={(event) =>
onAssignmentChange(day.date, event.target.value)
}
>
<option value="empty">-- Unbesetzt --</option>
{allChildren.map((child) => {
const famId = child.familyId;
const count = famId ? (familyAssignmentCounts.get(famId) ?? 0) : 0;
return (
<option key={child.id} value={child.id}>
{child.parentName} ({child.name}) {count}x
</option>
);
})}
</select>
{!isDraft && (
<span className="text-[11px] font-semibold text-[oklch(0.60_0.18_45)] bg-[oklch(0.95_0.02_45)] border border-[oklch(0.85_0.05_45)] px-2 py-0.5 rounded-md shrink-0">
Nachträglich änderbar
</span>
)}
</div>
) : assignedChild ? (
<span className="text-sm text-[var(--ink)]">
{assignedChild.parentName}{" "}
<span className="text-[var(--ink-muted)]">
({assignedChild.name})
</span>
</span>
) : (
<span className="text-sm font-medium text-[var(--danger)]">
Unbesetzt!
</span>
)}
</div>
</div>
);
})}
</div>
</Card>
);
}
+195 -307
View File
@@ -1,81 +1,83 @@
import Link from "next/link"; import Link from "next/link";
import type { ComponentType } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle, ClipboardList } from "lucide-react"; import {
import { DutyAssignmentStatus, UserRole, NotdienstPlanStatus } from "@prisma/client"; CalendarCheck2,
CalendarClock,
ChevronRight,
Clock3,
ListTodo,
Users,
type LucideProps,
} from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { getTargetMonthData } from "@/lib/date-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { import { Stat } from "@/components/ui/stat";
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { OnboardingDialog } from "@/components/OnboardingDialog"; import { OnboardingDialog } from "@/components/OnboardingDialog";
import { AlertButton } from "./alert-button"; import { AbsenceCard, UpcomingAbsencesCard } from "./absence-card";
import { AbsenceCard } from "./absence-card";
import { NewsTicker } from "./news-ticker";
export const metadata = { title: "Übersicht · Kita-Planer" }; export const metadata = { title: "Übersicht · Kita-Planer" };
export default async function DashboardPage() { export default async function DashboardPage() {
const session = await requireKitaSession(); const session = await requireKitaSession();
const now = new Date();
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const targetData = getTargetMonthData();
const targetMonthStart = new Date(
Date.UTC(targetData.targetYear, targetData.targetMonth - 1, 1),
);
const targetMonthEnd = new Date(
Date.UTC(targetData.targetYear, targetData.targetMonth, 1),
);
const kita = await prisma.kita.findUniqueOrThrow({ const kita = await prisma.kita.findUniqueOrThrow({
where: { id: session.user.kitaId }, where: { id: session.user.kitaId },
select: { select: {
name: true, name: true,
notdienstModuleEnabled: true, notdienstModuleEnabled: true,
notdienstMinPerChildPerMonth: true,
terminModuleEnabled: true, terminModuleEnabled: true,
adressbuchModuleEnabled: true, adressbuchModuleEnabled: true,
}, },
}); });
const today = new Date();
today.setHours(0, 0, 0, 0);
// Schnelle Statistiken und persönliche Dienste für die Übersicht.
const [ const [
familyCount, familyCount,
childCount, childCount,
upcomingDuties, todayAbsenceCount,
notdienstAvailabilityCount,
onboardingUser, onboardingUser,
absenceChildren, absenceChildren,
upcomingAbsences, upcomingAbsences,
latestAnnouncements,
] = await Promise.all([ ] = await Promise.all([
prisma.family.count({ prisma.family.count({
where: { kitaId: session.user.kitaId }, where: { kitaId: session.user.kitaId },
}), }),
prisma.child.count({ prisma.child.count({
where: { kitaId: session.user.kitaId }, where: { kitaId: session.user.kitaId, active: true },
}),
prisma.absence.count({
where: {
kitaId: session.user.kitaId,
startDate: { lte: today },
endDate: { gte: today },
},
}),
prisma.notdienstAvailability.count({
where: {
kitaId: session.user.kitaId,
date: {
gte: targetMonthStart,
lt: targetMonthEnd,
},
},
}), }),
session.user.familyId
? prisma.dutyAssignment.findMany({
where: {
kitaId: session.user.kitaId,
familyId: session.user.familyId,
status: DutyAssignmentStatus.PLANNED,
endDate: { gte: today },
},
select: {
id: true,
startDate: true,
endDate: true,
dutyType: {
select: {
name: true,
},
},
},
orderBy: { startDate: "asc" },
take: 5,
})
: Promise.resolve([]),
prisma.user.findFirstOrThrow({ prisma.user.findFirstOrThrow({
where: { where: {
id: session.user.id, id: session.user.id,
@@ -140,45 +142,25 @@ export default async function DashboardPage() {
take: 6, take: 6,
}) })
: Promise.resolve([]), : Promise.resolve([]),
prisma.announcement.findMany({
where: { kitaId: session.user.kitaId },
select: {
id: true,
title: true,
content: true,
createdAt: true,
author: {
select: {
firstName: true,
lastName: true,
},
},
attachments: {
select: {
id: true,
fileName: true,
fileUrl: true,
fileType: true,
},
},
reads: {
where: { userId: session.user.id },
select: { id: true },
take: 1,
},
},
orderBy: { createdAt: "desc" },
take: 3,
}),
]); ]);
const isAdmin =
session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.SUPERADMIN;
const canReportAbsences = session.user.role !== UserRole.ERZIEHER; const canReportAbsences = session.user.role !== UserRole.ERZIEHER;
const requiredNotdienstDays = Math.max(
childCount * kita.notdienstMinPerChildPerMonth,
1,
);
const openNotdienstDays = Math.max(
requiredNotdienstDays - notdienstAvailabilityCount,
0,
);
const todayPresentCount = Math.max(childCount - todayAbsenceCount, 0);
const firstName =
session.user.name?.split(" ")[0] ??
session.user.email?.split("@")[0] ??
"zusammen";
return ( return (
<div className="px-8 py-8"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<OnboardingDialog <OnboardingDialog
user={{ user={{
phone: onboardingUser.phone, phone: onboardingUser.phone,
@@ -195,247 +177,153 @@ export default async function DashboardPage() {
} }
/> />
<div className="mb-8"> <header className="mb-9 flex items-start justify-between gap-8">
<h1 className="text-2xl font-semibold tracking-tight"> <div>
Willkommen, {session.user.name?.split(" ")[0] ?? "zusammen"}! <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
</h1> ÜBERSICHT · {kita.name}
<p className="mt-1 text-sm text-muted-foreground"> </p>
Hier ist die Übersicht für <strong>{kita.name}</strong>. <h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
</p> Willkommen{" "}
</div> <em className="font-normal italic text-[var(--accent-deep)]">
zurück
<NewsTicker </em>
items={latestAnnouncements.map((announcement) => ({ , {firstName}.
id: announcement.id, </h1>
title: announcement.title, <p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
content: announcement.content, Heute, {format(now, "EEEE, d. MMMM yyyy", { locale: de })}. Hier
createdAt: format(announcement.createdAt, "dd.MM.yyyy", { siehst du auf einen Blick, was bei {kita.name} ansteht und kannst
locale: de, dein Kind schnell für Abwesenheiten melden.
}), </p>
authorName: `${announcement.author.firstName} ${announcement.author.lastName}`,
isUnread: announcement.reads.length === 0,
attachments: announcement.attachments.map((attachment) => ({
id: attachment.id,
fileName: attachment.fileName,
fileUrl: attachment.fileUrl,
fileType: attachment.fileType,
})),
}))}
/>
{/* Heutiger Notdienst (Nur für Admins/Koordinatoren) */}
{isAdmin && (
<div className="mb-8">
<TodaysNotdienstCard kitaId={session.user.kitaId!} />
</div> </div>
)} <div className="mt-9 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 py-2 text-[13px] font-semibold text-[var(--ink-soft)]">
<Clock3 className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(now, "EEE, d. MMM yyyy", { locale: de })}
</div>
</header>
{canReportAbsences && ( <div className="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
<AbsenceCard <div className="min-w-0">
today={format(today, "yyyy-MM-dd")} {canReportAbsences ? (
childOptions={absenceChildren.map((child) => ({ <AbsenceCard
id: child.id, today={format(today, "yyyy-MM-dd")}
name: `${child.firstName} ${child.lastName}`, childOptions={absenceChildren.map((child) => ({
}))} id: child.id,
absences={upcomingAbsences.map((absence) => ({ name: `${child.firstName} ${child.lastName}`,
id: absence.id, }))}
childName: `${absence.child.firstName} ${absence.child.lastName}`, absences={upcomingAbsences.map((absence) => ({
reason: absence.reason, id: absence.id,
note: absence.note, childName: `${absence.child.firstName} ${absence.child.lastName}`,
startDate: format(absence.startDate, "yyyy-MM-dd"), reason: absence.reason,
endDate: format(absence.endDate, "yyyy-MM-dd"), note: absence.note,
}))} startDate: format(absence.startDate, "yyyy-MM-dd"),
/> endDate: format(absence.endDate, "yyyy-MM-dd"),
)} }))}
/>
) : null}
</div>
{upcomingDuties.length > 0 && ( <aside>
<Card className="mb-8 border-emerald-200 bg-emerald-50/50"> <UpcomingAbsencesCard
<CardHeader className="pb-3"> absences={upcomingAbsences.map((absence) => ({
<CardTitle className="flex items-center gap-2 text-lg"> id: absence.id,
<ClipboardList className="h-5 w-5 text-emerald-700" /> childName: `${absence.child.firstName} ${absence.child.lastName}`,
Deine anstehenden Dienste reason: absence.reason,
</CardTitle> note: absence.note,
<CardDescription> startDate: format(absence.startDate, "yyyy-MM-dd"),
Die nächsten Einteilungen für deinen Haushalt. endDate: format(absence.endDate, "yyyy-MM-dd"),
</CardDescription> }))}
</CardHeader> />
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3"> </aside>
{upcomingDuties.map((duty) => (
<div key={duty.id} className="rounded-md border bg-background p-3">
<p className="font-medium">{duty.dutyType.name}</p>
<p className="mt-1 text-sm text-muted-foreground">
{format(duty.startDate, "dd.MM.", { locale: de })} bis{" "}
{format(duty.endDate, "dd.MM.yyyy", { locale: de })}
</p>
</div>
))}
</CardContent>
</Card>
)}
{/* Statistik-Kacheln */}
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
label="Familien"
value={familyCount}
description="registrierte Familien"
icon={<Users className="h-5 w-5 text-muted-foreground" />}
/>
<StatCard
label="Kinder"
value={childCount}
description="in der Einrichtung"
icon={<ShieldCheck className="h-5 w-5 text-muted-foreground" />}
/>
</div> </div>
{/* Schnell-Aktionen für Admins */} <section className="mt-5 grid grid-cols-4 gap-3.5 max-[1100px]:grid-cols-2 max-[640px]:grid-cols-1">
{isAdmin && ( <Stat label="Familien" value={familyCount} hint="registrierte Familien" />
<Card> <Stat label="Kinder" value={childCount} hint="in der Einrichtung" />
<CardHeader> <Stat
<CardTitle className="text-base">Schnellstart</CardTitle> label={`Notdienst ${targetData.monthName}`}
<CardDescription> value={notdienstAvailabilityCount}
Die nächsten Schritte, um deine Kita einzurichten. suffix={`/ ${requiredNotdienstDays}`}
</CardDescription> hint={`Tage abgedeckt · ${openNotdienstDays} offen`}
</CardHeader> />
<CardContent className="flex flex-wrap gap-3"> <Stat
<Button asChild size="sm"> label="Heute anwesend"
<Link href="/dashboard/families"> value={todayPresentCount}
<Users className="mr-2 h-4 w-4" /> hint="Kinder in der Kita"
Familien verwalten />
</Link> </section>
</Button>
{kita.terminModuleEnabled && ( <section className="mt-7">
<Button asChild size="sm" variant="outline"> <h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
<Link href="/dashboard/termine"> Schnellstart
<CalendarCheck2 className="mr-2 h-4 w-4" /> </h2>
Termine (bald verfügbar) <p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
</Link> Häufige Aktionen für Vorstand und Koordination.
</Button> </p>
)} <div className="mt-4 grid grid-cols-3 gap-3 max-[1100px]:grid-cols-1">
</CardContent> <QuickCard
</Card> href="/dashboard/families"
)} icon={Users}
title="Familien verwalten"
sub="Familien anlegen, Kinder zuordnen, Kontakte pflegen."
/>
{kita.terminModuleEnabled ? (
<QuickCard
href="/dashboard/kalender"
icon={CalendarClock}
title="Terminkalender"
sub="Schließtage, Elternabende und Feste planen."
/>
) : null}
{kita.notdienstModuleEnabled ? (
<QuickCard
href="/dashboard/notdienst/plan"
icon={ListTodo}
title="Notdienst-Planung"
sub="Verfügbarkeiten überblicken und Dienste zuweisen."
/>
) : null}
{kita.adressbuchModuleEnabled ? (
<QuickCard
href="/dashboard/adressbuch"
icon={CalendarCheck2}
title="Adressbuch"
sub="Kontakte der Kita-Gemeinschaft schnell finden."
/>
) : null}
</div>
</section>
</div> </div>
); );
} }
function StatCard({ function QuickCard({
label, href,
value, icon: Icon,
description, title,
icon, sub,
}: { }: {
label: string; href: string;
value: number; icon: ComponentType<LucideProps>;
description: string; title: string;
icon: React.ReactNode; sub: string;
}) { }) {
return ( return (
<Card> <Link
<CardHeader className="flex flex-row items-center justify-between pb-2"> href={href}
<CardTitle className="text-sm font-medium text-muted-foreground"> className="group flex min-h-[86px] items-center gap-4 rounded-[14px] border border-[var(--hairline)] bg-[var(--surface)] p-4 transition hover:border-[var(--accent-mid)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
{label} >
</CardTitle> <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
{icon} <Icon className="h-4.5 w-4.5 [stroke-width:1.7]" />
</CardHeader> </div>
<CardContent> <div className="min-w-0 flex-1">
<div className="text-3xl font-bold">{value}</div> <p className="truncate text-sm font-semibold text-[var(--ink)]">
<p className="mt-1 text-xs text-muted-foreground">{description}</p> {title}
</CardContent> </p>
</Card> <p className="mt-1 line-clamp-2 text-[13px] leading-5 text-[var(--ink-soft)]">
); {sub}
} </p>
</div>
async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) { <ChevronRight className="h-4 w-4 shrink-0 text-[var(--ink-faint)] transition group-hover:translate-x-1 group-hover:text-[var(--accent-deep)]" />
const today = new Date(); </Link>
today.setHours(0, 0, 0, 0);
// Wir suchen das Assignment für heute, aber nur wenn der Plan PUBLISHED ist
const assignment = await prisma.notdienstAssignment.findFirst({
where: {
kitaId,
date: today,
plan: { status: NotdienstPlanStatus.PUBLISHED },
},
select: {
id: true,
child: {
select: {
firstName: true,
lastName: true,
family: {
select: {
name: true,
users: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
},
alerts: {
select: {
status: true,
},
},
},
});
if (!assignment) return null;
const parentNames = assignment.child.family.users
.map((parent) => `${parent.firstName} ${parent.lastName}`)
.join(", ");
const existingAlert = assignment.alerts[0]; // Kann PENDING, CONFIRMED oder CANCELLED sein
return (
<Card className="border-primary/20 bg-primary/5">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-primary" />
Heutiger Notdienst
</CardTitle>
<CardDescription>
Laut dem veröffentlichten Plan ist heute folgende Familie eingeteilt:
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium text-base">
{parentNames || assignment.child.family.name}
</p>
<p className="text-sm text-muted-foreground">
Kind: {assignment.child.firstName} {assignment.child.lastName}
</p>
</div>
<div className="flex items-center gap-3">
{existingAlert ? (
<Badge
variant={
existingAlert.status === "CONFIRMED"
? "success"
: existingAlert.status === "PENDING"
? "warning"
: "destructive"
}
className="px-3 py-1 text-sm"
>
{existingAlert.status === "CONFIRMED"
? "Einsatz bestätigt"
: existingAlert.status === "PENDING"
? "Wartet auf Bestätigung"
: "Abgebrochen"}
</Badge>
) : (
<AlertButton assignmentId={assignment.id} />
)}
</div>
</CardContent>
</Card>
); );
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useTransition } from "react"; import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Save } from "lucide-react"; import { Loader2, Save } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -15,10 +16,12 @@ type ContactFormProps = {
street: string | null; street: string | null;
postalCode: string | null; postalCode: string | null;
city: string | null; city: string | null;
directoryOptIn: boolean;
}; };
}; };
export function ContactForm({ contact }: ContactFormProps) { export function ContactForm({ contact }: ContactFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
function handleSubmit(formData: FormData) { function handleSubmit(formData: FormData) {
@@ -28,6 +31,7 @@ export function ContactForm({ contact }: ContactFormProps) {
street: String(formData.get("street") ?? ""), street: String(formData.get("street") ?? ""),
postalCode: String(formData.get("postalCode") ?? ""), postalCode: String(formData.get("postalCode") ?? ""),
city: String(formData.get("city") ?? ""), city: String(formData.get("city") ?? ""),
directoryOptIn: formData.get("directoryOptIn") === "on",
}); });
if (result.error) { if (result.error) {
@@ -36,13 +40,19 @@ export function ContactForm({ contact }: ContactFormProps) {
} }
toast.success("Kontaktdaten gespeichert."); toast.success("Kontaktdaten gespeichert.");
router.refresh();
}); });
} }
return ( return (
<form action={handleSubmit} className="grid gap-4"> <form action={handleSubmit} className="grid gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="phone">Telefonnummer</Label> <Label
htmlFor="phone"
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Telefonnummer
</Label>
<Input <Input
id="phone" id="phone"
name="phone" name="phone"
@@ -53,7 +63,12 @@ export function ContactForm({ contact }: ContactFormProps) {
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="street">Straße und Hausnummer</Label> <Label
htmlFor="street"
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Straße und Hausnummer
</Label>
<Input <Input
id="street" id="street"
name="street" name="street"
@@ -62,9 +77,14 @@ export function ContactForm({ contact }: ContactFormProps) {
/> />
</div> </div>
<div className="grid gap-3 sm:grid-cols-[120px_1fr]"> <div className="grid gap-3 sm:grid-cols-[100px_1fr]">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="postalCode">PLZ</Label> <Label
htmlFor="postalCode"
className="text-xs font-semibold text-[var(--ink-soft)]"
>
PLZ
</Label>
<Input <Input
id="postalCode" id="postalCode"
name="postalCode" name="postalCode"
@@ -73,7 +93,12 @@ export function ContactForm({ contact }: ContactFormProps) {
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="city">Ort</Label> <Label
htmlFor="city"
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Ort
</Label>
<Input <Input
id="city" id="city"
name="city" name="city"
@@ -83,7 +108,23 @@ export function ContactForm({ contact }: ContactFormProps) {
</div> </div>
</div> </div>
<div className="flex justify-end"> <div className="flex items-center gap-2 mt-2">
<input
type="checkbox"
id="directoryOptIn"
name="directoryOptIn"
defaultChecked={contact.directoryOptIn}
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
/>
<Label
htmlFor="directoryOptIn"
className="font-normal cursor-pointer text-xs text-[var(--ink-soft)]"
>
Im Adressbuch anzeigen (Datenschutzerklärung)
</Label>
</div>
<div className="flex justify-end pt-1">
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{isPending ? ( {isPending ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -23,7 +23,7 @@ export function DeleteAccountDialog() {
const [confirmText, setConfirmText] = useState(""); const [confirmText, setConfirmText] = useState("");
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const isConfirmed = confirmText === "LÖSCHEN"; const isConfirmed = confirmText === "DELETE";
const handleDelete = () => { const handleDelete = () => {
if (!isConfirmed) return; if (!isConfirmed) return;
@@ -44,18 +44,19 @@ export function DeleteAccountDialog() {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="destructive" className="w-full sm:w-auto"> <Button variant="destructive" className="w-full sm:w-auto">
<AlertTriangle className="h-4 w-4 mr-2" /> <AlertTriangle className="h-4 w-4" />
Account & Daten endgültig löschen Account & Daten endgültig löschen
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="border-destructive/30"> <DialogContent className="border-[var(--danger)]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-destructive flex items-center gap-2"> <DialogTitle className="flex items-center gap-2 text-[var(--danger)]">
<AlertTriangle className="h-5 w-5" /> <AlertTriangle className="h-5 w-5" />
Gefahrenzone: Account löschen Gefahrenzone: Account löschen
</DialogTitle> </DialogTitle>
<DialogDescription className="text-base pt-2"> <DialogDescription className="pt-2 text-base">
Diese Aktion kann <strong className="text-foreground">nicht</strong> rückgängig gemacht werden. Diese Aktion kann <strong className="text-[var(--ink)]">nicht</strong>{" "}
rückgängig gemacht werden.
Wenn du das letzte Elternteil im Haushalt bist, wird die gesamte Wenn du das letzte Elternteil im Haushalt bist, wird die gesamte
Familie inklusive Kinder gelöscht. Gibt es ein weiteres Elternteil, Familie inklusive Kinder gelöscht. Gibt es ein weiteres Elternteil,
bleibt der Haushalt für diese Person erhalten und nur dein Account bleibt der Haushalt für diese Person erhalten und nur dein Account
@@ -63,16 +64,17 @@ export function DeleteAccountDialog() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="bg-destructive/10 text-destructive-foreground p-3 text-sm rounded-md my-2 border border-destructive/20"> <div className="my-2 rounded-lg border border-[var(--danger)] bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]">
Bitte tippe das Wort <strong>LÖSCHEN</strong> in das Feld unten ein, um den Vorgang zu bestätigen. Bitte tippe <strong>DELETE</strong> in das Feld unten ein, um den
Vorgang zu bestätigen.
</div> </div>
<div className="grid gap-2 py-4"> <div className="grid gap-2 py-4">
<Input <Input
value={confirmText} value={confirmText}
onChange={(e) => setConfirmText(e.target.value)} onChange={(e) => setConfirmText(e.target.value)}
placeholder="LÖSCHEN" placeholder="DELETE"
className="border-destructive/50 focus-visible:ring-destructive" className="border-[var(--danger)] focus-visible:ring-[var(--focus-ring)]"
/> />
</div> </div>
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { type FormEvent, useState, useTransition } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react"; import { Pencil, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -16,6 +17,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { getChildBirthdateRange } from "@/lib/child-birthdate";
import { createMyChild, deleteMyChild, updateMyChild } from "../actions"; import { createMyChild, deleteMyChild, updateMyChild } from "../actions";
export type MyChild = { export type MyChild = {
@@ -25,6 +27,18 @@ export type MyChild = {
dateOfBirth: string | null; dateOfBirth: string | null;
}; };
type ChildPayload = {
firstName: string;
lastName: string;
dateOfBirth: string;
};
const emptyChildPayload: ChildPayload = {
firstName: "",
lastName: "",
dateOfBirth: "",
};
export function MyChildrenManager({ export function MyChildrenManager({
items, items,
canManage, canManage,
@@ -33,38 +47,41 @@ export function MyChildrenManager({
canManage: boolean; canManage: boolean;
}) { }) {
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createPayload, setCreatePayload] =
useState<ChildPayload>(emptyChildPayload);
const [editingChild, setEditingChild] = useState<MyChild | null>(null); const [editingChild, setEditingChild] = useState<MyChild | null>(null);
const [editPayload, setEditPayload] =
useState<ChildPayload>(emptyChildPayload);
const [childToDelete, setChildToDelete] = useState<MyChild | null>(null);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
function payloadFromForm(formData: FormData) { function openEditor(child: MyChild) {
return { setEditingChild(child);
firstName: String(formData.get("firstName") ?? ""), setEditPayload(payloadFromChild(child));
lastName: String(formData.get("lastName") ?? ""),
dateOfBirth: String(formData.get("dateOfBirth") ?? ""),
};
} }
function handleCreate(formData: FormData) { function handleCreate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
startTransition(async () => { startTransition(async () => {
const result = await createMyChild(payloadFromForm(formData)); const result = await createMyChild(createPayload);
if (result.error) { if (result.error) {
toast.error(result.error); toast.error(result.error);
return; return;
} }
toast.success("Kind angelegt."); toast.success("Kind angelegt.");
setCreatePayload(emptyChildPayload);
setCreateOpen(false); setCreateOpen(false);
}); });
} }
function handleUpdate(formData: FormData) { function handleUpdate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!editingChild) return; if (!editingChild) return;
startTransition(async () => { startTransition(async () => {
const result = await updateMyChild( const result = await updateMyChild(editingChild.id, editPayload);
editingChild.id,
payloadFromForm(formData),
);
if (result.error) { if (result.error) {
toast.error(result.error); toast.error(result.error);
return; return;
@@ -76,11 +93,6 @@ export function MyChildrenManager({
} }
function handleDelete(child: MyChild) { function handleDelete(child: MyChild) {
const confirmed = confirm(
`${child.firstName} ${child.lastName} wirklich entfernen?`,
);
if (!confirmed) return;
startTransition(async () => { startTransition(async () => {
const result = await deleteMyChild(child.id); const result = await deleteMyChild(child.id);
if (result.error) { if (result.error) {
@@ -89,37 +101,52 @@ export function MyChildrenManager({
} }
toast.success("Kind entfernt."); toast.success("Kind entfernt.");
setChildToDelete(null);
}); });
} }
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Kinder verknüpft.</p> <p className="text-sm italic text-[var(--ink-faint)]">
Keine Kinder verknüpft.
</p>
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{items.map((child) => ( {items.map((child) => (
<div <div
key={child.id} key={child.id}
className="flex items-center justify-between gap-3 rounded-md bg-muted/50 p-2" className="flex items-center justify-between gap-3 rounded-lg bg-[var(--surface-2)] p-[10px_12px]"
> >
<div> <div className="flex min-w-0 items-center gap-3">
<p className="font-medium"> <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
{child.firstName} {child.lastName} {getInitials(child)}
</p> </div>
{child.dateOfBirth && ( <div className="min-w-0">
<p className="text-xs text-muted-foreground"> <p className="truncate text-sm font-semibold text-[var(--ink)]">
Geboren am {new Date(child.dateOfBirth).toLocaleDateString("de-DE")} {child.firstName} {child.lastName}
</p> </p>
)} {child.dateOfBirth ? (
<p className="text-xs text-[var(--ink-soft)]">
Geboren am{" "}
{new Date(child.dateOfBirth).toLocaleDateString("de-DE")}
</p>
) : (
<p className="text-xs italic text-[var(--ink-faint)]">
Geburtsdatum fehlt
</p>
)}
</div>
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setEditingChild(child)} className="h-7 w-7"
onClick={() => openEditor(child)}
disabled={isPending || !canManage} disabled={isPending || !canManage}
aria-label="Kind bearbeiten"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
@@ -127,9 +154,10 @@ export function MyChildrenManager({
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-destructive hover:text-destructive" className="h-7 w-7 text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => handleDelete(child)} onClick={() => setChildToDelete(child)}
disabled={isPending || !canManage} disabled={isPending || !canManage}
aria-label="Kind löschen"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@@ -140,22 +168,32 @@ export function MyChildrenManager({
)} )}
{canManage ? ( {canManage ? (
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) setCreatePayload(emptyChildPayload);
}}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button type="button" variant="outline" size="sm" className="gap-2"> <Button type="button" variant="pill" size="sm" className="w-full">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Kind hinzufügen Kind hinzufügen
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<form action={handleCreate}> <form onSubmit={handleCreate}>
<DialogHeader> <DialogHeader>
<DialogTitle>Kind hinzufügen</DialogTitle> <DialogTitle>Kind hinzufügen</DialogTitle>
<DialogDescription> <DialogDescription>
Dieses Kind wird mit deinem Haushalt verknüpft. Dieses Kind wird mit deinem Haushalt verknüpft.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ChildFields /> <ChildFields
idPrefix="create-child"
value={createPayload}
onChange={setCreatePayload}
/>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
@@ -173,7 +211,7 @@ export function MyChildrenManager({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) : ( ) : (
<p className="text-xs text-muted-foreground"> <p className="text-xs italic text-[var(--ink-faint)]">
Dein Account ist noch keinem Haushalt zugeordnet. Dein Account ist noch keinem Haushalt zugeordnet.
</p> </p>
)} )}
@@ -183,11 +221,17 @@ export function MyChildrenManager({
onOpenChange={(open) => !open && setEditingChild(null)} onOpenChange={(open) => !open && setEditingChild(null)}
> >
<DialogContent> <DialogContent>
<form action={handleUpdate}> <form onSubmit={handleUpdate}>
<DialogHeader> <DialogHeader>
<DialogTitle>Kind bearbeiten</DialogTitle> <DialogTitle>Kind bearbeiten</DialogTitle>
</DialogHeader> </DialogHeader>
{editingChild && <ChildFields child={editingChild} />} {editingChild && (
<ChildFields
idPrefix="edit-child"
value={editPayload}
onChange={setEditPayload}
/>
)}
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
@@ -204,40 +248,107 @@ export function MyChildrenManager({
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<ConfirmDialog
open={!!childToDelete}
onOpenChange={(open) => {
if (!open) setChildToDelete(null);
}}
title="Kind entfernen?"
description={
childToDelete
? `${childToDelete.firstName} ${childToDelete.lastName} wird aus deinem Haushalt entfernt.`
: "Dieses Kind wird aus deinem Haushalt entfernt."
}
confirmLabel="Entfernen"
pendingLabel="Wird entfernt..."
isPending={isPending}
onConfirm={() => {
if (childToDelete) handleDelete(childToDelete);
}}
/>
</div> </div>
); );
} }
function ChildFields({ child }: { child?: MyChild }) { function ChildFields({
idPrefix,
value,
onChange,
}: {
idPrefix: string;
value: ChildPayload;
onChange: (value: ChildPayload) => void;
}) {
const birthdateRange = getChildBirthdateRange();
return ( return (
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="child-firstName">Vorname</Label> <Label
htmlFor={`${idPrefix}-firstName`}
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Vorname
</Label>
<Input <Input
id="child-firstName" id={`${idPrefix}-firstName`}
name="firstName" name="firstName"
defaultValue={child?.firstName ?? ""} value={value.firstName}
onChange={(event) =>
onChange({ ...value, firstName: event.target.value })
}
required required
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="child-lastName">Nachname</Label> <Label
htmlFor={`${idPrefix}-lastName`}
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Nachname
</Label>
<Input <Input
id="child-lastName" id={`${idPrefix}-lastName`}
name="lastName" name="lastName"
defaultValue={child?.lastName ?? ""} value={value.lastName}
onChange={(event) =>
onChange({ ...value, lastName: event.target.value })
}
required required
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="child-dateOfBirth">Geburtsdatum</Label> <Label
htmlFor={`${idPrefix}-dateOfBirth`}
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Geburtsdatum
</Label>
<Input <Input
id="child-dateOfBirth" id={`${idPrefix}-dateOfBirth`}
name="dateOfBirth" name="dateOfBirth"
type="date" type="date"
defaultValue={child?.dateOfBirth ?? ""} min={birthdateRange.min}
max={birthdateRange.max}
value={value.dateOfBirth}
onChange={(event) =>
onChange({ ...value, dateOfBirth: event.target.value })
}
/> />
</div> </div>
</div> </div>
); );
} }
function payloadFromChild(child: MyChild): ChildPayload {
return {
firstName: child.firstName,
lastName: child.lastName,
dateOfBirth: child.dateOfBirth ?? "",
};
}
function getInitials(child: MyChild) {
return `${child.firstName[0] ?? ""}${child.lastName[0] ?? ""}`.toUpperCase();
}
+114 -15
View File
@@ -2,32 +2,95 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { NotdienstPlanStatus } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import {
CHILD_MAX_AGE_YEARS,
getChildBirthdateRange,
parseDateInput,
} from "@/lib/child-birthdate";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
const childSchema = z.object({ // Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(), // Klammern oder Schrägstriche. Mindestens 6 Ziffern damit es plausibel ist.
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(), // Schließt damit "abc-123-not-a-phone" aus, lässt aber internationale Formate zu.
dateOfBirth: z.string().optional(), const phonePattern = /^[+]?[0-9][\d\s\-()/.]{4,}$/;
});
// PLZ: Ziffern/Buchstaben/Leerzeichen/Bindestriche, 310 Zeichen.
// Kein striktes DE-Format (5 Ziffern), damit auch AT/CH/UK denkbar sind.
const postalCodePattern = /^[0-9A-Za-z][0-9A-Za-z\s-]{2,9}$/;
const childSchema = z
.object({
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
dateOfBirth: z.string().optional(),
})
.superRefine((data, ctx) => {
if (!data.dateOfBirth) return;
const parsed = parseDateInput(data.dateOfBirth);
if (!parsed) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: "Bitte ein gültiges Datum angeben.",
});
return;
}
const { min, max } = getChildBirthdateRange();
if (data.dateOfBirth > max) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: "Geburtsdatum darf nicht in der Zukunft liegen.",
});
}
if (data.dateOfBirth < min) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: `Kinder dürfen maximal ${CHILD_MAX_AGE_YEARS} Jahre alt sein.`,
});
}
});
const familySchema = z.object({ const familySchema = z.object({
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(), familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
}); });
const contactSchema = z.object({ const contactSchema = z.object({
phone: z.string().trim().max(50).optional(), phone: z
.string()
.trim()
.max(50)
.optional()
.refine(
(v) => !v || phonePattern.test(v),
"Bitte eine gültige Telefonnummer angeben.",
),
street: z.string().trim().max(120).optional(), street: z.string().trim().max(120).optional(),
postalCode: z.string().trim().max(20).optional(), postalCode: z
.string()
.trim()
.max(20)
.optional()
.refine(
(v) => !v || postalCodePattern.test(v),
"Bitte eine gültige Postleitzahl angeben.",
),
city: z.string().trim().max(100).optional(), city: z.string().trim().max(100).optional(),
directoryOptIn: z.boolean().optional(),
}); });
function parseDateInput(value?: string) { // Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
if (!value) return null; // damit der User nicht nur "Ungültige Eingabedaten" sieht.
function firstZodMessage(
const date = new Date(`${value}T00:00:00`); error: z.ZodError,
return Number.isNaN(date.getTime()) ? null : date; fallback: string,
): string {
const issue = error.issues[0];
return issue?.message || fallback;
} }
async function requireOwnFamilyChild( async function requireOwnFamilyChild(
@@ -51,7 +114,7 @@ export async function createMyChild(rawPayload: unknown) {
} }
if (!parsed.success) { if (!parsed.success) {
return { error: "Ungültige Eingabedaten." }; return { error: firstZodMessage(parsed.error, "Ungültige Eingabedaten.") };
} }
try { try {
@@ -117,10 +180,24 @@ export async function updateMyContact(rawPayload: unknown) {
const parsed = contactSchema.safeParse(rawPayload); const parsed = contactSchema.safeParse(rawPayload);
if (!parsed.success) { if (!parsed.success) {
return { error: "Ungültige Kontaktdaten." }; return { error: firstZodMessage(parsed.error, "Ungültige Kontaktdaten.") };
} }
try { try {
const currentUser = await prisma.user.findUniqueOrThrow({
where: { id: session.user.id },
select: { directoryOptInAt: true },
});
let newDirectoryOptInAt: Date | null = currentUser.directoryOptInAt;
if (parsed.data.directoryOptIn) {
if (!newDirectoryOptInAt) {
newDirectoryOptInAt = new Date();
}
} else {
newDirectoryOptInAt = null;
}
await prisma.user.update({ await prisma.user.update({
where: { id: session.user.id }, where: { id: session.user.id },
data: { data: {
@@ -128,6 +205,7 @@ export async function updateMyContact(rawPayload: unknown) {
street: parsed.data.street || null, street: parsed.data.street || null,
postalCode: parsed.data.postalCode || null, postalCode: parsed.data.postalCode || null,
city: parsed.data.city || null, city: parsed.data.city || null,
directoryOptInAt: newDirectoryOptInAt,
}, },
}); });
@@ -146,7 +224,7 @@ export async function updateMyChild(childId: string, rawPayload: unknown) {
const parsed = childSchema.safeParse(rawPayload); const parsed = childSchema.safeParse(rawPayload);
if (!parsed.success) { if (!parsed.success) {
return { error: "Ungültige Eingabedaten." }; return { error: firstZodMessage(parsed.error, "Ungültige Eingabedaten.") };
} }
try { try {
@@ -224,6 +302,27 @@ export async function deleteMyAccount() {
return { error: "Account wurde nicht gefunden." }; return { error: "Account wurde nicht gefunden." };
} }
const today = new Date();
today.setHours(0, 0, 0, 0);
const activeNotdienstAssignments = user.familyId
? await prisma.notdienstAssignment.count({
where: {
kitaId: session.user.kitaId,
date: { gte: today },
plan: { status: NotdienstPlanStatus.PUBLISHED },
child: { familyId: user.familyId },
},
})
: 0;
if (activeNotdienstAssignments > 0) {
return {
error:
"Dein Account kann nicht gelöscht werden, solange aktive Notdienst-Verpflichtungen bestehen.",
};
}
if (!user.familyId) { if (!user.familyId) {
await prisma.user.delete({ await prisma.user.delete({
where: { id: user.id }, where: { id: user.id },
+198 -140
View File
@@ -1,19 +1,28 @@
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { import {
AtSign,
Baby, Baby,
CalendarHeart, CalendarHeart,
Home, Home,
Mail, Mail,
Phone, Phone,
Shield, Shield,
Trash2,
UserCircle, UserCircle,
} from "lucide-react"; } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { requireKitaSession } from "@/lib/auth-utils";
import { Badge } from "@/components/ui/badge"; import { prisma } from "@/lib/prisma";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DefItem, DefList, EmptyDefValue } from "@/components/ui/def-list";
import { RoleBadge } from "@/components/ui/role-badge";
import { DeleteAccountDialog } from "./_components/delete-account-dialog"; import { DeleteAccountDialog } from "./_components/delete-account-dialog";
import { MyChildrenManager } from "./_components/my-children-manager"; import { MyChildrenManager } from "./_components/my-children-manager";
import { CreateFamilyDialog } from "./_components/create-family-dialog"; import { CreateFamilyDialog } from "./_components/create-family-dialog";
@@ -24,133 +33,139 @@ export const metadata = { title: "Mein Profil · Kita-Planer" };
export default async function ProfilPage() { export default async function ProfilPage() {
const session = await requireKitaSession(); const session = await requireKitaSession();
const user = await prisma.user.findUniqueOrThrow({ const [kita, user] = await Promise.all([
where: { id: session.user.id }, prisma.kita.findUniqueOrThrow({
select: { where: { id: session.user.kitaId },
id: true, select: { name: true },
email: true, }),
firstName: true, prisma.user.findUniqueOrThrow({
lastName: true, where: { id: session.user.id },
role: true, select: {
familyId: true, id: true,
phone: true, email: true,
street: true, firstName: true,
postalCode: true, lastName: true,
city: true, role: true,
createdAt: true, familyId: true,
family: { phone: true,
select: { street: true,
name: true, postalCode: true,
children: { city: true,
select: { directoryOptInAt: true,
id: true, createdAt: true,
firstName: true, family: {
lastName: true, select: {
dateOfBirth: true, name: true,
children: {
select: {
id: true,
firstName: true,
lastName: true,
dateOfBirth: true,
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
}, },
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
}, },
}, },
}, dutyAssignments: {
dutyAssignments: { select: {
select: { id: true,
id: true, duty: {
duty: { select: {
select: { name: true,
name: true, },
}, },
}, },
}, },
}, },
}, }),
}); ]);
const fullName = `${user.firstName} ${user.lastName}`.trim();
return ( return (
<div className="flex h-full flex-col gap-6 p-6 max-w-4xl mx-auto w-full"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div> <header className="mb-9">
<h1 className="text-2xl font-bold tracking-tight">Mein Profil</h1> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<p className="text-muted-foreground"> PROFIL · {kita.name}
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Mein{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
Profil
</em>
</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen. Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen.
</p> </p>
</div> </header>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid items-start gap-5 lg:grid-cols-2">
{/* Persönliche Daten */} <Card className="self-stretch">
<Card> <CardHeader className="pb-5">
<CardHeader> <div className="flex items-start gap-3">
<CardTitle className="flex items-center gap-2"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<UserCircle className="h-5 w-5" /> <UserCircle className="h-4 w-4 [stroke-width:1.7]" />
Persönliche Daten </div>
</CardTitle> <div>
<CardTitle>Persönliche Daten</CardTitle>
<CardDescription className="mt-2">
Read-only Stammdaten deines Accounts.
</CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent>
<div> <DefList>
<div className="text-sm text-muted-foreground">Name</div> <DefItem icon={UserCircle} label="Name" value={fullName} />
<div className="font-medium text-lg"> <DefItem icon={Mail} label="E-Mail" value={user.email} />
{user.firstName} {user.lastName} <DefItem
</div> icon={Phone}
</div> label="Telefon"
<div> value={user.phone ? user.phone : <EmptyDefValue />}
<div className="text-sm text-muted-foreground flex items-center gap-1"> />
<Mail className="h-3.5 w-3.5" /> E-Mail <DefItem icon={Shield} label="Rolle" value={<RoleBadge role={user.role} />} />
</div> <DefItem
<div className="font-medium">{user.email}</div> icon={Home}
</div> label="Haushalt"
<div> value={
<div className="text-sm text-muted-foreground flex items-center gap-1"> user.family ? (
<Phone className="h-3.5 w-3.5" /> Telefon user.family.name
</div> ) : (
<div className="font-medium"> <div className="grid gap-3 rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3">
{user.phone ?? "Noch nicht hinterlegt"} <p className="text-sm font-normal leading-5 text-[var(--ink-soft)]">
</div> Dein Account ist noch keinem Haushalt zugeordnet.
</div> </p>
<div> <CreateFamilyDialog
<div className="text-sm text-muted-foreground">Rolle im Verein</div> defaultFamilyName={`Familie ${user.lastName}`}
<Badge variant={user.role === "ELTERN" ? "secondary" : "default"} className="mt-1"> />
{user.role} </div>
</Badge> )
</div> }
<div> />
<div className="text-sm text-muted-foreground flex items-center gap-1"> <DefItem
<Home className="h-3.5 w-3.5" /> Haushalt icon={CalendarHeart}
</div> label="Mitglied seit"
{user.family ? ( value={format(user.createdAt, "PPP", { locale: de })}
<div className="font-medium">{user.family.name}</div> />
) : ( </DefList>
<div className="mt-2 space-y-3 rounded-md border border-dashed p-3">
<p className="text-sm text-muted-foreground">
Dein Account ist noch keinem Haushalt zugeordnet. Lege hier
deinen eigenen Haushalt an, wenn du selbst Elternteil in der
Kita bist.
</p>
<CreateFamilyDialog
defaultFamilyName={`Familie ${user.lastName}`}
/>
</div>
)}
</div>
<div className="pt-2 border-t mt-2">
<div className="text-sm text-muted-foreground flex items-center gap-1">
<CalendarHeart className="h-3.5 w-3.5" /> Mitglied seit
</div>
<div className="text-sm">
{format(user.createdAt, "PPP", { locale: de })}
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
<div className="flex flex-col gap-6"> <div className="grid gap-4">
<Card> <Card>
<CardHeader> <CardHeader className="pb-5">
<CardTitle className="flex items-center gap-2"> <div className="flex items-start gap-3">
<Phone className="h-5 w-5" /> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
Kontaktdaten <AtSign className="h-4 w-4 [stroke-width:1.7]" />
</CardTitle> </div>
<CardDescription> <div>
Diese Angaben helfen bei Notdienst, Adressbuch und Abstimmung in <CardTitle>Kontaktdaten</CardTitle>
der Kita. <CardDescription className="mt-2">
</CardDescription> Für Notdienst, Adressbuch und Abstimmung in der Kita.
</CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ContactForm <ContactForm
@@ -159,18 +174,25 @@ export default async function ProfilPage() {
street: user.street, street: user.street,
postalCode: user.postalCode, postalCode: user.postalCode,
city: user.city, city: user.city,
directoryOptIn: !!user.directoryOptInAt,
}} }}
/> />
</CardContent> </CardContent>
</Card> </Card>
{/* Kinder */}
<Card> <Card>
<CardHeader> <CardHeader className="pb-5">
<CardTitle className="flex items-center gap-2"> <div className="flex items-start gap-3">
<Baby className="h-5 w-5" /> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
Meine Kinder <Baby className="h-4 w-4 [stroke-width:1.7]" />
</CardTitle> </div>
<div>
<CardTitle>Meine Kinder</CardTitle>
<CardDescription className="mt-2">
Kinder deines Haushalts verwalten.
</CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<MyChildrenManager <MyChildrenManager
@@ -187,23 +209,53 @@ export default async function ProfilPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Ämter */}
<Card> <Card>
<CardHeader> <CardHeader className="pb-5">
<CardTitle className="flex items-center gap-2"> <div className="flex items-start gap-3">
<Shield className="h-5 w-5" /> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
Meine Ämter & Dienste <Shield className="h-4 w-4 [stroke-width:1.7]" />
</CardTitle> </div>
<div>
<CardTitle>Meine Ämter & Dienste</CardTitle>
<CardDescription className="mt-2">
Feste Aufgaben und laufende Zuständigkeiten.
</CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{user.dutyAssignments.length === 0 ? ( {user.dutyAssignments.length === 0 ? (
<p className="text-sm text-muted-foreground">Du hast aktuell keine festen Ämter übernommen.</p> <div className="flex min-h-32 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Shield className="h-4 w-4 [stroke-width:1.7]" />
</div>
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
Keine festen Ämter übernommen
</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">
Dienste und Rollen erscheinen hier, sobald sie zugewiesen
sind.
</p>
</div>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="grid gap-2">
{user.dutyAssignments.map((assignment) => ( {user.dutyAssignments.map((assignment) => (
<Badge key={assignment.id} variant="default" className="text-sm py-1 px-3"> <div
{assignment.duty.name} key={assignment.id}
</Badge> className="flex items-center justify-between gap-3 rounded-lg bg-[var(--surface-2)] p-[10px_12px]"
>
<div>
<p className="text-sm font-semibold text-[var(--ink)]">
{assignment.duty.name}
</p>
<p className="text-xs text-[var(--ink-soft)]">
laufender Dienst
</p>
</div>
<span className="rounded-full border border-[var(--accent-mid)] bg-[var(--accent-soft)] px-2.5 py-1 text-[10.5px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.1em]">
Aktiv
</span>
</div>
))} ))}
</div> </div>
)} )}
@@ -212,18 +264,24 @@ export default async function ProfilPage() {
</div> </div>
</div> </div>
<Card className="border-destructive/20 mt-8"> <Card className="mt-5 bg-[var(--surface-2)]">
<CardHeader> <CardContent className="flex items-center justify-between gap-6 pt-[22px] max-[760px]:flex-col max-[760px]:items-start">
<CardDescription> <div className="max-w-2xl">
Hier kannst du dein Profil und je nach Haushaltssituation auch die <div className="flex items-center gap-2 text-[var(--danger)]">
zugehörigen Familiendaten DSGVO-konform löschen. <Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</CardDescription> <h2 className="text-lg font-medium tracking-[-0.02em]">
</CardHeader> Danger Zone
<CardContent> </h2>
</div>
<p className="mt-2 text-[13.5px] leading-5 text-[var(--ink-soft)]">
Hier kannst du dein Profil und je nach Haushaltssituation auch die
zugehörigen Familiendaten DSGVO-konform löschen. Aktive
Notdienst-Verpflichtungen blockieren die Löschung.
</p>
</div>
<DeleteAccountDialog /> <DeleteAccountDialog />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }
+207
View File
@@ -0,0 +1,207 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { EventParticipationStatus } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { AlertTriangle, Check, X } from "lucide-react";
import { toast } from "sonner";
import { submitEventRsvp } from "@/actions/rsvp";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export type DashboardRsvpEventDto = {
id: string;
title: string;
startDate: string;
rsvpDeadline: string | null;
children: {
id: string;
name: string;
status: EventParticipationStatus | null;
}[];
};
export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] }) {
if (events.length === 0) return null;
return (
<Card className="mb-8 border-[var(--warn)] bg-[var(--warn-soft)]">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-[var(--ink)]">
<AlertTriangle className="h-5 w-5 text-[var(--warn)] [stroke-width:1.7]" />
Aktion erforderlich
</CardTitle>
<CardDescription className="text-[var(--ink-soft)]">
Für diese Termine fehlt noch mindestens eine Rückmeldung deiner
Familie.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
{events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="font-medium text-[var(--ink)]">{event.title}</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Rückmeldung bis{" "}
{format(
new Date(event.rsvpDeadline ?? event.startDate),
"dd.MM.yyyy HH:mm",
{ locale: de },
)}
</p>
</div>
<RsvpDialog event={event} />
</div>
))}
</CardContent>
</Card>
);
}
function RsvpDialog({ event }: { event: DashboardRsvpEventDto }) {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const initialSelection = useMemo(
() =>
Object.fromEntries(
event.children.map((child) => [child.id, child.status ?? ""]),
) as Record<string, EventParticipationStatus | "">,
[event.children],
);
const [selection, setSelection] = useState(initialSelection);
function submit() {
const missingChild = event.children.find((child) => !selection[child.id]);
if (missingChild) {
toast.error(`Bitte Rückmeldung für ${missingChild.name} auswählen.`);
return;
}
startTransition(async () => {
for (const child of event.children) {
const status = selection[child.id];
if (!status) continue;
const result = await submitEventRsvp({
eventId: event.id,
childId: child.id,
status,
});
if (result.error) {
toast.error(result.error);
return;
}
}
toast.success("Rückmeldung gespeichert.");
setOpen(false);
});
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen) setSelection(initialSelection);
}}
>
<DialogTrigger asChild>
<Button size="sm">Jetzt abstimmen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Rückmeldung für {event.title}</DialogTitle>
<DialogDescription>
Wähle pro Kind aus, ob es am Termin teilnimmt.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
{event.children.map((child) => (
<div
key={child.id}
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<p className="font-medium text-[var(--ink)]">{child.name}</p>
{child.status && (
<Badge variant="outline">bereits gemeldet</Badge>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]">
<input
type="radio"
name={`rsvp-${child.id}`}
checked={
selection[child.id] ===
EventParticipationStatus.ATTENDING
}
onChange={() =>
setSelection((current) => ({
...current,
[child.id]: EventParticipationStatus.ATTENDING,
}))
}
/>
<Check className="h-4 w-4 text-[var(--good)] [stroke-width:1.7]" />
Nimmt teil
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]">
<input
type="radio"
name={`rsvp-${child.id}`}
checked={
selection[child.id] ===
EventParticipationStatus.NOT_ATTENDING
}
onChange={() =>
setSelection((current) => ({
...current,
[child.id]: EventParticipationStatus.NOT_ATTENDING,
}))
}
/>
<X className="h-4 w-4 text-[var(--danger)] [stroke-width:1.7]" />
Nimmt nicht teil
</label>
</div>
</div>
))}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Abbrechen
</Button>
<Button type="button" disabled={isPending} onClick={submit}>
{isPending ? "Speichern..." : "Rückmeldung speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+327
View File
@@ -0,0 +1,327 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
ArrowLeft,
ArrowRight,
CheckCircle2,
HelpCircle,
ShieldCheck,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
getMarketingFeature,
marketingFeatures,
} from "@/lib/marketing/features";
type FeaturePageProps = {
params: Promise<{
slug: string;
}>;
};
export const dynamicParams = false;
export function generateStaticParams() {
return marketingFeatures.map((feature) => ({
slug: feature.slug,
}));
}
export async function generateMetadata({
params,
}: FeaturePageProps): Promise<Metadata> {
const { slug } = await params;
const feature = getMarketingFeature(slug);
if (!feature) {
return {
title: "Feature nicht gefunden",
};
}
return {
title: feature.seoTitle,
description: feature.seoDescription,
openGraph: {
title: feature.seoTitle,
description: feature.seoDescription,
type: "website",
locale: "de_DE",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Ein Kind spielt mit bunten Holzklötzen.",
},
],
},
twitter: {
card: "summary_large_image",
title: feature.seoTitle,
description: feature.seoDescription,
images: ["/og-image.png"],
},
};
}
export default async function FeaturePage({ params }: FeaturePageProps) {
const { slug } = await params;
const feature = getMarketingFeature(slug);
if (!feature) {
notFound();
}
const Icon = feature.icon;
return (
<div className="min-h-screen bg-[#f8faf8] text-slate-950">
<header className="border-b border-slate-200 bg-white/95">
<div className="mx-auto flex h-20 w-full max-w-7xl items-center justify-between px-5 sm:px-6">
<Link href="/" className="flex items-center gap-3 text-slate-950">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-950 text-white">
<ShieldCheck className="h-5 w-5" />
</span>
<span className="text-base font-semibold tracking-tight">
Kita-Planer
</span>
</Link>
<nav className="flex items-center gap-2">
<Button asChild variant="ghost">
<Link href="/login">Login</Link>
</Button>
<Button asChild>
<Link href="/#kontakt">Demo anfragen</Link>
</Button>
</nav>
</div>
</header>
<main>
<section className="bg-slate-950 px-5 py-20 text-white sm:px-6 lg:py-24">
<div className="mx-auto grid w-full max-w-7xl gap-12 lg:grid-cols-[0.95fr_1.05fr] lg:items-center">
<div>
<Button
asChild
variant="ghost"
className="-ml-3 mb-8 text-slate-300 hover:bg-white/10 hover:text-white"
>
<Link href="/">
<ArrowLeft className="h-4 w-4" />
Zur Startseite
</Link>
</Button>
<Badge className="mb-6 border-white/15 bg-white/10 text-white hover:bg-white/10">
{feature.eyebrow}
</Badge>
<h1 className="max-w-4xl text-balance text-4xl font-semibold leading-tight sm:text-5xl lg:text-6xl">
{feature.heroTitle}
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-300">
{feature.heroDescription}
</p>
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
<Button
asChild
size="lg"
className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90"
>
<Link href="/#kontakt">
Demo anfragen
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<Button
asChild
size="lg"
variant="outline"
className="h-12 border-white/35 bg-white/10 px-6 text-white hover:bg-white/20 hover:text-white"
>
<Link href="/register">Kita registrieren</Link>
</Button>
</div>
</div>
<div className="rounded-lg border border-white/10 bg-white/[0.06] p-6 shadow-2xl shadow-slate-950/30">
<div
className={`mb-6 flex h-14 w-14 items-center justify-center rounded-lg ring-1 ${feature.accent}`}
>
<Icon className="h-7 w-7" />
</div>
<p className="text-sm font-semibold uppercase tracking-wide text-emerald-200">
In einem Satz
</p>
<p className="mt-4 text-2xl font-semibold leading-snug">
{feature.description}
</p>
<div className="mt-8 grid gap-3 text-sm text-slate-300">
{feature.benefits.slice(0, 3).map((benefit) => (
<div key={benefit.title} className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-300" />
<span>{benefit.title}</span>
</div>
))}
</div>
</div>
</div>
</section>
<section className="mx-auto grid w-full max-w-7xl gap-6 px-5 py-20 sm:px-6 lg:grid-cols-2">
<Card className="border-slate-200 bg-white">
<CardHeader>
<CardTitle className="text-2xl tracking-tight">
{feature.problemTitle}
</CardTitle>
<CardDescription className="text-base leading-7 text-slate-600">
{feature.problemText}
</CardDescription>
</CardHeader>
</Card>
<Card className="border-slate-200 bg-white">
<CardHeader>
<CardTitle className="text-2xl tracking-tight">
{feature.solutionTitle}
</CardTitle>
<CardDescription className="text-base leading-7 text-slate-600">
{feature.solutionText}
</CardDescription>
</CardHeader>
</Card>
</section>
<section className="px-5 pb-20 sm:px-6">
<div className="mx-auto w-full max-w-7xl">
<div className="mb-10 max-w-3xl">
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
Was das konkret bringt
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Weniger Abstimmungslast, mehr Klarheit im Kita-Alltag.
</h2>
</div>
<div className="grid gap-5 md:grid-cols-2">
{feature.benefits.map((benefit) => (
<Card key={benefit.title} className="border-slate-200 bg-white">
<CardHeader>
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<CheckCircle2 className="h-5 w-5" />
</div>
<CardTitle className="text-xl tracking-tight">
{benefit.title}
</CardTitle>
<CardDescription className="text-sm leading-6 text-slate-600">
{benefit.text}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
</section>
<section className="bg-slate-950 px-5 py-18 text-white sm:px-6">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<div className="max-w-3xl">
<Badge className="mb-5 border-emerald-300/20 bg-emerald-300/10 text-emerald-100 hover:bg-emerald-300/10">
Vertrauen & Datenschutz
</Badge>
<h2 className="text-3xl font-semibold tracking-tight">
{feature.trustTitle}
</h2>
<p className="mt-4 text-base leading-7 text-slate-300">
{feature.trustText}
</p>
</div>
<Button
asChild
size="lg"
className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90 lg:shrink-0"
>
<Link href="/datenschutz">Datenschutz ansehen</Link>
</Button>
</div>
</section>
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6">
<div className="mb-10 max-w-3xl">
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
Fragen aus dem Vorstand
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Kurz beantwortet.
</h2>
</div>
<div className="grid gap-4 lg:grid-cols-3">
{feature.faqs.map((faq) => (
<Card key={faq.question} className="border-slate-200 bg-white">
<CardHeader>
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-slate-100 text-slate-700">
<HelpCircle className="h-5 w-5" />
</div>
<CardTitle className="text-lg leading-snug">
{faq.question}
</CardTitle>
<CardDescription className="text-sm leading-6 text-slate-600">
{faq.answer}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</section>
<section className="px-5 pb-20 sm:px-6">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 rounded-lg border border-slate-200 bg-white p-8 shadow-sm sm:p-10 md:flex-row md:items-center md:justify-between">
<div className="max-w-2xl">
<h2 className="text-2xl font-semibold tracking-tight">
Wollt ihr {feature.shortTitle} im echten Kita-Alltag sehen?
</h2>
<p className="mt-3 text-sm leading-6 text-slate-600">
Schreibt uns kurz, wie ihr heute organisiert seid. Wir zeigen
euch, wie Kita-Planer zu eurem Elternverein passen kann.
</p>
</div>
<Button asChild size="lg">
<Link href="/#kontakt">
Demo anfragen
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div>
</section>
</main>
<footer className="border-t border-slate-200 bg-white">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-5 py-8 text-sm text-slate-500 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<span>© {new Date().getFullYear()} Kita-Planer</span>
<nav className="flex gap-5">
<Link href="/impressum" className="hover:text-slate-950">
Impressum
</Link>
<Link href="/datenschutz" className="hover:text-slate-950">
Datenschutz
</Link>
<Link href="/#kontakt" className="hover:text-slate-950">
Kontakt
</Link>
</nav>
</div>
</footer>
</div>
);
}
+49 -66
View File
@@ -1,86 +1,69 @@
@import "tailwindcss"; @import "tailwindcss";
@import "./styles/tokens.css";
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--bg);
--color-foreground: var(--foreground); --color-foreground: var(--ink);
--color-card: var(--card); --color-card: var(--surface);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--ink);
--color-popover: var(--popover); --color-popover: var(--surface);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--ink);
--color-primary: var(--primary); --color-primary: var(--accent-deep);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: oklch(0.98 0.005 45);
--color-secondary: var(--secondary); --color-secondary: var(--surface-2);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--ink);
--color-muted: var(--muted); --color-muted: var(--surface-week);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--ink-muted);
--color-accent: var(--accent); --color-accent: var(--accent-soft);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-deep);
--color-destructive: var(--destructive); --color-destructive: var(--danger);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: oklch(0.98 0.005 45);
--color-border: var(--border); --color-border: var(--hairline);
--color-input: var(--input); --color-input: var(--hairline);
--color-ring: var(--ring); --color-ring: var(--accent);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-manrope);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-manrope);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
} }
:root { :root {
--background: oklch(1 0 0); --background: var(--bg);
--foreground: oklch(0.145 0 0); --foreground: var(--ink);
--card: oklch(1 0 0); --card: var(--surface);
--card-foreground: oklch(0.145 0 0); --card-foreground: var(--ink);
--popover: oklch(1 0 0); --popover: var(--surface);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: var(--ink);
--primary: oklch(0.205 0 0); --primary: var(--accent-deep);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.98 0.005 45);
--secondary: oklch(0.97 0 0); --secondary: var(--surface-2);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: var(--ink);
--muted: oklch(0.97 0 0); --muted: var(--surface-week);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: var(--ink-muted);
--accent: oklch(0.97 0 0); --accent-foreground: var(--accent-deep);
--accent-foreground: oklch(0.205 0 0); --destructive: var(--danger);
--destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.98 0.005 45);
--destructive-foreground: oklch(0.985 0 0); --border: var(--hairline);
--border: oklch(0.922 0 0); --input: var(--hairline);
--input: oklch(0.922 0 0); --ring: var(--accent);
--ring: oklch(0.708 0 0);
--radius: 0.625rem; --radius: 0.625rem;
} }
@media (prefers-color-scheme: dark) {
:root {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 0.1);
--input: oklch(1 0 0 / 0.15);
--ring: oklch(0.556 0 0);
}
}
@layer base { @layer base {
* { * {
border-color: var(--border); border-color: var(--border);
} }
body { body {
background-color: var(--background); background-color: var(--bg);
color: var(--foreground); background-image: var(--paper-pattern);
background-size: 16px 16px;
color: var(--ink);
font-family: var(--font-sans), system-ui, sans-serif; font-family: var(--font-sans), system-ui, sans-serif;
} }
::selection {
background: var(--accent-soft);
color: var(--accent-deep);
}
} }
+37 -5
View File
@@ -34,6 +34,16 @@ const inviteSchema = z
const PRIVACY_POLICY_VERSION = "2026-05-01"; const PRIVACY_POLICY_VERSION = "2026-05-01";
export type InviteState = { export type InviteState = {
// Bumped per call so the client can key uncontrolled inputs and force a
// remount after a failed submit (React's <form action> wipes them
// otherwise).
attempt: number;
// Echoed checkbox states so failed submits don't lose the user's consent
// ticks. Passwords are NEVER echoed back from the server.
values?: {
acceptPrivacyPolicy?: boolean;
directoryOptIn?: boolean;
};
errors?: { errors?: {
password?: string[]; password?: string[];
confirmPassword?: string[]; confirmPassword?: string[];
@@ -43,10 +53,20 @@ export type InviteState = {
}; };
}; };
function echoCheckboxValues(formData: FormData): InviteState["values"] {
return {
acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy") === "on",
directoryOptIn: formData.get("directoryOptIn") === "on",
};
}
export async function acceptInviteAction( export async function acceptInviteAction(
_prev: InviteState, prev: InviteState,
formData: FormData, formData: FormData,
): Promise<InviteState> { ): Promise<InviteState> {
const attempt = prev.attempt + 1;
const values = echoCheckboxValues(formData);
const parsed = inviteSchema.safeParse({ const parsed = inviteSchema.safeParse({
token: formData.get("token"), token: formData.get("token"),
password: formData.get("password"), password: formData.get("password"),
@@ -56,7 +76,7 @@ export async function acceptInviteAction(
}); });
if (!parsed.success) { if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }; return { attempt, values, errors: parsed.error.flatten().fieldErrors };
} }
const { token, password, directoryOptIn } = parsed.data; const { token, password, directoryOptIn } = parsed.data;
@@ -68,10 +88,16 @@ export async function acceptInviteAction(
}); });
if (!verificationToken) { if (!verificationToken) {
return { errors: { _form: ["Einladungslink ist ungültig."] } }; return {
attempt,
values,
errors: { _form: ["Einladungslink ist ungültig."] },
};
} }
if (verificationToken.expires < now) { if (verificationToken.expires < now) {
return { return {
attempt,
values,
errors: { errors: {
_form: [ _form: [
"Dieser Einladungslink ist abgelaufen. Bitte wende dich an deinen Administrator.", "Dieser Einladungslink ist abgelaufen. Bitte wende dich an deinen Administrator.",
@@ -89,11 +115,17 @@ export async function acceptInviteAction(
}); });
if (!user) { if (!user) {
return { errors: { _form: ["Benutzer nicht gefunden."] } }; return {
attempt,
values,
errors: { _form: ["Benutzer nicht gefunden."] },
};
} }
if (user.passwordHash !== "") { if (user.passwordHash !== "") {
// Invite wurde bereits eingelöst // Invite wurde bereits eingelöst
return { return {
attempt,
values,
errors: { errors: {
_form: [ _form: [
"Dieser Einladungslink wurde bereits verwendet. Bitte melde dich an.", "Dieser Einladungslink wurde bereits verwendet. Bitte melde dich an.",
@@ -131,5 +163,5 @@ export async function acceptInviteAction(
}); });
// Unreachable signIn redirected. // Unreachable signIn redirected.
return {}; return { attempt };
} }
+54 -7
View File
@@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { acceptInviteAction, type InviteState } from "./actions"; import { acceptInviteAction, type InviteState } from "./actions";
const initialState: InviteState = {}; const initialInviteState: InviteState = { attempt: 0 };
// ===================================================================== // =====================================================================
// InviteForm · Client Component // InviteForm · Client Component
@@ -27,9 +27,14 @@ export function InviteForm({
}) { }) {
const [state, formAction, pending] = useActionState( const [state, formAction, pending] = useActionState(
acceptInviteAction, acceptInviteAction,
initialState, initialInviteState,
); );
// Bumping the key on each attempt forces uncontrolled inputs (including
// checkboxes) to remount with the echoed defaultChecked values after a
// failed submit. Passwords stay empty for security.
const attemptKey = state.attempt;
return ( return (
<form action={formAction} className="space-y-5"> <form action={formAction} className="space-y-5">
{/* Token als verstecktes Feld */} {/* Token als verstecktes Feld */}
@@ -45,17 +50,30 @@ export function InviteForm({
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="password">Passwort wählen</Label> <Label htmlFor="password">Passwort wählen</Label>
<Input <Input
key={`password-${attemptKey}`}
id="password" id="password"
name="password" name="password"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
required required
aria-invalid={!!state.errors?.password} aria-invalid={!!state.errors?.password}
aria-describedby={
state.errors?.password ? "password-error" : "password-helper"
}
/> />
{state.errors?.password?.[0] ? ( {state.errors?.password?.[0] ? (
<p className="text-xs text-destructive">{state.errors.password[0]}</p> <p
id="password-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{state.errors.password[0]}
</p>
) : ( ) : (
<p className="text-xs text-muted-foreground">Mindestens 8 Zeichen.</p> <p id="password-helper" className="text-xs text-muted-foreground">
Mindestens 8 Zeichen.
</p>
)} )}
</div> </div>
@@ -63,15 +81,26 @@ export function InviteForm({
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="confirmPassword">Passwort bestätigen</Label> <Label htmlFor="confirmPassword">Passwort bestätigen</Label>
<Input <Input
key={`confirmPassword-${attemptKey}`}
id="confirmPassword" id="confirmPassword"
name="confirmPassword" name="confirmPassword"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
required required
aria-invalid={!!state.errors?.confirmPassword} aria-invalid={!!state.errors?.confirmPassword}
aria-describedby={
state.errors?.confirmPassword
? "confirmPassword-error"
: undefined
}
/> />
{state.errors?.confirmPassword?.[0] && ( {state.errors?.confirmPassword?.[0] && (
<p className="text-xs text-destructive"> <p
id="confirmPassword-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{state.errors.confirmPassword[0]} {state.errors.confirmPassword[0]}
</p> </p>
)} )}
@@ -85,10 +114,17 @@ export function InviteForm({
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Checkbox <Checkbox
key={`acceptPrivacyPolicy-${attemptKey}`}
id="acceptPrivacyPolicy" id="acceptPrivacyPolicy"
name="acceptPrivacyPolicy" name="acceptPrivacyPolicy"
required required
defaultChecked={state.values?.acceptPrivacyPolicy === true}
aria-invalid={!!state.errors?.acceptPrivacyPolicy} aria-invalid={!!state.errors?.acceptPrivacyPolicy}
aria-describedby={
state.errors?.acceptPrivacyPolicy
? "acceptPrivacyPolicy-error"
: undefined
}
/> />
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal"> <Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal">
@@ -104,7 +140,12 @@ export function InviteForm({
gelesen und akzeptiere sie. <span className="text-destructive">*</span> gelesen und akzeptiere sie. <span className="text-destructive">*</span>
</Label> </Label>
{state.errors?.acceptPrivacyPolicy?.[0] && ( {state.errors?.acceptPrivacyPolicy?.[0] && (
<p className="text-xs text-destructive"> <p
id="acceptPrivacyPolicy-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{state.errors.acceptPrivacyPolicy[0]} {state.errors.acceptPrivacyPolicy[0]}
</p> </p>
)} )}
@@ -113,8 +154,10 @@ export function InviteForm({
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Checkbox <Checkbox
key={`directoryOptIn-${attemptKey}`}
id="directoryOptIn" id="directoryOptIn"
name="directoryOptIn" name="directoryOptIn"
defaultChecked={state.values?.directoryOptIn === true}
aria-invalid={!!state.errors?.directoryOptIn} aria-invalid={!!state.errors?.directoryOptIn}
/> />
<div className="space-y-0.5"> <div className="space-y-0.5">
@@ -128,7 +171,11 @@ export function InviteForm({
{/* Globaler Fehler */} {/* Globaler Fehler */}
{state.errors?._form?.[0] && ( {state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <p
role="alert"
aria-live="polite"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{state.errors._form[0]} {state.errors._form[0]}
</p> </p>
)} )}
+6 -10
View File
@@ -1,22 +1,18 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Manrope } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { UmamiAnalytics } from "@/components/analytics/umami-analytics"; import { UmamiAnalytics } from "@/components/analytics/umami-analytics";
const geistSans = Geist({ const manrope = Manrope({
variable: "--font-geist-sans", variable: "--font-manrope",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500", "600", "700"],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL( metadataBase: new URL(
process.env.NEXT_PUBLIC_SITE_URL ?? "https://kita-planer.example.com", process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de",
), ),
title: { title: {
default: "Der digitale Kita-Planer für Elternvereine", default: "Der digitale Kita-Planer für Elternvereine",
@@ -56,7 +52,7 @@ export default function RootLayout({
return ( return (
<html <html
lang="de" lang="de"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${manrope.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
{children} {children}
+9 -5
View File
@@ -18,6 +18,7 @@ import { signIn } from "@/auth";
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(), email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(),
password: z.string().min(1, "Passwort fehlt."), password: z.string().min(1, "Passwort fehlt."),
callbackUrl: z.string().optional(),
}); });
export type LoginState = { export type LoginState = {
@@ -28,6 +29,12 @@ export type LoginState = {
}; };
}; };
function safeCallbackUrl(value: string | undefined) {
if (!value) return "/dashboard";
if (!value.startsWith("/") || value.startsWith("//")) return "/dashboard";
return value;
}
export async function loginAction( export async function loginAction(
_prev: LoginState, _prev: LoginState,
formData: FormData, formData: FormData,
@@ -37,16 +44,13 @@ export async function loginAction(
return { errors: parsed.error.flatten().fieldErrors }; return { errors: parsed.error.flatten().fieldErrors };
} }
const { email, password } = parsed.data; const { email, password } = parsed.data;
const redirectTo = safeCallbackUrl(parsed.data.callbackUrl);
try { try {
await signIn("credentials", { await signIn("credentials", {
email, email,
password, password,
// Auth-Utils entscheiden im Folge-Render, ob /onboarding oder /dashboard. redirectTo,
// Wir landen aus Sicherheitsgründen erstmal generisch auf der Wurzel.
// Der Proxy (proxy.ts) übernimmt das Routing:
// → /onboarding wenn kein kitaId, → /dashboard wenn vorhanden.
redirectTo: "/",
}); });
} catch (err) { } catch (err) {
// signIn wirft NEXT_REDIRECT bei Erfolg → das müssen wir bewusst // signIn wirft NEXT_REDIRECT bei Erfolg → das müssen wir bewusst
+60 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useActionState } from "react"; import { useActionState, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -10,11 +10,29 @@ import { loginAction, type LoginState } from "./actions";
const initialState: LoginState = {}; const initialState: LoginState = {};
export function LoginForm() { export function LoginForm({
callbackUrl,
devAdminEmail,
devAdminPassword,
devParentEmail,
devParentPassword,
}: {
callbackUrl: string;
devAdminEmail?: string;
devAdminPassword?: string;
devParentEmail?: string;
devParentPassword?: string;
}) {
const [state, formAction, pending] = useActionState(loginAction, initialState); const [state, formAction, pending] = useActionState(loginAction, initialState);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const showDevHelpers = !!(devAdminEmail || devParentEmail);
return ( return (
<form action={formAction} className="space-y-4"> <form action={formAction} className="space-y-4">
<input type="hidden" name="callbackUrl" value={callbackUrl} />
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="email">E-Mail</Label> <Label htmlFor="email">E-Mail</Label>
<Input <Input
@@ -24,6 +42,8 @@ export function LoginForm() {
autoComplete="email" autoComplete="email"
required required
aria-invalid={!!state.errors?.email} aria-invalid={!!state.errors?.email}
value={email}
onChange={(e) => setEmail(e.target.value)}
/> />
{state.errors?.email?.[0] && ( {state.errors?.email?.[0] && (
<p className="text-xs text-destructive">{state.errors.email[0]}</p> <p className="text-xs text-destructive">{state.errors.email[0]}</p>
@@ -39,6 +59,8 @@ export function LoginForm() {
autoComplete="current-password" autoComplete="current-password"
required required
aria-invalid={!!state.errors?.password} aria-invalid={!!state.errors?.password}
value={password}
onChange={(e) => setPassword(e.target.value)}
/> />
{state.errors?.password?.[0] && ( {state.errors?.password?.[0] && (
<p className="text-xs text-destructive">{state.errors.password[0]}</p> <p className="text-xs text-destructive">{state.errors.password[0]}</p>
@@ -54,6 +76,42 @@ export function LoginForm() {
<Button type="submit" className="w-full" disabled={pending}> <Button type="submit" className="w-full" disabled={pending}>
{pending ? "Anmelden…" : "Anmelden"} {pending ? "Anmelden…" : "Anmelden"}
</Button> </Button>
{showDevHelpers && (
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/40 p-3 space-y-2 text-xs">
<p className="text-center font-medium text-muted-foreground">Dev-Schnellzugriff (nur lokal):</p>
<div className="grid grid-cols-2 gap-2">
{devAdminEmail && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setEmail(devAdminEmail);
setPassword(devAdminPassword || "");
}}
className="text-xs py-1.5 h-auto cursor-pointer"
>
🔑 Admin
</Button>
)}
{devParentEmail && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setEmail(devParentEmail);
setPassword(devParentPassword || "");
}}
className="text-xs py-1.5 h-auto cursor-pointer"
>
👪 Eltern
</Button>
)}
</div>
</div>
)}
</form> </form>
); );
} }
+31 -3
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getPostLoginRedirect } from "@/lib/post-login-redirect";
import { import {
Card, Card,
CardContent, CardContent,
@@ -14,11 +15,32 @@ import { LoginForm } from "./login-form";
export const metadata = { title: "Anmelden · Kita-Planer" }; export const metadata = { title: "Anmelden · Kita-Planer" };
export default async function LoginPage() { type LoginPageProps = {
searchParams?: Promise<{ callbackUrl?: string | string[] }>;
};
function getSafeCallbackUrl(value: string | string[] | undefined) {
const callbackUrl = Array.isArray(value) ? value[0] : value;
if (!callbackUrl) return "/dashboard";
if (!callbackUrl.startsWith("/") || callbackUrl.startsWith("//")) {
return "/dashboard";
}
return callbackUrl;
}
export default async function LoginPage({ searchParams }: LoginPageProps) {
const session = await auth(); const session = await auth();
if (session?.user?.id) { if (session?.user?.id) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding"); redirect(getPostLoginRedirect(session.user));
} }
const resolvedSearchParams = await searchParams;
const callbackUrl = getSafeCallbackUrl(resolvedSearchParams?.callbackUrl);
const isDev = process.env.NODE_ENV === "development";
const devAdminEmail = isDev ? process.env.DEV_LOGIN_ADMIN_EMAIL : undefined;
const devAdminPassword = isDev ? process.env.DEV_LOGIN_ADMIN_PASSWORD : undefined;
const devParentEmail = isDev ? process.env.DEV_LOGIN_PARENT_EMAIL : undefined;
const devParentPassword = isDev ? process.env.DEV_LOGIN_PARENT_PASSWORD : undefined;
return ( return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12"> <div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
@@ -28,7 +50,13 @@ export default async function LoginPage() {
<CardDescription>Willkommen zurück.</CardDescription> <CardDescription>Willkommen zurück.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<LoginForm /> <LoginForm
callbackUrl={callbackUrl}
devAdminEmail={devAdminEmail}
devAdminPassword={devAdminPassword}
devParentEmail={devParentEmail}
devParentPassword={devParentPassword}
/>
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
Noch keinen Account?{" "} Noch keinen Account?{" "}
<Link href="/register" className="font-medium text-foreground underline"> <Link href="/register" className="font-medium text-foreground underline">
+61 -17
View File
@@ -16,21 +16,35 @@ import { prisma } from "@/lib/prisma";
// — kein "halb-eingerichteter" Zwischenzustand. // — kein "halb-eingerichteter" Zwischenzustand.
// ===================================================================== // =====================================================================
const onboardingSchema = z.object({ const onboardingSchema = z
kitaName: z .object({
.string() kitaName: z
.min(2, "Mindestens 2 Zeichen.") .string()
.max(120, "Maximal 120 Zeichen.") .trim()
.trim(), .min(2, "Bitte mindestens 2 Zeichen angeben.")
notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), .max(120, "Maximal 120 Zeichen."),
terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
notdienstMinPerChildPerMonth: z.coerce adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
.number() notdienstMinPerChildPerMonth: z.coerce
.int("Bitte ganze Zahl.") .number()
.min(0, "Nicht negativ.") .int("Bitte eine ganze Zahl angeben.")
.max(31, "Maximal 31 Tage pro Monat."), .min(0, "Bitte einen Wert von 0 oder mehr angeben.")
}); .max(31, "Maximal 31 Tage pro Monat."),
})
.superRefine((data, ctx) => {
// Mindest-Verfügbarkeit nur relevant, wenn das Notdienst-Modul aktiv ist.
// Bei aktivem Modul muss mindestens 1 Tag eingetragen sein — 0 wäre
// fachlich sinnlos und würde den Planer leerlaufen lassen.
if (data.notdienstModuleEnabled && data.notdienstMinPerChildPerMonth < 1) {
ctx.addIssue({
path: ["notdienstMinPerChildPerMonth"],
code: "custom",
message:
"Bei aktivem Notdienst-Modul: mindestens 1 Tag pro Monat angeben.",
});
}
});
function checkboxToBool(value: unknown): boolean { function checkboxToBool(value: unknown): boolean {
// HTML-Checkbox: "on" wenn aktiviert, sonst nicht im FormData. // HTML-Checkbox: "on" wenn aktiviert, sonst nicht im FormData.
@@ -38,6 +52,17 @@ function checkboxToBool(value: unknown): boolean {
} }
export type OnboardingState = { export type OnboardingState = {
// Bumped after every action call so the client can key uncontrolled inputs
// and force a remount with the echoed values from a failed submit.
attempt: number;
// Echoed inputs so a failed submit doesn't wipe the form.
values?: {
kitaName?: string;
notdienstModuleEnabled?: boolean;
terminModuleEnabled?: boolean;
adressbuchModuleEnabled?: boolean;
notdienstMinPerChildPerMonth?: number;
};
errors?: { errors?: {
kitaName?: string[]; kitaName?: string[];
notdienstMinPerChildPerMonth?: string[]; notdienstMinPerChildPerMonth?: string[];
@@ -45,10 +70,23 @@ export type OnboardingState = {
}; };
}; };
function echoValues(formData: FormData): OnboardingState["values"] {
const min = formData.get("notdienstMinPerChildPerMonth");
return {
kitaName: String(formData.get("kitaName") ?? ""),
notdienstModuleEnabled: formData.get("notdienstModuleEnabled") === "on",
terminModuleEnabled: formData.get("terminModuleEnabled") === "on",
adressbuchModuleEnabled: formData.get("adressbuchModuleEnabled") === "on",
notdienstMinPerChildPerMonth:
min === null || min === "" ? undefined : Number(min),
};
}
export async function completeOnboardingAction( export async function completeOnboardingAction(
_prev: OnboardingState, prev: OnboardingState,
formData: FormData, formData: FormData,
): Promise<OnboardingState> { ): Promise<OnboardingState> {
const attempt = prev.attempt + 1;
const session = await auth(); const session = await auth();
if (!session?.user) { if (!session?.user) {
redirect("/login"); redirect("/login");
@@ -61,7 +99,11 @@ export async function completeOnboardingAction(
const parsed = onboardingSchema.safeParse(Object.fromEntries(formData)); const parsed = onboardingSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) { if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }; return {
attempt,
values: echoValues(formData),
errors: parsed.error.flatten().fieldErrors,
};
} }
const data = parsed.data; const data = parsed.data;
@@ -99,6 +141,8 @@ export async function completeOnboardingAction(
} }
if (err instanceof Prisma.PrismaClientKnownRequestError) { if (err instanceof Prisma.PrismaClientKnownRequestError) {
return { return {
attempt,
values: echoValues(formData),
errors: { _form: ["Datenbankfehler — bitte erneut versuchen."] }, errors: { _form: ["Datenbankfehler — bitte erneut versuchen."] },
}; };
} }
+86 -23
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useActionState } from "react"; import { useActionState, useId } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
@@ -9,14 +9,22 @@ import { Label } from "@/components/ui/label";
import { completeOnboardingAction, type OnboardingState } from "./actions"; import { completeOnboardingAction, type OnboardingState } from "./actions";
const initialState: OnboardingState = {}; const initialOnboardingState: OnboardingState = { attempt: 0 };
export function OnboardingForm() { export function OnboardingForm() {
const [state, formAction, pending] = useActionState( const [state, formAction, pending] = useActionState(
completeOnboardingAction, completeOnboardingAction,
initialState, initialOnboardingState,
); );
// Bumping the key on each attempt forces uncontrolled inputs to remount with
// the echoed defaultValue from the server — otherwise React's <form action>
// semantics would wipe them after a failed submit.
const attemptKey = state.attempt;
const kitaError = state.errors?.kitaName?.[0];
const minError = state.errors?.notdienstMinPerChildPerMonth?.[0];
return ( return (
<form action={formAction} className="space-y-8"> <form action={formAction} className="space-y-8">
{/* ----- Schritt 1: Name ----- */} {/* ----- Schritt 1: Name ----- */}
@@ -27,14 +35,24 @@ export function OnboardingForm() {
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="kitaName">Name des Elternvereins / der Kita</Label> <Label htmlFor="kitaName">Name des Elternvereins / der Kita</Label>
<Input <Input
key={`kitaName-${attemptKey}`}
id="kitaName" id="kitaName"
name="kitaName" name="kitaName"
required required
placeholder="z.B. Waldameisen e.V." placeholder="z.B. Waldameisen e.V."
aria-invalid={!!state.errors?.kitaName} defaultValue={state.values?.kitaName ?? ""}
aria-invalid={!!kitaError}
aria-describedby={kitaError ? "kitaName-error" : undefined}
/> />
{state.errors?.kitaName?.[0] && ( {kitaError && (
<p className="text-xs text-destructive">{state.errors.kitaName[0]}</p> <p
id="kitaName-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{kitaError}
</p>
)} )}
</div> </div>
</fieldset> </fieldset>
@@ -45,22 +63,25 @@ export function OnboardingForm() {
Schritt 2 · Module aktivieren Schritt 2 · Module aktivieren
</legend> </legend>
<ModuleCheckbox <ModuleCheckbox
key={`notdienstModuleEnabled-${attemptKey}`}
name="notdienstModuleEnabled" name="notdienstModuleEnabled"
label="Notdienst-Planung" label="Notdienst-Planung"
description="Verfügbarkeiten erfassen, Plan generieren, bei Krankheitsausfall alarmieren." description="Verfügbarkeiten erfassen, Plan generieren, bei Krankheitsausfall alarmieren."
defaultChecked defaultChecked={state.values?.notdienstModuleEnabled ?? true}
/> />
<ModuleCheckbox <ModuleCheckbox
key={`terminModuleEnabled-${attemptKey}`}
name="terminModuleEnabled" name="terminModuleEnabled"
label="Terminkalender" label="Terminkalender"
description="Kita-Feste, Schließtage und private Anfragen koordinieren." description="Kita-Feste, Schließtage und private Anfragen koordinieren."
defaultChecked defaultChecked={state.values?.terminModuleEnabled ?? true}
/> />
<ModuleCheckbox <ModuleCheckbox
key={`adressbuchModuleEnabled-${attemptKey}`}
name="adressbuchModuleEnabled" name="adressbuchModuleEnabled"
label="Eltern-Adressbuch" label="Eltern-Adressbuch"
description="Eltern können sich auf Opt-In-Basis untereinander finden." description="Eltern können sich auf Opt-In-Basis untereinander finden."
defaultChecked defaultChecked={state.values?.adressbuchModuleEnabled ?? true}
/> />
</fieldset> </fieldset>
@@ -74,29 +95,49 @@ export function OnboardingForm() {
Mindest-Verfügbarkeiten pro Kind und Monat Mindest-Verfügbarkeiten pro Kind und Monat
</Label> </Label>
<Input <Input
key={`notdienstMinPerChildPerMonth-${attemptKey}`}
id="notdienstMinPerChildPerMonth" id="notdienstMinPerChildPerMonth"
name="notdienstMinPerChildPerMonth" name="notdienstMinPerChildPerMonth"
type="number" type="number"
min={0} min={0}
max={31} max={31}
defaultValue={2} defaultValue={state.values?.notdienstMinPerChildPerMonth ?? 2}
required required
aria-invalid={!!state.errors?.notdienstMinPerChildPerMonth} aria-invalid={!!minError}
aria-describedby={
minError
? "notdienstMinPerChildPerMonth-error"
: "notdienstMinPerChildPerMonth-helper"
}
/> />
<p className="text-xs text-muted-foreground"> {!minError && (
Wie viele Tage müssen Eltern pro Monat als verfügbar markieren? <p
Diesen Wert kannst du später jederzeit ändern. id="notdienstMinPerChildPerMonth-helper"
</p> className="text-xs text-muted-foreground"
{state.errors?.notdienstMinPerChildPerMonth?.[0] && ( >
<p className="text-xs text-destructive"> Wie viele Tage müssen Eltern pro Monat als verfügbar markieren?
{state.errors.notdienstMinPerChildPerMonth[0]} Diesen Wert kannst du später jederzeit ändern.
</p>
)}
{minError && (
<p
id="notdienstMinPerChildPerMonth-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{minError}
</p> </p>
)} )}
</div> </div>
</fieldset> </fieldset>
{state.errors?._form?.[0] && ( {state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <p
role="alert"
aria-live="polite"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{state.errors._form[0]} {state.errors._form[0]}
</p> </p>
)} )}
@@ -119,12 +160,34 @@ function ModuleCheckbox({
description: string; description: string;
defaultChecked?: boolean; defaultChecked?: boolean;
}) { }) {
const id = useId();
const titleId = `${id}-title`;
const descId = `${id}-desc`;
return ( return (
<label className="flex items-start gap-3 rounded-md border p-4 transition-colors hover:bg-muted/40"> // The wrapping <label htmlFor> keeps the whole card clickable to toggle
<Checkbox name={name} defaultChecked={defaultChecked} className="mt-0.5" /> // the checkbox. aria-labelledby narrows the accessible name to just the
// module title, with the description exposed via aria-describedby so
// screen readers can present it as secondary info rather than as part of
// the primary label.
<label
htmlFor={id}
className="flex cursor-pointer items-start gap-3 rounded-md border p-4 transition-colors hover:bg-muted/40"
>
<Checkbox
id={id}
name={name}
defaultChecked={defaultChecked}
aria-labelledby={titleId}
aria-describedby={descId}
className="mt-0.5"
/>
<div className="grid gap-1 leading-none"> <div className="grid gap-1 leading-none">
<span className="text-sm font-medium">{label}</span> <span id={titleId} className="text-sm font-medium">
<span className="text-xs text-muted-foreground">{description}</span> {label}
</span>
<span id={descId} className="text-xs text-muted-foreground">
{description}
</span>
</div> </div>
</label> </label>
); );
+3 -1
View File
@@ -30,7 +30,9 @@ export default async function OnboardingPage() {
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12"> <div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-2xl"> <Card className="w-full max-w-2xl">
<CardHeader className="space-y-2"> <CardHeader className="space-y-2">
<CardTitle>Willkommen, {session.user.name?.split(" ")[0] ?? "Gründer:in"}!</CardTitle> <CardTitle as="h1">
Willkommen, {session.user.name?.split(" ")[0] ?? "Gründer:in"}!
</CardTitle>
<CardDescription> <CardDescription>
Lass uns deine Kita in 3 kurzen Schritten einrichten. Du kannst alle Lass uns deine Kita in 3 kurzen Schritten einrichten. Du kannst alle
Einstellungen später noch anpassen. Einstellungen später noch anpassen.
+438 -98
View File
@@ -1,24 +1,30 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation";
import { import {
ArrowRight, ArrowRight,
CalendarDays, Bell,
CheckCircle2, CheckCircle2,
ClipboardList,
Clock,
DatabaseZap, DatabaseZap,
FileStack,
Fingerprint, Fingerprint,
HeartHandshake,
LockKeyhole, LockKeyhole,
Mail, Mail,
Megaphone,
MessageSquareText, MessageSquareText,
ShieldAlert, MessagesSquare,
PhoneCall,
Search,
Route,
ShieldCheck, ShieldCheck,
Stethoscope, Table2,
Trash2, Trash2,
UserCog,
UsersRound, UsersRound,
} from "lucide-react"; } from "lucide-react";
import { auth } from "@/auth";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -28,16 +34,20 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { marketingFeatures } from "@/lib/marketing/features";
import { ContactForm } from "./contact-form"; import { ContactForm } from "./contact-form";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Kita-Planer für Elternvereine", title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description: description:
"Die einfache Plattform für Elternvereine, Dienste und Kommunikation: plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.", "Die einfache Plattform für Elterninitiativen, freie Kitas und Kita-Vereine: organisiert Dienste, Termine, Krankmeldungen und offizielle Kommunikation an einem Ort.",
alternates: {
canonical: "/",
},
openGraph: { openGraph: {
title: "Kita-Planer für Elternvereine", title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description: description:
"Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.", "Weniger Chat-Chaos, weniger Excel-Listen, mehr Ruhe im ehrenamtlichen Kita-Alltag.",
type: "website", type: "website",
locale: "de_DE", locale: "de_DE",
images: [ images: [
@@ -51,9 +61,9 @@ export const metadata: Metadata = {
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "Kita-Planer für Elternvereine", title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description: description:
"Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.", "Organisiert Dienste, Termine, Krankmeldungen und offizielle Kommunikation an einem Ort.",
images: ["/og-image.png"], images: ["/og-image.png"],
}, },
}; };
@@ -61,60 +71,20 @@ export const metadata: Metadata = {
const heroImage = const heroImage =
"https://images.unsplash.com/photo-1503454537195-1dcabb73ffb9?auto=format&fit=crop&w=2400&q=86"; "https://images.unsplash.com/photo-1503454537195-1dcabb73ffb9?auto=format&fit=crop&w=2400&q=86";
const features = [
{
icon: ShieldAlert,
title: "Fairer Notdienst-Planer",
description:
"Automatische Generierung, gerechte Verteilung und 1-Klick-Alarmierung, wenn morgens Ersatz gebraucht wird.",
accent: "bg-red-50 text-red-700 ring-red-100",
},
{
icon: CalendarDays,
title: "Smarter Kalender",
description:
"Feste, Termine, Raumbuchungen und digitale Mitbring-Listen in einem verbindlichen Kalender für alle Familien.",
accent: "bg-sky-50 text-sky-700 ring-sky-100",
},
{
icon: UsersRound,
title: "Sicheres Adressbuch",
description:
"Haushaltsbasiert, DSGVO-konform und mit strengem Opt-In: Kontaktdaten werden nur sichtbar, wenn Eltern zustimmen.",
accent: "bg-emerald-50 text-emerald-700 ring-emerald-100",
},
{
icon: Stethoscope,
title: "Digitale Krankmeldung",
description:
"Ein Klick statt Dauerläuten am Morgen. Eltern melden Kinder digital ab, ErzieherInnen sehen auf dem Tablet sofort den Überblick für den Morgenkreis.",
accent: "bg-amber-50 text-amber-700 ring-amber-100",
badge: "Neu",
},
{
icon: Megaphone,
title: "Schwarzes Brett",
description:
"Der WhatsApp-Killer für Vorstände: offizielle Ankündigungen mit Dateianhängen, Lesebestätigung und optionalem E-Mail-Push.",
accent: "bg-indigo-50 text-indigo-700 ring-indigo-100",
badge: "Neu",
},
];
const privacyPoints = [ const privacyPoints = [
{ {
icon: DatabaseZap, icon: DatabaseZap,
title: "Strikt isolierte Mandanten", title: "Strikt isolierte Mandanten",
text: "Jede Kita arbeitet innerhalb ihrer eigenen kitaId-Grenze. Datenabfragen sind konsequent tenant-isoliert.", text: "Jede Kita arbeitet technisch streng getrennt pro Kita. Rollen und Datenabfragen bleiben konsequent tenant-isoliert.",
}, },
{ {
icon: Fingerprint, icon: Fingerprint,
title: "Privacy by Design", title: "Kontaktfreigabe per Opt-in",
text: "Adressbuchdaten erscheinen nur nach aktivem Opt-In pro Elternteil. Nicht freigegebene Kontaktdaten verlassen den Server nicht.", text: "Adressbuchdaten erscheinen nur nach aktivem Opt-In pro Elternteil. Nicht freigegebene Kontaktdaten verlassen den Server nicht.",
}, },
{ {
icon: Trash2, icon: Trash2,
title: "Recht auf Vergessenwerden", title: "Recht auf Löschung",
text: "Eltern können ihren Account selbst löschen. Wenn sie der letzte Haushaltspartner sind, werden die Familiendaten sauber entfernt.", text: "Eltern können ihren Account selbst löschen. Wenn sie der letzte Haushaltspartner sind, werden die Familiendaten sauber entfernt.",
}, },
{ {
@@ -122,25 +92,106 @@ const privacyPoints = [
title: "Geschützte Anhänge", title: "Geschützte Anhänge",
text: "Dokumente vom Schwarzen Brett liegen nicht öffentlich und werden nur nach Session- und Kita-Prüfung ausgeliefert.", text: "Dokumente vom Schwarzen Brett liegen nicht öffentlich und werden nur nach Session- und Kita-Prüfung ausgeliefert.",
}, },
{
icon: UserCog,
title: "Rollenrechte pro Kita",
text: "Vorstand, Koordination, Eltern und Team sehen nur die Bereiche, die zu ihrer Aufgabe und Kita-Zugehörigkeit passen.",
},
{
icon: FileStack,
title: "Klare Datenpfade",
text: "Löschungen sind bewusst vorgesehen; Fragen zu Export und Übergabe werden im Setup sauber geklärt.",
},
]; ];
const proofPoints = [ const proofPoints = [
"Notdienst, Kalender und Abwesenheiten in einem System", "Für Elterninitiativen, freie Kitas und Kita-Vereine",
"Gebaut für Vorstände, Eltern und ErzieherInnen", "Dienste, Kalender, Abwesenheiten und offizielle Infos in einem System",
"Klare Rollenrechte statt Chat-Chaos", "Gebaut für Vorstände, Eltern, Koordination und Team",
]; ];
// Eingeloggte User von der Landingpage direkt weiterleiten. const replacedTools = [
export default async function LandingPage() { {
const session = await auth(); icon: MessagesSquare,
if (session?.user?.id) { title: "WhatsApp für offizielle Infos",
redirect(session.user.kitaId ? "/dashboard" : "/onboarding"); text: "Ankündigungen, Anhänge und Lesestatus gehören in einen verbindlichen Kanal, nicht zwischen spontane Rückfragen.",
} accent: "bg-emerald-50 text-emerald-700 ring-emerald-100",
},
{
icon: Table2,
title: "Excel-Listen für Dienste",
text: "Verfügbarkeiten, Mindesttage und Dienstpläne werden strukturierter als in Tabellen mit Version 17-final-final.",
accent: "bg-lime-50 text-lime-700 ring-lime-100",
},
{
icon: FileStack,
title: "Verstreute PDFs und Mail-Anhänge",
text: "Wichtige Dokumente hängen am passenden Kontext und bleiben geschützt abrufbar.",
accent: "bg-red-50 text-red-700 ring-red-100",
},
{
icon: ClipboardList,
title: "Manuelle Kontaktlisten",
text: "Familien, Kinder und freigegebene Kontaktdaten werden kontrolliert statt lose herumgereicht.",
accent: "bg-sky-50 text-sky-700 ring-sky-100",
},
{
icon: PhoneCall,
title: "Telefonchaos bei Krankmeldungen",
text: "Eltern melden Abwesenheiten digital; das Team sieht morgens schneller, wer fehlt.",
accent: "bg-amber-50 text-amber-700 ring-amber-100",
},
];
const audienceRoles = [
{
icon: UserCog,
title: "Vorstand & Admin",
text: "Kita, Familien, Termine, Einladungen und Rechte verwalten, ohne dass alles an einer privaten Ablage hängt.",
},
{
icon: UsersRound,
title: "Eltern",
text: "Termine sehen, Kinder krankmelden, Verfügbarkeiten eintragen und freigegebene Kontakte finden.",
},
{
icon: HeartHandshake,
title: "ErzieherInnen & Koordination",
text: "Tagesüberblick, Abwesenheiten, Dienste und relevante Informationen an einem Ort sehen.",
},
];
const onboardingTimeAnchors = [
"Demo-Call (30 Min)",
"Setup-Termin (1 Woche später)",
"Familien einladen (Woche 2)",
"Erste echte Dienstrunde (Woche 34)",
] as const;
const onboardingSteps = [
{
title: onboardingTimeAnchors[0],
text: "Ihr beschreibt kurz eure aktuelle Organisation und die größten Reibungspunkte.",
},
{
title: onboardingTimeAnchors[1],
text: "Wir schauen gemeinsam auf Rollen, Module, Datenschutzfragen und den sinnvollen Einstieg.",
},
{
title: onboardingTimeAnchors[2],
text: "Der Vorstand bringt Eltern, Kinder und Zuständigkeiten in eine saubere Struktur.",
},
{
title: onboardingTimeAnchors[3],
text: "Dienste, Kalender, Krankmeldungen, Adressbuch und Schwarzes Brett ersetzen nach und nach Nebenkanäle.",
},
];
export default function LandingPage() {
return ( return (
<div className="min-h-screen bg-[#f8faf8] text-slate-950"> <div className="min-h-screen bg-[#f8faf8] text-slate-950">
<header className="absolute left-0 right-0 top-0 z-20"> <header className="absolute left-0 right-0 top-0 z-20">
<div className="mx-auto flex h-20 w-full max-w-7xl items-center justify-between px-5 sm:px-6"> <div className="mx-auto flex min-h-20 w-full max-w-7xl items-center justify-between gap-3 px-5 py-4 sm:px-6">
<Link href="/" className="flex items-center gap-3 text-white"> <Link href="/" className="flex items-center gap-3 text-white">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/12 ring-1 ring-white/30"> <span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/12 ring-1 ring-white/30">
<ShieldCheck className="h-5 w-5" /> <ShieldCheck className="h-5 w-5" />
@@ -150,7 +201,7 @@ export default async function LandingPage() {
</span> </span>
</Link> </Link>
<nav className="flex items-center gap-2"> <nav className="flex flex-wrap items-center justify-end gap-2">
<Button <Button
asChild asChild
variant="ghost" variant="ghost"
@@ -162,36 +213,40 @@ export default async function LandingPage() {
asChild asChild
className="bg-white text-slate-950 shadow-sm hover:bg-white/90" className="bg-white text-slate-950 shadow-sm hover:bg-white/90"
> >
<Link href="/register">Kita registrieren</Link> <Link href="/#kontakt">Demo anfragen</Link>
</Button> </Button>
</nav> </nav>
</div> </div>
</header> </header>
<main> <main>
<section <section className="relative min-h-[calc(100svh-56px)] overflow-hidden bg-slate-950">
className="relative min-h-[calc(100svh-56px)] overflow-hidden bg-slate-950" <Image
style={{ src={heroImage}
backgroundImage: `linear-gradient(90deg, rgba(2, 6, 23, 0.9), rgba(15, 23, 42, 0.68), rgba(15, 23, 42, 0.18)), url(${heroImage})`, alt=""
backgroundPosition: "center", fill
backgroundSize: "cover", preload
}} sizes="100vw"
> className="object-cover object-center"
/>
<div
aria-hidden
className="absolute inset-0 bg-[linear-gradient(90deg,rgba(2,6,23,0.92),rgba(15,23,42,0.72)_45%,rgba(15,23,42,0.35)_75%,rgba(15,23,42,0.55))] sm:bg-[linear-gradient(90deg,rgba(2,6,23,0.9),rgba(15,23,42,0.68),rgba(15,23,42,0.18))]"
/>
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-[#f8faf8] to-transparent" /> <div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-[#f8faf8] to-transparent" />
<div className="relative z-10 mx-auto flex min-h-[calc(100svh-56px)] w-full max-w-7xl items-center px-5 pb-20 pt-28 sm:px-6"> <div className="relative z-10 mx-auto flex min-h-[calc(100svh-56px)] w-full max-w-7xl items-center px-5 pb-20 pt-28 sm:px-6">
<div className="max-w-4xl text-white"> <div className="max-w-4xl text-white">
<Badge className="mb-6 border-white/20 bg-white/12 text-white hover:bg-white/12"> <Badge className="mb-6 border-white/20 bg-white/12 text-white hover:bg-white/12">
Kita-Betriebssystem für Elternvereine Kita-Betriebssystem für Elterninitiativen und Kita-Vereine
</Badge> </Badge>
<h1 className="max-w-4xl text-balance text-5xl font-semibold leading-[1.02] sm:text-6xl lg:text-7xl"> <h1 className="max-w-4xl text-balance text-5xl font-semibold leading-[1.02] sm:text-6xl lg:text-7xl">
Die einfache Plattform für Elternvereine, Dienste und Weniger Chat-Chaos. Weniger Excel. Mehr Ruhe im Kita-Verein.
Kommunikation.
</h1> </h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-white/82 sm:text-xl"> <p className="mt-6 max-w-2xl text-lg leading-8 text-white/82 sm:text-xl">
Organisiert Notdienste, Termine, Krankmeldungen und offizielle Für Elterninitiativen, freie Kitas und Kita-Vereine, die
Kommunikation an einem Ort. Schluss mit Zettelwirtschaft und Dienste, Termine, Krankmeldungen und offizielle Kommunikation
WhatsApp-Chaos. ehrenamtlich organisieren.
</p> </p>
<div className="mt-9 flex flex-col gap-3 sm:flex-row"> <div className="mt-9 flex flex-col gap-3 sm:flex-row">
@@ -200,8 +255,8 @@ export default async function LandingPage() {
size="lg" size="lg"
className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90" className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90"
> >
<Link href="/register"> <Link href="/#kontakt">
Kita registrieren Demo anfragen
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Link> </Link>
</Button> </Button>
@@ -211,11 +266,11 @@ export default async function LandingPage() {
variant="outline" variant="outline"
className="h-12 border-white/35 bg-white/10 px-6 text-white hover:bg-white/20 hover:text-white" className="h-12 border-white/35 bg-white/10 px-6 text-white hover:bg-white/20 hover:text-white"
> >
<Link href="/login">Login</Link> <Link href="/register">Kita kostenlos registrieren</Link>
</Button> </Button>
</div> </div>
<div className="mt-10 grid max-w-3xl gap-3 text-sm text-white/78 md:grid-cols-3"> <div className="mt-10 grid max-w-4xl gap-3 text-sm text-white/78 md:grid-cols-3">
{proofPoints.map((item) => ( {proofPoints.map((item) => (
<div key={item} className="flex items-center gap-2"> <div key={item} className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-300" /> <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-300" />
@@ -227,6 +282,110 @@ export default async function LandingPage() {
</div> </div>
</section> </section>
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6 lg:py-24">
<div className="mb-10 grid gap-5 lg:grid-cols-[0.78fr_1fr] lg:items-end">
<div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
Produkt statt Versprechen
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
So sieht operative Kita-Arbeit aus, wenn sie nicht mehr im Chat
liegt.
</h2>
</div>
<p className="max-w-2xl text-sm leading-6 text-slate-600 lg:justify-self-end">
Der Notdienst-Planer sammelt Verfügbarkeiten, zeigt offene
Familien und macht aus losen Antworten einen veröffentlichbaren
Monatsplan.
</p>
</div>
<ProductBrowserMock />
</section>
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6 lg:py-24">
<div className="mb-10 grid gap-5 lg:grid-cols-[0.85fr_1.15fr] lg:items-end">
<div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
Raus aus den Nebenkanälen
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Kita-Planer ersetzt die Dinge, die Vorstände abends noch
zusammenhalten müssen.
</h2>
</div>
<p className="max-w-2xl text-sm leading-6 text-slate-600 lg:justify-self-end">
Elternvereine funktionieren nur, wenn Informationen zuverlässig
ankommen. Deshalb bündelt Kita-Planer die wiederkehrenden
Alltagslisten, Anfragen und offiziellen Nachrichten an einem Ort.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
{replacedTools.map((item) => {
const Icon = item.icon;
return (
<div
key={item.title}
className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm"
>
<div
className={`mb-4 flex h-10 w-10 items-center justify-center rounded-lg ring-1 ${item.accent}`}
>
<Icon className="h-5 w-5" />
</div>
<h3 className="font-semibold tracking-tight">{item.title}</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
{item.text}
</p>
</div>
);
})}
</div>
</section>
<section className="border-y border-slate-200 bg-white px-5 py-20 sm:px-6">
<div className="mx-auto grid w-full max-w-7xl gap-10 lg:grid-cols-[0.9fr_1.1fr] lg:items-start">
<div>
<Badge className="mb-5 bg-slate-950 text-white hover:bg-slate-950">
Für den Vereinsalltag
</Badge>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Eine Oberfläche für Vorstand, Eltern und Kita-Team.
</h2>
<p className="mt-5 max-w-xl text-sm leading-6 text-slate-600">
Kita-Planer trennt Zuständigkeiten, ohne neue Silos zu bauen.
Jede Rolle sieht die Aufgaben und Informationen, die sie im
gemeinsamen Kita-Alltag wirklich braucht.
</p>
</div>
<div className="grid gap-4">
{audienceRoles.map((role) => {
const Icon = role.icon;
return (
<div
key={role.title}
className="grid gap-4 rounded-lg border border-slate-200 bg-[#f8faf8] p-5 shadow-sm sm:grid-cols-[auto_1fr] sm:items-start"
>
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-white text-emerald-700 ring-1 ring-emerald-100">
<Icon className="h-5 w-5" />
</div>
<div>
<h3 className="font-semibold tracking-tight">
{role.title}
</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
{role.text}
</p>
</div>
</div>
);
})}
</div>
</div>
</section>
<section className="mx-auto w-full max-w-7xl px-5 py-24 sm:px-6"> <section className="mx-auto w-full max-w-7xl px-5 py-24 sm:px-6">
<div className="mb-12 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between"> <div className="mb-12 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl"> <div className="max-w-3xl">
@@ -246,7 +405,7 @@ export default async function LandingPage() {
</div> </div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-6"> <div className="grid gap-5 md:grid-cols-2 xl:grid-cols-6">
{features.map((feature, index) => { {marketingFeatures.map((feature, index) => {
const Icon = feature.icon; const Icon = feature.icon;
const isWide = index > 2; const isWide = index > 2;
return ( return (
@@ -270,20 +429,71 @@ export default async function LandingPage() {
)} )}
</div> </div>
<div> <div>
<CardTitle className="text-xl tracking-tight"> <CardTitle as="h3" className="text-xl tracking-tight">
{feature.title} {feature.title}
</CardTitle> </CardTitle>
<CardDescription className="mt-3 text-sm leading-6 text-slate-600"> <CardDescription className="mt-3 text-sm leading-6 text-slate-600">
{feature.description} {feature.description}
</CardDescription> </CardDescription>
<Button
asChild
variant="link"
className="mt-4 h-auto p-0 text-slate-950"
>
<Link href={`/features/${feature.slug}`}>
Mehr erfahren
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-0">
<FeaturePreview slug={feature.slug} />
</CardContent>
</Card> </Card>
); );
})} })}
</div> </div>
</section> </section>
<section className="mx-auto w-full max-w-7xl px-5 pb-24 sm:px-6">
<div className="grid gap-10 rounded-lg border border-slate-200 bg-white p-6 shadow-sm lg:grid-cols-[0.85fr_1.15fr] lg:p-10">
<div>
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<Route className="h-6 w-6" />
</div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
So startet ihr
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Erst verstehen, dann sauber einführen.
</h2>
<p className="mt-5 text-sm leading-6 text-slate-600">
Elternvereine haben wenig Zeit für Tool-Einführung. Der Einstieg
soll deshalb mit euren echten Abläufen beginnen, nicht mit einer
langen Konfigurationsliste.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{onboardingSteps.map((step, index) => (
<div
key={step.title}
className="rounded-lg border border-slate-200 bg-[#f8faf8] p-5"
>
<div className="mb-4 flex h-8 w-8 items-center justify-center rounded-full bg-slate-950 text-sm font-semibold text-white">
{index + 1}
</div>
<h3 className="font-semibold tracking-tight">{step.title}</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
{step.text}
</p>
</div>
))}
</div>
</div>
</section>
<section className="bg-slate-950 px-5 py-24 text-white sm:px-6"> <section className="bg-slate-950 px-5 py-24 text-white sm:px-6">
<div className="mx-auto grid w-full max-w-7xl gap-12 lg:grid-cols-[0.92fr_1.08fr] lg:items-start"> <div className="mx-auto grid w-full max-w-7xl gap-12 lg:grid-cols-[0.92fr_1.08fr] lg:items-start">
<div> <div>
@@ -297,14 +507,14 @@ export default async function LandingPage() {
Datenschutz ist nicht die Fußnote. Er ist die Architektur. Datenschutz ist nicht die Fußnote. Er ist die Architektur.
</h2> </h2>
<p className="mt-5 max-w-xl text-base leading-7 text-slate-300"> <p className="mt-5 max-w-xl text-base leading-7 text-slate-300">
Elternvereine verwalten besonders sensible Daten. Deshalb ist Kita-Vereine verwalten besonders sensible Daten. Deshalb ist
Kita-Planer so gebaut, dass Sichtbarkeit, Löschung und Kita-Planer so gebaut, dass Sichtbarkeit, Rollenrechte,
Mandantentrennung nicht vom guten Willen einzelner Chats Löschung und Mandantentrennung nicht vom guten Willen einzelner
abhängen. Chats abhängen.
</p> </p>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{privacyPoints.map((point) => { {privacyPoints.map((point) => {
const Icon = point.icon; const Icon = point.icon;
return ( return (
@@ -331,19 +541,19 @@ export default async function LandingPage() {
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div className="max-w-2xl"> <div className="max-w-2xl">
<h2 className="text-2xl font-semibold tracking-tight"> <h2 className="text-2xl font-semibold tracking-tight">
Bereit für eine Kita-Organisation ohne Nebenkanäle? Bereit für weniger Nebenkanäle im Kita-Verein?
</h2> </h2>
<p className="mt-3 text-sm leading-6 text-slate-600"> <p className="mt-3 text-sm leading-6 text-slate-600">
Registriere eure Kita, lade den Vorstand ein und starte mit Fragt eine Demo an und prüft gemeinsam, welche Abläufe zuerst
einem System, das für Elternvereine und Datenschutz gebaut ist. aus Chat, Tabelle und Telefonliste herauswandern sollten.
</p> </p>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row"> <div className="flex flex-col gap-3 sm:flex-row">
<Button asChild size="lg"> <Button asChild size="lg">
<Link href="/register">Kita registrieren</Link> <Link href="/#kontakt">Demo anfragen</Link>
</Button> </Button>
<Button asChild size="lg" variant="outline"> <Button asChild size="lg" variant="outline">
<Link href="/login">Login</Link> <Link href="/register">Kita kostenlos registrieren</Link>
</Button> </Button>
</div> </div>
</div> </div>
@@ -389,7 +599,7 @@ export default async function LandingPage() {
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100"> <div className="mb-2 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<Mail className="h-5 w-5" /> <Mail className="h-5 w-5" />
</div> </div>
<CardTitle className="text-2xl tracking-tight"> <CardTitle as="h3" className="text-2xl tracking-tight">
Anfrage senden Anfrage senden
</CardTitle> </CardTitle>
<CardDescription className="text-sm leading-6"> <CardDescription className="text-sm leading-6">
@@ -424,3 +634,133 @@ export default async function LandingPage() {
</div> </div>
); );
} }
function ProductBrowserMock() {
return (
<div className="overflow-hidden rounded-2xl border border-emerald-100 bg-white shadow-[0_24px_70px_rgba(15,23,42,0.12)]">
<div className="flex items-center justify-between border-b border-slate-200 bg-[#fbf8ef] px-4 py-3">
<div className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full bg-red-300" />
<span className="h-3 w-3 rounded-full bg-amber-300" />
<span className="h-3 w-3 rounded-full bg-emerald-300" />
</div>
<div className="rounded-full border border-slate-200 bg-white px-4 py-1 text-xs font-medium text-slate-500">
app.kita-planer.de/dashboard/notdienst
</div>
<div className="w-16" />
</div>
<div className="bg-[#fbf8ef] p-2 sm:p-4">
<Image
src="/landing/notdienst-calendar-linen.png"
alt="Notdienst-Kalender im Kita-Planer mit ausgewählten Tagen im Juni 2026"
width={2262}
height={1640}
sizes="(min-width: 1280px) 1184px, calc(100vw - 40px)"
className="h-auto w-full rounded-xl border border-slate-200 bg-[#fbf8ef]"
/>
</div>
</div>
);
}
function FeaturePreview({ slug }: { slug: string }) {
if (slug === "notdienst-planer") {
return (
<div className="rounded-xl border border-red-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center justify-between text-xs">
<span className="font-semibold text-slate-700">Juni</span>
<span className="rounded-full bg-red-50 px-2 py-1 text-red-700">
12/16
</span>
</div>
<div className="grid grid-cols-5 gap-1.5">
{["", "bg-red-100", "", "bg-red-100", "", "bg-red-100", "", "", "bg-red-100", ""].map(
(cell, index) => (
<span
key={index}
className={`h-7 rounded-md border border-red-100 bg-white ${cell}`}
/>
),
)}
</div>
</div>
);
}
if (slug === "kita-kalender") {
return (
<div className="rounded-xl border border-sky-100 bg-[#fbf8ef] p-3">
{[
["10", "Schließtag", "bg-red-400"],
["18", "Elternabend", "bg-indigo-400"],
["29", "Sommerfest", "bg-emerald-500"],
].map(([date, title, color]) => (
<div
key={title}
className="mb-2 last:mb-0 flex items-center gap-3 rounded-lg bg-white px-3 py-2 text-xs"
>
<span className="font-semibold tabular-nums text-slate-950">
{date}
</span>
<span className={`h-5 w-1 rounded-full ${color}`} />
<span className="text-slate-600">{title}</span>
</div>
))}
</div>
);
}
if (slug === "eltern-adressbuch") {
return (
<div className="rounded-xl border border-emerald-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-400">
<Search className="h-3.5 w-3.5" />
Familie suchen
</div>
{["Meier", "Mustermann"].map((name) => (
<div key={name} className="mb-2 last:mb-0 flex items-center gap-2">
<span className="h-7 w-7 rounded-lg bg-emerald-50" />
<span className="h-2 flex-1 rounded-full bg-slate-200" />
<span className="h-2 w-10 rounded-full bg-emerald-100" />
</div>
))}
</div>
);
}
if (slug === "krankmeldung") {
return (
<div className="rounded-xl border border-amber-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center gap-2 text-xs font-semibold text-slate-700">
<Clock className="h-4 w-4 text-amber-700" />
Heute fehlen 2 Kinder
</div>
{["Kai · krank", "Mia · Arzttermin"].map((item) => (
<div
key={item}
className="mb-2 last:mb-0 rounded-lg bg-white px-3 py-2 text-xs text-slate-600"
>
{item}
</div>
))}
</div>
);
}
return (
<div className="rounded-xl border border-indigo-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center gap-2 text-xs font-semibold text-slate-700">
<Bell className="h-4 w-4 text-indigo-700" />
Neue Mitteilung
</div>
<div className="space-y-2">
<span className="block h-2.5 w-4/5 rounded-full bg-slate-200" />
<span className="block h-2.5 w-3/5 rounded-full bg-slate-200" />
<span className="inline-flex rounded-full bg-indigo-50 px-2 py-1 text-xs text-indigo-700">
PDF angehängt
</span>
</div>
</div>
);
}
+29 -3
View File
@@ -32,6 +32,16 @@ const registerSchema = z.object({
const PRIVACY_POLICY_VERSION = "2026-05-01"; const PRIVACY_POLICY_VERSION = "2026-05-01";
export type RegisterState = { export type RegisterState = {
// Bumped after every action call so the client can key uncontrolled inputs
// and force a remount with the echoed values from a failed submit.
attempt: number;
// Echoed inputs (sans password) so failed submits don't wipe the form.
values?: {
firstName?: string;
lastName?: string;
email?: string;
acceptPrivacyPolicy?: boolean;
};
errors?: { errors?: {
email?: string[]; email?: string[];
password?: string[]; password?: string[];
@@ -42,13 +52,27 @@ export type RegisterState = {
}; };
}; };
function echoValues(formData: FormData): RegisterState["values"] {
return {
firstName: String(formData.get("firstName") ?? ""),
lastName: String(formData.get("lastName") ?? ""),
email: String(formData.get("email") ?? ""),
acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy") === "on",
};
}
export async function registerAction( export async function registerAction(
_prevState: RegisterState, prevState: RegisterState,
formData: FormData, formData: FormData,
): Promise<RegisterState> { ): Promise<RegisterState> {
const attempt = prevState.attempt + 1;
const parsed = registerSchema.safeParse(Object.fromEntries(formData)); const parsed = registerSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) { if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }; return {
attempt,
values: echoValues(formData),
errors: parsed.error.flatten().fieldErrors,
};
} }
const { email, password, firstName, lastName } = parsed.data; const { email, password, firstName, lastName } = parsed.data;
@@ -77,6 +101,8 @@ export async function registerAction(
err.code === "P2002" err.code === "P2002"
) { ) {
return { return {
attempt,
values: echoValues(formData),
errors: { errors: {
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."], email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
}, },
@@ -93,5 +119,5 @@ export async function registerAction(
}); });
// Unreachable signIn redirected. // Unreachable signIn redirected.
return {}; return { attempt };
} }
+2 -1
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getPostLoginRedirect } from "@/lib/post-login-redirect";
import { import {
Card, Card,
CardContent, CardContent,
@@ -17,7 +18,7 @@ export const metadata = { title: "Registrieren · Kita-Planer" };
export default async function RegisterPage() { export default async function RegisterPage() {
const session = await auth(); const session = await auth();
if (session?.user) { if (session?.user) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding"); redirect(getPostLoginRedirect(session.user));
} }
return ( return (
+66 -8
View File
@@ -9,43 +9,58 @@ import { Label } from "@/components/ui/label";
import { registerAction, type RegisterState } from "./actions"; import { registerAction, type RegisterState } from "./actions";
const initialState: RegisterState = {}; const initialRegisterState: RegisterState = { attempt: 0 };
export function RegisterForm() { export function RegisterForm() {
const [state, formAction, pending] = useActionState(registerAction, initialState); const [state, formAction, pending] = useActionState(
registerAction,
initialRegisterState,
);
// Bumping the key on each attempt forces uncontrolled inputs to remount with
// the echoed defaultValue from the server — otherwise React's <form action>
// semantics would wipe them after a failed submit.
const attemptKey = state.attempt;
return ( return (
<form action={formAction} className="space-y-4"> <form action={formAction} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<FormField <FormField
key={`firstName-${attemptKey}`}
id="firstName" id="firstName"
name="firstName" name="firstName"
label="Vorname" label="Vorname"
autoComplete="given-name" autoComplete="given-name"
required required
defaultValue={state.values?.firstName ?? ""}
error={state.errors?.firstName?.[0]} error={state.errors?.firstName?.[0]}
/> />
<FormField <FormField
key={`lastName-${attemptKey}`}
id="lastName" id="lastName"
name="lastName" name="lastName"
label="Nachname" label="Nachname"
autoComplete="family-name" autoComplete="family-name"
required required
defaultValue={state.values?.lastName ?? ""}
error={state.errors?.lastName?.[0]} error={state.errors?.lastName?.[0]}
/> />
</div> </div>
<FormField <FormField
key={`email-${attemptKey}`}
id="email" id="email"
name="email" name="email"
type="email" type="email"
label="E-Mail" label="E-Mail"
autoComplete="email" autoComplete="email"
required required
defaultValue={state.values?.email ?? ""}
error={state.errors?.email?.[0]} error={state.errors?.email?.[0]}
/> />
<FormField <FormField
key={`password-${attemptKey}`}
id="password" id="password"
name="password" name="password"
type="password" type="password"
@@ -57,23 +72,51 @@ export function RegisterForm() {
/> />
<div className="flex items-start gap-3 pt-2"> <div className="flex items-start gap-3 pt-2">
<Checkbox id="acceptPrivacyPolicy" name="acceptPrivacyPolicy" required /> <Checkbox
key={`acceptPrivacyPolicy-${attemptKey}`}
id="acceptPrivacyPolicy"
name="acceptPrivacyPolicy"
required
defaultChecked={state.values?.acceptPrivacyPolicy === true}
aria-invalid={!!state.errors?.acceptPrivacyPolicy?.[0]}
aria-describedby={
state.errors?.acceptPrivacyPolicy?.[0]
? "acceptPrivacyPolicy-error"
: undefined
}
/>
<div className="grid gap-1 leading-none"> <div className="grid gap-1 leading-none">
<Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal"> <Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal">
Ich habe die{" "} Ich habe die{" "}
<a href="/datenschutz" className="underline" target="_blank" rel="noreferrer"> <a
href="/datenschutz"
className="underline"
target="_blank"
rel="noreferrer"
>
Datenschutzerklärung Datenschutzerklärung
</a>{" "} </a>{" "}
gelesen und akzeptiere sie. gelesen und akzeptiere sie.
</Label> </Label>
{state.errors?.acceptPrivacyPolicy?.[0] && ( {state.errors?.acceptPrivacyPolicy?.[0] && (
<p className="text-xs text-destructive">{state.errors.acceptPrivacyPolicy[0]}</p> <p
id="acceptPrivacyPolicy-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{state.errors.acceptPrivacyPolicy[0]}
</p>
)} )}
</div> </div>
</div> </div>
{state.errors?._form?.[0] && ( {state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <p
role="alert"
aria-live="polite"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{state.errors._form[0]} {state.errors._form[0]}
</p> </p>
)} )}
@@ -93,6 +136,7 @@ function FormField({
required, required,
autoComplete, autoComplete,
helperText, helperText,
defaultValue,
error, error,
}: { }: {
id: string; id: string;
@@ -102,8 +146,11 @@ function FormField({
required?: boolean; required?: boolean;
autoComplete?: string; autoComplete?: string;
helperText?: string; helperText?: string;
defaultValue?: string;
error?: string; error?: string;
}) { }) {
const errorId = error ? `${id}-error` : undefined;
const helperId = !error && helperText ? `${id}-helper` : undefined;
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label> <Label htmlFor={id}>{label}</Label>
@@ -113,12 +160,23 @@ function FormField({
type={type} type={type}
required={required} required={required}
autoComplete={autoComplete} autoComplete={autoComplete}
defaultValue={defaultValue}
aria-invalid={!!error} aria-invalid={!!error}
aria-describedby={errorId ?? helperId}
/> />
{error ? ( {error ? (
<p className="text-xs text-destructive">{error}</p> <p
id={errorId}
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{error}
</p>
) : helperText ? ( ) : helperText ? (
<p className="text-xs text-muted-foreground">{helperText}</p> <p id={helperId} className="text-xs text-muted-foreground">
{helperText}
</p>
) : null} ) : null}
</div> </div>
); );
+28
View File
@@ -0,0 +1,28 @@
import type { MetadataRoute } from "next";
function getSiteUrl() {
return process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de";
}
export default function robots(): MetadataRoute.Robots {
const siteUrl = getSiteUrl();
return {
rules: {
userAgent: "*",
allow: ["/", "/features/", "/impressum", "/datenschutz"],
disallow: [
"/api/",
"/dashboard/",
"/invite/",
"/alert/",
"/login",
"/register",
"/onboarding/",
"/forbidden",
],
},
sitemap: `${siteUrl}/sitemap.xml`,
host: siteUrl,
};
}
+39
View File
@@ -0,0 +1,39 @@
import type { MetadataRoute } from "next";
import { marketingFeatures } from "@/lib/marketing/features";
function getSiteUrl() {
return process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de";
}
export default function sitemap(): MetadataRoute.Sitemap {
const siteUrl = getSiteUrl();
const lastModified = new Date();
return [
{
url: siteUrl,
lastModified,
changeFrequency: "weekly",
priority: 1,
},
{
url: `${siteUrl}/impressum`,
lastModified,
changeFrequency: "yearly",
priority: 0.2,
},
{
url: `${siteUrl}/datenschutz`,
lastModified,
changeFrequency: "yearly",
priority: 0.2,
},
...marketingFeatures.map((feature) => ({
url: `${siteUrl}/features/${feature.slug}`,
lastModified,
changeFrequency: "monthly" as const,
priority: 0.8,
})),
];
}
+37
View File
@@ -0,0 +1,37 @@
:root {
/* Surfaces */
--bg: oklch(0.965 0.012 75);
--surface: oklch(0.985 0.008 75);
--surface-2: oklch(0.975 0.010 75);
--surface-week: oklch(0.945 0.014 75);
/* Ink */
--ink: oklch(0.26 0.015 45);
--ink-soft: oklch(0.45 0.015 45);
--ink-muted: oklch(0.60 0.012 45);
--ink-faint: oklch(0.76 0.010 45);
/* Hairlines */
--hairline: oklch(0.90 0.012 75);
--hairline-soft: oklch(0.93 0.010 75);
/* Accent */
--accent: oklch(0.58 0.11 38);
--accent-deep: oklch(0.42 0.10 38);
--accent-soft: oklch(0.93 0.04 38);
--accent-mid: oklch(0.82 0.07 38);
/* Semantic */
--good: oklch(0.55 0.10 155);
--good-soft: oklch(0.94 0.03 155);
--warn: oklch(0.62 0.10 70);
--warn-soft: oklch(0.95 0.04 70);
--danger: oklch(0.55 0.13 25);
--danger-soft: oklch(0.95 0.04 25);
--focus-ring: oklch(0.85 0.07 38 / 0.30);
--paper-pattern: radial-gradient(
oklch(0.85 0.02 75 / 0.10) 1px,
transparent 1px
);
}
@@ -1,11 +1,14 @@
import Script from "next/script"; import Script from "next/script";
import { marketingFeatureSlugs } from "@/lib/marketing/features";
const publicAnalyticsPaths = new Set([ const publicAnalyticsPaths = new Set([
"/", "/",
"/impressum", "/impressum",
"/datenschutz", "/datenschutz",
"/login", "/login",
"/register", "/register",
...marketingFeatureSlugs.map((slug) => `/features/${slug}`),
]); ]);
export function UmamiAnalytics() { export function UmamiAnalytics() {
@@ -0,0 +1,162 @@
"use client";
import React, { useState } from "react";
import { LogOut, Menu, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SidebarNav } from "./sidebar-nav";
import { UserRole } from "@prisma/client";
type DashboardShellProps = {
kitaName: string;
userName: string;
userRole: UserRole;
children: React.ReactNode;
signOutAction: () => void | Promise<void>;
};
export function DashboardShell({
kitaName,
userName,
userRole,
children,
signOutAction,
}: DashboardShellProps) {
const [isOpen, setIsOpen] = useState(false);
const displayName = userName;
const initials = getInitials(displayName);
return (
<div className="grid min-h-screen grid-cols-[256px_minmax(0,1fr)] bg-[var(--bg)] max-[760px]:grid-cols-1">
{/* ── Desktop Sidebar ── */}
<aside className="print:hidden sticky top-0 flex h-screen shrink-0 flex-col border-r border-[var(--hairline)] bg-[var(--surface)] max-[760px]:hidden">
{/* Logo / Kita-Name */}
<div className="flex h-[82px] items-center gap-3 border-b border-[var(--hairline-soft)] px-5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-deep)] text-[13px] font-bold text-white">
{kitaName.slice(0, 1).toUpperCase()}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-none text-[var(--ink)]">
{kitaName}
</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">Kita-Planer</p>
</div>
</div>
{/* Navigation */}
<div className="flex-1 overflow-y-auto py-6">
<SidebarNav role={userRole} />
</div>
{/* Footer: User-Info + Abmelden */}
<div className="border-t border-[var(--hairline-soft)] p-4">
<div className="mb-3 flex items-center gap-2.5 px-1">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
{initials}
</div>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--ink)]">
{displayName}
</p>
<p className="truncate text-[11px] font-semibold uppercase text-[var(--ink-muted)]">
{userRole}
</p>
</div>
</div>
<form action={signOutAction}>
<Button
type="submit"
variant="ghost"
size="sm"
className="w-full justify-start text-[var(--ink-soft)] hover:text-[var(--danger)]"
>
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</form>
</div>
</aside>
{/* ── Mobile Top Header Bar ── */}
<header className="print:hidden hidden max-[760px]:flex sticky top-0 z-40 h-16 items-center justify-between border-b border-[var(--hairline)] bg-[var(--surface)] px-5">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-deep)] text-[13px] font-bold text-white">
{kitaName.slice(0, 1).toUpperCase()}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-none text-[var(--ink)]">
{kitaName}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setIsOpen(!isOpen)}
aria-label={isOpen ? "Menü schließen" : "Menü öffnen"}
>
{isOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</Button>
</header>
{/* ── Mobile Sidebar Drawer/Overlay ── */}
{isOpen && (
<div className="print:hidden fixed inset-0 z-30 hidden max-[760px]:block">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/40 backdrop-blur-xs transition-opacity"
onClick={() => setIsOpen(false)}
/>
{/* Drawer content */}
<aside className="fixed bottom-0 top-16 left-0 flex w-[280px] max-w-[85vw] flex-col border-r border-[var(--hairline)] bg-[var(--surface)] shadow-2xl transition-transform">
<div className="flex-1 overflow-y-auto py-6" onClick={() => setIsOpen(false)}>
<SidebarNav role={userRole} />
</div>
{/* Footer */}
<div className="border-t border-[var(--hairline-soft)] p-4">
<div className="mb-3 flex items-center gap-2.5 px-1">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
{initials}
</div>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--ink)]">
{displayName}
</p>
<p className="truncate text-[11px] font-semibold uppercase text-[var(--ink-muted)]">
{userRole}
</p>
</div>
</div>
<form action={signOutAction}>
<Button
type="submit"
variant="ghost"
size="sm"
className="w-full justify-start text-[var(--ink-soft)] hover:text-[var(--danger)]"
>
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</form>
</div>
</aside>
</div>
)}
{/* ── Main Content Area ── */}
<main className="min-w-0 overflow-y-auto max-[760px]:pt-0">
{children}
</main>
</div>
);
}
function getInitials(value: string) {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return value.slice(0, 2).toUpperCase();
}
+53 -26
View File
@@ -1,6 +1,5 @@
"use client"; "use client";
import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { import {
LayoutDashboard, LayoutDashboard,
@@ -15,24 +14,28 @@ import {
Megaphone, Megaphone,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { NavItem } from "@/components/ui/nav-item";
import { UserRole } from "@prisma/client"; import { UserRole } from "@prisma/client";
const navItems = [ const navItems = [
{ {
section: "Heute",
href: "/dashboard", href: "/dashboard",
label: "Übersicht", label: "Übersicht",
icon: LayoutDashboard, icon: LayoutDashboard,
exact: true, exact: true,
}, },
{ {
section: "Heute",
href: "/dashboard/notdienst", href: "/dashboard/notdienst",
label: "Notdienst (Eltern)", label: "Notdienst (Eltern)",
icon: CalendarCheck2, icon: CalendarCheck2,
exact: true, exact: true,
badge: "4",
}, },
{ {
section: "Heute",
href: "/dashboard/notdienst/plan", href: "/dashboard/notdienst/plan",
label: "Notdienst-Planung", label: "Notdienst-Planung",
icon: CalendarCheck2, icon: CalendarCheck2,
@@ -40,18 +43,21 @@ const navItems = [
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR], allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
}, },
{ {
section: "Heute",
href: "/dashboard/kalender", href: "/dashboard/kalender",
label: "Terminkalender", label: "Terminkalender",
icon: CalendarDays, icon: CalendarDays,
exact: false, exact: false,
}, },
{ {
section: "Heute",
href: "/dashboard/adressbuch", href: "/dashboard/adressbuch",
label: "Adressbuch", label: "Adressbuch",
icon: Contact, icon: Contact,
exact: false, exact: false,
}, },
{ {
section: "Verwaltung",
href: "/dashboard/families", href: "/dashboard/families",
label: "Familienverwaltung", label: "Familienverwaltung",
icon: Users, icon: Users,
@@ -59,6 +65,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN], allowedRoles: [UserRole.ADMIN],
}, },
{ {
section: "Verwaltung",
href: "/dashboard/admin/dienste", href: "/dashboard/admin/dienste",
label: "Dienstplan", label: "Dienstplan",
icon: ClipboardList, icon: ClipboardList,
@@ -66,6 +73,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN], allowedRoles: [UserRole.ADMIN],
}, },
{ {
section: "Verwaltung",
href: "/dashboard/admin/news", href: "/dashboard/admin/news",
label: "Schwarzes Brett", label: "Schwarzes Brett",
icon: Megaphone, icon: Megaphone,
@@ -73,6 +81,15 @@ const navItems = [
allowedRoles: [UserRole.ADMIN], allowedRoles: [UserRole.ADMIN],
}, },
{ {
section: "Verwaltung",
href: "/dashboard/admin/kalender",
label: "Event-Anmeldungen",
icon: CalendarCheck2,
exact: false,
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
},
{
section: "Verwaltung",
href: "/dashboard/admin/abwesenheiten", href: "/dashboard/admin/abwesenheiten",
label: "Abwesenheiten", label: "Abwesenheiten",
icon: Stethoscope, icon: Stethoscope,
@@ -80,6 +97,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ERZIEHER], allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ERZIEHER],
}, },
{ {
section: "Verwaltung",
href: "/dashboard/erzieher", href: "/dashboard/erzieher",
label: "ErzieherInnen", label: "ErzieherInnen",
icon: GraduationCap, icon: GraduationCap,
@@ -87,6 +105,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN], allowedRoles: [UserRole.ADMIN],
}, },
{ {
section: "Verwaltung",
href: "/dashboard/profil", href: "/dashboard/profil",
label: "Mein Profil", label: "Mein Profil",
icon: UserCircle, icon: UserCircle,
@@ -96,32 +115,40 @@ const navItems = [
export function SidebarNav({ role }: { role: UserRole }) { export function SidebarNav({ role }: { role: UserRole }) {
const pathname = usePathname(); const pathname = usePathname();
const visibleItems = navItems.filter(
({ allowedRoles }) =>
!allowedRoles || (allowedRoles as UserRole[]).includes(role),
);
const sections = ["Heute", "Verwaltung"];
return ( return (
<nav className="flex flex-col gap-1 px-3"> <nav className="flex flex-col gap-8 px-5">
{navItems.map(({ href, label, icon: Icon, exact, allowedRoles }) => { {sections.map((section) => (
// Filter out items not allowed for the current role <div key={section}>
if (allowedRoles && !(allowedRoles as UserRole[]).includes(role)) { <p className="mb-3 px-2.5 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.18em]">
return null; {section}
} </p>
<div className="flex flex-col gap-1">
const isActive = exact ? pathname === href : pathname.startsWith(href); {visibleItems
return ( .filter((item) => item.section === section)
<Link .map(({ href, label, icon, exact, badge }) => {
key={href} const isActive = exact
href={href} ? pathname === href
className={cn( : pathname.startsWith(href);
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors", return (
isActive <NavItem
? "bg-primary text-primary-foreground" key={href}
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground", href={href}
)} label={label}
> icon={icon}
<Icon className="h-4 w-4 shrink-0" /> active={isActive}
{label} badge={badge}
</Link> />
); );
})} })}
</div>
</div>
))}
</nav> </nav>
); );
} }
+7 -7
View File
@@ -4,21 +4,21 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", "inline-flex items-center rounded-full border px-2.5 py-0.5 text-[11px] font-semibold transition-colors focus:outline-none focus:ring-[3px] focus:ring-[var(--focus-ring)]",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground", "border-transparent bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)]",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground", "border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]",
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground", "border-transparent bg-[var(--danger-soft)] text-[var(--danger)]",
outline: "text-foreground", outline: "border-[var(--hairline)] text-[var(--ink-soft)]",
success: success:
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400", "border-transparent bg-[var(--good-soft)] text-[var(--good)]",
warning: warning:
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", "border-transparent bg-[var(--warn-soft)] text-[var(--warn)]",
}, },
}, },
defaultVariants: { defaultVariants: {
+14 -10
View File
@@ -5,24 +5,28 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center whitespace-nowrap text-[13.5px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] disabled:pointer-events-none disabled:opacity-50 active:translate-y-px [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:stroke-[1.7]",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default:
"rounded-lg bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)] hover:bg-[oklch(0.36_0.10_38)]",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink)] hover:border-[var(--accent-mid)]",
outline: outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground", "rounded-lg border border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost:
"rounded-lg border border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
destructive: destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90", "rounded-lg bg-[var(--danger)] text-[oklch(0.98_0.005_45)] hover:brightness-95",
link: "text-primary underline-offset-4 hover:underline", link: "rounded-lg text-[var(--accent-deep)] underline-offset-4 hover:underline",
pill:
"rounded-full border border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
}, },
size: { size: {
default: "h-10 px-4 py-2", default: "h-10 gap-2 px-[18px] py-2.5",
sm: "h-9 rounded-md px-3", sm: "h-9 gap-2 px-3",
lg: "h-11 rounded-md px-8", lg: "h-11 gap-2 px-8",
icon: "h-10 w-10", icon: "h-10 w-10",
}, },
}, },
+37 -15
View File
@@ -1,15 +1,33 @@
import * as React from "react"; import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const cardVariants = cva(
({ className, ...props }, ref) => ( "relative rounded-[14px] border border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink)]",
{
variants: {
variant: {
default: "",
featured:
"overflow-hidden border-[var(--accent-mid)] bg-linear-to-b from-[oklch(0.985_0.012_50)] to-[var(--surface)] before:absolute before:inset-x-0 before:top-0 before:h-0.5 before:bg-[var(--accent)]",
},
},
defaultVariants: {
variant: "default",
},
},
);
interface CardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardVariants> {}
const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className, variant, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn( className={cn(cardVariants({ variant, className }))}
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props} {...props}
/> />
), ),
@@ -18,16 +36,20 @@ Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} /> <div ref={ref} className={cn("flex flex-col space-y-1.5 p-[22px_24px]", className)} {...props} />
), ),
); );
CardHeader.displayName = "CardHeader"; CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( type CardTitleProps = React.HTMLAttributes<HTMLElement> & {
({ className, ...props }, ref) => ( as?: "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
<div };
ref={ref}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)} const CardTitle = React.forwardRef<HTMLElement, CardTitleProps>(
({ className, as: Comp = "div", ...props }, ref) => (
<Comp
ref={ref as React.Ref<HTMLDivElement>}
className={cn("text-lg font-medium leading-none tracking-[-0.02em]", className)}
{...props} {...props}
/> />
), ),
@@ -38,7 +60,7 @@ const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HT
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-[13.5px] text-[var(--ink-soft)]", className)}
{...props} {...props}
/> />
), ),
@@ -47,14 +69,14 @@ CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> <div ref={ref} className={cn("p-[22px_24px] pt-0", className)} {...props} />
), ),
); );
CardContent.displayName = "CardContent"; CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} /> <div ref={ref} className={cn("flex items-center p-[22px_24px] pt-0", className)} {...props} />
), ),
); );
CardFooter.displayName = "CardFooter"; CardFooter.displayName = "CardFooter";
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { type ReactNode } from "react";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
type ConfirmDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: ReactNode;
confirmLabel: string;
cancelLabel?: string;
pendingLabel?: string;
isPending?: boolean;
children?: ReactNode;
onConfirm: () => void | Promise<void>;
};
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel,
cancelLabel = "Abbrechen",
pendingLabel = "Wird ausgeführt...",
isPending = false,
children,
onConfirm,
}: ConfirmDialogProps) {
async function handleConfirm() {
await onConfirm();
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
<DialogContent className="border-[var(--danger)]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-[var(--danger)]">
<AlertTriangle className="h-5 w-5" />
{title}
</DialogTitle>
<DialogDescription className="pt-1 text-sm leading-6">
{description}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-[var(--danger)] bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]">
Diese Aktion kann nicht rückgängig gemacht werden.
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
{cancelLabel}
</Button>
<Button
type="button"
variant="destructive"
onClick={handleConfirm}
disabled={isPending}
>
{isPending ? pendingLabel : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type { ComponentType, ReactNode } from "react";
import type { LucideProps } from "lucide-react";
import { cn } from "@/lib/utils";
export function DefList({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<dl className={cn("divide-y divide-[var(--hairline-soft)]", className)}>
{children}
</dl>
);
}
export function DefItem({
icon: Icon,
label,
value,
}: {
icon?: ComponentType<LucideProps>;
label: string;
value: ReactNode;
}) {
return (
<div className="grid gap-1 py-3 first:pt-0 last:pb-0">
<dt className="flex items-center gap-1.5 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
{Icon ? <Icon className="h-3.5 w-3.5 [stroke-width:1.7]" /> : null}
{label}
</dt>
<dd className="text-sm font-medium text-[var(--ink)] [overflow-wrap:anywhere] [font-variant-numeric:tabular-nums]">
{value}
</dd>
</div>
);
}
export function EmptyDefValue({
children = "Noch nicht hinterlegt",
}: {
children?: ReactNode;
}) {
return (
<span className="font-normal italic text-[var(--ink-faint)]">
{children}
</span>
);
}
+5 -5
View File
@@ -18,7 +18,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-[oklch(0.25_0.015_45_/_0.20)] backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className, className,
)} )}
{...props} {...props}
@@ -35,13 +35,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-[var(--hairline)] bg-[var(--surface)] p-6 text-[var(--ink)] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-[14px]",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <DialogPrimitive.Close className="absolute right-4 top-4 rounded-md text-[var(--ink-muted)] opacity-80 transition-opacity hover:bg-[var(--surface-2)] hover:text-[var(--ink)] hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface)] disabled:pointer-events-none data-[state=open]:bg-[var(--accent-soft)] data-[state=open]:text-[var(--accent-deep)]">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Schließen</span> <span className="sr-only">Schließen</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
@@ -85,7 +85,7 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", "text-lg font-medium leading-none tracking-[-0.02em] text-[var(--ink)]",
className, className,
)} )}
{...props} {...props}
@@ -99,7 +99,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-[var(--ink-soft)]", className)}
{...props} {...props}
/> />
)); ));
+2
View File
@@ -140,6 +140,8 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
return ( return (
<p <p
id={formMessageId} id={formMessageId}
role="alert"
aria-live="polite"
className={cn("text-sm text-destructive", className)} className={cn("text-sm text-destructive", className)}
{...props} {...props}
> >
+1 -1
View File
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2.5 text-[13.5px] text-[var(--ink)] transition file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-[var(--ink-faint)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
ref={ref} ref={ref}
+41
View File
@@ -0,0 +1,41 @@
import Link from "next/link";
import type { ComponentType } from "react";
import type { LucideProps } from "lucide-react";
import { cn } from "@/lib/utils";
type NavItemProps = {
href: string;
label: string;
icon: ComponentType<LucideProps>;
active?: boolean;
badge?: number | string;
};
export function NavItem({
href,
label,
icon: Icon,
active = false,
badge,
}: NavItemProps) {
return (
<Link
href={href}
className={cn(
"relative flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13.5px] font-medium text-[var(--ink-soft)] transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]",
"hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
active &&
"bg-[var(--accent-soft)] font-semibold text-[var(--accent-deep)] before:absolute before:left-[-14px] before:top-1.5 before:h-[calc(100%-12px)] before:w-[3px] before:rounded-full before:bg-[var(--accent)]",
)}
>
<Icon className="h-4 w-4 shrink-0 [stroke-width:1.7]" />
<span className="min-w-0 flex-1 truncate">{label}</span>
{badge ? (
<span className="rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-2 py-0.5 text-[11px] font-semibold text-[var(--ink-muted)]">
{badge}
</span>
) : null}
</Link>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { UserRole } from "@prisma/client";
import { cn } from "@/lib/utils";
type RoleBadgeProps = {
role: UserRole;
className?: string;
};
const roleLabels: Record<string, string> = {
SUPERADMIN: "Admin",
ADMIN: "Vorstand",
KOORDINATOR: "Koordinator",
ELTERN: "Eltern",
ERZIEHER: "Erzieher",
};
export function RoleBadge({ role, className }: RoleBadgeProps) {
return (
<span
className={cn(
"inline-flex w-fit rounded-full border border-[var(--accent-mid)] bg-[var(--accent-soft)] px-2.5 py-1 text-[10.5px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.1em]",
className,
)}
>
{roleLabels[role] ?? role}
</span>
);
}

Some files were not shown because too many files have changed in this diff Show More