continued the kita-planer

This commit is contained in:
t.indorf
2026-05-08 14:32:14 +02:00
parent b686e714ff
commit 7aff691803
85 changed files with 9434 additions and 588 deletions
+15
View File
@@ -0,0 +1,15 @@
.git
.next
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
.env.*
!.env.example
.DS_Store
coverage
uploads
var/uploads
deploy
public/og-candidates
+1
View File
@@ -19,6 +19,7 @@
# production # production
/build /build
/uploads
# misc # misc
.DS_Store .DS_Store
+38
View File
@@ -0,0 +1,38 @@
FROM node:22-alpine AS base
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
RUN apk add --no-cache openssl
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV DATABASE_URL=postgresql://build:build@localhost:5432/build?schema=public
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build:prod
FROM base AS runner
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
ENV UPLOAD_DIR=/app/uploads/announcements
COPY --from=builder /app ./
RUN mkdir -p /app/uploads/announcements && chmod +x /app/scripts/start-production.sh
EXPOSE 3000
CMD ["./scripts/start-production.sh"]
+55
View File
@@ -0,0 +1,55 @@
# Kita-Planer Deployment
Dieses Verzeichnis enthaelt die ausfuellbaren Vorlagen fuer das Production-Deployment.
## Dateien
- `production.env.example`: Vorlage fuer alle benoetigten Environment Variables.
- `production-checklist.md`: Checkliste fuer Domain, Datenbank, Auth, Mail, Uploads und Rechtliches.
- `../Dockerfile`: Container-Build fuer Coolify oder andere Docker-Hosts.
- `../docker-compose.coolify.yml`: Compose-Vorlage fuer Coolify mit required Environment Variables.
## Empfohlener Ablauf mit Coolify und Gitea
1. Repository in deine Gitea-Instanz pushen.
2. In Coolify eine neue Application aus diesem Gitea-Repository anlegen.
3. Als Build Pack `Docker Compose` waehlen.
4. Als Compose-Datei `docker-compose.coolify.yml` eintragen.
5. Die bestehende Coolify-Postgres-Datenbank ueber ihre interne `DATABASE_URL` verbinden.
6. Environment Variables aus `production.env.example` in Coolify eintragen.
7. Domain auf den `app`-Service mit internem Port `3000` zeigen lassen.
8. Deploy starten.
Beim Container-Start fuehrt `scripts/start-production.sh` automatisch aus:
```bash
npx prisma db push
```
Coolify erkennt Variablen aus `docker-compose.coolify.yml` und zeigt required Variablen
wie `DATABASE_URL`, `AUTH_SECRET`, `AUTH_URL`, `NEXTAUTH_URL`, `NEXT_PUBLIC_SITE_URL`,
`RESEND_API_KEY`, `EMAIL_FROM` und `ADMIN_EMAIL` in der UI an.
## Welche Coolify-Postgres-URL?
Wenn die App und die Postgres-DB im selben Coolify-Projekt/Netzwerk laufen, nimm
die interne Datenbank-URL aus Coolify, nicht die oeffentliche externe URL. Das ist
stabiler und vermeidet unnoetigen Traffic ueber den Host.
Das Format bleibt:
```txt
postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
```
## Wichtige Hinweise
- Das aktuelle Projekt nutzt noch `prisma db push` statt versionierter Prisma-Migrationen.
Fuer den ersten MVP-Deploy ist das praktikabel, fuer laengerfristigen Betrieb sollten
Migrationen eingefuehrt werden.
- `UPLOAD_DIR` muss persistent sein. Die Coolify-Compose-Datei mountet dafuer ein
Docker-Volume nach `/app/uploads`.
- `NEXT_PUBLIC_SITE_URL` muss die echte Domain enthalten, damit OpenGraph-Bilder in
WhatsApp, LinkedIn und Co. korrekt als absolute URL aufgeloest werden.
- `NEXT_PUBLIC_SITE_URL` wird auch als Build-Arg an Docker uebergeben. In Coolify sollte
diese Variable deshalb fuer Build und Runtime verfuegbar sein.
+60
View File
@@ -0,0 +1,60 @@
# Production Checklist
## 1. Domain & Basis-URLs
- [ ] Produktionsdomain festlegen: `https://[deine-domain.de]`
- [ ] `NEXT_PUBLIC_SITE_URL` auf die Produktionsdomain setzen
- [ ] `AUTH_URL` und `NEXTAUTH_URL` auf die Produktionsdomain setzen
- [ ] Nach Deployment die WhatsApp-/LinkedIn-Vorschau mit der finalen URL testen
## 2. Datenbank
- [ ] Interne `DATABASE_URL` der bestehenden Coolify-Postgres-DB kopieren
- [ ] `DATABASE_URL` in der Coolify-App eintragen
- [ ] Beim ersten App-Start wird das Schema automatisch mit `npx prisma db push --skip-generate` angewendet
- [ ] Fuer spaetere Releases idealerweise Prisma-Migrationen einfuehren
## 3. Auth & Sicherheit
- [ ] `AUTH_SECRET` neu generieren: `openssl rand -base64 32`
- [ ] Keine `.env`-Dateien mit echten Secrets committen
- [ ] Dashboard ist per Metadata auf `noindex,nofollow` gesetzt
- [ ] Produktionsdomain auf HTTPS betreiben
## 4. Mail
- [ ] Eigene Versanddomain bei Resend verifizieren
- [ ] `RESEND_API_KEY` fuer Produktion setzen
- [ ] `EMAIL_FROM` auf eine verifizierte Absenderadresse setzen
- [ ] `ADMIN_EMAIL` fuer Kontaktformular setzen
- [ ] Kontaktformular und Einladung/E-Mail-Flows einmal in Produktion testen
## 5. Uploads
- [ ] Coolify-Volume `kita_planer_uploads` fuer `/app/uploads` verwenden
- [ ] Sicherstellen, dass Uploads nicht aus Versehen oeffentlich ausgeliefert werden
- [ ] Backup-Strategie fuer Uploads klaeren
## 6. Rechtliches
- [ ] Platzhalter im Impressum ersetzen
- [ ] Platzhalter in der Datenschutzerklaerung ersetzen
- [ ] Hosting-Anbieter und Mail-Anbieter in Datenschutztext eintragen
- [ ] Rechtliche Texte vor Livegang pruefen lassen
## 7. Build & Smoke Test
- [ ] Dependencies installieren: `npm ci`
- [ ] Production Build erstellen: `npm run build:prod`
- [ ] App starten: `npm run start:prod`
- [ ] Startseite, Login, Dashboard und Kontaktformular testen
## 8. Coolify
- [ ] Code nach Gitea pushen
- [ ] In Coolify eine Application aus dem Gitea-Repository anlegen
- [ ] Build Pack `Docker Compose` waehlen
- [ ] Compose-Datei `docker-compose.coolify.yml` verwenden
- [ ] Environment Variables in Coolify eintragen, nicht ins Repo committen
- [ ] Domain auf den `app`-Service und Container-Port `3000` routen
- [ ] Upload-Volume `kita_planer_uploads` bleibt zwischen Deployments erhalten
+34
View File
@@ -0,0 +1,34 @@
# =====================================================================
# Kita-Planer · Production Environment Template
# ---------------------------------------------------------------------
# Werte in der Hosting-Plattform als Environment Variables eintragen
# oder lokal als `.env.production` / Server-Env verwenden.
# Keine echten Secrets committen.
# =====================================================================
# PostgreSQL
# In Coolify am besten die interne URL deiner bestehenden Postgres-DB verwenden.
# Beispiel: postgresql://USER:PASSWORD@HOST:5432/kita_planer?schema=public
DATABASE_URL="postgresql://[USER]:[PASSWORD]@[HOST]:[PORT]/[DATABASE]?schema=public"
# Auth.js / NextAuth
# Generieren mit: openssl rand -base64 32
AUTH_SECRET="[GENERATE_ME]"
# Oeffentliche Produktions-URL, ohne Slash am Ende.
# Beide Werte setzen, weil bestehender Code und Auth-Tooling beide Namen kennen.
AUTH_URL="https://[deine-domain.de]"
NEXTAUTH_URL="https://[deine-domain.de]"
NEXT_PUBLIC_SITE_URL="https://[deine-domain.de]"
# Mailversand via Resend
RESEND_API_KEY="re_[PRODUCTION_API_KEY]"
EMAIL_FROM="Kita-Planer <[absender@deine-domain.de]>"
ADMIN_EMAIL="[kontakt@deine-domain.de]"
# Persistenter Upload-Speicher fuer Anhaenge vom Schwarzen Brett.
# Muss auf dem Server dauerhaft erhalten bleiben und darf nicht im Repo liegen.
UPLOAD_DIR="/var/lib/kita-planer/uploads/announcements"
# Optional: Nur bewusst fuer initiales Demo-Seeding in Produktion setzen.
# ALLOW_DATABASE_SEED="true"
+28
View File
@@ -0,0 +1,28 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:?}
environment:
NODE_ENV: production
PORT: 3000
HOSTNAME: 0.0.0.0
DATABASE_URL: ${DATABASE_URL:?}
AUTH_SECRET: ${AUTH_SECRET:?}
AUTH_URL: ${AUTH_URL:?}
NEXTAUTH_URL: ${NEXTAUTH_URL:?}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:?}
RESEND_API_KEY: ${RESEND_API_KEY:?}
EMAIL_FROM: ${EMAIL_FROM:?}
ADMIN_EMAIL: ${ADMIN_EMAIL:?}
UPLOAD_DIR: /app/uploads/announcements
volumes:
- kita_planer_uploads:/app/uploads
ports:
- "3000"
restart: unless-stopped
volumes:
kita_planer_uploads:
+18
View File
@@ -0,0 +1,18 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: kita_planer_db
environment:
POSTGRES_USER: kita
POSTGRES_PASSWORD: kita
POSTGRES_DB: kita_planer
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
postgres_data:
+1836 -5
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -5,7 +5,9 @@
"scripts": { "scripts": {
"dev": "NODE_ENV=development prisma db push && NODE_ENV=development prisma db seed && next dev", "dev": "NODE_ENV=development prisma db push && NODE_ENV=development prisma db seed && next dev",
"build": "next build", "build": "next build",
"build:prod": "prisma generate && next build",
"start": "next start", "start": "next start",
"start:prod": "next start",
"lint": "eslint", "lint": "eslint",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
@@ -18,10 +20,13 @@
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.2", "@auth/prisma-adapter": "^2.11.2",
"@hookform/resolvers": "^5.2.2",
"@prisma/client": "^6.19.3", "@prisma/client": "^6.19.3",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@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",
@@ -35,6 +40,9 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.75.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"resend": "^6.12.3", "resend": "^6.12.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
+179 -23
View File
@@ -32,6 +32,7 @@ enum UserRole {
SUPERADMIN SUPERADMIN
ADMIN ADMIN
KOORDINATOR KOORDINATOR
ERZIEHER
ELTERN ELTERN
} }
@@ -73,6 +74,18 @@ enum NotdienstAlertStatus {
CANCELLED CANCELLED
} }
enum DutyAssignmentStatus {
PLANNED
DONE
CANCELLED
}
enum AbsenceReason {
ILLNESS
VACATION
OTHER
}
// ===================================================================== // =====================================================================
// TENANT // TENANT
// ===================================================================== // =====================================================================
@@ -96,10 +109,15 @@ model Kita {
// Relations (Cascade auf alle Tenant-Daten) // Relations (Cascade auf alle Tenant-Daten)
users User[] users User[]
families Family[]
children Child[] children Child[]
educators Educator[] educators Educator[]
parentDuties ParentDuty[] parentDuties ParentDuty[]
parentDutyAssignments ParentDutyAssignment[] parentDutyAssignments ParentDutyAssignment[]
dutyTypes DutyType[]
dutyAssignments DutyAssignment[]
absences Absence[]
announcements Announcement[]
invitations Invitation[] invitations Invitation[]
termine Termin[] termine Termin[]
mitbringselItems MitbringselItem[] mitbringselItems MitbringselItem[]
@@ -107,11 +125,33 @@ model Kita {
notdienstAvailabilities NotdienstAvailability[] notdienstAvailabilities NotdienstAvailability[]
notdienstAssignments NotdienstAssignment[] notdienstAssignments NotdienstAssignment[]
notdienstAlerts NotdienstAlert[] notdienstAlerts NotdienstAlert[]
childParents ChildParent[]
@@map("kitas") @@map("kitas")
} }
// =====================================================================
// FAMILIES / HAUSHALTE
// =====================================================================
model Family {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
children Child[]
dutyAssignments DutyAssignment[]
@@index([kitaId])
@@map("families")
}
// ===================================================================== // =====================================================================
// USERS // USERS
// ===================================================================== // =====================================================================
@@ -124,6 +164,11 @@ model User {
kitaId String? kitaId String?
kita Kita? @relation(fields: [kitaId], references: [id], onDelete: Cascade) kita Kita? @relation(fields: [kitaId], references: [id], onDelete: Cascade)
/// Optional, weil Admins/Superadmins keinem Haushalt angehören müssen.
/// Eltern-User werden beim Löschen ihrer Familie kaskadiert entfernt.
familyId String?
family Family? @relation(fields: [familyId], references: [id], onDelete: Cascade)
email String @unique email String @unique
passwordHash String passwordHash String
@@ -157,13 +202,14 @@ model User {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Relations // Relations
childLinks ChildParent[]
dutyAssignments ParentDutyAssignment[] dutyAssignments ParentDutyAssignment[]
notdienstAvailabilities NotdienstAvailability[] notdienstAvailabilities NotdienstAvailability[]
notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent") notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent")
notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger") notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger")
notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator") notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator")
announcementsAuthored Announcement[] @relation("AnnouncementAuthor")
announcementReads AnnouncementRead[]
termineCreated Termin[] @relation("TerminCreator") termineCreated Termin[] @relation("TerminCreator")
termineApproved Termin[] @relation("TerminApprover") termineApproved Termin[] @relation("TerminApprover")
@@ -172,6 +218,7 @@ model User {
invitationsCreated Invitation[] @relation("InvitationCreator") invitationsCreated Invitation[] @relation("InvitationCreator")
@@index([kitaId]) @@index([kitaId])
@@index([familyId])
@@index([kitaId, role]) @@index([kitaId, role])
@@map("users") @@map("users")
} }
@@ -185,6 +232,9 @@ model Child {
kitaId String kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade) kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
familyId String
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
firstName String firstName String
lastName String lastName String
dateOfBirth DateTime? dateOfBirth DateTime?
@@ -195,34 +245,15 @@ model Child {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
parentLinks ChildParent[]
notdienstAvailabilities NotdienstAvailability[] notdienstAvailabilities NotdienstAvailability[]
notdienstAssignments NotdienstAssignment[] notdienstAssignments NotdienstAssignment[]
absences Absence[]
@@index([kitaId]) @@index([kitaId])
@@index([familyId])
@@map("children") @@map("children")
} }
/// Verknüpfung Kind ↔ Elternteil (m:n).
/// Kaskadiert über beide Seiten, damit das Löschen eines Users
/// oder Kindes keine Datenleichen hinterlässt.
model ChildParent {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
childId String
child Child @relation(fields: [childId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([childId, userId])
@@index([kitaId])
@@index([userId])
@@map("child_parents")
}
// ===================================================================== // =====================================================================
// EDUCATORS (ErzieherInnen — reine Stammdaten, keine Logins, Modul 4) // EDUCATORS (ErzieherInnen — reine Stammdaten, keine Logins, Modul 4)
// ===================================================================== // =====================================================================
@@ -285,6 +316,131 @@ model ParentDutyAssignment {
@@map("parent_duty_assignments") @@map("parent_duty_assignments")
} }
// =====================================================================
// DUTY PLANNING (Top-Down-Dienstplan fuer Haushalte)
// =====================================================================
model DutyType {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignments DutyAssignment[]
@@unique([kitaId, name])
@@index([kitaId])
@@map("duty_types")
}
model DutyAssignment {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
familyId String
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
dutyTypeId String
dutyType DutyType @relation(fields: [dutyTypeId], references: [id], onDelete: Cascade)
startDate DateTime @db.Date
endDate DateTime @db.Date
status DutyAssignmentStatus @default(PLANNED)
reminderSentAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([kitaId, dutyTypeId, startDate])
@@index([kitaId, startDate])
@@index([familyId, startDate])
@@map("duty_assignments")
}
// =====================================================================
// ABSENCES (Abwesenheits- und Krankmeldungen)
// =====================================================================
model Absence {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
childId String
child Child @relation(fields: [childId], references: [id], onDelete: Cascade)
startDate DateTime @db.Date
endDate DateTime @db.Date
reason AbsenceReason
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([kitaId, startDate, endDate])
@@index([childId, startDate])
@@map("absences")
}
// =====================================================================
// ANNOUNCEMENTS (Digitales Schwarzes Brett)
// =====================================================================
model Announcement {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
title String
content String
authorId String
author User @relation("AnnouncementAuthor", fields: [authorId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
attachments Attachment[]
reads AnnouncementRead[]
@@index([kitaId, createdAt])
@@index([authorId])
@@map("announcements")
}
model Attachment {
id String @id @default(cuid())
announcementId String
announcement Announcement @relation(fields: [announcementId], references: [id], onDelete: Cascade)
fileName String
fileUrl String
fileType String
@@index([announcementId])
@@map("attachments")
}
model AnnouncementRead {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
announcementId String
announcement Announcement @relation(fields: [announcementId], references: [id], onDelete: Cascade)
readAt DateTime @default(now())
@@unique([userId, announcementId])
@@index([announcementId])
@@map("announcement_reads")
}
// ===================================================================== // =====================================================================
// INVITATIONS (Invite-Only Onboarding, Modul 3) // INVITATIONS (Invite-Only Onboarding, Modul 3)
// ===================================================================== // =====================================================================
+182 -22
View File
@@ -1,4 +1,5 @@
import { import {
AbsenceReason,
InvitationStatus, InvitationStatus,
NotdienstAlertStatus, NotdienstAlertStatus,
NotdienstPlanStatus, NotdienstPlanStatus,
@@ -29,6 +30,7 @@ const RESET_MODE = process.argv.includes("--reset");
const DEMO_USER_EMAILS = [ const DEMO_USER_EMAILS = [
"super@kita-planer.local", "super@kita-planer.local",
"admin@waldameisen.local", "admin@waldameisen.local",
"erzieher@waldameisen.local",
"mueller@waldameisen.local", "mueller@waldameisen.local",
"schmidt@waldameisen.local", "schmidt@waldameisen.local",
"yilmaz@waldameisen.local", "yilmaz@waldameisen.local",
@@ -52,6 +54,16 @@ function addDays(date: Date, days: number) {
return dateOnly(next); return dateOnly(next);
} }
function addWeeks(date: Date, weeks: number) {
return addDays(date, weeks * 7);
}
function startOfIsoWeek(date: Date) {
const normalized = dateOnly(date);
const day = normalized.getDay() || 7;
return addDays(normalized, 1 - day);
}
function startOfMonth(date: Date) { function startOfMonth(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), 1); return new Date(date.getFullYear(), date.getMonth(), 1);
} }
@@ -156,6 +168,27 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
}, },
}); });
const familyMueller = await prisma.family.create({
data: {
kitaId: kita.id,
name: "Familie Mueller",
},
});
const familySchmidtYilmaz = await prisma.family.create({
data: {
kitaId: kita.id,
name: "Familie Schmidt-Yilmaz",
},
});
const familyFischer = await prisma.family.create({
data: {
kitaId: kita.id,
name: "Familie Fischer",
},
});
const admin = await prisma.user.create({ const admin = await prisma.user.create({
data: { data: {
kitaId: kita.id, kitaId: kita.id,
@@ -175,9 +208,28 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
}, },
}); });
const erzieherUser = await prisma.user.create({
data: {
kitaId: kita.id,
email: "erzieher@waldameisen.local",
passwordHash,
firstName: "Eva",
lastName: "Erzieherin",
role: UserRole.ERZIEHER,
privacyPolicyAcceptedAt: consentAt,
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
emailVerifiedAt: consentAt,
phone: "+49 30 1000 2000",
street: "Kitaweg 1",
postalCode: "10115",
city: "Berlin",
},
});
const koordinator = await prisma.user.create({ const koordinator = await prisma.user.create({
data: { data: {
kitaId: kita.id, kitaId: kita.id,
familyId: familyMueller.id,
email: "mueller@waldameisen.local", email: "mueller@waldameisen.local",
passwordHash, passwordHash,
firstName: "Maria", firstName: "Maria",
@@ -197,6 +249,7 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
const elternSchmidt = await prisma.user.create({ const elternSchmidt = await prisma.user.create({
data: { data: {
kitaId: kita.id, kitaId: kita.id,
familyId: familySchmidtYilmaz.id,
email: "schmidt@waldameisen.local", email: "schmidt@waldameisen.local",
passwordHash, passwordHash,
firstName: "Lukas", firstName: "Lukas",
@@ -211,6 +264,7 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
const elternYilmaz = await prisma.user.create({ const elternYilmaz = await prisma.user.create({
data: { data: {
kitaId: kita.id, kitaId: kita.id,
familyId: familySchmidtYilmaz.id,
email: "yilmaz@waldameisen.local", email: "yilmaz@waldameisen.local",
passwordHash, passwordHash,
firstName: "Aylin", firstName: "Aylin",
@@ -230,6 +284,7 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
const pendingParent = await prisma.user.create({ const pendingParent = await prisma.user.create({
data: { data: {
kitaId: kita.id, kitaId: kita.id,
familyId: familyFischer.id,
email: "pending@waldameisen.local", email: "pending@waldameisen.local",
passwordHash: "", passwordHash: "",
firstName: "Lena", firstName: "Lena",
@@ -240,8 +295,12 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
return { return {
kita, kita,
familyMueller,
familySchmidtYilmaz,
familyFischer,
superAdmin, superAdmin,
admin, admin,
erzieherUser,
koordinator, koordinator,
elternSchmidt, elternSchmidt,
elternYilmaz, elternYilmaz,
@@ -251,72 +310,58 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
async function createChildren({ async function createChildren({
kita, kita,
koordinator, familyMueller,
elternSchmidt, familySchmidtYilmaz,
elternYilmaz, familyFischer,
pendingParent,
}: SeedContext) { }: SeedContext) {
const anna = await prisma.child.create({ const anna = await prisma.child.create({
data: { data: {
kitaId: kita.id, kitaId: kita.id,
familyId: familyMueller.id,
firstName: "Anna", firstName: "Anna",
lastName: "Mueller", lastName: "Mueller",
dateOfBirth: new Date("2021-03-15"), dateOfBirth: new Date("2021-03-15"),
parentLinks: {
create: { kitaId: kita.id, userId: koordinator.id },
},
}, },
}); });
const ben = await prisma.child.create({ const ben = await prisma.child.create({
data: { data: {
kitaId: kita.id, kitaId: kita.id,
familyId: familyMueller.id,
firstName: "Ben", firstName: "Ben",
lastName: "Mueller", lastName: "Mueller",
dateOfBirth: new Date("2023-07-22"), dateOfBirth: new Date("2023-07-22"),
notes: "Geschwisterkind von Anna.", notes: "Geschwisterkind von Anna.",
parentLinks: {
create: { kitaId: kita.id, userId: koordinator.id },
},
}, },
}); });
const clara = await prisma.child.create({ const clara = await prisma.child.create({
data: { data: {
kitaId: kita.id, kitaId: kita.id,
familyId: familySchmidtYilmaz.id,
firstName: "Clara", firstName: "Clara",
lastName: "Schmidt", lastName: "Schmidt",
dateOfBirth: new Date("2022-11-03"), dateOfBirth: new Date("2022-11-03"),
parentLinks: {
create: { kitaId: kita.id, userId: elternSchmidt.id },
},
}, },
}); });
const emil = await prisma.child.create({ const emil = await prisma.child.create({
data: { data: {
kitaId: kita.id, kitaId: kita.id,
familyId: familySchmidtYilmaz.id,
firstName: "Emil", firstName: "Emil",
lastName: "Yilmaz", lastName: "Yilmaz",
dateOfBirth: new Date("2021-09-09"), dateOfBirth: new Date("2021-09-09"),
parentLinks: {
create: [
{ kitaId: kita.id, userId: elternYilmaz.id },
{ kitaId: kita.id, userId: elternSchmidt.id },
],
},
}, },
}); });
const nina = await prisma.child.create({ const nina = await prisma.child.create({
data: { data: {
kitaId: kita.id, kitaId: kita.id,
familyId: familyFischer.id,
firstName: "Nina", firstName: "Nina",
lastName: "Fischer", lastName: "Fischer",
dateOfBirth: new Date("2022-05-30"), dateOfBirth: new Date("2022-05-30"),
parentLinks: {
create: { kitaId: kita.id, userId: pendingParent.id },
},
}, },
}); });
@@ -384,6 +429,83 @@ async function createParentDuties({
}); });
} }
async function createAbsences(
{ kita }: SeedContext,
children: Awaited<ReturnType<typeof createChildren>>,
) {
const today = dateOnly(new Date());
await prisma.absence.createMany({
data: [
{
kitaId: kita.id,
childId: children.nina.id,
startDate: today,
endDate: today,
reason: AbsenceReason.ILLNESS,
note: "Fieber, bleibt heute zuhause.",
},
{
kitaId: kita.id,
childId: children.emil.id,
startDate: addDays(today, 1),
endDate: addDays(today, 2),
reason: AbsenceReason.VACATION,
note: "Familienbesuch.",
},
],
});
}
async function createDutyPlan({
kita,
familyMueller,
familySchmidtYilmaz,
familyFischer,
}: SeedContext) {
const waesche = await prisma.dutyType.create({
data: {
kitaId: kita.id,
name: "Waeschedienst",
description: "Woechentlicher Dienstplan fuer Kita-Waesche.",
},
});
const einkauf = await prisma.dutyType.create({
data: {
kitaId: kita.id,
name: "Einkauf",
description: "Woechentlicher Einkauf nach Kita-Liste.",
},
});
const families = [familyMueller, familySchmidtYilmaz, familyFischer];
const currentWeek = startOfIsoWeek(new Date());
await prisma.dutyAssignment.createMany({
data: Array.from({ length: 8 }).flatMap((_, index) => {
const startDate = addWeeks(currentWeek, index);
const endDate = addDays(startDate, 6);
return [
{
kitaId: kita.id,
dutyTypeId: waesche.id,
familyId: families[index % families.length].id,
startDate,
endDate,
},
{
kitaId: kita.id,
dutyTypeId: einkauf.id,
familyId: families[(index + 1) % families.length].id,
startDate,
endDate,
},
];
}),
});
}
async function createInvites({ kita, admin, pendingParent }: SeedContext) { async function createInvites({ kita, admin, pendingParent }: SeedContext) {
const expires = addDays(new Date(), 7); const expires = addDays(new Date(), 7);
@@ -408,6 +530,39 @@ async function createInvites({ kita, admin, pendingParent }: SeedContext) {
}); });
} }
async function createAnnouncements({
kita,
admin,
koordinator,
}: SeedContext) {
const sommerfest = await prisma.announcement.create({
data: {
kitaId: kita.id,
title: "Sommerfest: Helferliste und Ablauf",
content:
"## Liebe Familien,\n\nunser Sommerfest findet naechsten Monat im Kita-Garten statt. Bitte merkt euch den Termin vor. Details zu Aufbau, Kuchen und Getraenken folgen ueber das Schwarze Brett.",
authorId: admin.id,
},
});
await prisma.announcement.create({
data: {
kitaId: kita.id,
title: "Neue Garderoben-Regelung",
content:
"Ab Montag bitten wir alle Familien, Wechselkleidung wieder in die beschrifteten Boxen zu legen. So bleibt der Morgen fuer Kinder und Team entspannter.",
authorId: admin.id,
},
});
await prisma.announcementRead.create({
data: {
userId: koordinator.id,
announcementId: sommerfest.id,
},
});
}
async function createTermine({ async function createTermine({
kita, kita,
admin, admin,
@@ -601,7 +756,10 @@ async function createDemoData() {
const educators = await createEducators(context.kita.id); const educators = await createEducators(context.kita.id);
await createParentDuties(context); await createParentDuties(context);
await createDutyPlan(context);
await createAbsences(context, children);
await createInvites(context); await createInvites(context);
await createAnnouncements(context);
await createTermine(context); await createTermine(context);
await createNotdienstData(context, children, educators); await createNotdienstData(context, children, educators);
@@ -622,6 +780,7 @@ function printSummary(
kita, kita,
superAdmin, superAdmin,
admin, admin,
erzieherUser,
koordinator, koordinator,
elternSchmidt, elternSchmidt,
elternYilmaz, elternYilmaz,
@@ -636,6 +795,7 @@ function printSummary(
console.log(` Logins (Passwort jeweils: ${DEFAULT_PASSWORD})`); console.log(` Logins (Passwort jeweils: ${DEFAULT_PASSWORD})`);
console.log(` Superadmin: ${superAdmin.email}`); console.log(` Superadmin: ${superAdmin.email}`);
console.log(` Admin: ${admin.email}`); console.log(` Admin: ${admin.email}`);
console.log(` Erzieherin: ${erzieherUser.email}`);
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}`);
Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -eu
echo "Applying Prisma schema..."
npx prisma db push --skip-generate
echo "Starting Kita-Planer..."
exec npm run start:prod
+198
View File
@@ -0,0 +1,198 @@
"use server";
import { revalidatePath } from "next/cache";
import { AbsenceReason, UserRole } from "@prisma/client";
import { z } from "zod";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
const reportAbsenceSchema = z.object({
childId: z.string().min(1),
startDate: z.string().min(1),
endDate: z.string().min(1),
reason: z.nativeEnum(AbsenceReason),
note: z.string().trim().max(500).optional(),
});
const dailyAbsenceSchema = z.object({
date: z.string().min(1),
});
export type DailyAbsenceDto = {
id: string;
childName: string;
familyName: string;
reason: AbsenceReason;
note: string | null;
startDate: string;
endDate: string;
};
function parseDateOnly(value: string) {
const date = new Date(`${value.slice(0, 10)}T00:00:00`);
if (Number.isNaN(date.getTime())) {
throw new Error("Ungueltiges Datum.");
}
return date;
}
function toDateKey(date: Date) {
return date.toISOString().slice(0, 10);
}
export async function reportAbsence(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = reportAbsenceSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
}
const startDate = parseDateOnly(parsed.data.startDate);
const endDate = parseDateOnly(parsed.data.endDate);
if (endDate < startDate) {
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
}
const child = await prisma.child.findFirst({
where: {
id: parsed.data.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." };
}
const overlappingAbsence = await prisma.absence.findFirst({
where: {
kitaId: session.user.kitaId,
childId: child.id,
startDate: { lte: endDate },
endDate: { gte: startDate },
},
select: { id: true },
});
if (overlappingAbsence) {
return { error: "Für diesen Zeitraum existiert bereits eine Abmeldung." };
}
await prisma.absence.create({
data: {
kitaId: session.user.kitaId,
childId: child.id,
startDate,
endDate,
reason: parsed.data.reason,
note: parsed.data.note || null,
},
});
revalidatePath("/dashboard");
revalidatePath("/dashboard/admin/abwesenheiten");
return { success: true };
}
export async function deleteAbsence(id: string) {
const session = await requireKitaSession();
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
}
const absence = await prisma.absence.findFirst({
where: {
id,
kitaId: session.user.kitaId,
child: {
familyId: session.user.familyId,
},
},
select: { id: true },
});
if (!absence) {
return { error: "Diese Abmeldung gehört nicht zu deinem Haushalt." };
}
await prisma.absence.delete({
where: { id: absence.id },
});
revalidatePath("/dashboard");
revalidatePath("/dashboard/admin/abwesenheiten");
return { success: true };
}
export async function getDailyAbsences(
rawPayload: unknown,
): Promise<{ absences?: DailyAbsenceDto[]; error?: string }> {
const session = await requireRole([
UserRole.ADMIN,
UserRole.KOORDINATOR,
UserRole.ERZIEHER,
]);
if (!session.user.kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = dailyAbsenceSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültiges Datum." };
}
const date = parseDateOnly(parsed.data.date);
const rows = await prisma.absence.findMany({
where: {
kitaId: session.user.kitaId,
startDate: { lte: date },
endDate: { gte: date },
},
select: {
id: true,
reason: true,
note: true,
startDate: true,
endDate: true,
child: {
select: {
firstName: true,
lastName: true,
family: {
select: {
name: true,
},
},
},
},
},
orderBy: [
{ child: { lastName: "asc" } },
{ child: { firstName: "asc" } },
],
});
return {
absences: rows.map((absence) => ({
id: absence.id,
childName: `${absence.child.firstName} ${absence.child.lastName}`,
familyName: absence.child.family.name,
reason: absence.reason,
note: absence.note,
startDate: toDateKey(absence.startDate),
endDate: toDateKey(absence.endDate),
})),
};
}
+245
View File
@@ -0,0 +1,245 @@
"use server";
import { randomUUID } from "crypto";
import { mkdir, rm, writeFile } from "fs/promises";
import path from "path";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { Prisma, UserRole } from "@prisma/client";
import { z } from "zod";
import { NewsEmail } from "@/emails/NewsEmail";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
const ALLOWED_TYPES = new Map([
["application/pdf", ".pdf"],
["image/jpeg", ".jpg"],
["image/png", ".png"],
]);
const ALLOWED_EXTENSIONS = new Set([".pdf", ".jpg", ".jpeg", ".png"]);
const audienceSchema = z.enum(["ELTERN", "ERZIEHER", "ALLE"]);
const announcementSchema = z.object({
title: z.string().trim().min(3).max(160),
content: z.string().trim().min(3).max(20_000),
sendEmail: z.boolean(),
audience: audienceSchema,
});
function getUploadDir() {
return path.resolve(process.env.UPLOAD_DIR ?? "uploads", "announcements");
}
function getSafeFileName(name: string) {
return name.replace(/[^\w.\- äöüÄÖÜß]/g, "_").slice(0, 160);
}
function validateAttachment(file: File) {
const extension = path.extname(file.name).toLowerCase();
const typeAllowed = ALLOWED_TYPES.has(file.type);
const extensionAllowed = ALLOWED_EXTENSIONS.has(extension);
if (!typeAllowed && !extensionAllowed) {
return "Nur PDF, JPG und PNG sind erlaubt.";
}
if (file.size > MAX_ATTACHMENT_SIZE) {
return "Anhänge dürfen maximal 10 MB groß sein.";
}
return null;
}
async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSchema>) {
const where: Prisma.UserWhereInput =
audience === "ELTERN"
? {
kitaId,
familyId: { not: null },
emailVerifiedAt: { not: null },
}
: audience === "ERZIEHER"
? {
kitaId,
role: UserRole.ERZIEHER,
emailVerifiedAt: { not: null },
}
: {
kitaId,
emailVerifiedAt: { not: null },
OR: [{ familyId: { not: null } }, { role: UserRole.ERZIEHER }],
};
const users = await prisma.user.findMany({
where,
select: { email: true },
});
return Array.from(new Set(users.map((user) => user.email)));
}
export async function createAnnouncement(formData: FormData) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = announcementSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
sendEmail: formData.get("sendEmail") === "on",
audience: formData.get("audience") || "ELTERN",
});
if (!parsed.success) {
return { error: "Bitte Titel und Inhalt ausfüllen." };
}
const attachmentFiles = formData
.getAll("attachments")
.filter((value): value is File => value instanceof File && value.size > 0);
for (const file of attachmentFiles) {
const error = validateAttachment(file);
if (error) {
return { error };
}
}
const attachmentInputs = attachmentFiles.map((file) => {
const id = randomUUID();
return {
id,
file,
fileName: getSafeFileName(file.name),
fileType: file.type || "application/octet-stream",
fileUrl: `/api/announcements/attachments/${id}`,
};
});
const announcement = await prisma.announcement.create({
data: {
kitaId,
title: parsed.data.title,
content: parsed.data.content,
authorId: session.user.id,
attachments: {
create: attachmentInputs.map((attachment) => ({
id: attachment.id,
fileName: attachment.fileName,
fileType: attachment.fileType,
fileUrl: attachment.fileUrl,
})),
},
},
select: {
id: true,
title: true,
content: true,
},
});
if (attachmentInputs.length > 0) {
const uploadDir = getUploadDir();
try {
await mkdir(uploadDir, { recursive: true });
await Promise.all(
attachmentInputs.map(async (attachment) => {
const arrayBuffer = await attachment.file.arrayBuffer();
await writeFile(
path.join(uploadDir, attachment.id),
Buffer.from(arrayBuffer),
);
}),
);
} catch (error) {
await prisma.announcement.delete({ where: { id: announcement.id } });
await Promise.all(
attachmentInputs.map((attachment) =>
rm(path.join(uploadDir, attachment.id), { force: true }),
),
);
console.error("Ankündigungs-Anhang konnte nicht gespeichert werden:", error);
return { error: "Anhang konnte nicht gespeichert werden." };
}
}
let emailError: string | null = null;
if (parsed.data.sendEmail) {
const configError = getAppEmailConfigError();
if (configError) {
emailError = configError;
} else {
const recipients = await getRecipients(kitaId, parsed.data.audience);
if (recipients.length > 0) {
const result = await sendAppEmail({
to: recipients,
subject: `Neue Kita-Ankündigung: ${announcement.title}`,
react: createElement(NewsEmail, {
title: announcement.title,
content: announcement.content,
dashboardUrl: `${process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"}/dashboard`,
}),
});
if (!result.success) {
emailError = result.error;
}
}
}
}
revalidatePath("/dashboard");
revalidatePath("/dashboard/admin/news");
if (emailError) {
return {
success: true,
warning: `Ankündigung gespeichert, E-Mail-Versand fehlgeschlagen: ${emailError}`,
};
}
return { success: true };
}
export async function markAnnouncementRead(announcementId: string) {
const session = await requireKitaSession();
const announcement = await prisma.announcement.findFirst({
where: {
id: announcementId,
kitaId: session.user.kitaId,
},
select: { id: true },
});
if (!announcement) {
return { error: "Ankündigung wurde nicht gefunden." };
}
await prisma.announcementRead.upsert({
where: {
userId_announcementId: {
userId: session.user.id,
announcementId: announcement.id,
},
},
create: {
userId: session.user.id,
announcementId: announcement.id,
},
update: {
readAt: new Date(),
},
});
revalidatePath("/dashboard");
return { success: true };
}
+50
View File
@@ -0,0 +1,50 @@
"use server";
import { createElement } from "react";
import { ContactRequestEmail } from "@/emails/ContactRequestEmail";
import {
contactRequestSchema,
contactRequestTypeLabels,
type ContactRequestInput,
} from "@/lib/contact-schema";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
const DEFAULT_ADMIN_EMAIL = "kontakt@kita-planer.local";
export async function submitContactRequest(data: ContactRequestInput) {
const parsed = contactRequestSchema.safeParse(data);
if (!parsed.success) {
return {
success: false,
error: "Bitte prüfe deine Eingaben und versuche es erneut.",
};
}
const configError = getAppEmailConfigError();
if (configError) {
return {
success: false,
error: configError,
};
}
const adminEmail = process.env.ADMIN_EMAIL || DEFAULT_ADMIN_EMAIL;
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) {
return {
success: false,
error: result.error,
};
}
return { success: true };
}
+1 -1
View File
@@ -28,7 +28,7 @@ export async function confirmAlertAction(token: string) {
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true }; return { success: true };
} catch (error: any) { } catch {
return { error: "Fehler bei der Bestätigung." }; return { error: "Fehler bei der Bestätigung." };
} }
} }
+23 -4
View File
@@ -25,10 +25,29 @@ export default async function AlertPage({
const alert = await prisma.notdienstAlert.findUnique({ const alert = await prisma.notdienstAlert.findUnique({
where: { confirmationToken: token }, where: { confirmationToken: token },
include: { select: {
parentUser: true, status: true,
kita: true, parentUser: {
assignment: { include: { child: true } }, select: {
firstName: true,
},
},
kita: {
select: {
name: true,
},
},
assignment: {
select: {
date: true,
child: {
select: {
firstName: true,
lastName: true,
},
},
},
},
}, },
}); });
@@ -0,0 +1,66 @@
import { readFile } from "fs/promises";
import path from "path";
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs";
function getUploadPath(attachmentId: string) {
const uploadDir = path.resolve(
process.env.UPLOAD_DIR ?? "uploads",
"announcements",
);
return path.join(uploadDir, attachmentId);
}
function contentDisposition(fileName: string) {
const fallback = fileName.replace(/[^\w.\-]/g, "_");
return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth();
const user = session?.user;
if (!user?.id || !user.kitaId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const attachment = await prisma.attachment.findFirst({
where: {
id,
announcement: {
kitaId: user.kitaId,
},
},
select: {
id: true,
fileName: true,
fileType: true,
},
});
if (!attachment) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
try {
const file = await readFile(getUploadPath(attachment.id));
return new Response(new Uint8Array(file), {
headers: {
"Content-Type": attachment.fileType,
"Content-Disposition": contentDisposition(attachment.fileName),
"Cache-Control": "private, max-age=300",
},
});
} catch (error) {
console.error("Anhang konnte nicht gelesen werden:", error);
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
}
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { useTransition } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Send } from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { submitContactRequest } from "@/actions/contact";
import {
contactRequestSchema,
contactRequestTypeLabels,
type ContactRequestInput,
} from "@/lib/contact-schema";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
export function ContactForm() {
const [isPending, startTransition] = useTransition();
const form = useForm<ContactRequestInput>({
resolver: zodResolver(contactRequestSchema),
defaultValues: {
name: "",
kitaName: "",
email: "",
requestType: "DEMO",
message: "",
},
});
function onSubmit(values: ContactRequestInput) {
startTransition(async () => {
const result = await submitContactRequest(values);
if (!result.success) {
toast.error(result.error);
return;
}
toast.success("Danke! Wir melden uns in Kürze bei euch.");
form.reset({
name: "",
kitaName: "",
email: "",
requestType: "DEMO",
message: "",
});
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-5">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Anna Vorstand"
autoComplete="name"
className="h-12 bg-white"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="kitaName"
render={({ field }) => (
<FormItem>
<FormLabel>Name der Kita / Initiative</FormLabel>
<FormControl>
<Input
placeholder="Waldameisen e.V."
autoComplete="organization"
className="h-12 bg-white"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>E-Mail-Adresse</FormLabel>
<FormControl>
<Input
type="email"
placeholder="vorstand@eure-kita.de"
autoComplete="email"
className="h-12 bg-white"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="requestType"
render={({ field }) => (
<FormItem>
<FormLabel>Art der Anfrage</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="h-12 bg-white">
<SelectValue placeholder="Anfrage auswählen" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(contactRequestTypeLabels).map(
([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>Nachricht</FormLabel>
<FormControl>
<Textarea
rows={6}
placeholder="Erzählt kurz, wie eure Kita aktuell organisiert ist und wobei ihr Unterstützung sucht."
className="resize-none bg-white"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" size="lg" disabled={isPending} className="h-12">
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
{isPending ? "Wird gesendet..." : "Anfrage senden"}
</Button>
</form>
</Form>
);
}
+279
View File
@@ -0,0 +1,279 @@
"use client";
import Link from "next/link";
import { useMemo, useTransition } from "react";
import { AbsenceReason } from "@prisma/client";
import { Baby, CalendarX2, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { deleteAbsence, reportAbsence } from "@/actions/absences";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
type ChildOption = {
id: string;
name: string;
};
type AbsenceListItem = {
id: string;
childName: string;
reason: AbsenceReason;
note: string | null;
startDate: string;
endDate: string;
};
export function AbsenceCard({
childOptions,
absences,
today,
}: {
childOptions: ChildOption[];
absences: AbsenceListItem[];
today: string;
}) {
const [isPending, startTransition] = useTransition();
const defaultChildId = childOptions[0]?.id;
const hasChildren = childOptions.length > 0;
function handleSubmit(formData: FormData) {
startTransition(async () => {
const result = await reportAbsence({
childId: String(formData.get("childId") ?? ""),
startDate: String(formData.get("startDate") ?? ""),
endDate: String(formData.get("endDate") ?? ""),
reason: String(formData.get("reason") ?? ""),
note: String(formData.get("note") ?? ""),
});
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Abwesenheit eingetragen.");
});
}
function handleDelete(absenceId: string) {
startTransition(async () => {
const result = await deleteAbsence(absenceId);
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Abwesenheit storniert.");
});
}
return (
<Card className="mb-8 border-amber-200 bg-amber-50/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<CalendarX2 className="h-5 w-5 text-amber-700" />
Kind abmelden
</CardTitle>
<CardDescription>
Melde dein Kind für ganze Tage ab. Die Kita sieht die Meldung direkt
in der Tagesübersicht.
</CardDescription>
</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">
<div className="grid gap-2">
<Label htmlFor="absence-startDate">Von</Label>
<Input
id="absence-startDate"
name="startDate"
type="date"
defaultValue={today}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="absence-endDate">Bis</Label>
<Input
id="absence-endDate"
name="endDate"
type="date"
defaultValue={today}
required
/>
</div>
</div>
<div className="grid gap-2">
<Label>Grund</Label>
<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>
</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}>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Abwesenheit melden
</Button>
</div>
</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">
<Baby className="h-10 w-10 text-muted-foreground" />
<p className="mt-4 font-medium">Noch keine Kinder hinterlegt</p>
<p className="mt-1 text-sm text-muted-foreground">
Lege zuerst deine Kinder im Profil an, dann kannst du sie hier
abmelden.
</p>
<Button asChild className="mt-4" variant="outline">
<Link href="/dashboard/profil">Zum Profil</Link>
</Button>
</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>
</Card>
);
}
function AbsenceRow({
absence,
disabled,
onDelete,
}: {
absence: AbsenceListItem;
disabled: boolean;
onDelete: (id: string) => void;
}) {
const range = useMemo(() => {
const start = new Date(`${absence.startDate}T00:00:00`);
const end = new Date(`${absence.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 absence.startDate === absence.endDate
? endLabel
: `${startLabel} - ${endLabel}`;
}, [absence.endDate, absence.startDate]);
return (
<div className="rounded-md border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">{absence.childName}</p>
<p className="mt-0.5 text-sm text-muted-foreground">{range}</p>
</div>
<Button
type="button"
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 className="mt-2 flex flex-wrap items-center gap-2">
<ReasonBadge reason={absence.reason} />
{absence.note && (
<span className="text-xs text-muted-foreground">{absence.note}</span>
)}
</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>;
}
+52 -54
View File
@@ -15,10 +15,7 @@ import { prisma } from "@/lib/prisma";
const BASE_URL = const BASE_URL =
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"; process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
export async function triggerAlertAction( export async function triggerAlertAction(assignmentId: string) {
assignmentId: string,
parentUserId: 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!;
@@ -36,6 +33,19 @@ export async function triggerAlertAction(
id: true, id: true,
firstName: true, firstName: true,
lastName: true, lastName: true,
family: {
select: {
users: {
where: { role: UserRole.ELTERN },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
},
},
},
},
}, },
}, },
}, },
@@ -50,7 +60,9 @@ export async function triggerAlertAction(
return { error: "Notdienst-Einteilung wurde nicht gefunden." }; return { error: "Notdienst-Einteilung wurde nicht gefunden." };
} }
if (!parentUserId) { const parents = assignment.child.family.users;
if (parents.length === 0) {
return { error: "Kein Elternteil für diesen Notdienst hinterlegt." }; return { error: "Kein Elternteil für diesen Notdienst hinterlegt." };
} }
@@ -59,64 +71,50 @@ export async function triggerAlertAction(
return { error: mailConfigError }; return { error: mailConfigError };
} }
const parentLink = await prisma.childParent.findFirst({
where: {
kitaId,
childId: assignment.child.id,
userId: parentUserId,
},
include: {
user: {
select: {
email: true,
firstName: true,
lastName: true,
},
},
},
});
if (!parentLink) {
return { error: "Das Elternteil passt nicht zu dieser Einteilung." };
}
const token = crypto.randomUUID();
await prisma.notdienstAlert.create({
data: {
kitaId,
assignmentId,
parentUserId,
triggeredById: session.user.id,
status: NotdienstAlertStatus.PENDING,
confirmationToken: token,
},
});
const alertUrl = `${BASE_URL}/alert/${token}`;
const dateLabel = format(assignment.date, "EEEE, dd. MMMM yyyy", { const dateLabel = format(assignment.date, "EEEE, dd. MMMM yyyy", {
locale: de, locale: de,
}); });
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`; const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
const alertJobs: { email: string; token: string }[] = [];
const emailResult = await sendAppEmail({ await prisma.$transaction(
to: parentLink.user.email, parents.map((parent) => {
subject: `Dringender Notdienst-Alarm für ${dateLabel}`, const token = crypto.randomUUID();
react: createElement(AlertEmail, { alertJobs.push({ email: parent.email, token });
date: dateLabel, return prisma.notdienstAlert.create({
childName, data: {
confirmLink: alertUrl, kitaId,
assignmentId,
parentUserId: parent.id,
triggeredById: session.user.id,
status: NotdienstAlertStatus.PENDING,
confirmationToken: token,
},
});
}), }),
}); );
for (const job of alertJobs) {
const emailResult = await sendAppEmail({
to: job.email,
subject: `Dringender Notdienst-Alarm für ${dateLabel}`,
react: createElement(AlertEmail, {
date: dateLabel,
childName,
confirmLink: `${BASE_URL}/alert/${job.token}`,
}),
});
if (!emailResult.success) {
revalidatePath("/dashboard");
return {
error: `Alarm wurde angelegt, aber mindestens eine E-Mail konnte nicht versendet werden: ${emailResult.error}`,
};
}
}
revalidatePath("/dashboard"); revalidatePath("/dashboard");
if (!emailResult.success) {
return {
error: `Alarm wurde angelegt, aber die E-Mail konnte nicht versendet werden: ${emailResult.error}`,
};
}
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -0,0 +1,164 @@
import Link from "next/link";
import { addDays, format } from "date-fns";
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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
export const metadata = { title: "Abwesenheiten · Kita-Planer" };
export default async function AdminAbwesenheitenPage({
searchParams,
}: {
searchParams: { date?: string };
}) {
const selectedDate = parseDateParam(searchParams.date);
const dateKey = format(selectedDate, "yyyy-MM-dd");
const result = await getDailyAbsences({ date: dateKey });
const absences = result.absences ?? [];
return (
<div className="min-h-full bg-slate-50 px-4 py-6 sm:px-8">
<div className="mx-auto max-w-5xl space-y-6">
<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">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Stethoscope className="h-4 w-4" />
Tagesübersicht
</div>
<h1 className="mt-2 text-3xl font-bold tracking-tight">
Heute fehlen ({absences.length} Kinder)
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{format(selectedDate, "EEEE, dd. MMMM yyyy", { locale: de })}
</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>
);
}
function parseDateParam(value: string | undefined) {
if (!value) return new Date();
const parsed = new Date(`${value}T00:00:00`);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
}
function hrefForDate(date: Date) {
return `/dashboard/admin/abwesenheiten?date=${format(date, "yyyy-MM-dd")}`;
}
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>;
}
+247
View File
@@ -0,0 +1,247 @@
"use server";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { DutyAssignmentStatus, UserRole } from "@prisma/client";
import { endOfWeek, format, startOfDay, startOfWeek } from "date-fns";
import { de } from "date-fns/locale";
import { z } from "zod";
import { DutyReminderEmail } from "@/emails/DutyReminderEmail";
import { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
const dutyTypeSchema = z.object({
name: z.string().trim().min(2).max(60),
description: z.string().trim().max(240).optional(),
});
const assignmentSchema = z.object({
dutyTypeId: z.string().min(1),
familyId: z.string().nullable(),
startDate: z.string().min(1),
endDate: z.string().min(1),
});
async function requireDutyAdmin() {
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
if (!session.user.kitaId) {
throw new Error("Kein Mandant zugeordnet.");
}
return { session, kitaId: session.user.kitaId };
}
function parseDateOnly(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new Error("Ungueltiges Datum.");
}
return startOfDay(date);
}
export async function createDutyType(formData: FormData) {
const { kitaId } = await requireDutyAdmin();
const parsed = dutyTypeSchema.safeParse({
name: formData.get("name"),
description: formData.get("description") || undefined,
});
if (!parsed.success) {
return;
}
try {
await prisma.dutyType.create({
data: {
kitaId,
name: parsed.data.name,
description: parsed.data.description,
},
});
revalidatePath("/dashboard/admin/dienste");
} catch (error) {
console.error("Fehler beim Erstellen des Diensttyps:", error);
}
}
export async function deleteDutyType(formData: FormData) {
const { kitaId } = await requireDutyAdmin();
const dutyTypeId = String(formData.get("dutyTypeId") ?? "");
if (!dutyTypeId) {
return;
}
await prisma.dutyType.deleteMany({
where: { id: dutyTypeId, kitaId },
});
revalidatePath("/dashboard/admin/dienste");
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard");
}
export async function upsertDutyAssignment(rawPayload: unknown) {
const { kitaId } = await requireDutyAdmin();
const parsed = assignmentSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungueltige Eingabedaten." };
}
const startDate = parseDateOnly(parsed.data.startDate);
const endDate = parseDateOnly(parsed.data.endDate);
if (endDate < startDate) {
return { error: "Das Enddatum liegt vor dem Startdatum." };
}
const dutyType = await prisma.dutyType.findFirst({
where: { id: parsed.data.dutyTypeId, kitaId },
select: { id: true },
});
if (!dutyType) {
return { error: "Diensttyp gehoert nicht zu dieser Kita." };
}
if (!parsed.data.familyId) {
await prisma.dutyAssignment.deleteMany({
where: {
kitaId,
dutyTypeId: parsed.data.dutyTypeId,
startDate,
},
});
revalidatePath("/dashboard/admin/dienste");
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard");
return { success: true };
}
const family = await prisma.family.findFirst({
where: { id: parsed.data.familyId, kitaId },
select: { id: true },
});
if (!family) {
return { error: "Familie gehoert nicht zu dieser Kita." };
}
await prisma.dutyAssignment.upsert({
where: {
kitaId_dutyTypeId_startDate: {
kitaId,
dutyTypeId: parsed.data.dutyTypeId,
startDate,
},
},
create: {
kitaId,
dutyTypeId: parsed.data.dutyTypeId,
familyId: parsed.data.familyId,
startDate,
endDate,
status: DutyAssignmentStatus.PLANNED,
},
update: {
familyId: parsed.data.familyId,
endDate,
status: DutyAssignmentStatus.PLANNED,
reminderSentAt: null,
},
});
revalidatePath("/dashboard/admin/dienste");
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard");
return { success: true };
}
export async function checkAndSendDutyReminders() {
const { kitaId } = await requireDutyAdmin();
const configError = getAppEmailConfigError();
if (configError) {
return { error: configError };
}
const now = new Date();
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
const weekEnd = endOfWeek(now, { weekStartsOn: 1 });
const assignments = await prisma.dutyAssignment.findMany({
where: {
kitaId,
status: DutyAssignmentStatus.PLANNED,
reminderSentAt: null,
startDate: {
gte: startOfDay(weekStart),
lte: startOfDay(weekEnd),
},
},
select: {
id: true,
startDate: true,
endDate: true,
dutyType: {
select: {
name: true,
},
},
family: {
select: {
name: true,
users: {
where: {
emailVerifiedAt: { not: null },
},
select: {
email: true,
},
},
},
},
},
});
let sent = 0;
let skipped = 0;
const errors: string[] = [];
for (const assignment of assignments) {
const recipients = assignment.family.users.map((user) => user.email);
if (recipients.length === 0) {
skipped += 1;
continue;
}
const weekLabel = `${format(assignment.startDate, "dd.MM.", {
locale: de,
})} - ${format(assignment.endDate, "dd.MM.yyyy", { locale: de })}`;
const result = await sendAppEmail({
to: recipients,
subject: `Erinnerung: ${assignment.dutyType.name} diese Woche`,
react: createElement(DutyReminderEmail, {
familyName: assignment.family.name,
dutyName: assignment.dutyType.name,
weekLabel,
}),
});
if (!result.success) {
errors.push(`${assignment.family.name}: ${result.error}`);
continue;
}
await prisma.dutyAssignment.update({
where: { id: assignment.id },
data: { reminderSentAt: new Date() },
});
sent += recipients.length;
}
revalidatePath("/dashboard/admin/dienste");
return { success: true, sent, skipped, errors };
}
@@ -0,0 +1,211 @@
"use client";
import { useState, useTransition } from "react";
import { BellRing, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
checkAndSendDutyReminders,
upsertDutyAssignment,
} from "./actions";
const UNASSIGNED = "__unassigned__";
export type DutyPlanWeekDto = {
key: string;
label: string;
startDate: string;
endDate: string;
};
export type DutyPlanDutyTypeDto = {
id: string;
name: string;
description: string | null;
};
export type DutyPlanFamilyDto = {
id: string;
name: string;
};
export type DutyPlanAssignmentDto = {
id: string;
dutyTypeId: string;
familyId: string;
startDate: string;
};
export function DutyPlanner({
weeks,
dutyTypes,
families,
assignments,
}: {
weeks: DutyPlanWeekDto[];
dutyTypes: DutyPlanDutyTypeDto[];
families: DutyPlanFamilyDto[];
assignments: DutyPlanAssignmentDto[];
}) {
const [isPending, startTransition] = useTransition();
const [pendingCell, setPendingCell] = useState<string | null>(null);
const assignmentByCell = new Map(
assignments.map((assignment) => [
`${assignment.startDate}:${assignment.dutyTypeId}`,
assignment,
]),
);
function handleAssignmentChange(
week: DutyPlanWeekDto,
dutyTypeId: string,
value: string,
) {
const cellKey = `${week.startDate}:${dutyTypeId}`;
setPendingCell(cellKey);
startTransition(async () => {
const result = await upsertDutyAssignment({
dutyTypeId,
familyId: value === UNASSIGNED ? null : value,
startDate: week.startDate,
endDate: week.endDate,
});
if (result.error) {
toast.error(result.error);
} else {
toast.success(
value === UNASSIGNED
? "Zuweisung entfernt"
: "Dienstplan aktualisiert",
);
}
setPendingCell(null);
});
}
function sendReminders() {
startTransition(async () => {
const result = await checkAndSendDutyReminders();
if (result.error) {
toast.error(result.error);
return;
}
toast.success(
`Erinnerungen versendet: ${result.sent ?? 0}. Ohne Empfaenger: ${
result.skipped ?? 0
}.`,
);
if (result.errors?.length) {
toast.warning(`${result.errors.length} Erinnerung(en) fehlgeschlagen.`);
}
});
}
if (dutyTypes.length === 0) {
return (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-sm font-medium">Noch keine Diensttypen</p>
<p className="mt-1 text-sm text-muted-foreground">
Lege zuerst einen Diensttyp an, danach erscheint hier das Planungsgrid.
</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={sendReminders}
disabled={isPending}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<BellRing className="h-4 w-4" />
)}
Reminder diese Woche senden
</Button>
</div>
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[760px] border-collapse text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="sticky left-0 z-10 w-56 bg-muted/95 px-4 py-3 text-left font-medium">
Woche
</th>
{dutyTypes.map((dutyType) => (
<th
key={dutyType.id}
className="min-w-56 px-4 py-3 text-left font-medium"
>
<div>{dutyType.name}</div>
{dutyType.description && (
<div className="mt-1 line-clamp-1 text-xs font-normal text-muted-foreground">
{dutyType.description}
</div>
)}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((week) => (
<tr key={week.key} className="border-b last:border-b-0">
<td className="sticky left-0 z-10 bg-background px-4 py-3 font-medium">
{week.label}
</td>
{dutyTypes.map((dutyType) => {
const cellKey = `${week.startDate}:${dutyType.id}`;
const currentAssignment = assignmentByCell.get(cellKey);
return (
<td key={cellKey} className="px-4 py-3">
<Select
value={currentAssignment?.familyId ?? UNASSIGNED}
onValueChange={(value) =>
handleAssignmentChange(week, dutyType.id, value)
}
disabled={isPending && pendingCell === cellKey}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Familie wählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNASSIGNED}>
Nicht zugewiesen
</SelectItem>
{families.map((family) => (
<SelectItem key={family.id} value={family.id}>
{family.name}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+284
View File
@@ -0,0 +1,284 @@
import Link from "next/link";
import { addWeeks, format, getISOWeek, startOfDay, startOfWeek } from "date-fns";
import { de } from "date-fns/locale";
import { CalendarRange, ClipboardList, Plus, Trash2 } from "lucide-react";
import { UserRole } from "@prisma/client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { createDutyType, deleteDutyType } from "./actions";
import {
DutyPlanner,
type DutyPlanAssignmentDto,
type DutyPlanDutyTypeDto,
type DutyPlanFamilyDto,
type DutyPlanWeekDto,
} from "./duty-planner";
export const metadata = { title: "Dienstplan · Kita-Planer" };
const DEFAULT_WEEK_COUNT = 12;
const MAX_WEEK_COUNT = 104;
export default async function DienstePage({
searchParams,
}: {
searchParams: { start?: string; weeks?: string };
}) {
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
if (!session.user.kitaId) {
throw new Error("Kein Mandant zugeordnet.");
}
const rangeStart = getRangeStart(searchParams.start);
const weekCount = getWeekCount(searchParams.weeks);
const weeks = buildWeeks(rangeStart, weekCount);
const rangeEnd = startOfDay(new Date(weeks[weeks.length - 1].endDate));
const [dutyTypes, families, assignments] = await Promise.all([
prisma.dutyType.findMany({
where: { kitaId: session.user.kitaId },
select: {
id: true,
name: true,
description: true,
},
orderBy: { name: "asc" },
}),
prisma.family.findMany({
where: { kitaId: session.user.kitaId },
select: {
id: true,
name: true,
},
orderBy: { name: "asc" },
}),
prisma.dutyAssignment.findMany({
where: {
kitaId: session.user.kitaId,
startDate: {
gte: rangeStart,
lte: rangeEnd,
},
},
select: {
id: true,
dutyTypeId: true,
familyId: true,
startDate: true,
},
}),
]);
const dutyTypeDtos: DutyPlanDutyTypeDto[] = dutyTypes;
const familyDtos: DutyPlanFamilyDto[] = families;
const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map((assignment) => ({
id: assignment.id,
dutyTypeId: assignment.dutyTypeId,
familyId: assignment.familyId,
startDate: toDateKey(assignment.startDate),
}));
return (
<div className="space-y-6 p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<ClipboardList className="h-4 w-4" />
Admin
</div>
<h1 className="mt-2 text-2xl font-bold tracking-tight">
Dienstplan-Manager
</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Plane Dienste pro Kalenderwoche und Haushalt. Jede Änderung wird
direkt gespeichert.
</p>
</div>
<Card className="w-full lg:max-w-xl">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<CalendarRange className="h-4 w-4" />
Zeitraum
</CardTitle>
<CardDescription>
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 className="grid gap-1.5">
<Label htmlFor="weeks">Wochen</Label>
<Input
id="weeks"
name="weeks"
type="number"
min={1}
max={MAX_WEEK_COUNT}
defaultValue={weekCount}
/>
</div>
<Button type="submit">Anzeigen</Button>
<Button asChild type="button" variant="outline">
<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>
<div className="grid gap-6 xl:grid-cols-[340px_1fr]">
<Card>
<CardHeader>
<CardTitle className="text-base">Diensttypen</CardTitle>
<CardDescription>
Beispiele sind Wäschedienst, Einkauf oder Gartendienst.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<form action={createDutyType} className="space-y-3">
<div className="grid gap-1.5">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" placeholder="Wäschedienst" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="description">Beschreibung</Label>
<Textarea
id="description"
name="description"
placeholder="Kurz beschreiben, was zu tun ist."
rows={3}
/>
</div>
<Button type="submit" size="sm">
<Plus className="h-4 w-4" />
Diensttyp anlegen
</Button>
</form>
<div className="space-y-2">
{dutyTypes.length === 0 ? (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
Noch keine Diensttypen angelegt.
</p>
) : (
dutyTypes.map((dutyType) => (
<div
key={dutyType.id}
className="flex items-start justify-between gap-3 rounded-md border p-3"
>
<div className="min-w-0">
<p className="font-medium">{dutyType.name}</p>
{dutyType.description && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{dutyType.description}
</p>
)}
</div>
<form action={deleteDutyType}>
<input
type="hidden"
name="dutyTypeId"
value={dutyType.id}
/>
<Button
type="submit"
variant="ghost"
size="icon"
aria-label={`${dutyType.name} löschen`}
>
<Trash2 className="h-4 w-4" />
</Button>
</form>
</div>
))
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Planung</CardTitle>
<CardDescription>
{weeks[0].label} bis {weeks[weeks.length - 1].label}
</CardDescription>
</CardHeader>
<CardContent>
<DutyPlanner
weeks={weeks}
dutyTypes={dutyTypeDtos}
families={familyDtos}
assignments={assignmentDtos}
/>
</CardContent>
</Card>
</div>
</div>
);
}
function getRangeStart(value: string | undefined) {
const parsed = value ? new Date(value) : new Date();
const safeDate = Number.isNaN(parsed.getTime()) ? new Date() : parsed;
return startOfDay(startOfWeek(safeDate, { weekStartsOn: 1 }));
}
function getWeekCount(value: string | undefined) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return DEFAULT_WEEK_COUNT;
return Math.min(Math.max(Math.trunc(parsed), 1), MAX_WEEK_COUNT);
}
function buildWeeks(start: Date, count: number): DutyPlanWeekDto[] {
return Array.from({ length: count }, (_, index) => {
const weekStart = addWeeks(start, index);
const weekEnd = addWeeks(start, index);
weekEnd.setDate(weekStart.getDate() + 6);
return {
key: toDateKey(weekStart),
label: `KW ${getISOWeek(weekStart)}: ${format(weekStart, "dd.MM.", {
locale: de,
})} - ${format(weekEnd, "dd.MM.", { locale: de })}`,
startDate: toDateKey(weekStart),
endDate: toDateKey(weekEnd),
};
});
}
function toDateKey(date: Date) {
return format(date, "yyyy-MM-dd");
}
function toDateInputValue(date: Date) {
return format(date, "yyyy-MM-dd");
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
import { useState, useTransition } from "react";
import { Loader2, Send, UploadCloud } from "lucide-react";
import { toast } from "sonner";
import { createAnnouncement } from "@/actions/announcements";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/markdown-content";
export function NewsForm() {
const [isPending, startTransition] = useTransition();
const [sendEmail, setSendEmail] = useState(false);
const [content, setContent] = useState("");
function handleSubmit(formData: FormData) {
startTransition(async () => {
if (sendEmail) {
formData.set("sendEmail", "on");
}
const result = await createAnnouncement(formData);
if (result.error) {
toast.error(result.error);
return;
}
if (result.warning) {
toast.warning(result.warning);
} else {
toast.success("Ankündigung veröffentlicht.");
}
});
}
return (
<form action={handleSubmit} className="grid gap-5">
<div className="grid gap-2">
<Label htmlFor="news-title">Titel</Label>
<Input
id="news-title"
name="title"
placeholder="Sommerfest: Aufbau und Mitbringliste"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="news-content">Text</Label>
<Textarea
id="news-content"
name="content"
rows={10}
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."}
required
/>
<p className="text-xs text-muted-foreground">
Markdown ist aktiv. HTML wird nicht interpretiert.
</p>
</div>
{content.trim() && (
<div className="rounded-md border bg-muted/30 p-4 text-sm">
<p className="mb-3 text-xs font-semibold uppercase text-muted-foreground">
Vorschau
</p>
<MarkdownContent content={content} />
</div>
)}
<div className="grid gap-2">
<Label htmlFor="attachments">Anhänge</Label>
<div className="rounded-md border border-dashed bg-background p-4">
<Input
id="attachments"
name="attachments"
type="file"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
multiple
/>
<p className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
<UploadCloud className="h-3.5 w-3.5" />
PDF, JPG und PNG bis 10 MB pro Datei.
</p>
</div>
</div>
<div className="rounded-md border p-4">
<div className="flex items-center gap-2">
<Checkbox
id="sendEmail"
checked={sendEmail}
onCheckedChange={(checked) => setSendEmail(checked === true)}
/>
<Label htmlFor="sendEmail" className="cursor-pointer">
Auch als E-Mail versenden
</Label>
</div>
{sendEmail && (
<div className="mt-4 grid gap-2">
<Label>Empfänger</Label>
<Select name="audience" defaultValue="ELTERN">
<SelectTrigger>
<SelectValue placeholder="Empfänger auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ELTERN">Eltern</SelectItem>
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
<SelectItem value="ALLE">Alle</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
Veröffentlichen
</Button>
</div>
</form>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { FileText, Megaphone } from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { NewsForm } from "./news-form";
export const metadata = { title: "Schwarzes Brett · Kita-Planer" };
export default async function AdminNewsPage() {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
throw new Error("Kein Mandant zugeordnet.");
}
const announcements = await prisma.announcement.findMany({
where: { kitaId },
select: {
id: true,
title: true,
createdAt: true,
attachments: {
select: {
id: true,
fileName: true,
},
},
author: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: { createdAt: "desc" },
take: 8,
});
return (
<div className="px-8 py-8">
<div className="mb-8">
<Badge className="mb-3 bg-emerald-100 text-emerald-800 hover:bg-emerald-100">
Offizielle Kita-Kommunikation
</Badge>
<h1 className="flex items-center gap-3 text-2xl font-semibold tracking-tight">
<Megaphone className="h-6 w-6 text-emerald-700" />
Digitales Schwarzes Brett
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
werden geschützt gespeichert und nur für eingeloggte Nutzer derselben
Kita ausgeliefert.
</p>
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<Card>
<CardHeader>
<CardTitle>Neue Ankündigung</CardTitle>
<CardDescription>
Markdown wird sicher gerendert; PDF, JPG und PNG können als
geschützte Anhänge hinzugefügt werden.
</CardDescription>
</CardHeader>
<CardContent>
<NewsForm />
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle className="text-base">Zuletzt veröffentlicht</CardTitle>
<CardDescription>
Die neuesten Meldungen auf dem Schwarzen Brett.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
{announcements.length === 0 ? (
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
Noch keine Ankündigungen veröffentlicht.
</p>
) : (
announcements.map((announcement) => (
<div
key={announcement.id}
className="rounded-md border bg-background p-3"
>
<div className="mb-2 flex items-start justify-between gap-2">
<p className="font-medium leading-snug">
{announcement.title}
</p>
{announcement.attachments.length > 0 && (
<Badge variant="secondary" className="shrink-0">
<FileText className="h-3 w-3" />
{announcement.attachments.length}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
locale: de,
})}{" "}
· {announcement.author.firstName}{" "}
{announcement.author.lastName}
</p>
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
);
}
+221 -69
View File
@@ -1,101 +1,253 @@
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 { Contact, Mail, Phone, Baby } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
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();
// Fetch only users who opted in to the directory const familyRows = await prisma.family.findMany({
const users = await prisma.user.findMany({ where: {
kitaId: session.user.kitaId,
users: {
some: {
directoryOptInAt: { not: null },
},
},
},
select: {
id: true,
name: true,
children: {
select: {
id: true,
firstName: true,
lastName: true,
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
},
users: {
select: {
id: true,
firstName: true,
lastName: true,
directoryOptInAt: true,
dutyAssignments: {
select: {
id: true,
duty: {
select: {
name: true,
},
},
},
},
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
},
},
orderBy: { name: "asc" },
});
const visibleContactRows = await prisma.user.findMany({
where: { where: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
directoryOptInAt: { not: null }, directoryOptInAt: { not: null },
familyId: { in: familyRows.map((family) => family.id) },
}, },
include: { select: {
childLinks: { id: true,
include: { child: true }, email: true,
}, phone: true,
dutyAssignments: {
include: { duty: true },
},
}, },
orderBy: { lastName: "asc" },
}); });
const visibleContactsByUserId = new Map(
visibleContactRows.map((user) => [user.id, user]),
);
const families: DirectoryFamilyDto[] = familyRows.map((family) => ({
id: family.id,
name: family.name,
children: family.children,
parents: family.users.map((user) => {
const hasDirectoryOptIn = !!user.directoryOptInAt;
const contact = visibleContactsByUserId.get(user.id);
return {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
hasDirectoryOptIn,
email: contact?.email,
phone: contact?.phone ?? undefined,
duties: user.dutyAssignments.map((assignment) => ({
id: assignment.id,
name: assignment.duty.name,
})),
};
}),
}));
return ( return (
<div className="flex h-full flex-col gap-6 p-6"> <div className="flex h-full flex-col gap-6 p-6">
<div> <div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Adressbuch</h1> <h1 className="text-2xl font-bold tracking-tight">Adressbuch</h1>
<p className="text-muted-foreground"> <p className="max-w-2xl text-sm text-muted-foreground">
Kontaktinformationen aller Eltern, die der Freigabe zugestimmt haben. Haushalte und Kontaktdaten, die explizit fuer das interne
Kita-Adressbuch freigegeben wurden.
</p> </p>
</div> </div>
{users.length === 0 ? ( {families.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-background p-10 text-center">
<Contact className="h-10 w-10 text-muted-foreground mb-4" /> <Contact className="mb-4 h-10 w-10 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold">Keine Kontakte</h3> <h3 className="text-lg font-semibold">Keine freigegebenen Kontakte</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground"> <p className="mt-2 max-w-sm text-sm text-muted-foreground">
Bisher hat niemand der Veröffentlichung im Adressbuch zugestimmt. Sobald mindestens ein Elternteil eines Haushalts zustimmt,
erscheint die Familie hier.
</p> </p>
</div> </div>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 lg:grid-cols-2">
{users.map((u) => ( {families.map((family) => (
<Card key={u.id} className="flex flex-col"> <Card key={family.id} className="overflow-hidden">
<CardHeader className="pb-3"> <CardHeader className="border-b bg-muted/30 pb-4">
<CardTitle className="flex justify-between items-start"> <div className="flex items-start justify-between gap-4">
<span className="text-lg"> <div>
{u.firstName} {u.lastName} <CardTitle className="flex items-center gap-2 text-lg">
</span> <UsersRound className="h-4 w-4 text-primary" />
{u.role === "ADMIN" || u.role === "KOORDINATOR" ? ( {family.name}
<Badge variant="secondary" className="text-[10px]">Vorstand</Badge> </CardTitle>
) : null} <CardDescription className="mt-1">
</CardTitle> {family.parents.filter((user) => user.hasDirectoryOptIn).length}{" "}
</CardHeader> von {family.parents.length} Elternteilen sichtbar
<CardContent className="flex flex-col gap-3 text-sm flex-1"> </CardDescription>
<div className="flex items-center gap-2 text-muted-foreground"> </div>
<Mail className="h-4 w-4 shrink-0" /> <Badge variant="success">Freigegeben</Badge>
<a href={`mailto:${u.email}`} className="hover:text-primary transition-colors truncate">
{u.email}
</a>
</div> </div>
{u.phone && ( </CardHeader>
<div className="flex items-center gap-2 text-muted-foreground">
<Phone className="h-4 w-4 shrink-0" />
<a href={`tel:${u.phone}`} className="hover:text-primary transition-colors">
{u.phone}
</a>
</div>
)}
{u.childLinks.length > 0 && (
<div className="flex items-start gap-2 text-muted-foreground mt-1">
<Baby className="h-4 w-4 shrink-0 mt-0.5" />
<div className="flex flex-wrap gap-1">
{u.childLinks.map(link => (
<span key={link.child.id} className="bg-muted px-1.5 py-0.5 rounded-md text-xs">
{link.child.firstName}
</span>
))}
</div>
</div>
)}
{u.dutyAssignments.length > 0 && ( <CardContent className="grid gap-5 pt-5">
<div className="mt-auto pt-3 border-t"> <section className="space-y-2">
<span className="text-xs font-medium text-muted-foreground mb-1 block">Ämter / Dienste:</span> <div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<div className="flex flex-wrap gap-1"> <Baby className="h-3.5 w-3.5" />
{u.dutyAssignments.map((assignment) => ( Kinder
<Badge key={assignment.duty.id} variant="outline" className="bg-primary/5 text-primary border-primary/20"> </div>
{assignment.duty.name} {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> </Badge>
))} ))}
</div> </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>
)} <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> </CardContent>
</Card> </Card>
))} ))}
+1 -3
View File
@@ -9,10 +9,8 @@ import { triggerAlertAction } from "./actions";
export function AlertButton({ export function AlertButton({
assignmentId, assignmentId,
parentUserId,
}: { }: {
assignmentId: string; assignmentId: string;
parentUserId: string;
}) { }) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -20,7 +18,7 @@ export function AlertButton({
if (!confirm("Bist du sicher? Dies löst den Notdienst-Alarm aus.")) return; if (!confirm("Bist du sicher? Dies löst den Notdienst-Alarm aus.")) return;
startTransition(async () => { startTransition(async () => {
const result = await triggerAlertAction(assignmentId, parentUserId); const result = await triggerAlertAction(assignmentId);
if ("error" in result && result.error) { if ("error" in result && result.error) {
toast.error(result.error); toast.error(result.error);
} else { } else {
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Educator } from "@prisma/client";
import { Plus, Pencil, Trash2 } from "lucide-react"; import { Plus, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -30,9 +29,16 @@ import {
import { createEducator, updateEducator, deleteEducator } from "../actions"; import { createEducator, updateEducator, deleteEducator } from "../actions";
export function ErzieherList({ educators }: { educators: Educator[] }) { type EducatorDto = {
id: string;
firstName: string;
lastName: string;
active: boolean;
};
export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
const [openCreate, setOpenCreate] = useState(false); const [openCreate, setOpenCreate] = useState(false);
const [editingEducator, setEditingEducator] = useState<Educator | null>(null); const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(null);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const handleCreate = (formData: FormData) => { const handleCreate = (formData: FormData) => {
+17 -5
View File
@@ -8,13 +8,17 @@ import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
const educatorSchema = z.object({ const educatorSchema = z.object({
firstName: z.string().min(1, "Vorname ist erforderlich"), firstName: z.string().min(1, "Vorname ist erforderlich").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich"), lastName: z.string().min(1, "Nachname ist erforderlich").max(100).trim(),
active: z.boolean().default(true), active: z.boolean().default(true),
}); });
export async function createEducator(rawPayload: unknown) { export async function createEducator(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]); const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = educatorSchema.safeParse(rawPayload); const parsed = educatorSchema.safeParse(rawPayload);
if (!parsed.success) { if (!parsed.success) {
@@ -24,7 +28,7 @@ export async function createEducator(rawPayload: unknown) {
try { try {
await prisma.educator.create({ await prisma.educator.create({
data: { data: {
kitaId: session.user.kitaId!, kitaId,
firstName: parsed.data.firstName, firstName: parsed.data.firstName,
lastName: parsed.data.lastName, lastName: parsed.data.lastName,
active: parsed.data.active, active: parsed.data.active,
@@ -41,6 +45,10 @@ export async function createEducator(rawPayload: unknown) {
export async function updateEducator(id: string, rawPayload: unknown) { export async function updateEducator(id: string, rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]); const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = educatorSchema.safeParse(rawPayload); const parsed = educatorSchema.safeParse(rawPayload);
if (!parsed.success) { if (!parsed.success) {
@@ -51,7 +59,7 @@ export async function updateEducator(id: string, rawPayload: unknown) {
await prisma.educator.update({ await prisma.educator.update({
where: { where: {
id, id,
kitaId: session.user.kitaId!, kitaId,
}, },
data: { data: {
firstName: parsed.data.firstName, firstName: parsed.data.firstName,
@@ -70,12 +78,16 @@ export async function updateEducator(id: string, rawPayload: unknown) {
export async function deleteEducator(id: string) { export async function deleteEducator(id: string) {
const session = await requireRole([UserRole.ADMIN]); const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try { try {
await prisma.educator.delete({ await prisma.educator.delete({
where: { where: {
id, id,
kitaId: session.user.kitaId!, kitaId,
}, },
}); });
+18 -5
View File
@@ -1,3 +1,4 @@
import { GraduationCap } 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";
@@ -11,16 +12,28 @@ export default async function ErzieherPage() {
const educators = await prisma.educator.findMany({ const educators = await prisma.educator.findMany({
where: { kitaId: session.user.kitaId! }, where: { kitaId: session.user.kitaId! },
select: {
id: true,
firstName: true,
lastName: true,
active: true,
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }], orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
}); });
return ( return (
<div className="flex h-full flex-col gap-6 p-6"> <div className="flex h-full flex-col gap-6 p-6">
<div> <div className="flex items-start justify-between gap-4">
<h1 className="text-2xl font-bold tracking-tight">ErzieherInnen-Verwaltung</h1> <div>
<p className="text-muted-foreground"> <h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
Verwalte die Stammdaten des Kita-Personals (nur für den Vorstand sichtbar). <GraduationCap className="h-6 w-6 text-primary" />
</p> ErzieherInnen-Verwaltung
</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Stammdaten des Kita-Personals für interne Planung und spätere
Notdienst-Alarmierung.
</p>
</div>
</div> </div>
<ErzieherList educators={educators} /> <ErzieherList educators={educators} />
+363 -86
View File
@@ -2,30 +2,46 @@
import crypto from "crypto"; import crypto from "crypto";
import { createElement } from "react"; import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { Prisma, UserRole } from "@prisma/client"; import { Prisma, UserRole } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireRole } from "@/lib/auth-utils";
import { InviteEmail } from "@/emails/InviteEmail"; import { InviteEmail } from "@/emails/InviteEmail";
import { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
// ===================================================================== const INVITE_TOKEN_TTL_DAYS = 7;
// /dashboard/families · Server Actions const BASE_URL =
// --------------------------------------------------------------------- process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
// addFamilyAction: Erstellt Elternteil + Kinder + VerificationToken in
// einer atomaren Prisma-Transaktion. Die kitaId kommt ausschließlich
// aus der validierten Session — nie aus dem Formular-Input.
// =====================================================================
const parentSchema = z.object({ const childSchema = z.object({
firstName: z.string().min(1, "Pflichtfeld.").max(100).trim(), firstName: z.string().min(1, "Vorname des Kindes fehlt.").max(100).trim(),
lastName: z.string().min(1, "Pflichtfeld.").max(100).trim(), lastName: z.string().min(1, "Nachname des Kindes fehlt.").max(100).trim(),
});
const parentInputSchema = z.object({
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
email: z email: z
.string() .string()
.email("Bitte eine gültige E-Mail-Adresse angeben.") .email("Bitte eine gültige E-Mail-Adresse angeben.")
.toLowerCase() .toLowerCase()
.trim(), .trim(),
});
const addFamilySchema = z.object({
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
parent1FirstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
parent1LastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
parent1Email: z
.string()
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.toLowerCase()
.trim(),
parent2FirstName: z.string().max(100).trim().optional(),
parent2LastName: z.string().max(100).trim().optional(),
parent2Email: z.string().trim().optional(),
childCount: z.coerce childCount: z.coerce
.number() .number()
.int() .int()
@@ -33,65 +49,139 @@ const parentSchema = z.object({
.max(10), .max(10),
}); });
const childSchema = z.object({ const updateFamilySchema = z.object({
firstName: z.string().min(1, "Vorname des Kindes fehlt.").max(100).trim(), familyId: z.string().min(1),
lastName: z.string().min(1, "Nachname des Kindes fehlt.").max(100).trim(), familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
parents: z.array(
parentInputSchema.extend({
id: z.string().min(1),
}),
).min(1).max(2),
newParents: z.array(parentInputSchema).max(1).default([]),
children: z
.array(
childSchema.extend({
id: z.string().min(1),
}),
)
.max(20),
newChildren: z.array(childSchema).max(10).default([]),
removedChildIds: z.array(z.string().min(1)).max(20).default([]),
}); });
export type AddFamilyState = { export type AddFamilyState = {
errors?: { errors?: {
firstName?: string[]; familyName?: string[];
lastName?: string[]; parent1FirstName?: string[];
email?: string[]; parent1LastName?: string[];
parent1Email?: string[];
parent2FirstName?: string[];
parent2LastName?: string[];
parent2Email?: string[];
children?: string[]; children?: string[];
_form?: string[]; _form?: string[];
}; };
success?: boolean; success?: boolean;
}; };
const PRIVACY_POLICY_VERSION = "2026-05-01"; type InviteJob = {
const INVITE_TOKEN_TTL_DAYS = 7; to: string;
const BASE_URL = parentName: string;
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"; inviteUrl: string;
};
function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
const firstName = data.parent2FirstName?.trim() ?? "";
const lastName = data.parent2LastName?.trim() ?? "";
const email = data.parent2Email?.trim() ?? "";
if (!firstName && !lastName && !email) {
return { ok: true as const, parent: null };
}
const parsed = parentInputSchema.safeParse({ firstName, lastName, email });
if (!parsed.success) {
return {
ok: false as const,
errors: {
parent2FirstName: parsed.error.flatten().fieldErrors.firstName,
parent2LastName: parsed.error.flatten().fieldErrors.lastName,
parent2Email: parsed.error.flatten().fieldErrors.email,
},
};
}
return { ok: true as const, parent: parsed.data };
}
function createInviteToken() {
const token = crypto.randomUUID();
const expires = new Date(
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
);
return { token, expires, inviteUrl: `${BASE_URL}/invite/${token}` };
}
async function sendInviteJobs(kitaName: string, jobs: InviteJob[]) {
for (const job of jobs) {
const result = await sendAppEmail({
to: job.to,
subject: `Einladung zu ${kitaName} im Kita-Planer`,
react: createElement(InviteEmail, {
parentName: job.parentName,
kitaName,
inviteLink: job.inviteUrl,
}),
});
if (!result.success) {
return result.error;
}
}
return null;
}
export async function addFamilyAction( export async function addFamilyAction(
_prev: AddFamilyState, _prev: AddFamilyState,
formData: FormData, formData: FormData,
): Promise<AddFamilyState> { ): Promise<AddFamilyState> {
// ── 1. Nur Admins dürfen Familien anlegen ────────────────────────── const session = await requireRole([UserRole.ADMIN]);
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]); const kitaId = session.user.kitaId;
// ── 2. Parent-Felder validieren ──────────────────────────────────── if (!kitaId) {
const parsedParent = parentSchema.safeParse(Object.fromEntries(formData)); return { errors: { _form: ["Kein Mandant zugeordnet."] } };
if (!parsedParent.success) {
return { errors: parsedParent.error.flatten().fieldErrors };
} }
const { firstName, lastName, email, childCount } = parsedParent.data;
// ── 3. Kinder-Felder validieren ──────────────────────────────────── const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
const childrenRaw: { firstName: string; lastName: string }[] = []; if (!parsedFamily.success) {
for (let i = 0; i < childCount; i++) { return { errors: parsedFamily.error.flatten().fieldErrors };
}
const optionalParent = optionalParentFromForm(parsedFamily.data);
if (!optionalParent.ok) {
return { errors: optionalParent.errors };
}
const childrenRaw: z.infer<typeof childSchema>[] = [];
for (let i = 0; i < parsedFamily.data.childCount; i++) {
const parsed = childSchema.safeParse({ const parsed = childSchema.safeParse({
firstName: formData.get(`childFirstName_${i}`), firstName: formData.get(`childFirstName_${i}`),
lastName: formData.get(`childLastName_${i}`), lastName: formData.get(`childLastName_${i}`),
}); });
if (!parsed.success) { if (!parsed.success) {
return { return {
errors: { children: [`Kind ${i + 1}: ${Object.values(parsed.error.flatten().fieldErrors).flat().join(", ")}`] }, errors: {
children: [
`Kind ${i + 1}: ${Object.values(parsed.error.flatten().fieldErrors).flat().join(", ")}`,
],
},
}; };
} }
childrenRaw.push(parsed.data); childrenRaw.push(parsed.data);
} }
// ── 4. Datenbank-Transaktion ───────────────────────────────────────
// kitaId kommt ausschließlich aus der Session (Mandanten-Isolation!).
// SUPERADMIN hat keine kitaId → dieser Pfad sollte nie erreicht werden,
// aber wir prüfen explizit, um den Typ zu narrowen.
const kitaId = session.user.kitaId;
if (!kitaId) {
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
}
const mailConfigError = getAppEmailConfigError(); const mailConfigError = getAppEmailConfigError();
if (mailConfigError) { if (mailConfigError) {
return { errors: { _form: [mailConfigError] } }; return { errors: { _form: [mailConfigError] } };
@@ -106,54 +196,68 @@ export async function addFamilyAction(
return { errors: { _form: ["Kita wurde nicht gefunden."] } }; return { errors: { _form: ["Kita wurde nicht gefunden."] } };
} }
const parentName = `${firstName} ${lastName}`; const parents = [
const token = crypto.randomUUID(); {
const inviteUrl = `${BASE_URL}/invite/${token}`; firstName: parsedFamily.data.parent1FirstName,
const expires = new Date( lastName: parsedFamily.data.parent1LastName,
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000, email: parsedFamily.data.parent1Email,
); },
...(optionalParent.parent ? [optionalParent.parent] : []),
];
const emailSet = new Set(parents.map((parent) => parent.email));
if (emailSet.size !== parents.length) {
return {
errors: {
_form: ["Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein."],
},
};
}
const inviteJobs: InviteJob[] = [];
try { try {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
// 4a. Elternteil anlegen (kein Passwort → leerer passwordHash) const family = await tx.family.create({
const parent = await tx.user.create({
data: { data: {
email,
firstName,
lastName,
passwordHash: "", // wird beim Invite-Einlösen gesetzt
role: UserRole.ELTERN,
kitaId, kitaId,
name: parsedFamily.data.familyName,
}, },
}); });
// 4b. Kinder anlegen + mit Elternteil verknüpfen for (const parent of parents) {
for (const child of childrenRaw) { const createdParent = await tx.user.create({
const createdChild = await tx.child.create({
data: { data: {
kitaId, kitaId,
firstName: child.firstName, familyId: family.id,
lastName: child.lastName, email: parent.email,
firstName: parent.firstName,
lastName: parent.lastName,
passwordHash: "",
role: UserRole.ELTERN,
}, },
}); });
const invite = createInviteToken();
await tx.childParent.create({ inviteJobs.push({
to: parent.email,
parentName: `${parent.firstName} ${parent.lastName}`,
inviteUrl: invite.inviteUrl,
});
await tx.verificationToken.create({
data: { data: {
kitaId, identifier: createdParent.id,
childId: createdChild.id, token: invite.token,
userId: parent.id, expires: invite.expires,
}, },
}); });
} }
// 4c. Einladungs-Token erstellen await tx.child.createMany({
// identifier = userId (kein PII im Token selbst) data: childrenRaw.map((child) => ({
await tx.verificationToken.create({ kitaId,
data: { familyId: family.id,
identifier: parent.id, firstName: child.firstName,
token, lastName: child.lastName,
expires, })),
},
}); });
}); });
} catch (err) { } catch (err) {
@@ -163,32 +267,205 @@ export async function addFamilyAction(
) { ) {
return { return {
errors: { errors: {
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."], _form: ["Mindestens eine E-Mail-Adresse existiert bereits."],
}, },
}; };
} }
throw err; throw err;
} }
const emailResult = await sendAppEmail({ const emailError = await sendInviteJobs(kita.name, inviteJobs);
to: email, if (emailError) {
subject: `Einladung zu ${kita.name} im Kita-Planer`,
react: createElement(InviteEmail, {
parentName,
kitaName: kita.name,
inviteLink: inviteUrl,
}),
});
if (!emailResult.success) {
return { return {
errors: { errors: {
_form: [ _form: [
`Familie wurde angelegt, aber die Einladung konnte nicht versendet werden: ${emailResult.error}`, `Familie wurde angelegt, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
], ],
}, },
}; };
} }
revalidatePath("/dashboard/families");
revalidatePath("/dashboard");
return { success: true };
}
export async function updateFamilyAction(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = updateFamilySchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const {
familyId,
familyName,
parents,
newParents,
children,
newChildren,
removedChildIds,
} = parsed.data;
const parentEmails = [...parents, ...newParents].map((parent) => parent.email);
if (new Set(parentEmails).size !== parentEmails.length) {
return { error: "Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein." };
}
const removedChildIdSet = new Set(removedChildIds);
const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id));
if (activeChildren.length + newChildren.length === 0) {
return { error: "Eine Familie benötigt mindestens ein Kind." };
}
if (newParents.length > 0) {
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
}
}
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (!kita) {
return { error: "Kita wurde nicht gefunden." };
}
const inviteJobs: InviteJob[] = [];
try {
const existingFamily = await prisma.family.findFirst({
where: { id: familyId, kitaId },
select: {
id: true,
users: { select: { id: true } },
children: { select: { id: true } },
},
});
if (!existingFamily) {
return { error: "Familie wurde nicht gefunden." };
}
const existingParentIds = new Set(existingFamily.users.map((user) => user.id));
const existingChildIds = new Set(existingFamily.children.map((child) => child.id));
if (!parents.every((parent) => existingParentIds.has(parent.id))) {
return { error: "Mindestens ein Elternteil gehört nicht zu dieser Familie." };
}
if (!children.every((child) => existingChildIds.has(child.id))) {
return { error: "Mindestens ein Kind gehört nicht zu dieser Familie." };
}
if (!removedChildIds.every((childId) => existingChildIds.has(childId))) {
return { error: "Mindestens ein Kind gehört nicht zu dieser Familie." };
}
if (existingParentIds.size + newParents.length > 2) {
return { error: "Pro Familie sind maximal zwei Elternteile vorgesehen." };
}
await prisma.$transaction(async (tx) => {
await tx.family.update({
where: { id: familyId },
data: { name: familyName },
});
for (const parent of parents) {
await tx.user.update({
where: { id: parent.id },
data: {
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
},
});
}
for (const parent of newParents) {
const createdParent = await tx.user.create({
data: {
kitaId,
familyId,
email: parent.email,
firstName: parent.firstName,
lastName: parent.lastName,
passwordHash: "",
role: UserRole.ELTERN,
},
});
const invite = createInviteToken();
inviteJobs.push({
to: parent.email,
parentName: `${parent.firstName} ${parent.lastName}`,
inviteUrl: invite.inviteUrl,
});
await tx.verificationToken.create({
data: {
identifier: createdParent.id,
token: invite.token,
expires: invite.expires,
},
});
}
for (const child of activeChildren) {
await tx.child.update({
where: { id: child.id },
data: {
firstName: child.firstName,
lastName: child.lastName,
},
});
}
for (const childId of removedChildIds) {
await tx.child.delete({ where: { id: childId } });
}
for (const child of newChildren) {
await tx.child.create({
data: {
kitaId,
familyId,
firstName: child.firstName,
lastName: child.lastName,
},
});
}
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
}
console.error("Fehler beim Aktualisieren der Familie:", error);
return { error: "Familie konnte nicht aktualisiert werden." };
}
const emailError = await sendInviteJobs(kita.name, inviteJobs);
if (emailError) {
return {
error: `Familie wurde aktualisiert, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
};
}
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard");
return { success: true }; return { success: true };
} }
@@ -25,6 +25,7 @@ const initialState: AddFamilyState = {};
// Verwaltet: // Verwaltet:
// • Dialog-Open/Close-State // • Dialog-Open/Close-State
// • Dynamische Kinderliste (min. 1, max. 10) // • Dynamische Kinderliste (min. 1, max. 10)
// • Ein oder zwei Elternteile pro Haushalt
// • useActionState → Server-Action-Fehler anzeigen // • useActionState → Server-Action-Fehler anzeigen
// • Bei Erfolg (state.success) Dialog automatisch schließen // • Bei Erfolg (state.success) Dialog automatisch schließen
// ===================================================================== // =====================================================================
@@ -62,8 +63,8 @@ export function AddFamilyDialog() {
<DialogHeader> <DialogHeader>
<DialogTitle>Familie hinzufügen</DialogTitle> <DialogTitle>Familie hinzufügen</DialogTitle>
<DialogDescription> <DialogDescription>
Lege das Elternteil und die zugehörigen Kinder an. Der Elternteil Lege einen Haushalt mit Elternteilen und Kindern an. Die
erhält einen Einladungslink per E-Mail, um sein Passwort zu setzen. Elternteile erhalten Einladungslinks per E-Mail.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -71,34 +72,77 @@ export function AddFamilyDialog() {
{/* Anzahl Kinder als verstecktes Feld für die Server Action */} {/* Anzahl Kinder als verstecktes Feld für die Server Action */}
<input type="hidden" name="childCount" value={childCount} /> <input type="hidden" name="childCount" value={childCount} />
{/* ── Elternteil ─────────────────────────────────────────── */} <FormField
id="familyName"
name="familyName"
label="Familienname"
placeholder="Familie Schmidt"
error={state.errors?.familyName?.[0]}
/>
{/* ── Elternteile ────────────────────────────────────────── */}
<fieldset className="space-y-4"> <fieldset className="space-y-4">
<legend className="text-sm font-semibold">Elternteil</legend> <legend className="text-sm font-semibold">Elternteil 1</legend>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<FormField <FormField
id="firstName" id="parent1FirstName"
name="firstName" name="parent1FirstName"
label="Vorname" label="Vorname"
autoComplete="given-name" autoComplete="given-name"
error={state.errors?.firstName?.[0]} error={state.errors?.parent1FirstName?.[0]}
/> />
<FormField <FormField
id="lastName" id="parent1LastName"
name="lastName" name="parent1LastName"
label="Nachname" label="Nachname"
autoComplete="family-name" autoComplete="family-name"
error={state.errors?.lastName?.[0]} error={state.errors?.parent1LastName?.[0]}
/> />
</div> </div>
<FormField <FormField
id="email" id="parent1Email"
name="email" name="parent1Email"
type="email" type="email"
label="E-Mail-Adresse" label="E-Mail-Adresse"
autoComplete="email" autoComplete="email"
error={state.errors?.email?.[0]} error={state.errors?.parent1Email?.[0]}
/>
</fieldset>
<fieldset className="space-y-4">
<legend className="text-sm font-semibold">
Elternteil 2 <span className="font-normal text-muted-foreground">(optional)</span>
</legend>
<div className="grid grid-cols-2 gap-3">
<FormField
id="parent2FirstName"
name="parent2FirstName"
label="Vorname"
autoComplete="given-name"
required={false}
error={state.errors?.parent2FirstName?.[0]}
/>
<FormField
id="parent2LastName"
name="parent2LastName"
label="Nachname"
autoComplete="family-name"
required={false}
error={state.errors?.parent2LastName?.[0]}
/>
</div>
<FormField
id="parent2Email"
name="parent2Email"
type="email"
label="E-Mail-Adresse"
autoComplete="email"
required={false}
error={state.errors?.parent2Email?.[0]}
/> />
</fieldset> </fieldset>
@@ -199,6 +243,8 @@ function FormField({
label, label,
type = "text", type = "text",
autoComplete, autoComplete,
placeholder,
required = true,
error, error,
}: { }: {
id: string; id: string;
@@ -206,6 +252,8 @@ function FormField({
label: string; label: string;
type?: string; type?: string;
autoComplete?: string; autoComplete?: string;
placeholder?: string;
required?: boolean;
error?: string; error?: string;
}) { }) {
return ( return (
@@ -216,7 +264,8 @@ function FormField({
name={name} name={name}
type={type} type={type}
autoComplete={autoComplete} autoComplete={autoComplete}
required placeholder={placeholder}
required={required}
aria-invalid={!!error} aria-invalid={!!error}
/> />
{error && <p className="text-xs text-destructive">{error}</p>} {error && <p className="text-xs text-destructive">{error}</p>}
+40 -8
View File
@@ -13,7 +13,12 @@ const dutySchema = z.object({
}); });
export async function createDuty(rawPayload: unknown) { export async function createDuty(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = dutySchema.safeParse(rawPayload); const parsed = dutySchema.safeParse(rawPayload);
if (!parsed.success) { if (!parsed.success) {
@@ -23,7 +28,7 @@ export async function createDuty(rawPayload: unknown) {
try { try {
await prisma.parentDuty.create({ await prisma.parentDuty.create({
data: { data: {
kitaId: session.user.kitaId!, kitaId,
name: parsed.data.name, name: parsed.data.name,
description: parsed.data.description, description: parsed.data.description,
}, },
@@ -38,13 +43,17 @@ export async function createDuty(rawPayload: unknown) {
} }
export async function deleteDuty(dutyId: string) { export async function deleteDuty(dutyId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try { try {
await prisma.parentDuty.delete({ await prisma.parentDuty.delete({
where: { where: {
id: dutyId, id: dutyId,
kitaId: session.user.kitaId!, kitaId,
}, },
}); });
revalidatePath("/dashboard/families"); revalidatePath("/dashboard/families");
@@ -57,9 +66,28 @@ export async function deleteDuty(dutyId: string) {
} }
export async function assignDuty(userId: string, dutyId: string) { export async function assignDuty(userId: string, dutyId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try { try {
const [user, duty] = await Promise.all([
prisma.user.findFirst({
where: { id: userId, kitaId },
select: { id: true },
}),
prisma.parentDuty.findFirst({
where: { id: dutyId, kitaId },
select: { id: true },
}),
]);
if (!user || !duty) {
return { error: "Nutzer oder Amt gehört nicht zu dieser Kita." };
}
// Check if assignment already exists // Check if assignment already exists
const existing = await prisma.parentDutyAssignment.findUnique({ const existing = await prisma.parentDutyAssignment.findUnique({
where: { where: {
@@ -73,7 +101,7 @@ export async function assignDuty(userId: string, dutyId: string) {
await prisma.parentDutyAssignment.create({ await prisma.parentDutyAssignment.create({
data: { data: {
kitaId: session.user.kitaId!, kitaId,
userId: userId, userId: userId,
dutyId: dutyId, dutyId: dutyId,
}, },
@@ -89,13 +117,17 @@ export async function assignDuty(userId: string, dutyId: string) {
} }
export async function removeDutyAssignment(assignmentId: string) { export async function removeDutyAssignment(assignmentId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try { try {
await prisma.parentDutyAssignment.delete({ await prisma.parentDutyAssignment.delete({
where: { where: {
id: assignmentId, id: assignmentId,
kitaId: session.user.kitaId!, kitaId,
}, },
}); });
+22 -6
View File
@@ -2,7 +2,6 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Plus, X, Shield, Trash2 } from "lucide-react"; import { Plus, X, Shield, Trash2 } from "lucide-react";
import { ParentDuty, ParentDutyAssignment, User } from "@prisma/client";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -15,13 +14,30 @@ import {
DialogTrigger, DialogTrigger,
} 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 { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { createDuty, assignDuty, removeDutyAssignment, deleteDuty } from "./duty-actions"; import { createDuty, assignDuty, removeDutyAssignment, deleteDuty } from "./duty-actions";
type DutyWithAssignments = ParentDuty & { type DutyWithAssignments = {
assignments: ParentDutyAssignment[]; id: string;
name: string;
assignments: {
id: string;
}[];
};
type DutyUser = {
id: string;
firstName: string;
lastName: string;
};
type UserDutyAssignment = {
id: string;
dutyId: string;
duty: {
name: string;
};
}; };
export function DutyManager({ export function DutyManager({
@@ -29,9 +45,9 @@ export function DutyManager({
allDuties, allDuties,
userAssignments, userAssignments,
}: { }: {
user: User; user: DutyUser;
allDuties: DutyWithAssignments[]; allDuties: DutyWithAssignments[];
userAssignments: (ParentDutyAssignment & { duty: ParentDuty })[]; userAssignments: UserDutyAssignment[];
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -0,0 +1,350 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { Pencil, Plus, 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 { updateFamilyAction } from "./actions";
type EditableParent = {
id: string;
firstName: string;
lastName: string;
email: string;
};
type EditableChild = {
id: string;
firstName: string;
lastName: string;
};
type DraftParent = EditableParent & {
kind: "existing" | "new";
};
type DraftChild = EditableChild & {
kind: "existing" | "new";
};
type EditableFamily = {
id: string;
name: string;
parents: EditableParent[];
children: EditableChild[];
};
export function EditFamilyDialog({ family }: { family: EditableFamily }) {
const [isPending, startTransition] = useTransition();
const initialParents = useMemo(
() =>
family.parents.map((parent) => ({
...parent,
kind: "existing" as const,
})),
[family.parents],
);
const initialChildren = useMemo(
() =>
family.children.map((child) => ({
...child,
kind: "existing" as const,
})),
[family.children],
);
const [parents, setParents] = useState<DraftParent[]>(initialParents);
const [children, setChildren] = useState<DraftChild[]>(initialChildren);
const [removedChildIds, setRemovedChildIds] = useState<string[]>([]);
function resetState() {
setParents(initialParents);
setChildren(initialChildren);
setRemovedChildIds([]);
}
function addParent() {
if (parents.length >= 2) return;
setParents((current) => [
...current,
{
id: `new-parent-${crypto.randomUUID()}`,
firstName: "",
lastName: "",
email: "",
kind: "new",
},
]);
}
function removeNewParent(parent: DraftParent) {
if (parent.kind !== "new") return;
setParents((current) => current.filter((item) => item.id !== parent.id));
}
function addChild() {
setChildren((current) => [
...current,
{
id: `new-child-${crypto.randomUUID()}`,
firstName: "",
lastName: "",
kind: "new",
},
]);
}
function removeChild(child: DraftChild) {
setChildren((current) => current.filter((item) => item.id !== child.id));
if (child.kind === "existing") {
setRemovedChildIds((current) => [...current, child.id]);
}
}
function handleSubmit(formData: FormData) {
const payload = {
familyId: family.id,
familyName: String(formData.get("familyName") ?? ""),
parents: parents
.filter((parent) => parent.kind === "existing")
.map((parent) => ({
id: parent.id,
firstName: String(formData.get(`parentFirstName_${parent.id}`) ?? ""),
lastName: String(formData.get(`parentLastName_${parent.id}`) ?? ""),
email: String(formData.get(`parentEmail_${parent.id}`) ?? ""),
})),
newParents: parents
.filter((parent) => parent.kind === "new")
.map((parent) => ({
firstName: String(formData.get(`parentFirstName_${parent.id}`) ?? ""),
lastName: String(formData.get(`parentLastName_${parent.id}`) ?? ""),
email: String(formData.get(`parentEmail_${parent.id}`) ?? ""),
})),
children: children
.filter((child) => child.kind === "existing")
.map((child) => ({
id: child.id,
firstName: String(formData.get(`childFirstName_${child.id}`) ?? ""),
lastName: String(formData.get(`childLastName_${child.id}`) ?? ""),
})),
newChildren: children
.filter((child) => child.kind === "new")
.map((child) => ({
firstName: String(formData.get(`childFirstName_${child.id}`) ?? ""),
lastName: String(formData.get(`childLastName_${child.id}`) ?? ""),
})),
removedChildIds,
};
startTransition(async () => {
const result = await updateFamilyAction(payload);
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Familie aktualisiert.");
setRemovedChildIds([]);
});
}
return (
<Dialog onOpenChange={(open) => !open && resetState()}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1">
<Pencil className="h-4 w-4" />
<span className="hidden sm:inline">Details</span>
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<form action={handleSubmit}>
<DialogHeader>
<DialogTitle>Familie bearbeiten</DialogTitle>
<DialogDescription>
Aktualisiere Haushalt, Elternteile und Kinder.
</DialogDescription>
</DialogHeader>
<div className="grid gap-6 py-4">
<div className="grid gap-2">
<Label htmlFor={`familyName-${family.id}`}>Familienname</Label>
<Input
id={`familyName-${family.id}`}
name="familyName"
defaultValue={family.name}
required
/>
</div>
<fieldset className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<legend className="text-sm font-semibold">Elternteile</legend>
{parents.length < 2 && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1"
onClick={addParent}
disabled={isPending}
>
<Plus className="h-3.5 w-3.5" />
Elternteil hinzufügen
</Button>
)}
</div>
{parents.map((parent, index) => (
<div key={parent.id} className="rounded-md border p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-medium text-muted-foreground">
Elternteil {index + 1}
</p>
{parent.kind === "new" && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-destructive hover:text-destructive"
onClick={() => removeNewParent(parent)}
disabled={isPending}
>
<Trash2 className="h-3.5 w-3.5" />
Entfernen
</Button>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Field
id={`parentFirstName-${parent.id}`}
name={`parentFirstName_${parent.id}`}
label="Vorname"
defaultValue={parent.firstName}
/>
<Field
id={`parentLastName-${parent.id}`}
name={`parentLastName_${parent.id}`}
label="Nachname"
defaultValue={parent.lastName}
/>
</div>
<div className="mt-3">
<Field
id={`parentEmail-${parent.id}`}
name={`parentEmail_${parent.id}`}
label="E-Mail-Adresse"
type="email"
defaultValue={parent.email}
/>
</div>
</div>
))}
</fieldset>
<fieldset className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<legend className="text-sm font-semibold">Kinder</legend>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1"
onClick={addChild}
disabled={isPending}
>
<Plus className="h-3.5 w-3.5" />
Kind hinzufügen
</Button>
</div>
{children.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keine Kinder verknüpft.
</p>
) : (
children.map((child, index) => (
<div key={child.id} className="rounded-md border p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-medium text-muted-foreground">
Kind {index + 1}
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-destructive hover:text-destructive"
onClick={() => removeChild(child)}
disabled={isPending}
>
<Trash2 className="h-3.5 w-3.5" />
Entfernen
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Field
id={`childFirstName-${child.id}`}
name={`childFirstName_${child.id}`}
label="Vorname"
defaultValue={child.firstName}
/>
<Field
id={`childLastName-${child.id}`}
name={`childLastName_${child.id}`}
label="Nachname"
defaultValue={child.lastName}
/>
</div>
</div>
))
)}
</fieldset>
</div>
<DialogFooter>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichert..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function Field({
id,
name,
label,
type = "text",
defaultValue,
}: {
id: string;
name: string;
label: string;
type?: string;
defaultValue: string;
}) {
return (
<div className="grid gap-2">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
name={name}
type={type}
defaultValue={defaultValue}
required
/>
</div>
);
}
+100 -40
View File
@@ -13,54 +13,79 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { AddFamilyDialog } from "./add-family-dialog"; import { AddFamilyDialog } from "./add-family-dialog";
import { DutyManager } from "./duty-manager"; 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" };
// ===================================================================== // =====================================================================
// /dashboard/families · Server Component // /dashboard/families · Server Component
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Zeigt alle Elternteile (ELTERN) und deren Kinder für die aktuelle // Zeigt alle Haushalte und deren Eltern/Kinder für die aktuelle
// kitaId. Tenant-Filter ist garantiert: kitaId aus requireRole-Session. // kitaId. Tenant-Filter ist garantiert: kitaId aus requireRole-Session.
// ===================================================================== // =====================================================================
export default async function FamiliesPage() { export default async function FamiliesPage() {
// Guard: Nur Admins (und Koordinatoren) dürfen diese Seite sehen // Guard: Nur Admins dürfen die vollständige Familienliste sehen.
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const session = await requireRole([UserRole.ADMIN]);
// Alle ELTERN-User der Kita mit ihren verknüpften Kindern laden const kitaId = session.user.kitaId!;
// Alle Haushalte der Kita mit Elternteilen und Kindern laden.
// kitaId IMMER aus der Session — nie aus URL/Params // kitaId IMMER aus der Session — nie aus URL/Params
const families = await prisma.user.findMany({ const families = await prisma.family.findMany({
where: { where: { kitaId },
kitaId: session.user.kitaId!,
role: UserRole.ELTERN,
},
select: { select: {
id: true, id: true,
firstName: true, name: true,
lastName: true, users: {
email: true,
passwordHash: true, // "" → Invite ausstehend; sonst aktiv
emailVerifiedAt: true,
childLinks: {
select: { select: {
child: { id: true,
select: { id: true, firstName: true, lastName: true }, firstName: true,
lastName: true,
email: true,
emailVerifiedAt: true,
dutyAssignments: {
select: {
id: true,
dutyId: true,
duty: {
select: {
name: true,
},
},
},
}, },
}, },
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
}, },
dutyAssignments: { children: {
include: { duty: true }, select: { id: true, firstName: true, lastName: true },
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
}, },
}, },
orderBy: [{ lastName: "asc" }, { firstName: "asc" }], orderBy: { name: "asc" },
}); });
const allDuties = await prisma.parentDuty.findMany({ const allDuties = await prisma.parentDuty.findMany({
where: { kitaId: session.user.kitaId! }, where: { kitaId },
include: { assignments: true }, select: {
id: true,
name: true,
assignments: {
select: {
id: true,
},
},
},
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}); });
const childCount = families.reduce(
(acc, family) => acc + family.children.length,
0,
);
const canManageDuties = session.user.role === UserRole.ADMIN;
return ( return (
<div className="px-8 py-8"> <div className="px-8 py-8">
{/* Header */} {/* Header */}
@@ -72,7 +97,7 @@ export default async function FamiliesPage() {
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{families.length === 0 {families.length === 0
? "Noch keine Familien angelegt." ? "Noch keine Familien angelegt."
: `${families.length} ${families.length === 1 ? "Familie" : "Familien"} · ${families.reduce((acc, f) => acc + f.childLinks.length, 0)} Kinder`} : `${families.length} ${families.length === 1 ? "Familie" : "Familien"} · ${childCount} Kinder`}
</p> </p>
</div> </div>
<AddFamilyDialog /> <AddFamilyDialog />
@@ -85,8 +110,8 @@ export default async function FamiliesPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Elternteil</TableHead> <TableHead>Familie</TableHead>
<TableHead>E-Mail</TableHead> <TableHead>Elternteile</TableHead>
<TableHead>Kinder</TableHead> <TableHead>Kinder</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead> <TableHead className="text-right">Aktionen</TableHead>
@@ -94,23 +119,35 @@ export default async function FamiliesPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{families.map((family) => { {families.map((family) => {
const isActive = !!family.emailVerifiedAt; const hasPendingInvite = family.users.some(
const children = family.childLinks.map((l) => l.child); (user) => !user.emailVerifiedAt,
);
return ( return (
<TableRow key={family.id}> <TableRow key={family.id}>
<TableCell className="font-medium"> <TableCell className="font-medium">
{family.firstName} {family.lastName} {family.name}
</TableCell>
<TableCell className="text-muted-foreground">
{family.email}
</TableCell> </TableCell>
<TableCell> <TableCell>
{children.length === 0 ? ( <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> <span className="text-sm text-muted-foreground"></span>
) : ( ) : (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{children.map((child) => ( {family.children.map((child) => (
<span <span
key={child.id} key={child.id}
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium" className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium"
@@ -122,16 +159,39 @@ export default async function FamiliesPage() {
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={isActive ? "success" : "warning"}> <Badge variant={hasPendingInvite ? "warning" : "success"}>
{isActive ? "Aktiv" : "Eingeladen"} {hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<DutyManager <div className="flex flex-wrap justify-end gap-1">
user={family as any} <EditFamilyDialog
allDuties={allDuties as any} family={{
userAssignments={family.dutyAssignments} 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> </TableCell>
</TableRow> </TableRow>
); );
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { MitbringselItem } from "@prisma/client";
import { Trash2, Plus, Utensils } from "lucide-react"; import { Trash2, Plus, Utensils } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -9,7 +8,10 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { addMitbringsel, deleteMitbringsel } from "../actions"; import { addMitbringsel, deleteMitbringsel } from "../actions";
type ItemWithUser = MitbringselItem & { type MitbringselItemDto = {
id: string;
userId: string;
content: string;
user: { firstName: string; lastName: string }; user: { firstName: string; lastName: string };
}; };
@@ -20,7 +22,7 @@ export function MitbringselList({
isAdmin, isAdmin,
}: { }: {
terminId: string; terminId: string;
items: ItemWithUser[]; items: MitbringselItemDto[];
currentUserId: string; currentUserId: string;
isAdmin: boolean; isAdmin: boolean;
}) { }) {
@@ -5,17 +5,20 @@ 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 { Check, X, Clock, CalendarIcon } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Termin, User } from "@prisma/client";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { approveTermin, rejectTermin } from "../actions"; import { approveTermin, rejectTermin } from "../actions";
type PendingTermin = Termin & { export type PendingTerminDto = {
id: string;
title: string;
startDate: Date;
allDay: boolean;
createdBy: { firstName: string; lastName: string } | null; createdBy: { firstName: string; lastName: string } | null;
}; };
export function PendingAnfragen({ termine }: { termine: PendingTermin[] }) { 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"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
@@ -37,7 +40,7 @@ export function PendingAnfragen({ termine }: { termine: PendingTermin[] }) {
); );
} }
function PendingTerminCard({ termin }: { termin: PendingTermin }) { function PendingTerminCard({ termin }: { termin: PendingTerminDto }) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const handleApprove = () => { const handleApprove = () => {
@@ -1,11 +1,11 @@
"use client"; "use client";
import { Termin, MitbringselItem, TerminType, TerminStatus } 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 { Calendar, Clock, MapPin, AlignLeft } from "lucide-react"; import { AlignLeft, Calendar, Clock, WashingMachine } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { MitbringselList } from "./mitbringsel-list"; import { MitbringselList } from "./mitbringsel-list";
import { toggleMitbringselList } from "../actions"; import { toggleMitbringselList } from "../actions";
@@ -14,18 +14,43 @@ import { Label } from "@/components/ui/label";
import { useTransition } from "react"; import { useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
type TerminWithItems = Termin & { export type TerminListItemDto = {
mitbringselItems?: (MitbringselItem & { kind: "termin";
id: string;
title: string;
description: string | null;
type: TerminType;
status: TerminStatus;
startDate: Date;
endDate: Date;
allDay: boolean;
mitbringselListEnabled: boolean;
mitbringselItems?: {
id: string;
userId: string;
content: string;
user: { firstName: string; lastName: string }; user: { firstName: string; lastName: string };
})[]; }[];
}; };
export type DutyCalendarItemDto = {
kind: "duty";
id: string;
title: string;
familyName: string;
status: DutyAssignmentStatus;
startDate: Date;
endDate: Date;
};
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto;
export function TerminList({ export function TerminList({
termine, termine,
userId, userId,
isAdmin, isAdmin,
}: { }: {
termine: TerminWithItems[]; termine: CalendarListItemDto[];
userId: string; userId: string;
isAdmin: boolean; isAdmin: boolean;
}) { }) {
@@ -46,23 +71,58 @@ export function TerminList({
return ( return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{termine.map((termin) => ( {termine.map((termin) => (
<TerminCard termin.kind === "duty" ? (
key={termin.id} <DutyCard key={`duty-${termin.id}`} duty={termin} />
termin={termin} ) : (
userId={userId} <TerminCard
isAdmin={isAdmin} key={`termin-${termin.id}`}
/> termin={termin}
userId={userId}
isAdmin={isAdmin}
/>
)
))} ))}
</div> </div>
); );
} }
function DutyCard({ duty }: { duty: DutyCalendarItemDto }) {
return (
<Card className="flex flex-col overflow-hidden border-emerald-200 bg-emerald-50/60">
<CardHeader className="pb-3">
<div className="mb-2 flex items-start justify-between gap-2">
<Badge className="border-transparent bg-emerald-600 text-white hover:bg-emerald-700">
Elterndienst
</Badge>
</div>
<CardTitle className="line-clamp-2 leading-tight">
{duty.title}
</CardTitle>
<CardDescription className="mt-1 flex items-center gap-1 text-sm">
<Clock className="h-3.5 w-3.5" />
{format(duty.startDate, "dd.MM.", { locale: de })} -{" "}
{format(duty.endDate, "dd.MM.yyyy", { locale: de })}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 pb-4">
<div className="flex items-start gap-2 text-sm text-emerald-900">
<WashingMachine className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Eingeteilt: <span className="font-medium">{duty.familyName}</span>
</p>
</div>
</CardContent>
</Card>
);
}
function TerminCard({ function TerminCard({
termin, termin,
userId, userId,
isAdmin, isAdmin,
}: { }: {
termin: TerminWithItems; termin: TerminListItemDto;
userId: string; userId: string;
isAdmin: boolean; isAdmin: boolean;
}) { }) {
+20 -4
View File
@@ -55,6 +55,10 @@ export async function createTerminRequest(rawPayload: unknown) {
export async function createTerminAdmin(rawPayload: unknown) { export async function createTerminAdmin(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); 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); const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) { if (!parsed.success) {
@@ -66,7 +70,7 @@ export async function createTerminAdmin(rawPayload: unknown) {
try { try {
await prisma.termin.create({ await prisma.termin.create({
data: { data: {
kitaId: session.user.kitaId, kitaId,
createdById: session.user.id, createdById: session.user.id,
title: data.title, title: data.title,
description: data.description, description: data.description,
@@ -90,12 +94,16 @@ export async function createTerminAdmin(rawPayload: unknown) {
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;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try { try {
await prisma.termin.update({ await prisma.termin.update({
where: { where: {
id: terminId, id: terminId,
kitaId: session.user.kitaId, kitaId,
}, },
data: { data: {
status: TerminStatus.CONFIRMED, status: TerminStatus.CONFIRMED,
@@ -114,12 +122,16 @@ export async function approveTermin(terminId: string) {
export async function rejectTermin(terminId: string, reason?: string) { export async function rejectTermin(terminId: string, reason?: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try { try {
await prisma.termin.update({ await prisma.termin.update({
where: { where: {
id: terminId, id: terminId,
kitaId: session.user.kitaId, kitaId,
}, },
data: { data: {
status: TerminStatus.REJECTED, status: TerminStatus.REJECTED,
@@ -139,12 +151,16 @@ export async function rejectTermin(terminId: string, reason?: string) {
export async function toggleMitbringselList(terminId: string, enabled: boolean) { export async function toggleMitbringselList(terminId: string, enabled: boolean) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try { try {
await prisma.termin.update({ await prisma.termin.update({
where: { where: {
id: terminId, id: terminId,
kitaId: session.user.kitaId, kitaId,
}, },
data: { data: {
mitbringselListEnabled: enabled, mitbringselListEnabled: enabled,
+110 -22
View File
@@ -1,11 +1,11 @@
import { DutyAssignmentStatus, TerminStatus, UserRole } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { UserRole, TerminStatus } from "@prisma/client";
import { TerminList } from "./_components/termin-list";
import { PendingAnfragen } from "./_components/pending-anfragen";
import { TerminRequestModal } from "./_components/termin-request-modal";
import { AdminTerminModal } from "./_components/admin-termin-modal"; import { AdminTerminModal } from "./_components/admin-termin-modal";
import { CalendarDays } from "lucide-react"; import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
import { TerminList, type CalendarListItemDto } from "./_components/termin-list";
import { TerminRequestModal } from "./_components/termin-request-modal";
export default async function KalenderPage({ export default async function KalenderPage({
searchParams, searchParams,
@@ -14,51 +14,139 @@ export default async function KalenderPage({
}) { }) {
const session = await requireKitaSession(); const session = await requireKitaSession();
const isAdmin = const isAdmin =
session.user.role === UserRole.ADMIN || session.user.role === UserRole.KOORDINATOR; session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.KOORDINATOR;
const currentTab = searchParams.tab || "übersicht"; const currentTab = searchParams.tab || "übersicht";
// Fetch confirmed events const confirmedTermineRows = await prisma.termin.findMany({
const confirmedTermine = await prisma.termin.findMany({
where: { where: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
status: TerminStatus.CONFIRMED, status: TerminStatus.CONFIRMED,
}, },
include: { select: {
id: true,
title: true,
description: true,
type: true,
status: true,
startDate: true,
endDate: true,
allDay: true,
mitbringselListEnabled: true,
mitbringselItems: { mitbringselItems: {
include: { select: {
user: { select: { firstName: true, lastName: true } }, id: true,
userId: true,
content: true,
user: {
select: {
firstName: true,
lastName: true,
},
},
}, },
}, },
}, },
orderBy: { startDate: "asc" }, orderBy: { startDate: "asc" },
}); });
// Fetch user's own pending events const myPendingTermineRows = await prisma.termin.findMany({
const myPendingTermine = await prisma.termin.findMany({
where: { where: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
status: TerminStatus.PENDING, status: TerminStatus.PENDING,
createdById: session.user.id, createdById: session.user.id,
}, },
select: {
id: true,
title: true,
description: true,
type: true,
status: true,
startDate: true,
endDate: true,
allDay: true,
mitbringselListEnabled: true,
mitbringselItems: {
select: {
id: true,
userId: true,
content: true,
user: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
orderBy: { startDate: "asc" }, orderBy: { startDate: "asc" },
}); });
// Combine for general view const dutyAssignmentRows = await prisma.dutyAssignment.findMany({
const allUserTermine = [...confirmedTermine, ...myPendingTermine].sort( where: {
(a, b) => a.startDate.getTime() - b.startDate.getTime() kitaId: session.user.kitaId,
); status: DutyAssignmentStatus.PLANNED,
endDate: { gte: new Date() },
},
select: {
id: true,
startDate: true,
endDate: true,
status: true,
dutyType: {
select: {
name: true,
},
},
family: {
select: {
name: true,
},
},
},
orderBy: { startDate: "asc" },
});
// If admin, fetch all pending events const allUserTermine: CalendarListItemDto[] = [
let allPendingTermine: any[] = []; ...confirmedTermineRows.map((termin) => ({
...termin,
kind: "termin" as const,
})),
...myPendingTermineRows.map((termin) => ({
...termin,
kind: "termin" as const,
})),
...dutyAssignmentRows.map((assignment) => ({
kind: "duty" as const,
id: assignment.id,
title: assignment.dutyType.name,
familyName: assignment.family.name,
status: assignment.status,
startDate: assignment.startDate,
endDate: assignment.endDate,
})),
].sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
let allPendingTermine: PendingTerminDto[] = [];
if (isAdmin) { if (isAdmin) {
allPendingTermine = await prisma.termin.findMany({ allPendingTermine = await prisma.termin.findMany({
where: { where: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
status: TerminStatus.PENDING, status: TerminStatus.PENDING,
}, },
include: { select: {
createdBy: { select: { firstName: true, lastName: true } }, id: true,
title: true,
startDate: true,
allDay: true,
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
}, },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
@@ -94,7 +182,7 @@ export default async function KalenderPage({
</a> </a>
<a <a
href="/dashboard/kalender?tab=anfragen" href="/dashboard/kalender?tab=anfragen"
className={`pb-2 text-sm font-medium transition-colors hover:text-primary flex items-center gap-2 ${ className={`flex items-center gap-2 pb-2 text-sm font-medium transition-colors hover:text-primary ${
currentTab === "anfragen" currentTab === "anfragen"
? "border-b-2 border-primary text-primary" ? "border-b-2 border-primary text-primary"
: "text-muted-foreground" : "text-muted-foreground"
+10 -3
View File
@@ -1,13 +1,20 @@
import Link from "next/link"; import type { Metadata } from "next";
import { Baby, LogOut } from "lucide-react"; import { Baby, LogOut } from "lucide-react";
import { auth, 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 { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { SidebarNav } from "@/components/dashboard/sidebar-nav"; import { SidebarNav } from "@/components/dashboard/sidebar-nav";
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
},
};
// ===================================================================== // =====================================================================
// Dashboard-Layout · Linke Sidebar // Dashboard-Layout · Linke Sidebar
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -47,7 +54,7 @@ export default async function DashboardLayout({
{/* Navigation */} {/* Navigation */}
<div className="flex-1 overflow-y-auto py-4"> <div className="flex-1 overflow-y-auto py-4">
<SidebarNav role={session.user.role as any} /> <SidebarNav role={session.user.role} />
</div> </div>
{/* Footer: User-Info + Abmelden */} {/* Footer: User-Info + Abmelden */}
+155
View File
@@ -0,0 +1,155 @@
"use client";
import { useState, useTransition } from "react";
import { Bell, FileText, Megaphone } from "lucide-react";
import { markAnnouncementRead } from "@/actions/announcements";
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,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { MarkdownContent } from "@/components/markdown-content";
export type NewsTickerItemDto = {
id: string;
title: string;
content: string;
createdAt: string;
authorName: string;
isUnread: boolean;
attachments: {
id: string;
fileName: string;
fileUrl: string;
fileType: string;
}[];
};
export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
const [selected, setSelected] = useState<NewsTickerItemDto | null>(null);
const [readIds, setReadIds] = useState<Set<string>>(new Set());
const [, startTransition] = useTransition();
if (items.length === 0) {
return null;
}
function openAnnouncement(item: NewsTickerItemDto) {
setSelected(item);
if (!item.isUnread || readIds.has(item.id)) return;
setReadIds((current) => new Set(current).add(item.id));
startTransition(async () => {
await markAnnouncementRead(item.id);
});
}
return (
<>
<Card className="mb-8 border-emerald-200 bg-emerald-50/60">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<Megaphone className="h-5 w-5 text-emerald-700" />
Schwarzes Brett
</CardTitle>
<CardDescription>
Die neuesten offiziellen Ankündigungen deiner Kita.
</CardDescription>
</div>
<Badge variant="secondary">{items.length}</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-3 lg:grid-cols-3">
{items.map((item) => {
const isUnread = item.isUnread && !readIds.has(item.id);
return (
<button
key={item.id}
type="button"
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"
>
<div className="mb-3 flex items-start justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Bell className="h-3.5 w-3.5" />
{item.createdAt}
</div>
{isUnread && (
<Badge className="bg-emerald-600 text-white">NEU</Badge>
)}
</div>
<h3 className="line-clamp-2 font-semibold leading-snug">
{item.title}
</h3>
<p className="mt-2 line-clamp-3 text-sm leading-6 text-muted-foreground">
{item.content}
</p>
{item.attachments.length > 0 && (
<p className="mt-3 flex items-center gap-1 text-xs font-medium text-emerald-700">
<FileText className="h-3.5 w-3.5" />
{item.attachments.length} Anhang
{item.attachments.length === 1 ? "" : "e"}
</p>
)}
</button>
);
})}
</CardContent>
</Card>
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
{selected && (
<>
<DialogHeader>
<DialogTitle className="text-2xl">{selected.title}</DialogTitle>
<DialogDescription>
{selected.createdAt} · {selected.authorName}
</DialogDescription>
</DialogHeader>
<div className="rounded-md border bg-muted/30 p-4 text-sm">
<MarkdownContent content={selected.content} />
</div>
{selected.attachments.length > 0 && (
<div>
<p className="mb-2 text-sm font-medium">Anhänge</p>
<div className="grid gap-2">
{selected.attachments.map((attachment) => (
<Button
key={attachment.id}
asChild
variant="outline"
className="justify-start"
>
<a href={attachment.fileUrl} target="_blank" rel="noreferrer">
<FileText className="h-4 w-4" />
{attachment.fileName}
</a>
</Button>
))}
</div>
</div>
)}
</>
)}
</DialogContent>
</Dialog>
</>
);
}
+6 -4
View File
@@ -47,6 +47,9 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
if (!kitaId) { if (!kitaId) {
return { error: "Kein Mandant zugeordnet." }; return { error: "Kein Mandant zugeordnet." };
} }
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
}
// ── 2. Validierung (Soll-Tage) ───────────────────────────────────── // ── 2. Validierung (Soll-Tage) ─────────────────────────────────────
const kita = await prisma.kita.findUniqueOrThrow({ const kita = await prisma.kita.findUniqueOrThrow({
@@ -61,12 +64,12 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
}; };
} }
// Sicherstellen, dass alle Kinder zum angemeldeten User gehören und zur kitaId passen // Sicherstellen, dass alle Kinder zum Haushalt des angemeldeten Users gehören.
const validChildren = await prisma.child.findMany({ const validChildren = await prisma.child.findMany({
where: { where: {
id: { in: childrenIds }, id: { in: childrenIds },
kitaId, kitaId,
parentLinks: { some: { userId: session.user.id } }, familyId: session.user.familyId,
}, },
select: { id: true }, select: { id: true },
}); });
@@ -101,11 +104,10 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
// ── 3. Datenbank-Transaktion ─────────────────────────────────────── // ── 3. Datenbank-Transaktion ───────────────────────────────────────
try { try {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
// a) Alle *eigenen* bestehenden Einträge im Zielmonat für diese Kinder löschen // a) Alle bestehenden Haushaltseinträge im Zielmonat für diese Kinder löschen
await tx.notdienstAvailability.deleteMany({ await tx.notdienstAvailability.deleteMany({
where: { where: {
kitaId, kitaId,
userId: session.user.id,
childId: { in: childrenIds }, childId: { in: childrenIds },
date: { date: {
gte: new Date(targetYear, targetMonth - 1, 1), gte: new Date(targetYear, targetMonth - 1, 1),
@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { ArrowRight, CalendarHeart } from "lucide-react";
import { Button } from "@/components/ui/button";
import { NotdienstForm } from "./notdienst-form";
type NotdienstEntryProps = {
hasAnyAvailability: boolean;
targetYear: number;
targetMonth: number;
isLocked: boolean;
requiredDaysTotal: number;
initialSelectedDates: string[];
childrenIds: string[];
};
export function NotdienstEntry({
hasAnyAvailability,
targetYear,
targetMonth,
isLocked,
requiredDaysTotal,
initialSelectedDates,
childrenIds,
}: 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 (
<NotdienstForm
targetYear={targetYear}
targetMonth={targetMonth}
isLocked={isLocked}
requiredDaysTotal={requiredDaysTotal}
initialSelectedDates={initialSelectedDates}
childrenIds={childrenIds}
/>
);
}
+72 -29
View File
@@ -1,10 +1,12 @@
import Link from "next/link";
import { UserRole } from "@prisma/client"; import { UserRole } from "@prisma/client";
import { Info, Calendar } from "lucide-react"; import { Info, Calendar, ArrowRight, ShieldAlert } 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 } from "@/lib/date-utils"; import { getTargetMonthData } from "@/lib/date-utils";
import { NotdienstForm } from "./notdienst-form"; import { Button } from "@/components/ui/button";
import { NotdienstEntry } from "./notdienst-entry";
export const metadata = { title: "Notdienst · Kita-Planer" }; export const metadata = { title: "Notdienst · Kita-Planer" };
@@ -29,34 +31,51 @@ export default async function NotdienstPage() {
select: { notdienstMinPerChildPerMonth: true }, select: { notdienstMinPerChildPerMonth: true },
}); });
// Nur eigene, aktive Kinder laden // Nur aktive Kinder des eigenen Haushalts laden.
const children = await prisma.child.findMany({ const children = session.user.familyId
where: { ? await prisma.child.findMany({
kitaId, where: {
active: true, kitaId,
parentLinks: { some: { userId: session.user.id } }, familyId: session.user.familyId,
}, active: true,
select: { id: true, firstName: true }, },
}); select: { id: true, firstName: true },
})
: [];
const targetData = getTargetMonthData(); const targetData = getTargetMonthData();
const { targetYear, targetMonth, monthName, isLocked } = targetData; const { targetYear, targetMonth, monthName, isLocked } = targetData;
const requiredDaysTotal = children.length * kita.notdienstMinPerChildPerMonth; const requiredDaysTotal = children.length * kita.notdienstMinPerChildPerMonth;
const childIds = children.map((child) => child.id);
// Bisherige Einträge für den Zielmonat laden // Bisherige Einträge für den Zielmonat laden, haushaltsweit über die Kinder.
// Wir filtern bewusst nach den eigenen Kindern und dem User const [availabilities, availabilityCountForCurrentOrNextMonth] =
const availabilities = await prisma.notdienstAvailability.findMany({ children.length > 0
where: { ? await Promise.all([
kitaId, prisma.notdienstAvailability.findMany({
userId: session.user.id, where: {
date: { kitaId,
gte: new Date(targetYear, targetMonth - 1, 1), childId: { in: childIds },
lt: new Date(targetYear, targetMonth, 1), date: {
}, gte: new Date(targetYear, targetMonth - 1, 1),
}, lt: new Date(targetYear, targetMonth, 1),
select: { date: true }, },
}); },
select: { date: true },
}),
prisma.notdienstAvailability.count({
where: {
kitaId,
childId: { in: childIds },
date: {
gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
lt: new Date(new Date().getFullYear(), new Date().getMonth() + 2, 1),
},
},
}),
])
: [[], 0];
const selectedDates = availabilities.map((a) => a.date.toISOString()); const selectedDates = availabilities.map((a) => a.date.toISOString());
@@ -86,19 +105,43 @@ export default async function NotdienstPage() {
</div> </div>
{children.length === 0 ? ( {children.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground"> <NotdienstLockout />
Deinem Account sind noch keine Kinder zugeordnet.
</div>
) : ( ) : (
<NotdienstForm <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={children.map((c) => c.id)} childrenIds={childIds}
/> />
)} )}
</div> </div>
); );
} }
function NotdienstLockout() {
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="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">
<ShieldAlert className="h-10 w-10" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
Erst Kinder hinterlegen
</h2>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Bitte lege zuerst deine Kinder im Profil an, bevor du Notdienste
übernimmst.
</p>
<Button asChild className="mt-6">
<Link href="/dashboard/profil">
Zum Profil
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
);
}
+22 -7
View File
@@ -37,7 +37,7 @@ export async function generatePlanAction() {
lt: new Date(targetYear, targetMonth, 1), lt: new Date(targetYear, targetMonth, 1),
}, },
}, },
include: { child: true, user: true }, include: { child: { select: { familyId: true } } },
}); });
// 2. Werktage holen // 2. Werktage holen
@@ -63,8 +63,8 @@ export async function generatePlanAction() {
// Finde die Familie, die am wenigsten oft dran war // Finde die Familie, die am wenigsten oft dran war
// Zufall einbauen bei Gleichstand // Zufall einbauen bei Gleichstand
const sorted = availForDay.sort((a, b) => { const sorted = availForDay.sort((a, b) => {
const countA = familyUsageCount[a.userId] || 0; const countA = familyUsageCount[a.child.familyId] || 0;
const countB = familyUsageCount[b.userId] || 0; const countB = familyUsageCount[b.child.familyId] || 0;
if (countA === countB) { if (countA === countB) {
return Math.random() - 0.5; // Zufallsmischung return Math.random() - 0.5; // Zufallsmischung
} }
@@ -79,8 +79,8 @@ export async function generatePlanAction() {
}); });
// Usage-Counter erhöhen // Usage-Counter erhöhen
familyUsageCount[selected.userId] = familyUsageCount[selected.child.familyId] =
(familyUsageCount[selected.userId] || 0) + 1; (familyUsageCount[selected.child.familyId] || 0) + 1;
} }
// 4. In der DB speichern // 4. In der DB speichern
@@ -161,6 +161,19 @@ export async function updateAssignmentAction(
// Neues anlegen, falls ausgewählt // Neues anlegen, falls ausgewählt
if (newChildId) { if (newChildId) {
const child = await tx.child.findFirst({
where: {
id: newChildId,
kitaId,
active: true,
},
select: { id: true },
});
if (!child) {
throw new Error("Kind gehört nicht zu dieser Kita.");
}
await tx.notdienstAssignment.create({ await tx.notdienstAssignment.create({
data: { data: {
kitaId, kitaId,
@@ -174,7 +187,9 @@ export async function updateAssignmentAction(
revalidatePath("/dashboard/notdienst/plan"); revalidatePath("/dashboard/notdienst/plan");
return { success: true }; return { success: true };
} catch (error: any) { } catch (error) {
return { error: error.message || "Update fehlgeschlagen." }; return {
error: error instanceof Error ? error.message : "Update fehlgeschlagen.",
};
} }
} }
+27 -7
View File
@@ -19,9 +19,15 @@ export default async function PlanungsZentralePage() {
where: { where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth }, kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
}, },
include: { select: {
id: true,
status: true,
assignments: { assignments: {
include: { child: { include: { parentLinks: { include: { user: true } } } } }, select: {
id: true,
childId: true,
date: true,
},
}, },
}, },
}); });
@@ -34,16 +40,30 @@ export default async function PlanungsZentralePage() {
// aber für MVP reichen alle aktiven Kinder. // aber für MVP reichen alle aktiven Kinder.
const allChildrenRaw = await prisma.child.findMany({ const allChildrenRaw = await prisma.child.findMany({
where: { kitaId, active: true }, where: { kitaId, active: true },
include: { select: {
parentLinks: { include: { user: true } }, id: true,
firstName: true,
lastName: true,
family: {
select: {
name: true,
users: {
select: {
firstName: true,
lastName: true,
},
},
},
},
}, },
orderBy: { lastName: "asc" }, orderBy: { lastName: "asc" },
}); });
const allChildren = allChildrenRaw.map((c) => { const allChildren = allChildrenRaw.map((c) => {
// Einfachheit halber nehmen wir den ersten verknüpften Elternteil zur Anzeige const parentName =
const parent = c.parentLinks[0]?.user; c.family.users
const parentName = parent ? `${parent.firstName} ${parent.lastName}` : "Unbekannt"; .map((parent) => `${parent.firstName} ${parent.lastName}`)
.join(", ") || c.family.name;
return { return {
id: c.id, id: c.id,
name: `${c.firstName} ${c.lastName}`, name: `${c.firstName} ${c.lastName}`,
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, 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 { NotdienstPlanStatus } from "@prisma/client"; import { NotdienstPlanStatus } from "@prisma/client";
+247 -16
View File
@@ -1,6 +1,8 @@
import Link from "next/link"; import Link from "next/link";
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle } from "lucide-react"; import { format } from "date-fns";
import { UserRole, NotdienstPlanStatus } from "@prisma/client"; import { de } from "date-fns/locale";
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle, ClipboardList } from "lucide-react";
import { DutyAssignmentStatus, UserRole, NotdienstPlanStatus } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -13,7 +15,10 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { OnboardingDialog } from "@/components/OnboardingDialog";
import { AlertButton } from "./alert-button"; import { AlertButton } from "./alert-button";
import { AbsenceCard } from "./absence-card";
import { NewsTicker } from "./news-ticker";
export const metadata = { title: "Übersicht · Kita-Planer" }; export const metadata = { title: "Übersicht · Kita-Planer" };
@@ -30,22 +35,166 @@ export default async function DashboardPage() {
}, },
}); });
// Schnelle Statistiken für die Übersicht const today = new Date();
const [familyCount, childCount] = await Promise.all([ today.setHours(0, 0, 0, 0);
prisma.user.count({
where: { kitaId: session.user.kitaId, role: UserRole.ELTERN }, // Schnelle Statistiken und persönliche Dienste für die Übersicht.
const [
familyCount,
childCount,
upcomingDuties,
onboardingUser,
absenceChildren,
upcomingAbsences,
latestAnnouncements,
] = await Promise.all([
prisma.family.count({
where: { kitaId: session.user.kitaId },
}), }),
prisma.child.count({ prisma.child.count({
where: { kitaId: session.user.kitaId }, where: { kitaId: session.user.kitaId },
}), }),
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({
where: {
id: session.user.id,
kitaId: session.user.kitaId,
},
select: {
phone: true,
street: true,
postalCode: true,
city: true,
familyId: true,
role: true,
family: {
select: {
_count: {
select: {
children: true,
},
},
},
},
},
}),
session.user.familyId
? prisma.child.findMany({
where: {
kitaId: session.user.kitaId,
familyId: session.user.familyId,
active: true,
},
select: {
id: true,
firstName: true,
lastName: true,
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
})
: Promise.resolve([]),
session.user.familyId
? prisma.absence.findMany({
where: {
kitaId: session.user.kitaId,
endDate: { gte: today },
child: {
familyId: session.user.familyId,
},
},
select: {
id: true,
reason: true,
note: true,
startDate: true,
endDate: true,
child: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ startDate: "asc" }],
take: 6,
})
: 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 = const isAdmin =
session.user.role === UserRole.ADMIN || session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.SUPERADMIN; session.user.role === UserRole.SUPERADMIN;
const canReportAbsences = session.user.role !== UserRole.ERZIEHER;
return ( return (
<div className="px-8 py-8"> <div className="px-8 py-8">
<OnboardingDialog
user={{
phone: onboardingUser.phone,
street: onboardingUser.street,
postalCode: onboardingUser.postalCode,
city: onboardingUser.city,
familyId: onboardingUser.familyId,
role: onboardingUser.role,
}}
family={
onboardingUser.family
? { childrenCount: onboardingUser.family._count.children }
: null
}
/>
<div className="mb-8"> <div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
Willkommen, {session.user.name?.split(" ")[0] ?? "zusammen"}! Willkommen, {session.user.name?.split(" ")[0] ?? "zusammen"}!
@@ -55,6 +204,25 @@ export default async function DashboardPage() {
</p> </p>
</div> </div>
<NewsTicker
items={latestAnnouncements.map((announcement) => ({
id: announcement.id,
title: announcement.title,
content: announcement.content,
createdAt: format(announcement.createdAt, "dd.MM.yyyy", {
locale: de,
}),
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) */} {/* Heutiger Notdienst (Nur für Admins/Koordinatoren) */}
{isAdmin && ( {isAdmin && (
<div className="mb-8"> <div className="mb-8">
@@ -62,10 +230,53 @@ export default async function DashboardPage() {
</div> </div>
)} )}
{canReportAbsences && (
<AbsenceCard
today={format(today, "yyyy-MM-dd")}
childOptions={absenceChildren.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
}))}
absences={upcomingAbsences.map((absence) => ({
id: absence.id,
childName: `${absence.child.firstName} ${absence.child.lastName}`,
reason: absence.reason,
note: absence.note,
startDate: format(absence.startDate, "yyyy-MM-dd"),
endDate: format(absence.endDate, "yyyy-MM-dd"),
}))}
/>
)}
{upcomingDuties.length > 0 && (
<Card className="mb-8 border-emerald-200 bg-emerald-50/50">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ClipboardList className="h-5 w-5 text-emerald-700" />
Deine anstehenden Dienste
</CardTitle>
<CardDescription>
Die nächsten Einteilungen für deinen Haushalt.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{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 */} {/* Statistik-Kacheln */}
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard <StatCard
label="Elternteile" label="Familien"
value={familyCount} value={familyCount}
description="registrierte Familien" description="registrierte Familien"
icon={<Users className="h-5 w-5 text-muted-foreground" />} icon={<Users className="h-5 w-5 text-muted-foreground" />}
@@ -147,15 +358,38 @@ async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
date: today, date: today,
plan: { status: NotdienstPlanStatus.PUBLISHED }, plan: { status: NotdienstPlanStatus.PUBLISHED },
}, },
include: { select: {
child: { include: { parentLinks: { include: { user: true } } } }, id: true,
alerts: true, // Um zu sehen, ob schon ein Alarm ausgelöst wurde child: {
select: {
firstName: true,
lastName: true,
family: {
select: {
name: true,
users: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
},
alerts: {
select: {
status: true,
},
},
}, },
}); });
if (!assignment) return null; if (!assignment) return null;
const parent = assignment.child.parentLinks[0]?.user; const parentNames = assignment.child.family.users
.map((parent) => `${parent.firstName} ${parent.lastName}`)
.join(", ");
const existingAlert = assignment.alerts[0]; // Kann PENDING, CONFIRMED oder CANCELLED sein const existingAlert = assignment.alerts[0]; // Kann PENDING, CONFIRMED oder CANCELLED sein
return ( return (
@@ -172,7 +406,7 @@ async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<p className="font-medium text-base"> <p className="font-medium text-base">
{parent ? `${parent.firstName} ${parent.lastName}` : "Unbekannt"} {parentNames || assignment.child.family.name}
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Kind: {assignment.child.firstName} {assignment.child.lastName} Kind: {assignment.child.firstName} {assignment.child.lastName}
@@ -198,10 +432,7 @@ async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
: "Abgebrochen"} : "Abgebrochen"}
</Badge> </Badge>
) : ( ) : (
<AlertButton <AlertButton assignmentId={assignment.id} />
assignmentId={assignment.id}
parentUserId={parent?.id || ""}
/>
)} )}
</div> </div>
</CardContent> </CardContent>
@@ -0,0 +1,98 @@
"use client";
import { useTransition } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateMyContact } from "../actions";
type ContactFormProps = {
contact: {
phone: string | null;
street: string | null;
postalCode: string | null;
city: string | null;
};
};
export function ContactForm({ contact }: ContactFormProps) {
const [isPending, startTransition] = useTransition();
function handleSubmit(formData: FormData) {
startTransition(async () => {
const result = await updateMyContact({
phone: String(formData.get("phone") ?? ""),
street: String(formData.get("street") ?? ""),
postalCode: String(formData.get("postalCode") ?? ""),
city: String(formData.get("city") ?? ""),
});
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Kontaktdaten gespeichert.");
});
}
return (
<form action={handleSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="phone">Telefonnummer</Label>
<Input
id="phone"
name="phone"
type="tel"
defaultValue={contact.phone ?? ""}
placeholder="+49 30 123456"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="street">Straße und Hausnummer</Label>
<Input
id="street"
name="street"
defaultValue={contact.street ?? ""}
placeholder="Beispielweg 12"
/>
</div>
<div className="grid gap-3 sm:grid-cols-[120px_1fr]">
<div className="grid gap-2">
<Label htmlFor="postalCode">PLZ</Label>
<Input
id="postalCode"
name="postalCode"
defaultValue={contact.postalCode ?? ""}
placeholder="10115"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="city">Ort</Label>
<Input
id="city"
name="city"
defaultValue={contact.city ?? ""}
placeholder="Berlin"
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Speichern
</Button>
</div>
</form>
);
}
@@ -0,0 +1,92 @@
"use client";
import { useState, useTransition } from "react";
import { Home, Loader2 } 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 { createMyFamily } from "../actions";
export function CreateFamilyDialog({
defaultFamilyName,
}: {
defaultFamilyName: string;
}) {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
function handleSubmit(formData: FormData) {
startTransition(async () => {
const result = await createMyFamily({
familyName: String(formData.get("familyName") ?? ""),
});
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Haushalt angelegt.");
setOpen(false);
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" size="sm" className="gap-2">
<Home className="h-4 w-4" />
Haushalt anlegen
</Button>
</DialogTrigger>
<DialogContent>
<form action={handleSubmit}>
<DialogHeader>
<DialogTitle>Eigenen Haushalt anlegen</DialogTitle>
<DialogDescription>
Dein Account wird diesem Haushalt zugeordnet. Danach kannst du
deine Kinder im Profil verwalten.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-4">
<Label htmlFor="familyName">Haushaltsname</Label>
<Input
id="familyName"
name="familyName"
defaultValue={defaultFamilyName}
placeholder="Familie Beispiel"
required
/>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Haushalt speichern
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -56,8 +56,10 @@ export function DeleteAccountDialog() {
</DialogTitle> </DialogTitle>
<DialogDescription className="text-base pt-2"> <DialogDescription className="text-base pt-2">
Diese Aktion kann <strong className="text-foreground">nicht</strong> rückgängig gemacht werden. Diese Aktion kann <strong className="text-foreground">nicht</strong> rückgängig gemacht werden.
Alle deine personenbezogenen Daten, Verknüpfungen zu deinen Kindern, gebuchten Notdienste Wenn du das letzte Elternteil im Haushalt bist, wird die gesamte
und Mitbringsel-Einträge werden DSGVO-konform sofort und unwiderruflich aus der Datenbank gelöscht. Familie inklusive Kinder gelöscht. Gibt es ein weiteres Elternteil,
bleibt der Haushalt für diese Person erhalten und nur dein Account
wird entfernt.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -0,0 +1,243 @@
"use client";
import { useState, useTransition } from "react";
import { Pencil, Plus, 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 { createMyChild, deleteMyChild, updateMyChild } from "../actions";
export type MyChild = {
id: string;
firstName: string;
lastName: string;
dateOfBirth: string | null;
};
export function MyChildrenManager({
items,
canManage,
}: {
items: MyChild[];
canManage: boolean;
}) {
const [createOpen, setCreateOpen] = useState(false);
const [editingChild, setEditingChild] = useState<MyChild | null>(null);
const [isPending, startTransition] = useTransition();
function payloadFromForm(formData: FormData) {
return {
firstName: String(formData.get("firstName") ?? ""),
lastName: String(formData.get("lastName") ?? ""),
dateOfBirth: String(formData.get("dateOfBirth") ?? ""),
};
}
function handleCreate(formData: FormData) {
startTransition(async () => {
const result = await createMyChild(payloadFromForm(formData));
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Kind angelegt.");
setCreateOpen(false);
});
}
function handleUpdate(formData: FormData) {
if (!editingChild) return;
startTransition(async () => {
const result = await updateMyChild(
editingChild.id,
payloadFromForm(formData),
);
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Kind aktualisiert.");
setEditingChild(null);
});
}
function handleDelete(child: MyChild) {
const confirmed = confirm(
`${child.firstName} ${child.lastName} wirklich entfernen?`,
);
if (!confirmed) return;
startTransition(async () => {
const result = await deleteMyChild(child.id);
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Kind entfernt.");
});
}
return (
<div className="space-y-3">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Kinder verknüpft.</p>
) : (
<div className="flex flex-col gap-2">
{items.map((child) => (
<div
key={child.id}
className="flex items-center justify-between gap-3 rounded-md bg-muted/50 p-2"
>
<div>
<p className="font-medium">
{child.firstName} {child.lastName}
</p>
{child.dateOfBirth && (
<p className="text-xs text-muted-foreground">
Geboren am {new Date(child.dateOfBirth).toLocaleDateString("de-DE")}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setEditingChild(child)}
disabled={isPending || !canManage}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(child)}
disabled={isPending || !canManage}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{canManage ? (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm" className="gap-2">
<Plus className="h-4 w-4" />
Kind hinzufügen
</Button>
</DialogTrigger>
<DialogContent>
<form action={handleCreate}>
<DialogHeader>
<DialogTitle>Kind hinzufügen</DialogTitle>
<DialogDescription>
Dieses Kind wird mit deinem Haushalt verknüpft.
</DialogDescription>
</DialogHeader>
<ChildFields />
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setCreateOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
Speichern
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
) : (
<p className="text-xs text-muted-foreground">
Dein Account ist noch keinem Haushalt zugeordnet.
</p>
)}
<Dialog
open={!!editingChild}
onOpenChange={(open) => !open && setEditingChild(null)}
>
<DialogContent>
<form action={handleUpdate}>
<DialogHeader>
<DialogTitle>Kind bearbeiten</DialogTitle>
</DialogHeader>
{editingChild && <ChildFields child={editingChild} />}
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setEditingChild(null)}
disabled={isPending}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
Aktualisieren
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
function ChildFields({ child }: { child?: MyChild }) {
return (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="child-firstName">Vorname</Label>
<Input
id="child-firstName"
name="firstName"
defaultValue={child?.firstName ?? ""}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="child-lastName">Nachname</Label>
<Input
id="child-lastName"
name="lastName"
defaultValue={child?.lastName ?? ""}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="child-dateOfBirth">Geburtsdatum</Label>
<Input
id="child-dateOfBirth"
name="dateOfBirth"
type="date"
defaultValue={child?.dateOfBirth ?? ""}
/>
</div>
</div>
);
}
+247 -7
View File
@@ -1,24 +1,264 @@
"use server"; "use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { signOut } from "@/auth"; // Note: this is NextAuth v5 server side signOut if needed, but we do client side
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(),
});
const familySchema = z.object({
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
});
const contactSchema = z.object({
phone: z.string().trim().max(50).optional(),
street: z.string().trim().max(120).optional(),
postalCode: z.string().trim().max(20).optional(),
city: z.string().trim().max(100).optional(),
});
function parseDateInput(value?: string) {
if (!value) return null;
const date = new Date(`${value}T00:00:00`);
return Number.isNaN(date.getTime()) ? null : date;
}
async function requireOwnFamilyChild(
childId: string,
familyId: string | null,
kitaId: string,
) {
if (!familyId) return null;
return prisma.child.findFirst({
where: { id: childId, familyId, kitaId },
});
}
export async function createMyChild(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = childSchema.safeParse(rawPayload);
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
}
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
await prisma.child.create({
data: {
kitaId: session.user.kitaId,
familyId: session.user.familyId,
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
},
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
return { success: true };
} catch (error) {
console.error("Fehler beim Anlegen des Kindes:", error);
return { error: "Das Kind konnte nicht angelegt werden." };
}
}
export async function createMyFamily(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = familySchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Bitte gib einen gültigen Familiennamen ein." };
}
if (session.user.familyId) {
return { error: "Dein Account ist bereits einem Haushalt zugeordnet." };
}
try {
await prisma.$transaction(async (tx) => {
const family = await tx.family.create({
data: {
kitaId: session.user.kitaId,
name: parsed.data.familyName,
},
});
await tx.user.update({
where: { id: session.user.id },
data: { familyId: family.id },
});
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/adressbuch");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
return { error: "Der Haushalt konnte nicht angelegt werden." };
}
}
export async function updateMyContact(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = contactSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Kontaktdaten." };
}
try {
await prisma.user.update({
where: { id: session.user.id },
data: {
phone: parsed.data.phone || null,
street: parsed.data.street || null,
postalCode: parsed.data.postalCode || null,
city: parsed.data.city || null,
},
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard");
revalidatePath("/dashboard/adressbuch");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren der Kontaktdaten:", error);
return { error: "Kontaktdaten konnten nicht gespeichert werden." };
}
}
export async function updateMyChild(childId: string, rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = childSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
const child = await requireOwnFamilyChild(
childId,
session.user.familyId,
session.user.kitaId,
);
if (!child) {
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
}
await prisma.child.update({
where: { id: childId },
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
},
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren des Kindes:", error);
return { error: "Das Kind konnte nicht aktualisiert werden." };
}
}
export async function deleteMyChild(childId: string) {
const session = await requireKitaSession();
try {
const child = await requireOwnFamilyChild(
childId,
session.user.familyId,
session.user.kitaId,
);
if (!child) {
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
}
await prisma.child.delete({
where: { id: childId },
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
return { success: true };
} catch (error) {
console.error("Fehler beim Entfernen des Kindes:", error);
return { error: "Das Kind konnte nicht entfernt werden." };
}
}
export async function deleteMyAccount() { export async function deleteMyAccount() {
const session = await requireKitaSession(); const session = await requireKitaSession();
try { try {
// Da wir onDelete: Cascade überall konfiguriert haben, const user = await prisma.user.findFirst({
// löscht dieser eine Befehl den User, seine ChildParent-Links,
// seine MitbringselItems, seine NotdienstAvailabilities etc.
await prisma.user.delete({
where: { where: {
id: session.user.id, id: session.user.id,
kitaId: session.user.kitaId,
},
select: {
id: true,
familyId: true,
}, },
}); });
// The client component will trigger the actual NextAuth client signOut, if (!user) {
// but returning success indicates the DB deletion was successful. return { error: "Account wurde nicht gefunden." };
}
if (!user.familyId) {
await prisma.user.delete({
where: { id: user.id },
});
return { success: true };
}
const family = await prisma.family.findFirst({
where: {
id: user.familyId,
kitaId: session.user.kitaId,
},
select: {
id: true,
users: {
select: { id: true },
},
},
});
if (!family) {
return { error: "Haushalt wurde nicht gefunden." };
}
if (family.users.length <= 1) {
await prisma.family.delete({
where: { id: family.id },
});
return { success: true };
}
await prisma.user.delete({
where: { id: user.id },
});
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Löschen des Accounts:", error); console.error("Fehler beim Löschen des Accounts:", error);
+108 -21
View File
@@ -2,11 +2,22 @@ import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; 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 { UserCircle, Mail, Baby, Shield, CalendarHeart } from "lucide-react"; import {
Baby,
CalendarHeart,
Home,
Mail,
Phone,
Shield,
UserCircle,
} from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { DeleteAccountDialog } from "./_components/delete-account-dialog"; import { DeleteAccountDialog } from "./_components/delete-account-dialog";
import { MyChildrenManager } from "./_components/my-children-manager";
import { CreateFamilyDialog } from "./_components/create-family-dialog";
import { ContactForm } from "./_components/contact-form";
export const metadata = { title: "Mein Profil · Kita-Planer" }; export const metadata = { title: "Mein Profil · Kita-Planer" };
@@ -15,12 +26,41 @@ export default async function ProfilPage() {
const user = await prisma.user.findUniqueOrThrow({ const user = await prisma.user.findUniqueOrThrow({
where: { id: session.user.id }, where: { id: session.user.id },
include: { select: {
childLinks: { id: true,
include: { child: true }, email: true,
firstName: true,
lastName: true,
role: true,
familyId: true,
phone: true,
street: true,
postalCode: true,
city: true,
createdAt: true,
family: {
select: {
name: true,
children: {
select: {
id: true,
firstName: true,
lastName: true,
dateOfBirth: true,
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
},
},
}, },
dutyAssignments: { dutyAssignments: {
include: { duty: true }, select: {
id: true,
duty: {
select: {
name: true,
},
},
},
}, },
}, },
}); });
@@ -56,12 +96,39 @@ export default async function ProfilPage() {
</div> </div>
<div className="font-medium">{user.email}</div> <div className="font-medium">{user.email}</div>
</div> </div>
<div>
<div className="text-sm text-muted-foreground flex items-center gap-1">
<Phone className="h-3.5 w-3.5" /> Telefon
</div>
<div className="font-medium">
{user.phone ?? "Noch nicht hinterlegt"}
</div>
</div>
<div> <div>
<div className="text-sm text-muted-foreground">Rolle im Verein</div> <div className="text-sm text-muted-foreground">Rolle im Verein</div>
<Badge variant={user.role === "ELTERN" ? "secondary" : "default"} className="mt-1"> <Badge variant={user.role === "ELTERN" ? "secondary" : "default"} className="mt-1">
{user.role} {user.role}
</Badge> </Badge>
</div> </div>
<div>
<div className="text-sm text-muted-foreground flex items-center gap-1">
<Home className="h-3.5 w-3.5" /> Haushalt
</div>
{user.family ? (
<div className="font-medium">{user.family.name}</div>
) : (
<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="pt-2 border-t mt-2">
<div className="text-sm text-muted-foreground flex items-center gap-1"> <div className="text-sm text-muted-foreground flex items-center gap-1">
<CalendarHeart className="h-3.5 w-3.5" /> Mitglied seit <CalendarHeart className="h-3.5 w-3.5" /> Mitglied seit
@@ -74,6 +141,29 @@ export default async function ProfilPage() {
</Card> </Card>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Phone className="h-5 w-5" />
Kontaktdaten
</CardTitle>
<CardDescription>
Diese Angaben helfen bei Notdienst, Adressbuch und Abstimmung in
der Kita.
</CardDescription>
</CardHeader>
<CardContent>
<ContactForm
contact={{
phone: user.phone,
street: user.street,
postalCode: user.postalCode,
city: user.city,
}}
/>
</CardContent>
</Card>
{/* Kinder */} {/* Kinder */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -83,19 +173,17 @@ export default async function ProfilPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{user.childLinks.length === 0 ? ( <MyChildrenManager
<p className="text-sm text-muted-foreground">Keine Kinder verknüpft.</p> items={(user.family?.children ?? []).map((child) => ({
) : ( id: child.id,
<div className="flex flex-col gap-2"> firstName: child.firstName,
{user.childLinks.map((link) => ( lastName: child.lastName,
<div key={link.child.id} className="flex items-center justify-between p-2 rounded-md bg-muted/50"> dateOfBirth: child.dateOfBirth
<span className="font-medium"> ? child.dateOfBirth.toISOString().slice(0, 10)
{link.child.firstName} {link.child.lastName} : null,
</span> }))}
</div> canManage={!!user.familyId}
))} />
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -124,12 +212,11 @@ export default async function ProfilPage() {
</div> </div>
</div> </div>
{/* Danger Zone */}
<Card className="border-destructive/20 mt-8"> <Card className="border-destructive/20 mt-8">
<CardHeader> <CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription> <CardDescription>
Hier kannst du dein Profil und alle zugehörigen Daten DSGVO-konform löschen. Hier kannst du dein Profil und je nach Haushaltssituation auch die
zugehörigen Familiendaten DSGVO-konform löschen.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
+226
View File
@@ -0,0 +1,226 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
ArrowLeft,
DatabaseZap,
LockKeyhole,
Server,
ShieldCheck,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
export const metadata: Metadata = {
title: "Datenschutz | Kita-Planer",
description: "Datenschutzhinweise und Platzhalter für Kita-Planer.",
};
const overviewItems = [
{
icon: ShieldCheck,
title: "Verantwortliche Stelle",
text: "[Name / Organisation], [Adresse], [E-Mail-Adresse]",
},
{
icon: DatabaseZap,
title: "Verarbeitete Daten",
text: "Accountdaten, Familiendaten, Kita-Zuordnung, Dienste, Termine und Nachrichten.",
},
{
icon: Server,
title: "Hosting",
text: "[Hosting-Anbieter], [Serverstandort], [Auftragsverarbeitung vorhanden?]",
},
{
icon: LockKeyhole,
title: "Schutzmaßnahmen",
text: "Mandantentrennung, Rollenrechte, Session-Prüfung und Zugriffsbeschränkungen.",
},
];
export default function DatenschutzPage() {
return (
<main className="min-h-screen bg-[#f8faf8] text-slate-950">
<div className="mx-auto w-full max-w-5xl px-5 py-8 sm:px-6 lg:py-12">
<Button asChild variant="ghost" className="-ml-3 mb-8">
<Link href="/">
<ArrowLeft className="h-4 w-4" />
Zur Startseite
</Link>
</Button>
<section className="mb-10 rounded-lg bg-slate-950 px-6 py-10 text-white sm:px-10">
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-lg bg-emerald-300/12 text-emerald-200 ring-1 ring-emerald-300/20">
<ShieldCheck className="h-6 w-6" />
</div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-200">
Datenschutz
</p>
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">
Datenschutzerklärung
</h1>
<p className="mt-5 max-w-2xl text-sm leading-6 text-slate-300">
Diese Datenschutzerklärung ist ein Platzhalter. Bitte ersetze die
markierten Angaben und lasse den finalen Text vor Veröffentlichung
rechtlich prüfen.
</p>
</section>
<div className="grid gap-5 md:grid-cols-2">
{overviewItems.map((item) => {
const Icon = item.icon;
return (
<Card key={item.title} className="border-slate-200 bg-white">
<CardContent className="p-5">
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<Icon className="h-5 w-5" />
</div>
<h2 className="font-semibold tracking-tight">
{item.title}
</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
{item.text}
</p>
</CardContent>
</Card>
);
})}
</div>
<section className="mt-10 space-y-8 rounded-lg border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
<div>
<h2 className="text-xl font-semibold tracking-tight">
1. Verantwortliche Stelle
</h2>
<p className="mt-3 leading-7 text-slate-700">
Verantwortlich für die Datenverarbeitung ist:
<br />
[Name / Organisation]
<br />
[Straße Hausnummer]
<br />
[PLZ Ort]
<br />
E-Mail: [E-Mail-Adresse]
</p>
</div>
<div>
<h2 className="text-xl font-semibold tracking-tight">
2. Zwecke der Datenverarbeitung
</h2>
<p className="mt-3 leading-7 text-slate-700">
Wir verarbeiten personenbezogene Daten, um die Nutzung von
Kita-Planer zu ermöglichen. Dazu gehören insbesondere Login,
Rollenverwaltung, Familien- und Kinderdaten, Dienste,
Terminplanung, Abwesenheiten, Kontaktfreigaben und offizielle
Mitteilungen.
</p>
</div>
<div>
<h2 className="text-xl font-semibold tracking-tight">
3. Kategorien personenbezogener Daten
</h2>
<p className="mt-3 leading-7 text-slate-700">
Verarbeitet werden je nach Nutzung insbesondere Name,
E-Mail-Adresse, Telefonnummer, Adresse, Rollen im System,
Kita-Zugehörigkeit, Familiendaten, Kinderdaten, Dienst- und
Terminzuordnungen, Abwesenheitsmeldungen sowie technische
Nutzungsdaten wie Sitzungsinformationen.
</p>
</div>
<div>
<h2 className="text-xl font-semibold tracking-tight">
4. Rechtsgrundlagen
</h2>
<p className="mt-3 leading-7 text-slate-700">
Die Verarbeitung erfolgt je nach Kontext auf Grundlage von Art. 6
Abs. 1 lit. b DSGVO zur Vertragserfüllung, Art. 6 Abs. 1 lit. f
DSGVO aufgrund berechtigter Interessen an einer geordneten
Kita-Organisation oder auf Grundlage einer Einwilligung gemäß Art.
6 Abs. 1 lit. a DSGVO, zum Beispiel bei freiwilligen
Kontaktfreigaben.
</p>
</div>
<div>
<h2 className="text-xl font-semibold tracking-tight">
5. Empfänger und Auftragsverarbeiter
</h2>
<p className="mt-3 leading-7 text-slate-700">
Zugriff auf Daten erhalten nur berechtigte Nutzerinnen und Nutzer
innerhalb der jeweiligen Kita beziehungsweise des jeweiligen
Elternvereins. Für Hosting, E-Mail-Versand oder technische
Dienste können Auftragsverarbeiter eingesetzt werden:
[Hosting-Anbieter], [E-Mail-Anbieter], [weitere Dienstleister].
</p>
</div>
<div>
<h2 className="text-xl font-semibold tracking-tight">
6. Speicherdauer und Löschung
</h2>
<p className="mt-3 leading-7 text-slate-700">
Personenbezogene Daten werden nur so lange gespeichert, wie sie
für die genannten Zwecke erforderlich sind oder gesetzliche
Aufbewahrungspflichten bestehen. Nutzerkonten und zugehörige Daten
können nach Maßgabe der Systemfunktionen und gesetzlichen Vorgaben
gelöscht werden.
</p>
</div>
<div>
<h2 className="text-xl font-semibold tracking-tight">
7. Cookies und Sitzungen
</h2>
<p className="mt-3 leading-7 text-slate-700">
Kita-Planer verwendet technisch notwendige Cookies oder
vergleichbare Technologien, um Login-Sitzungen sicher
bereitzustellen. [Falls Analyse- oder Marketing-Tools eingesetzt
werden, hier ergänzen.]
</p>
</div>
<div>
<h2 className="text-xl font-semibold tracking-tight">
8. Rechte betroffener Personen
</h2>
<p className="mt-3 leading-7 text-slate-700">
Betroffene Personen haben nach Maßgabe der DSGVO Rechte auf
Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung,
Datenübertragbarkeit sowie Widerspruch. Erteilte Einwilligungen
können mit Wirkung für die Zukunft widerrufen werden.
</p>
</div>
<div>
<h2 className="text-xl font-semibold tracking-tight">
9. Beschwerderecht
</h2>
<p className="mt-3 leading-7 text-slate-700">
Betroffene Personen können sich bei einer
Datenschutzaufsichtsbehörde beschweren. Zuständig ist
insbesondere die Aufsichtsbehörde am Sitz der verantwortlichen
Stelle: [zuständige Datenschutzaufsichtsbehörde].
</p>
</div>
<div>
<h2 className="text-xl font-semibold tracking-tight">
10. Aktualität dieser Datenschutzerklärung
</h2>
<p className="mt-3 leading-7 text-slate-700">
Stand: [Datum]. Wir behalten uns vor, diese Datenschutzerklärung
anzupassen, wenn sich Funktionen, Rechtsgrundlagen oder
eingesetzte Dienstleister ändern.
</p>
</div>
</section>
</div>
</main>
);
}
+165
View File
@@ -0,0 +1,165 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowLeft, Building2, Mail, MapPin, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
export const metadata: Metadata = {
title: "Impressum | Kita-Planer",
description: "Impressum und Anbieterkennzeichnung von Kita-Planer.",
};
const contactItems = [
{
icon: Building2,
label: "Anbieter",
value: "[Name des Unternehmens / Vereins / der verantwortlichen Person]",
},
{
icon: MapPin,
label: "Adresse",
value: "[Straße Hausnummer], [PLZ Ort], [Land]",
},
{
icon: Mail,
label: "Kontakt",
value: "[E-Mail-Adresse] · [Telefonnummer optional]",
},
];
export default function ImpressumPage() {
return (
<main className="min-h-screen bg-[#f8faf8] text-slate-950">
<div className="mx-auto w-full max-w-5xl px-5 py-8 sm:px-6 lg:py-12">
<Button asChild variant="ghost" className="-ml-3 mb-8">
<Link href="/">
<ArrowLeft className="h-4 w-4" />
Zur Startseite
</Link>
</Button>
<section className="mb-10 rounded-lg bg-slate-950 px-6 py-10 text-white sm:px-10">
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-lg bg-emerald-300/12 text-emerald-200 ring-1 ring-emerald-300/20">
<ShieldCheck className="h-6 w-6" />
</div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-200">
Rechtliche Angaben
</p>
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">
Impressum
</h1>
<p className="mt-5 max-w-2xl text-sm leading-6 text-slate-300">
Diese Seite enthält Platzhalter und sollte vor Veröffentlichung mit
den korrekten Anbieterangaben ersetzt und rechtlich geprüft werden.
</p>
</section>
<div className="grid gap-5 md:grid-cols-3">
{contactItems.map((item) => {
const Icon = item.icon;
return (
<Card key={item.label} className="border-slate-200 bg-white">
<CardContent className="p-5">
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<Icon className="h-5 w-5" />
</div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500">
{item.label}
</h2>
<p className="mt-2 text-sm leading-6 text-slate-800">
{item.value}
</p>
</CardContent>
</Card>
);
})}
</div>
<section className="mt-10 space-y-8 rounded-lg border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
<div>
<h2 className="text-xl font-semibold tracking-tight">
Angaben gemäß § 5 TMG
</h2>
<p className="mt-3 leading-7 text-slate-700">
[Name / Firma / Verein]
<br />
[Vertretungsberechtigte Person, falls zutreffend]
<br />
[Straße Hausnummer]
<br />
[PLZ Ort]
<br />
[Land]
</p>
</div>
<div>
<h2 className="text-xl font-semibold tracking-tight">Kontakt</h2>
<p className="mt-3 leading-7 text-slate-700">
E-Mail: [E-Mail-Adresse]
<br />
Telefon: [Telefonnummer optional]
</p>
</div>
<div>
<h2 className="text-xl font-semibold tracking-tight">
Registereintrag
</h2>
<p className="mt-3 leading-7 text-slate-700">
Registergericht: [Registergericht, falls vorhanden]
<br />
Registernummer: [Registernummer, falls vorhanden]
</p>
</div>
<div>
<h2 className="text-xl font-semibold tracking-tight">
Umsatzsteuer-ID
</h2>
<p className="mt-3 leading-7 text-slate-700">
Umsatzsteuer-Identifikationsnummer gemäß § 27a
Umsatzsteuergesetz: [USt-IdNr., falls vorhanden]
</p>
</div>
<div>
<h2 className="text-xl font-semibold tracking-tight">
Verantwortlich für den Inhalt
</h2>
<p className="mt-3 leading-7 text-slate-700">
[Name der verantwortlichen Person]
<br />
[Adresse, falls abweichend]
</p>
</div>
<div>
<h2 className="text-xl font-semibold tracking-tight">
Haftung für Inhalte
</h2>
<p className="mt-3 leading-7 text-slate-700">
Als Diensteanbieter sind wir für eigene Inhalte auf diesen Seiten
nach den allgemeinen Gesetzen verantwortlich. Für fremde Inhalte
übernehmen wir keine Gewähr, sofern keine gesetzliche Pflicht zur
Prüfung oder Entfernung besteht.
</p>
</div>
<div>
<h2 className="text-xl font-semibold tracking-tight">
Haftung für Links
</h2>
<p className="mt-3 leading-7 text-slate-700">
Diese Anwendung kann Links zu externen Webseiten enthalten. Auf
deren Inhalte haben wir keinen Einfluss. Für die Inhalte
verlinkter Seiten ist stets der jeweilige Anbieter verantwortlich.
</p>
</div>
</section>
</div>
</main>
);
}
+2 -3
View File
@@ -48,8 +48,7 @@ export default async function InvitePage({
select: { select: {
firstName: true, firstName: true,
lastName: true, lastName: true,
email: true, emailVerifiedAt: true,
passwordHash: true,
kita: { select: { name: true } }, kita: { select: { name: true } },
}, },
}); });
@@ -57,7 +56,7 @@ export default async function InvitePage({
if (!user) notFound(); if (!user) notFound();
const userName = `${user.firstName} ${user.lastName}`; const userName = `${user.firstName} ${user.lastName}`;
const alreadyAccepted = user.passwordHash !== ""; const alreadyAccepted = !!user.emailVerifiedAt;
return ( return (
<div className="flex min-h-screen flex-col bg-muted/30"> <div className="flex min-h-screen flex-col bg-muted/30">
+31 -2
View File
@@ -14,8 +14,37 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Kita-Planer", metadataBase: new URL(
description: "Der digitale Kita-Planer für Elterninitiativen", process.env.NEXT_PUBLIC_SITE_URL ?? "https://kita-planer.example.com",
),
title: {
default: "Der digitale Kita-Planer für Elternvereine",
template: "%s | Kita-Planer",
},
description:
"Kita-Planer bündelt Dienste, Termine, Notdienste, Abwesenheiten und sichere Kommunikation für Elternvereine an einem Ort.",
openGraph: {
title: "Der digitale Kita-Planer für Elternvereine",
description:
"Organisiert Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.",
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: "Der digitale Kita-Planer für Elternvereine",
description:
"Organisiert Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.",
images: ["/og-image.png"],
},
}; };
export default function RootLayout({ export default function RootLayout({
+1 -1
View File
@@ -16,7 +16,7 @@ export const metadata = { title: "Anmelden · Kita-Planer" };
export default async function LoginPage() { export default async function LoginPage() {
const session = await auth(); const session = await auth();
if (session?.user) { if (session?.user?.id) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding"); redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
} }
+394 -72
View File
@@ -1,104 +1,426 @@
import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { CalendarCheck2, ShieldCheck, Users } from "lucide-react"; import {
ArrowRight,
CalendarDays,
CheckCircle2,
DatabaseZap,
Fingerprint,
LockKeyhole,
Mail,
Megaphone,
MessageSquareText,
ShieldAlert,
ShieldCheck,
Stethoscope,
Trash2,
UsersRound,
} from "lucide-react";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ContactForm } from "./contact-form";
// Eingeloggte User von der Landingpage direkt weiterleiten — die Routing- export const metadata: Metadata = {
// Logik selbst (Onboarding vs. Dashboard) übernimmt `requireKitaSession`. title: "Kita-Planer für Elternvereine",
description:
"Die einfache Plattform für Elternvereine, Dienste und Kommunikation: plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.",
openGraph: {
title: "Kita-Planer für Elternvereine",
description:
"Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.",
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: "Kita-Planer für Elternvereine",
description:
"Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.",
images: ["/og-image.png"],
},
};
const heroImage =
"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 = [
{
icon: DatabaseZap,
title: "Strikt isolierte Mandanten",
text: "Jede Kita arbeitet innerhalb ihrer eigenen kitaId-Grenze. Datenabfragen sind konsequent tenant-isoliert.",
},
{
icon: Fingerprint,
title: "Privacy by Design",
text: "Adressbuchdaten erscheinen nur nach aktivem Opt-In pro Elternteil. Nicht freigegebene Kontaktdaten verlassen den Server nicht.",
},
{
icon: Trash2,
title: "Recht auf Vergessenwerden",
text: "Eltern können ihren Account selbst löschen. Wenn sie der letzte Haushaltspartner sind, werden die Familiendaten sauber entfernt.",
},
{
icon: LockKeyhole,
title: "Geschützte Anhänge",
text: "Dokumente vom Schwarzen Brett liegen nicht öffentlich und werden nur nach Session- und Kita-Prüfung ausgeliefert.",
},
];
const proofPoints = [
"Notdienst, Kalender und Abwesenheiten in einem System",
"Gebaut für Vorstände, Eltern und ErzieherInnen",
"Klare Rollenrechte statt Chat-Chaos",
];
// Eingeloggte User von der Landingpage direkt weiterleiten.
export default async function LandingPage() { export default async function LandingPage() {
const session = await auth(); const session = await auth();
if (session?.user) { if (session?.user?.id) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding"); redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
} }
return ( return (
<div className="flex min-h-screen flex-col"> <div className="min-h-screen bg-[#f8faf8] text-slate-950">
<header className="border-b"> <header className="absolute left-0 right-0 top-0 z-20">
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-6"> <div className="mx-auto flex h-20 w-full max-w-7xl items-center justify-between px-5 sm:px-6">
<span className="text-lg font-semibold">Kita-Planer</span> <Link href="/" className="flex items-center gap-3 text-white">
<nav className="flex items-center gap-3"> <span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/12 ring-1 ring-white/30">
<Button asChild variant="ghost"> <ShieldCheck className="h-5 w-5" />
<Link href="/login">Anmelden</Link> </span>
<span className="text-base font-semibold tracking-tight">
Kita-Planer
</span>
</Link>
<nav className="flex items-center gap-2">
<Button
asChild
variant="ghost"
className="text-white hover:bg-white/10 hover:text-white"
>
<Link href="/login">Login</Link>
</Button> </Button>
<Button asChild> <Button
asChild
className="bg-white text-slate-950 shadow-sm hover:bg-white/90"
>
<Link href="/register">Kita registrieren</Link> <Link href="/register">Kita registrieren</Link>
</Button> </Button>
</nav> </nav>
</div> </div>
</header> </header>
<main className="flex-1"> <main>
<section className="mx-auto w-full max-w-6xl px-6 py-24"> <section
<div className="max-w-3xl"> className="relative min-h-[calc(100svh-56px)] overflow-hidden bg-slate-950"
<p className="mb-4 inline-block rounded-full bg-secondary px-3 py-1 text-xs font-medium text-secondary-foreground"> style={{
Für Elterninitiativen & Elternvereine backgroundImage: `linear-gradient(90deg, rgba(2, 6, 23, 0.9), rgba(15, 23, 42, 0.68), rgba(15, 23, 42, 0.18)), url(${heroImage})`,
</p> backgroundPosition: "center",
<h1 className="text-balance text-4xl font-semibold tracking-tight sm:text-5xl"> backgroundSize: "cover",
Der digitale Kita-Planer für Elterninitiativen. }}
</h1> >
<p className="mt-6 text-lg text-muted-foreground"> <div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-[#f8faf8] to-transparent" />
Notdienst-Planung, Terminkalender und Stammdaten endlich
an einem Ort. Schluss mit Excel-Tabellen, WhatsApp-Listen und <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">
vergessenen Geburtstagen. <div className="max-w-4xl text-white">
</p> <Badge className="mb-6 border-white/20 bg-white/12 text-white hover:bg-white/12">
<div className="mt-8 flex flex-wrap gap-3"> Kita-Betriebssystem für Elternvereine
<Button asChild size="lg"> </Badge>
<Link href="/register">Kita kostenlos registrieren</Link> <h1 className="max-w-4xl text-balance text-5xl font-semibold leading-[1.02] sm:text-6xl lg:text-7xl">
</Button> Die einfache Plattform für Elternvereine, Dienste und
<Button asChild size="lg" variant="outline"> Kommunikation.
<Link href="/login">Bereits Mitglied? Anmelden</Link> </h1>
</Button> <p className="mt-6 max-w-2xl text-lg leading-8 text-white/82 sm:text-xl">
Organisiert Notdienste, Termine, Krankmeldungen und offizielle
Kommunikation an einem Ort. Schluss mit Zettelwirtschaft und
WhatsApp-Chaos.
</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="/register">
Kita registrieren
<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="/login">Login</Link>
</Button>
</div>
<div className="mt-10 grid max-w-3xl gap-3 text-sm text-white/78 md:grid-cols-3">
{proofPoints.map((item) => (
<div key={item} className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-300" />
<span>{item}</span>
</div>
))}
</div>
</div> </div>
</div> </div>
</section>
<div className="mt-20 grid gap-8 sm:grid-cols-3"> <section className="mx-auto w-full max-w-7xl px-5 py-24 sm:px-6">
<FeatureCard <div className="mb-12 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
icon={<ShieldCheck className="h-6 w-6" />} <div className="max-w-3xl">
title="Notdienst-Planung" <p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
description="Verfügbarkeiten erfassen, fairen Plan automatisch generieren, Eltern bei Krankheitsausfall sofort alarmieren." Die 5 Säulen
/> </p>
<FeatureCard <h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
icon={<CalendarCheck2 className="h-6 w-6" />} Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam
title="Terminkalender" im Blick.
description="Kita-Feste, Schließtage und private Anfragen mit Mitbringsel-Listen — übersichtlich für alle Eltern." </h2>
/> </div>
<FeatureCard <p className="max-w-md text-sm leading-6 text-slate-600">
icon={<Users className="h-6 w-6" />} Kita-Planer bündelt operative Aufgaben, sensible Familiendaten und
title="Eltern-Adressbuch" offizielle Kommunikation in einer Oberfläche, die Vorstände
description="Spielverabredungen leichter machen — auf Opt-In-Basis und DSGVO-konform." kontrollieren und Eltern verstehen.
/> </p>
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-6">
{features.map((feature, index) => {
const Icon = feature.icon;
const isWide = index > 2;
return (
<Card
key={feature.title}
className={`group border-slate-200 bg-white/90 shadow-sm transition duration-200 hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg ${
isWide ? "xl:col-span-3" : "xl:col-span-2"
}`}
>
<CardHeader className="space-y-5">
<div className="flex items-start justify-between gap-4">
<div
className={`flex h-11 w-11 items-center justify-center rounded-lg ring-1 ${feature.accent}`}
>
<Icon className="h-5 w-5" />
</div>
{feature.badge && (
<Badge className="bg-slate-950 text-white hover:bg-slate-950">
{feature.badge}
</Badge>
)}
</div>
<div>
<CardTitle className="text-xl tracking-tight">
{feature.title}
</CardTitle>
<CardDescription className="mt-3 text-sm leading-6 text-slate-600">
{feature.description}
</CardDescription>
</div>
</CardHeader>
</Card>
);
})}
</div>
</section>
<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>
<div className="mb-7 flex h-16 w-16 items-center justify-center rounded-lg bg-emerald-400/12 text-emerald-200 ring-1 ring-emerald-300/20">
<ShieldCheck className="h-8 w-8" />
</div>
<Badge className="mb-5 border-emerald-300/20 bg-emerald-300/10 text-emerald-100 hover:bg-emerald-300/10">
Das Vorstands-Argument
</Badge>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Datenschutz ist nicht die Fußnote. Er ist die Architektur.
</h2>
<p className="mt-5 max-w-xl text-base leading-7 text-slate-300">
Elternvereine verwalten besonders sensible Daten. Deshalb ist
Kita-Planer so gebaut, dass Sichtbarkeit, Löschung und
Mandantentrennung nicht vom guten Willen einzelner Chats
abhängen.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{privacyPoints.map((point) => {
const Icon = point.icon;
return (
<div
key={point.title}
className="rounded-lg border border-white/10 bg-white/[0.06] p-5 shadow-sm transition hover:bg-white/[0.09]"
>
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-white/10 text-emerald-100">
<Icon className="h-5 w-5" />
</div>
<h3 className="font-semibold">{point.title}</h3>
<p className="mt-2 text-sm leading-6 text-slate-300">
{point.text}
</p>
</div>
);
})}
</div>
</div>
</section>
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6">
<div className="rounded-lg border border-slate-200 bg-white p-8 shadow-sm sm:p-10">
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div className="max-w-2xl">
<h2 className="text-2xl font-semibold tracking-tight">
Bereit für eine Kita-Organisation ohne Nebenkanäle?
</h2>
<p className="mt-3 text-sm leading-6 text-slate-600">
Registriere eure Kita, lade den Vorstand ein und starte mit
einem System, das für Elternvereine und Datenschutz gebaut ist.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Button asChild size="lg">
<Link href="/register">Kita registrieren</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link href="/login">Login</Link>
</Button>
</div>
</div>
</div>
</section>
<section id="kontakt" className="px-5 pb-24 sm:px-6">
<div className="mx-auto grid w-full max-w-7xl gap-6 rounded-lg border border-slate-200 bg-[#eef6f1] p-5 shadow-sm lg:grid-cols-[0.9fr_1.1fr] lg:p-8">
<div className="flex flex-col justify-between rounded-lg bg-slate-950 p-8 text-white">
<div>
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-lg bg-emerald-300/12 text-emerald-200 ring-1 ring-emerald-300/20">
<MessageSquareText className="h-6 w-6" />
</div>
<Badge className="mb-5 border-white/15 bg-white/10 text-white hover:bg-white/10">
Kontakt & Demo
</Badge>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Ihr wollt sehen, ob Kita-Planer zu eurem Verein passt?
</h2>
<p className="mt-5 text-sm leading-6 text-slate-300">
Schreibt uns kurz, wie ihr aktuell organisiert seid. Wir
melden uns mit einer passenden Demo, beantworten
Datenschutzfragen oder sprechen über ein individuelles Setup.
</p>
</div>
<div className="mt-10 grid gap-3 text-sm text-slate-200">
{[
"Demo für Vorstand und ErzieherInnen",
"Einordnung eurer bestehenden Abläufe",
"Antworten auf Datenschutz- und Hosting-Fragen",
].map((item) => (
<div key={item} className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-300" />
<span>{item}</span>
</div>
))}
</div>
</div>
<Card className="border-white/70 bg-white shadow-sm">
<CardHeader>
<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" />
</div>
<CardTitle className="text-2xl tracking-tight">
Anfrage senden
</CardTitle>
<CardDescription className="text-sm leading-6">
Seriös, unverbindlich und ohne Sales-Theater. Wir melden uns
in der Regel zeitnah mit einem konkreten Vorschlag.
</CardDescription>
</CardHeader>
<CardContent>
<ContactForm />
</CardContent>
</Card>
</div> </div>
</section> </section>
</main> </main>
<footer className="border-t"> <footer className="border-t border-slate-200 bg-white">
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-6 text-sm text-muted-foreground"> <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> <span>© {new Date().getFullYear()} Kita-Planer</span>
<span>Made with für Elternvereine</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> </div>
</footer> </footer>
</div> </div>
); );
} }
function FeatureCard({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<div className="rounded-lg border bg-card p-6">
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-md bg-secondary text-secondary-foreground">
{icon}
</div>
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
);
}
+12 -5
View File
@@ -8,7 +8,7 @@ import { prisma } from "@/lib/prisma";
// ===================================================================== // =====================================================================
// NextAuth.js (Auth.js v5) · Credentials-Provider mit JWT-Strategie // NextAuth.js (Auth.js v5) · Credentials-Provider mit JWT-Strategie
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Mandantenfähigkeit: `id`, `role`, `kitaId` werden über die JWT-/Session- // Mandantenfähigkeit: `id`, `role`, `kitaId`, `familyId` werden über die JWT-/Session-
// Callbacks aus der DB in jede Session durchgeschleift, damit jede // Callbacks aus der DB in jede Session durchgeschleift, damit jede
// Server Action / API-Route den Tenant-Filter setzen kann. // Server Action / API-Route den Tenant-Filter setzen kann.
// ===================================================================== // =====================================================================
@@ -80,6 +80,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
name: `${user.firstName} ${user.lastName}`.trim(), name: `${user.firstName} ${user.lastName}`.trim(),
role: user.role, role: user.role,
kitaId: user.kitaId, kitaId: user.kitaId,
familyId: user.familyId,
}; };
}, },
}), }),
@@ -97,22 +98,27 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
token.id = user.id; token.id = user.id;
token.role = user.role; token.role = user.role;
token.kitaId = user.kitaId; token.kitaId = user.kitaId;
token.familyId = user.familyId;
return token; return token;
} }
if (token.id) { const tokenUserId = token.id ?? token.sub;
if (tokenUserId) {
const fresh = await prisma.user.findUnique({ const fresh = await prisma.user.findUnique({
where: { id: token.id }, where: { id: tokenUserId },
select: { role: true, kitaId: true }, select: { role: true, kitaId: true, familyId: true },
}); });
if (!fresh) { if (!fresh) {
// User wurde gelöscht → Token entwerten. // User wurde gelöscht → Token entwerten.
// (Auth.js erkennt den fehlenden `sub`/`id` und meldet ab.) // (Auth.js erkennt den fehlenden `sub`/`id` und meldet ab.)
delete (token as Partial<typeof token>).id; delete (token as Partial<typeof token>).id;
delete (token as Partial<typeof token>).sub;
return token; return token;
} }
token.id = tokenUserId;
token.role = fresh.role; token.role = fresh.role;
token.kitaId = fresh.kitaId; token.kitaId = fresh.kitaId;
token.familyId = fresh.familyId;
} }
return token; return token;
@@ -125,9 +131,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
*/ */
async session({ session, token }) { async session({ session, token }) {
if (token && session.user) { if (token && session.user) {
session.user.id = token.id; session.user.id = token.id ?? token.sub;
session.user.role = token.role; session.user.role = token.role;
session.user.kitaId = token.kitaId; session.user.kitaId = token.kitaId;
session.user.familyId = token.familyId;
} }
return session; return session;
}, },
+173
View File
@@ -0,0 +1,173 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import {
ArrowRight,
CheckCircle2,
Home,
MapPin,
Phone,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
type UserRoleDto =
| "SUPERADMIN"
| "ADMIN"
| "KOORDINATOR"
| "ERZIEHER"
| "ELTERN";
type OnboardingDialogProps = {
user: {
phone: string | null;
street: string | null;
postalCode: string | null;
city: string | null;
familyId: string | null;
role: UserRoleDto;
};
family: {
childrenCount: number;
} | null;
};
const HIDE_ONBOARDING_DIALOG_KEY = "hideOnboardingDialog";
export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
const [open, setOpen] = useState(false);
const status = useMemo(() => {
const hasPhone = !!user.phone?.trim();
const hasAddress =
!!user.street?.trim() && !!user.postalCode?.trim() && !!user.city?.trim();
const needsFamilyData = user.role !== "ERZIEHER";
const hasFamilyAndChildren =
!needsFamilyData || (!!user.familyId && !!family?.childrenCount);
const missingItems = [
!hasPhone ? { label: "Telefonnummer fehlt", icon: Phone } : null,
!hasAddress ? { label: "Adresse fehlt", icon: MapPin } : null,
!hasFamilyAndChildren
? { label: "Haushalts-/Kinderdaten unvollständig", icon: Home }
: null,
].filter(Boolean) as { label: string; icon: typeof Phone }[];
const completedCount = [hasPhone, hasAddress, hasFamilyAndChildren].filter(
Boolean,
).length;
const progress = completedCount === 3 ? 100 : completedCount * 33;
return {
completedCount,
progress,
missingItems,
hasFamilyAndChildren,
isComplete: completedCount === 3,
};
}, [family?.childrenCount, user]);
useEffect(() => {
if (status.isComplete) return;
const hidden = localStorage.getItem(HIDE_ONBOARDING_DIALOG_KEY);
if (hidden !== "true") {
const timer = window.setTimeout(() => setOpen(true), 0);
return () => window.clearTimeout(timer);
}
}, [status.isComplete]);
if (status.isComplete) {
return null;
}
const ctaLabel = status.hasFamilyAndChildren
? "Kontaktdaten hinterlegen"
: "Haushalt & Kinder einrichten";
function hidePermanently() {
localStorage.setItem(HIDE_ONBOARDING_DIALOG_KEY, "true");
setOpen(false);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="overflow-hidden p-0 sm:max-w-xl">
<div className="bg-emerald-950 px-6 py-6 text-white">
<div className="mb-5 flex h-11 w-11 items-center justify-center rounded-full bg-emerald-300 text-emerald-950">
<Sparkles className="h-5 w-5" />
</div>
<DialogHeader className="space-y-2 text-left">
<DialogTitle className="text-2xl leading-tight">
Dein Kita-Profil ist fast bereit
</DialogTitle>
<DialogDescription className="text-emerald-50/80">
Vervollständige die wichtigsten Daten, damit Notdienst,
Adressbuch und Familienverwaltung reibungslos funktionieren.
</DialogDescription>
</DialogHeader>
</div>
<div className="space-y-5 px-6 py-6">
<div>
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
<span className="font-medium">Profil-Fortschritt</span>
<span className="text-muted-foreground">
{status.completedCount}/3 erledigt
</span>
</div>
<Progress value={status.progress} />
<p className="mt-2 text-xs text-muted-foreground">
{status.progress}% vollständig
</p>
</div>
<div className="rounded-md border bg-muted/30 p-4">
<p className="mb-3 text-sm font-medium">Noch offen</p>
<ul className="space-y-2">
{status.missingItems.map(({ label, icon: Icon }) => (
<li key={label} className="flex items-center gap-2 text-sm">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<Icon className="h-3.5 w-3.5" />
</span>
{label}
</li>
))}
</ul>
</div>
<div className="flex items-start gap-2 rounded-md bg-emerald-50 p-3 text-sm text-emerald-950">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Du kannst die App weiter nutzen. Diese Erinnerung hilft nur beim
sauberen Einrichten der wichtigsten MVP-Daten.
</p>
</div>
</div>
<DialogFooter className="gap-2 border-t px-6 py-4 sm:justify-between sm:space-x-0">
<Button type="button" variant="ghost" onClick={hidePermanently}>
Nicht mehr anzeigen
</Button>
<Button asChild onClick={() => setOpen(false)}>
<Link href="/dashboard/profil">
{ctaLabel}
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+34 -2
View File
@@ -2,7 +2,18 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { LayoutDashboard, Users, CalendarCheck2, CalendarDays, Contact, GraduationCap, UserCircle } from "lucide-react"; import {
LayoutDashboard,
Users,
CalendarCheck2,
CalendarDays,
Contact,
GraduationCap,
UserCircle,
ClipboardList,
Stethoscope,
Megaphone,
} from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -45,7 +56,28 @@ const navItems = [
label: "Familienverwaltung", label: "Familienverwaltung",
icon: Users, icon: Users,
exact: false, exact: false,
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR], allowedRoles: [UserRole.ADMIN],
},
{
href: "/dashboard/admin/dienste",
label: "Dienstplan",
icon: ClipboardList,
exact: false,
allowedRoles: [UserRole.ADMIN],
},
{
href: "/dashboard/admin/news",
label: "Schwarzes Brett",
icon: Megaphone,
exact: false,
allowedRoles: [UserRole.ADMIN],
},
{
href: "/dashboard/admin/abwesenheiten",
label: "Abwesenheiten",
icon: Stethoscope,
exact: false,
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ERZIEHER],
}, },
{ {
href: "/dashboard/erzieher", href: "/dashboard/erzieher",
+49
View File
@@ -0,0 +1,49 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
export function MarkdownContent({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children, ...props }) => (
<a
{...props}
className="font-medium text-primary underline underline-offset-4"
target="_blank"
rel="noreferrer"
>
{children}
</a>
),
p: ({ children }) => (
<p className="mb-3 leading-7 last:mb-0">{children}</p>
),
ul: ({ children }) => (
<ul className="mb-3 list-disc space-y-1 pl-5">{children}</ul>
),
ol: ({ children }) => (
<ol className="mb-3 list-decimal space-y-1 pl-5">{children}</ol>
),
h1: ({ children }) => (
<h1 className="mb-3 text-2xl font-semibold">{children}</h1>
),
h2: ({ children }) => (
<h2 className="mb-3 text-xl font-semibold">{children}</h2>
),
h3: ({ children }) => (
<h3 className="mb-2 text-lg font-semibold">{children}</h3>
),
blockquote: ({ children }) => (
<blockquote className="mb-3 border-l-4 pl-4 text-muted-foreground">
{children}
</blockquote>
),
}}
>
{content}
</ReactMarkdown>
);
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div className={cn("grid gap-2", className)} {...props} />
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
id={formMessageId}
className={cn("text-sm text-destructive", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};
+28
View File
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ComponentRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-transform"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
+122
View File
@@ -0,0 +1,122 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectItem = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
};
+37
View File
@@ -0,0 +1,37 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type SwitchProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"type" | "onChange"
> & {
onCheckedChange?: (checked: boolean) => void;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
};
const Switch = React.forwardRef<
HTMLInputElement,
SwitchProps
>(({ className, onCheckedChange, onChange, ...props }, ref) => (
<input
ref={ref}
type="checkbox"
role="switch"
onChange={(event) => {
onChange?.(event);
onCheckedChange?.(event.currentTarget.checked);
}}
className={cn(
"peer h-5 w-9 shrink-0 cursor-pointer appearance-none rounded-full border border-transparent bg-input shadow-sm transition-colors checked:bg-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"before:block before:h-4 before:w-4 before:translate-x-0 before:rounded-full before:bg-background before:shadow before:transition-transform checked:before:translate-x-4",
className,
)}
{...props}
/>
));
Switch.displayName = "Switch";
export { Switch };
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"flex min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background 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",
className,
)}
{...props}
/>
));
Textarea.displayName = "Textarea";
export { Textarea };
+143
View File
@@ -0,0 +1,143 @@
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
import {
contactRequestTypeLabels,
type ContactRequestInput,
} from "@/lib/contact-schema";
type ContactRequestEmailProps = ContactRequestInput;
export function ContactRequestEmail({
name,
kitaName,
email,
requestType,
message,
}: ContactRequestEmailProps) {
const inquiryLabel = contactRequestTypeLabels[requestType];
return (
<Html>
<Head />
<Preview>
Neue Kontaktanfrage von {name}
{kitaName ? ` (${kitaName})` : ""}
</Preview>
<Body style={body}>
<Container style={container}>
<Heading style={heading}>Neue Kontaktanfrage</Heading>
<Text style={intro}>
Über die Kita-Planer Landingpage ist eine neue Anfrage eingegangen.
</Text>
<Section style={details}>
<InfoRow label="Name" value={name} />
<InfoRow label="Kita / Initiative" value={kitaName || "Nicht angegeben"} />
<InfoRow label="E-Mail" value={email} />
<InfoRow label="Anliegen" value={inquiryLabel} />
</Section>
<Section style={messageBox}>
<Text style={label}>Nachricht</Text>
<Text style={messageText}>{message}</Text>
</Section>
<Text style={footer}>
Tipp: Direkt auf diese E-Mail antworten, um {name} unter {email} zu
erreichen.
</Text>
</Container>
</Body>
</Html>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<Text style={row}>
<strong>{label}:</strong> {value}
</Text>
);
}
const body = {
margin: 0,
backgroundColor: "#f8faf8",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
};
const container = {
margin: "0 auto",
padding: "32px 24px",
maxWidth: "620px",
};
const heading = {
margin: "0 0 12px",
color: "#0f172a",
fontSize: "28px",
lineHeight: "34px",
};
const intro = {
margin: "0 0 24px",
color: "#475569",
fontSize: "15px",
lineHeight: "24px",
};
const details = {
border: "1px solid #d7e5dc",
borderRadius: "10px",
backgroundColor: "#ffffff",
padding: "18px",
};
const row = {
margin: "0 0 10px",
color: "#0f172a",
fontSize: "15px",
lineHeight: "22px",
};
const messageBox = {
marginTop: "18px",
border: "1px solid #d7e5dc",
borderRadius: "10px",
backgroundColor: "#ffffff",
padding: "18px",
};
const label = {
margin: "0 0 8px",
color: "#64748b",
fontSize: "12px",
fontWeight: 700,
letterSpacing: "0.04em",
textTransform: "uppercase" as const,
};
const messageText = {
margin: 0,
color: "#0f172a",
fontSize: "15px",
lineHeight: "24px",
whiteSpace: "pre-wrap" as const,
};
const footer = {
margin: "22px 0 0",
color: "#64748b",
fontSize: "13px",
lineHeight: "20px",
};
+124
View File
@@ -0,0 +1,124 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
type DutyReminderEmailProps = {
familyName: string;
dutyName: string;
weekLabel: string;
};
export function DutyReminderEmail({
familyName,
dutyName,
weekLabel,
}: DutyReminderEmailProps) {
return (
<Html lang="de">
<Head />
<Preview>
Erinnerung: {familyName} ist diese Woche fuer {dutyName} eingeteilt.
</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.header}>
<Text style={styles.kicker}>Elterndienst</Text>
<Heading style={styles.heading}>Danke fuer eure Hilfe</Heading>
<Text style={styles.lead}>
Hallo {familyName}, diese Woche seid ihr fuer den Dienst{" "}
<strong>{dutyName}</strong> eingeteilt.
</Text>
</Section>
<Section style={styles.card}>
<Text style={styles.text}>
Zeitraum: <strong>{weekLabel}</strong>
</Text>
<Text style={styles.text}>
Danke, dass ihr mithelft, den Kita-Alltag verlaesslich zu
organisieren.
</Text>
</Section>
<Hr style={styles.hr} />
<Text style={styles.footer}>
Diese Erinnerung wurde automatisch vom Kita-Planer versendet.
</Text>
</Container>
</Body>
</Html>
);
}
const styles = {
body: {
margin: 0,
backgroundColor: "#f7f5ef",
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
color: "#24231f",
},
container: {
width: "100%",
maxWidth: "600px",
margin: "0 auto",
padding: "32px 20px",
},
header: {
padding: "28px",
backgroundColor: "#27423a",
borderRadius: "8px 8px 0 0",
},
kicker: {
margin: "0 0 20px",
color: "#d9e9c3",
fontSize: "12px",
fontWeight: 800,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
},
heading: {
margin: "0 0 12px",
color: "#ffffff",
fontSize: "30px",
lineHeight: "1.18",
fontWeight: 800,
},
lead: {
margin: 0,
color: "#eef4eb",
fontSize: "16px",
lineHeight: "1.55",
},
card: {
padding: "28px",
backgroundColor: "#ffffff",
border: "1px solid #dde3d7",
borderTop: "0",
borderRadius: "0 0 8px 8px",
},
text: {
margin: "0 0 16px",
color: "#3d423b",
fontSize: "16px",
lineHeight: "1.65",
},
hr: {
margin: "24px 0",
borderColor: "#dde3d7",
},
footer: {
margin: 0,
color: "#7b8279",
fontSize: "12px",
lineHeight: "1.5",
textAlign: "center" as const,
},
};
+120
View File
@@ -0,0 +1,120 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
type NewsEmailProps = {
title: string;
content: string;
dashboardUrl: string;
};
export function NewsEmail({ title, content, dashboardUrl }: NewsEmailProps) {
const excerpt =
content.length > 420 ? `${content.slice(0, 420).trim()}...` : content;
return (
<Html lang="de">
<Head />
<Preview>Neue Kita-Ankündigung: {title}</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.header}>
<Text style={styles.kicker}>Schwarzes Brett</Text>
<Heading style={styles.heading}>{title}</Heading>
</Section>
<Section style={styles.card}>
<Text style={styles.text}>{excerpt}</Text>
<Button href={dashboardUrl} style={styles.button}>
Im Kita-Planer lesen
</Button>
</Section>
<Hr style={styles.hr} />
<Text style={styles.footer}>
Diese offizielle Ankündigung wurde über den Kita-Planer versendet.
</Text>
</Container>
</Body>
</Html>
);
}
const styles = {
body: {
margin: 0,
backgroundColor: "#f5f3ee",
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
color: "#25231f",
},
container: {
width: "100%",
maxWidth: "600px",
margin: "0 auto",
padding: "32px 20px",
},
header: {
padding: "28px",
backgroundColor: "#243b36",
borderRadius: "8px 8px 0 0",
},
kicker: {
margin: "0 0 20px",
color: "#d8f2bd",
fontSize: "12px",
fontWeight: 800,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
},
heading: {
margin: 0,
color: "#ffffff",
fontSize: "28px",
lineHeight: "1.2",
fontWeight: 800,
},
card: {
padding: "28px",
backgroundColor: "#ffffff",
border: "1px solid #deded5",
borderTop: "0",
borderRadius: "0 0 8px 8px",
},
text: {
margin: "0 0 24px",
color: "#42423b",
fontSize: "15px",
lineHeight: "1.65",
whiteSpace: "pre-wrap" as const,
},
button: {
display: "inline-block",
padding: "13px 20px",
backgroundColor: "#f0b84b",
color: "#172119",
borderRadius: "6px",
fontSize: "15px",
fontWeight: 800,
textDecoration: "none",
},
hr: {
margin: "24px 0",
borderColor: "#deded5",
},
footer: {
margin: 0,
color: "#77766e",
fontSize: "12px",
lineHeight: "1.5",
textAlign: "center" as const,
},
};
+4 -2
View File
@@ -34,7 +34,7 @@ export async function getSession(): Promise<Session | null> {
export async function requireSession(): Promise<AuthenticatedSession> { export async function requireSession(): Promise<AuthenticatedSession> {
const session = await auth(); const session = await auth();
if (!session?.user) { if (!session?.user?.id) {
redirect("/login"); redirect("/login");
} }
return session as AuthenticatedSession; return session as AuthenticatedSession;
@@ -81,7 +81,9 @@ export async function requireRole(
// interne Helfer // interne Helfer
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
async function consentCheck(userId: string): Promise<boolean> { async function consentCheck(userId: string | undefined): Promise<boolean> {
if (!userId) return false;
// Lazy-Import, damit `auth-utils` selbst Edge-kompatibel bleibt // Lazy-Import, damit `auth-utils` selbst Edge-kompatibel bleibt
// (Prisma läuft nur in Node-Runtime). // (Prisma läuft nur in Node-Runtime).
const { prisma } = await import("@/lib/prisma"); const { prisma } = await import("@/lib/prisma");
+21
View File
@@ -0,0 +1,21 @@
import { z } from "zod";
export const contactRequestTypeLabels = {
GENERAL: "Allgemeine Frage",
DEMO: "Demo anfragen",
SETUP: "Individuelles Setup",
} as const;
export const contactRequestSchema = z.object({
name: z.string().trim().min(2, "Bitte gib deinen Namen ein.").max(120),
kitaName: z.string().trim().max(160).optional(),
email: z.string().trim().email("Bitte gib eine gültige E-Mail-Adresse ein."),
requestType: z.enum(["GENERAL", "DEMO", "SETUP"]),
message: z
.string()
.trim()
.min(10, "Bitte schreib uns kurz, worum es geht.")
.max(5000),
});
export type ContactRequestInput = z.infer<typeof contactRequestSchema>;
+6 -2
View File
@@ -1,13 +1,14 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Resend } from "resend"; import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY); let resendClient: Resend | null = null;
type SendAppEmailInput = { type SendAppEmailInput = {
to: string | string[]; to: string | string[];
subject: string; subject: string;
react: ReactNode; react: ReactNode;
from?: string; from?: string;
replyTo?: string | string[];
}; };
type SendAppEmailResult = type SendAppEmailResult =
@@ -31,6 +32,7 @@ export async function sendAppEmail({
subject, subject,
react, react,
from = process.env.EMAIL_FROM, from = process.env.EMAIL_FROM,
replyTo,
}: SendAppEmailInput): Promise<SendAppEmailResult> { }: SendAppEmailInput): Promise<SendAppEmailResult> {
if (!process.env.RESEND_API_KEY) { if (!process.env.RESEND_API_KEY) {
return { return {
@@ -47,11 +49,13 @@ export async function sendAppEmail({
} }
try { try {
const { data, error } = await resend.emails.send({ resendClient ??= new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resendClient.emails.send({
from: from!, from: from!,
to, to,
subject, subject,
react, react,
replyTo,
}); });
if (error) { if (error) {
+3 -6
View File
@@ -16,7 +16,6 @@ import { auth } from "@/auth";
// Eingeloggt + auf /login oder /register → / // Eingeloggt + auf /login oder /register → /
// ===================================================================== // =====================================================================
const PUBLIC_ROUTES = ["/", "/login", "/register", "/datenschutz"];
const ONBOARDING_ROUTE = "/onboarding"; const ONBOARDING_ROUTE = "/onboarding";
const PROTECTED_PREFIX = ["/dashboard", "/admin", "/onboarding"]; const PROTECTED_PREFIX = ["/dashboard", "/admin", "/onboarding"];
@@ -27,16 +26,14 @@ export async function proxy(request: NextRequest) {
// kein DB-Call, pure Token-Verifikation (Edge-sicher). // kein DB-Call, pure Token-Verifikation (Edge-sicher).
const session = await auth(); const session = await auth();
const user = session?.user; const user = session?.user;
const hasValidUser = !!user?.id;
const isPublicRoute = PUBLIC_ROUTES.some(
(route) => pathname === route || pathname.startsWith(route + "/"),
);
const isProtectedRoute = PROTECTED_PREFIX.some((prefix) => const isProtectedRoute = PROTECTED_PREFIX.some((prefix) =>
pathname.startsWith(prefix), pathname.startsWith(prefix),
); );
// ── 1. Nicht eingeloggt + geschützte Route ────────────────────────── // ── 1. Nicht eingeloggt + geschützte Route ──────────────────────────
if (!user && isProtectedRoute) { if (!hasValidUser && isProtectedRoute) {
const loginUrl = new URL("/login", request.nextUrl); const loginUrl = new URL("/login", request.nextUrl);
// callbackUrl für spätere Nutzung (optional) // callbackUrl für spätere Nutzung (optional)
loginUrl.searchParams.set("callbackUrl", pathname); loginUrl.searchParams.set("callbackUrl", pathname);
@@ -44,7 +41,7 @@ export async function proxy(request: NextRequest) {
} }
// ── 2. Eingeloggt ─────────────────────────────────────────────────── // ── 2. Eingeloggt ───────────────────────────────────────────────────
if (user) { if (hasValidUser && user) {
// 2a. Eingeloggter User auf Login/Register-Seite → Startseite, // 2a. Eingeloggter User auf Login/Register-Seite → Startseite,
// die dann selbst zu /dashboard oder /onboarding redirectet. // die dann selbst zu /dashboard oder /onboarding redirectet.
if (pathname === "/login" || pathname === "/register") { if (pathname === "/login" || pathname === "/register") {
+5 -1
View File
@@ -5,7 +5,8 @@ import type { UserRole } from "@prisma/client";
// Type-Augmentation für NextAuth (Auth.js v5) // Type-Augmentation für NextAuth (Auth.js v5)
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Erzwingt zur Compile-Zeit, dass `id`, `role` und `kitaId` in // Erzwingt zur Compile-Zeit, dass `id`, `role` und `kitaId` in
// `Session.user` und im JWT verfügbar sind. Damit ist die // `Session.user` und im JWT verfügbar sind. `familyId` transportiert den
// optionalen Haushalt fuer Eltern- und Notdienst-Flows. Damit ist die
// Mandanten-Isolation auf Typ-Ebene abgesichert: Jede Server Action / // Mandanten-Isolation auf Typ-Ebene abgesichert: Jede Server Action /
// Server Component, die `session.user.kitaId` nutzt, scheitert beim // Server Component, die `session.user.kitaId` nutzt, scheitert beim
// Build, falls die Eigenschaft je entfernt wird. // Build, falls die Eigenschaft je entfernt wird.
@@ -16,6 +17,7 @@ declare module "next-auth" {
id: string; id: string;
role: UserRole; role: UserRole;
kitaId: string | null; kitaId: string | null;
familyId: string | null;
} }
interface Session { interface Session {
@@ -23,6 +25,7 @@ declare module "next-auth" {
id: string; id: string;
role: UserRole; role: UserRole;
kitaId: string | null; kitaId: string | null;
familyId: string | null;
} & DefaultSession["user"]; } & DefaultSession["user"];
} }
} }
@@ -35,5 +38,6 @@ declare module "@auth/core/jwt" {
id: string; id: string;
role: UserRole; role: UserRole;
kitaId: string | null; kitaId: string | null;
familyId: string | null;
} }
} }
+1
View File
@@ -0,0 +1 @@