continued the kita-planer
This commit is contained in:
@@ -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
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
# production
|
||||
/build
|
||||
/uploads
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
+38
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
Generated
+1836
-5
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development prisma db push && NODE_ENV=development prisma db seed && next dev",
|
||||
"build": "next build",
|
||||
"build:prod": "prisma generate && next build",
|
||||
"start": "next start",
|
||||
"start:prod": "next start",
|
||||
"lint": "eslint",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
@@ -18,10 +20,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@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-slot": "^1.2.4",
|
||||
"@react-email/components": "^1.0.12",
|
||||
@@ -35,6 +40,9 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "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",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
+179
-23
@@ -32,6 +32,7 @@ enum UserRole {
|
||||
SUPERADMIN
|
||||
ADMIN
|
||||
KOORDINATOR
|
||||
ERZIEHER
|
||||
ELTERN
|
||||
}
|
||||
|
||||
@@ -73,6 +74,18 @@ enum NotdienstAlertStatus {
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum DutyAssignmentStatus {
|
||||
PLANNED
|
||||
DONE
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum AbsenceReason {
|
||||
ILLNESS
|
||||
VACATION
|
||||
OTHER
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// TENANT
|
||||
// =====================================================================
|
||||
@@ -96,10 +109,15 @@ model Kita {
|
||||
|
||||
// Relations (Cascade auf alle Tenant-Daten)
|
||||
users User[]
|
||||
families Family[]
|
||||
children Child[]
|
||||
educators Educator[]
|
||||
parentDuties ParentDuty[]
|
||||
parentDutyAssignments ParentDutyAssignment[]
|
||||
dutyTypes DutyType[]
|
||||
dutyAssignments DutyAssignment[]
|
||||
absences Absence[]
|
||||
announcements Announcement[]
|
||||
invitations Invitation[]
|
||||
termine Termin[]
|
||||
mitbringselItems MitbringselItem[]
|
||||
@@ -107,11 +125,33 @@ model Kita {
|
||||
notdienstAvailabilities NotdienstAvailability[]
|
||||
notdienstAssignments NotdienstAssignment[]
|
||||
notdienstAlerts NotdienstAlert[]
|
||||
childParents ChildParent[]
|
||||
|
||||
@@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
|
||||
// =====================================================================
|
||||
@@ -124,6 +164,11 @@ model User {
|
||||
kitaId String?
|
||||
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
|
||||
passwordHash String
|
||||
|
||||
@@ -157,13 +202,14 @@ model User {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
childLinks ChildParent[]
|
||||
dutyAssignments ParentDutyAssignment[]
|
||||
|
||||
notdienstAvailabilities NotdienstAvailability[]
|
||||
notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent")
|
||||
notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger")
|
||||
notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator")
|
||||
announcementsAuthored Announcement[] @relation("AnnouncementAuthor")
|
||||
announcementReads AnnouncementRead[]
|
||||
|
||||
termineCreated Termin[] @relation("TerminCreator")
|
||||
termineApproved Termin[] @relation("TerminApprover")
|
||||
@@ -172,6 +218,7 @@ model User {
|
||||
invitationsCreated Invitation[] @relation("InvitationCreator")
|
||||
|
||||
@@index([kitaId])
|
||||
@@index([familyId])
|
||||
@@index([kitaId, role])
|
||||
@@map("users")
|
||||
}
|
||||
@@ -185,6 +232,9 @@ model Child {
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
|
||||
familyId String
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
|
||||
firstName String
|
||||
lastName String
|
||||
dateOfBirth DateTime?
|
||||
@@ -195,34 +245,15 @@ model Child {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
parentLinks ChildParent[]
|
||||
notdienstAvailabilities NotdienstAvailability[]
|
||||
notdienstAssignments NotdienstAssignment[]
|
||||
absences Absence[]
|
||||
|
||||
@@index([kitaId])
|
||||
@@index([familyId])
|
||||
@@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)
|
||||
// =====================================================================
|
||||
@@ -285,6 +316,131 @@ model ParentDutyAssignment {
|
||||
@@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)
|
||||
// =====================================================================
|
||||
|
||||
+182
-22
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AbsenceReason,
|
||||
InvitationStatus,
|
||||
NotdienstAlertStatus,
|
||||
NotdienstPlanStatus,
|
||||
@@ -29,6 +30,7 @@ const RESET_MODE = process.argv.includes("--reset");
|
||||
const DEMO_USER_EMAILS = [
|
||||
"super@kita-planer.local",
|
||||
"admin@waldameisen.local",
|
||||
"erzieher@waldameisen.local",
|
||||
"mueller@waldameisen.local",
|
||||
"schmidt@waldameisen.local",
|
||||
"yilmaz@waldameisen.local",
|
||||
@@ -52,6 +54,16 @@ function addDays(date: Date, days: number) {
|
||||
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) {
|
||||
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({
|
||||
data: {
|
||||
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({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: familyMueller.id,
|
||||
email: "mueller@waldameisen.local",
|
||||
passwordHash,
|
||||
firstName: "Maria",
|
||||
@@ -197,6 +249,7 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
|
||||
const elternSchmidt = await prisma.user.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: familySchmidtYilmaz.id,
|
||||
email: "schmidt@waldameisen.local",
|
||||
passwordHash,
|
||||
firstName: "Lukas",
|
||||
@@ -211,6 +264,7 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
|
||||
const elternYilmaz = await prisma.user.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: familySchmidtYilmaz.id,
|
||||
email: "yilmaz@waldameisen.local",
|
||||
passwordHash,
|
||||
firstName: "Aylin",
|
||||
@@ -230,6 +284,7 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
|
||||
const pendingParent = await prisma.user.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: familyFischer.id,
|
||||
email: "pending@waldameisen.local",
|
||||
passwordHash: "",
|
||||
firstName: "Lena",
|
||||
@@ -240,8 +295,12 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
|
||||
|
||||
return {
|
||||
kita,
|
||||
familyMueller,
|
||||
familySchmidtYilmaz,
|
||||
familyFischer,
|
||||
superAdmin,
|
||||
admin,
|
||||
erzieherUser,
|
||||
koordinator,
|
||||
elternSchmidt,
|
||||
elternYilmaz,
|
||||
@@ -251,72 +310,58 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
|
||||
|
||||
async function createChildren({
|
||||
kita,
|
||||
koordinator,
|
||||
elternSchmidt,
|
||||
elternYilmaz,
|
||||
pendingParent,
|
||||
familyMueller,
|
||||
familySchmidtYilmaz,
|
||||
familyFischer,
|
||||
}: SeedContext) {
|
||||
const anna = await prisma.child.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: familyMueller.id,
|
||||
firstName: "Anna",
|
||||
lastName: "Mueller",
|
||||
dateOfBirth: new Date("2021-03-15"),
|
||||
parentLinks: {
|
||||
create: { kitaId: kita.id, userId: koordinator.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ben = await prisma.child.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: familyMueller.id,
|
||||
firstName: "Ben",
|
||||
lastName: "Mueller",
|
||||
dateOfBirth: new Date("2023-07-22"),
|
||||
notes: "Geschwisterkind von Anna.",
|
||||
parentLinks: {
|
||||
create: { kitaId: kita.id, userId: koordinator.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const clara = await prisma.child.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: familySchmidtYilmaz.id,
|
||||
firstName: "Clara",
|
||||
lastName: "Schmidt",
|
||||
dateOfBirth: new Date("2022-11-03"),
|
||||
parentLinks: {
|
||||
create: { kitaId: kita.id, userId: elternSchmidt.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const emil = await prisma.child.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: familySchmidtYilmaz.id,
|
||||
firstName: "Emil",
|
||||
lastName: "Yilmaz",
|
||||
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({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: familyFischer.id,
|
||||
firstName: "Nina",
|
||||
lastName: "Fischer",
|
||||
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) {
|
||||
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({
|
||||
kita,
|
||||
admin,
|
||||
@@ -601,7 +756,10 @@ async function createDemoData() {
|
||||
const educators = await createEducators(context.kita.id);
|
||||
|
||||
await createParentDuties(context);
|
||||
await createDutyPlan(context);
|
||||
await createAbsences(context, children);
|
||||
await createInvites(context);
|
||||
await createAnnouncements(context);
|
||||
await createTermine(context);
|
||||
await createNotdienstData(context, children, educators);
|
||||
|
||||
@@ -622,6 +780,7 @@ function printSummary(
|
||||
kita,
|
||||
superAdmin,
|
||||
admin,
|
||||
erzieherUser,
|
||||
koordinator,
|
||||
elternSchmidt,
|
||||
elternYilmaz,
|
||||
@@ -636,6 +795,7 @@ function printSummary(
|
||||
console.log(` Logins (Passwort jeweils: ${DEFAULT_PASSWORD})`);
|
||||
console.log(` Superadmin: ${superAdmin.email}`);
|
||||
console.log(` Admin: ${admin.email}`);
|
||||
console.log(` Erzieherin: ${erzieherUser.email}`);
|
||||
console.log(` Koordinator: ${koordinator.email}`);
|
||||
console.log(` Eltern: ${elternSchmidt.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 |
Executable
+8
@@ -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
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export async function confirmAlertAction(token: string) {
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
} catch {
|
||||
return { error: "Fehler bei der Bestätigung." };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,29 @@ export default async function AlertPage({
|
||||
|
||||
const alert = await prisma.notdienstAlert.findUnique({
|
||||
where: { confirmationToken: token },
|
||||
include: {
|
||||
parentUser: true,
|
||||
kita: true,
|
||||
assignment: { include: { child: true } },
|
||||
select: {
|
||||
status: true,
|
||||
parentUser: {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -15,10 +15,7 @@ import { prisma } from "@/lib/prisma";
|
||||
const BASE_URL =
|
||||
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||
|
||||
export async function triggerAlertAction(
|
||||
assignmentId: string,
|
||||
parentUserId: string,
|
||||
) {
|
||||
export async function triggerAlertAction(assignmentId: string) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
|
||||
@@ -36,6 +33,19 @@ export async function triggerAlertAction(
|
||||
id: true,
|
||||
firstName: 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." };
|
||||
}
|
||||
|
||||
if (!parentUserId) {
|
||||
const parents = assignment.child.family.users;
|
||||
|
||||
if (parents.length === 0) {
|
||||
return { error: "Kein Elternteil für diesen Notdienst hinterlegt." };
|
||||
}
|
||||
|
||||
@@ -59,63 +71,49 @@ export async function triggerAlertAction(
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
const dateLabel = format(assignment.date, "EEEE, dd. MMMM yyyy", {
|
||||
locale: de,
|
||||
});
|
||||
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
|
||||
const alertJobs: { email: string; token: string }[] = [];
|
||||
|
||||
if (!parentLink) {
|
||||
return { error: "Das Elternteil passt nicht zu dieser Einteilung." };
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
parents.map((parent) => {
|
||||
const token = crypto.randomUUID();
|
||||
|
||||
await prisma.notdienstAlert.create({
|
||||
alertJobs.push({ email: parent.email, token });
|
||||
return prisma.notdienstAlert.create({
|
||||
data: {
|
||||
kitaId,
|
||||
assignmentId,
|
||||
parentUserId,
|
||||
parentUserId: parent.id,
|
||||
triggeredById: session.user.id,
|
||||
status: NotdienstAlertStatus.PENDING,
|
||||
confirmationToken: token,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const alertUrl = `${BASE_URL}/alert/${token}`;
|
||||
const dateLabel = format(assignment.date, "EEEE, dd. MMMM yyyy", {
|
||||
locale: de,
|
||||
});
|
||||
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
|
||||
|
||||
for (const job of alertJobs) {
|
||||
const emailResult = await sendAppEmail({
|
||||
to: parentLink.user.email,
|
||||
to: job.email,
|
||||
subject: `Dringender Notdienst-Alarm für ${dateLabel}`,
|
||||
react: createElement(AlertEmail, {
|
||||
date: dateLabel,
|
||||
childName,
|
||||
confirmLink: alertUrl,
|
||||
confirmLink: `${BASE_URL}/alert/${job.token}`,
|
||||
}),
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
if (!emailResult.success) {
|
||||
revalidatePath("/dashboard");
|
||||
return {
|
||||
error: `Alarm wurde angelegt, aber die E-Mail konnte nicht versendet werden: ${emailResult.error}`,
|
||||
error: `Alarm wurde angelegt, aber mindestens eine E-Mail konnte nicht versendet werden: ${emailResult.error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
return { success: true };
|
||||
} catch (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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +1,253 @@
|
||||
import { Baby, Contact, Mail, Phone, ShieldCheck, UsersRound } from "lucide-react";
|
||||
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
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 {
|
||||
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() {
|
||||
const session = await requireKitaSession();
|
||||
|
||||
// Fetch only users who opted in to the directory
|
||||
const users = await prisma.user.findMany({
|
||||
const familyRows = await prisma.family.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: {
|
||||
kitaId: session.user.kitaId,
|
||||
directoryOptInAt: { not: null },
|
||||
familyId: { in: familyRows.map((family) => family.id) },
|
||||
},
|
||||
include: {
|
||||
childLinks: {
|
||||
include: { child: true },
|
||||
select: {
|
||||
id: 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 (
|
||||
<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>
|
||||
<p className="text-muted-foreground">
|
||||
Kontaktinformationen aller Eltern, die der Freigabe zugestimmt haben.
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Haushalte und Kontaktdaten, die explizit fuer das interne
|
||||
Kita-Adressbuch freigegeben wurden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{users.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">
|
||||
<Contact className="h-10 w-10 text-muted-foreground mb-4" />
|
||||
<h3 className="mt-4 text-lg font-semibold">Keine Kontakte</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
Bisher hat niemand der Veröffentlichung im Adressbuch zugestimmt.
|
||||
{families.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-background p-10 text-center">
|
||||
<Contact className="mb-4 h-10 w-10 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold">Keine freigegebenen Kontakte</h3>
|
||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||
Sobald mindestens ein Elternteil eines Haushalts zustimmt,
|
||||
erscheint die Familie hier.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{users.map((u) => (
|
||||
<Card key={u.id} className="flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex justify-between items-start">
|
||||
<span className="text-lg">
|
||||
{u.firstName} {u.lastName}
|
||||
</span>
|
||||
{u.role === "ADMIN" || u.role === "KOORDINATOR" ? (
|
||||
<Badge variant="secondary" className="text-[10px]">Vorstand</Badge>
|
||||
) : null}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{families.map((family) => (
|
||||
<Card key={family.id} className="overflow-hidden">
|
||||
<CardHeader className="border-b bg-muted/30 pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<UsersRound className="h-4 w-4 text-primary" />
|
||||
{family.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{family.parents.filter((user) => user.hasDirectoryOptIn).length}{" "}
|
||||
von {family.parents.length} Elternteilen sichtbar
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="success">Freigegeben</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm flex-1">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Mail className="h-4 w-4 shrink-0" />
|
||||
<a href={`mailto:${u.email}`} className="hover:text-primary transition-colors truncate">
|
||||
{u.email}
|
||||
</a>
|
||||
</div>
|
||||
{u.phone && (
|
||||
<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>
|
||||
))}
|
||||
<CardContent className="grid gap-5 pt-5">
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<Baby className="h-3.5 w-3.5" />
|
||||
Kinder
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{u.dutyAssignments.length > 0 && (
|
||||
<div className="mt-auto pt-3 border-t">
|
||||
<span className="text-xs font-medium text-muted-foreground mb-1 block">Ämter / Dienste:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.dutyAssignments.map((assignment) => (
|
||||
<Badge key={assignment.duty.id} variant="outline" className="bg-primary/5 text-primary border-primary/20">
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3">
|
||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
Kontakte
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{family.parents.map((user) => {
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="rounded-md border bg-background p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{user.firstName} {user.lastName}
|
||||
</p>
|
||||
{!user.hasDirectoryOptIn && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Kontakt nicht freigegeben.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{user.hasDirectoryOptIn ? (
|
||||
<Badge variant="outline">Opt-in</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{user.hasDirectoryOptIn && (
|
||||
<div className="mt-3 grid gap-2 text-sm text-muted-foreground">
|
||||
{user.email && (
|
||||
<a
|
||||
href={`mailto:${user.email}`}
|
||||
className="flex min-w-0 items-center gap-2 hover:text-primary"
|
||||
>
|
||||
<Mail className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{user.email}</span>
|
||||
</a>
|
||||
)}
|
||||
{user.phone && (
|
||||
<a
|
||||
href={`tel:${user.phone}`}
|
||||
className="flex items-center gap-2 hover:text-primary"
|
||||
>
|
||||
<Phone className="h-4 w-4 shrink-0" />
|
||||
{user.phone}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.duties.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5 border-t pt-3">
|
||||
{user.duties.map((duty) => (
|
||||
<Badge
|
||||
key={duty.id}
|
||||
variant="outline"
|
||||
className="bg-primary/5 text-primary"
|
||||
>
|
||||
{duty.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -9,10 +9,8 @@ import { triggerAlertAction } from "./actions";
|
||||
|
||||
export function AlertButton({
|
||||
assignmentId,
|
||||
parentUserId,
|
||||
}: {
|
||||
assignmentId: string;
|
||||
parentUserId: string;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
@@ -20,7 +18,7 @@ export function AlertButton({
|
||||
if (!confirm("Bist du sicher? Dies löst den Notdienst-Alarm aus.")) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await triggerAlertAction(assignmentId, parentUserId);
|
||||
const result = await triggerAlertAction(assignmentId);
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Educator } from "@prisma/client";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -30,9 +29,16 @@ import {
|
||||
|
||||
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 [editingEducator, setEditingEducator] = useState<Educator | null>(null);
|
||||
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleCreate = (formData: FormData) => {
|
||||
|
||||
@@ -8,13 +8,17 @@ import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const educatorSchema = z.object({
|
||||
firstName: z.string().min(1, "Vorname ist erforderlich"),
|
||||
lastName: z.string().min(1, "Nachname ist erforderlich"),
|
||||
firstName: z.string().min(1, "Vorname ist erforderlich").max(100).trim(),
|
||||
lastName: z.string().min(1, "Nachname ist erforderlich").max(100).trim(),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export async function createEducator(rawPayload: unknown) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
const parsed = educatorSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
@@ -24,7 +28,7 @@ export async function createEducator(rawPayload: unknown) {
|
||||
try {
|
||||
await prisma.educator.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId!,
|
||||
kitaId,
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
active: parsed.data.active,
|
||||
@@ -41,6 +45,10 @@ export async function createEducator(rawPayload: unknown) {
|
||||
|
||||
export async function updateEducator(id: string, rawPayload: unknown) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
const parsed = educatorSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
@@ -51,7 +59,7 @@ export async function updateEducator(id: string, rawPayload: unknown) {
|
||||
await prisma.educator.update({
|
||||
where: {
|
||||
id,
|
||||
kitaId: session.user.kitaId!,
|
||||
kitaId,
|
||||
},
|
||||
data: {
|
||||
firstName: parsed.data.firstName,
|
||||
@@ -70,12 +78,16 @@ export async function updateEducator(id: string, rawPayload: unknown) {
|
||||
|
||||
export async function deleteEducator(id: string) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.educator.delete({
|
||||
where: {
|
||||
id,
|
||||
kitaId: session.user.kitaId!,
|
||||
kitaId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GraduationCap } from "lucide-react";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
@@ -11,17 +12,29 @@ export default async function ErzieherPage() {
|
||||
|
||||
const educators = await prisma.educator.findMany({
|
||||
where: { kitaId: session.user.kitaId! },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
active: true,
|
||||
},
|
||||
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-6 p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">ErzieherInnen-Verwaltung</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Verwalte die Stammdaten des Kita-Personals (nur für den Vorstand sichtbar).
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<GraduationCap className="h-6 w-6 text-primary" />
|
||||
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>
|
||||
|
||||
<ErzieherList educators={educators} />
|
||||
</div>
|
||||
|
||||
@@ -2,30 +2,46 @@
|
||||
|
||||
import crypto from "crypto";
|
||||
import { createElement } from "react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { InviteEmail } from "@/emails/InviteEmail";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// =====================================================================
|
||||
// /dashboard/families · Server Actions
|
||||
// ---------------------------------------------------------------------
|
||||
// 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 INVITE_TOKEN_TTL_DAYS = 7;
|
||||
const BASE_URL =
|
||||
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||
|
||||
const parentSchema = z.object({
|
||||
firstName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
|
||||
lastName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
|
||||
const childSchema = z.object({
|
||||
firstName: z.string().min(1, "Vorname des Kindes fehlt.").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
|
||||
.string()
|
||||
.email("Bitte eine gültige E-Mail-Adresse angeben.")
|
||||
.toLowerCase()
|
||||
.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
|
||||
.number()
|
||||
.int()
|
||||
@@ -33,65 +49,139 @@ const parentSchema = z.object({
|
||||
.max(10),
|
||||
});
|
||||
|
||||
const childSchema = z.object({
|
||||
firstName: z.string().min(1, "Vorname des Kindes fehlt.").max(100).trim(),
|
||||
lastName: z.string().min(1, "Nachname des Kindes fehlt.").max(100).trim(),
|
||||
const updateFamilySchema = z.object({
|
||||
familyId: z.string().min(1),
|
||||
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 = {
|
||||
errors?: {
|
||||
firstName?: string[];
|
||||
lastName?: string[];
|
||||
email?: string[];
|
||||
familyName?: string[];
|
||||
parent1FirstName?: string[];
|
||||
parent1LastName?: string[];
|
||||
parent1Email?: string[];
|
||||
parent2FirstName?: string[];
|
||||
parent2LastName?: string[];
|
||||
parent2Email?: string[];
|
||||
children?: string[];
|
||||
_form?: string[];
|
||||
};
|
||||
success?: boolean;
|
||||
};
|
||||
|
||||
const PRIVACY_POLICY_VERSION = "2026-05-01";
|
||||
const INVITE_TOKEN_TTL_DAYS = 7;
|
||||
const BASE_URL =
|
||||
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||
type InviteJob = {
|
||||
to: string;
|
||||
parentName: string;
|
||||
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(
|
||||
_prev: AddFamilyState,
|
||||
formData: FormData,
|
||||
): Promise<AddFamilyState> {
|
||||
// ── 1. Nur Admins dürfen Familien anlegen ──────────────────────────
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
const kitaId = session.user.kitaId;
|
||||
|
||||
// ── 2. Parent-Felder validieren ────────────────────────────────────
|
||||
const parsedParent = parentSchema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsedParent.success) {
|
||||
return { errors: parsedParent.error.flatten().fieldErrors };
|
||||
if (!kitaId) {
|
||||
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
|
||||
}
|
||||
const { firstName, lastName, email, childCount } = parsedParent.data;
|
||||
|
||||
// ── 3. Kinder-Felder validieren ────────────────────────────────────
|
||||
const childrenRaw: { firstName: string; lastName: string }[] = [];
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsedFamily.success) {
|
||||
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({
|
||||
firstName: formData.get(`childFirstName_${i}`),
|
||||
lastName: formData.get(`childLastName_${i}`),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
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);
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
if (mailConfigError) {
|
||||
return { errors: { _form: [mailConfigError] } };
|
||||
@@ -106,54 +196,68 @@ export async function addFamilyAction(
|
||||
return { errors: { _form: ["Kita wurde nicht gefunden."] } };
|
||||
}
|
||||
|
||||
const parentName = `${firstName} ${lastName}`;
|
||||
const token = crypto.randomUUID();
|
||||
const inviteUrl = `${BASE_URL}/invite/${token}`;
|
||||
const expires = new Date(
|
||||
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
|
||||
);
|
||||
const parents = [
|
||||
{
|
||||
firstName: parsedFamily.data.parent1FirstName,
|
||||
lastName: parsedFamily.data.parent1LastName,
|
||||
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 {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 4a. Elternteil anlegen (kein Passwort → leerer passwordHash)
|
||||
const parent = await tx.user.create({
|
||||
const family = await tx.family.create({
|
||||
data: {
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
passwordHash: "", // wird beim Invite-Einlösen gesetzt
|
||||
kitaId,
|
||||
name: parsedFamily.data.familyName,
|
||||
},
|
||||
});
|
||||
|
||||
for (const parent of parents) {
|
||||
const createdParent = await tx.user.create({
|
||||
data: {
|
||||
kitaId,
|
||||
familyId: family.id,
|
||||
email: parent.email,
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
passwordHash: "",
|
||||
role: UserRole.ELTERN,
|
||||
kitaId,
|
||||
},
|
||||
});
|
||||
|
||||
// 4b. Kinder anlegen + mit Elternteil verknüpfen
|
||||
for (const child of childrenRaw) {
|
||||
const createdChild = await tx.child.create({
|
||||
data: {
|
||||
kitaId,
|
||||
firstName: child.firstName,
|
||||
lastName: child.lastName,
|
||||
},
|
||||
const invite = createInviteToken();
|
||||
inviteJobs.push({
|
||||
to: parent.email,
|
||||
parentName: `${parent.firstName} ${parent.lastName}`,
|
||||
inviteUrl: invite.inviteUrl,
|
||||
});
|
||||
|
||||
await tx.childParent.create({
|
||||
await tx.verificationToken.create({
|
||||
data: {
|
||||
kitaId,
|
||||
childId: createdChild.id,
|
||||
userId: parent.id,
|
||||
identifier: createdParent.id,
|
||||
token: invite.token,
|
||||
expires: invite.expires,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 4c. Einladungs-Token erstellen
|
||||
// identifier = userId (kein PII im Token selbst)
|
||||
await tx.verificationToken.create({
|
||||
data: {
|
||||
identifier: parent.id,
|
||||
token,
|
||||
expires,
|
||||
},
|
||||
await tx.child.createMany({
|
||||
data: childrenRaw.map((child) => ({
|
||||
kitaId,
|
||||
familyId: family.id,
|
||||
firstName: child.firstName,
|
||||
lastName: child.lastName,
|
||||
})),
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -163,32 +267,205 @@ export async function addFamilyAction(
|
||||
) {
|
||||
return {
|
||||
errors: {
|
||||
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
|
||||
_form: ["Mindestens eine E-Mail-Adresse existiert bereits."],
|
||||
},
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const emailResult = await sendAppEmail({
|
||||
to: email,
|
||||
subject: `Einladung zu ${kita.name} im Kita-Planer`,
|
||||
react: createElement(InviteEmail, {
|
||||
parentName,
|
||||
kitaName: kita.name,
|
||||
inviteLink: inviteUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!emailResult.success) {
|
||||
const emailError = await sendInviteJobs(kita.name, inviteJobs);
|
||||
if (emailError) {
|
||||
return {
|
||||
errors: {
|
||||
_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 };
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const initialState: AddFamilyState = {};
|
||||
// Verwaltet:
|
||||
// • Dialog-Open/Close-State
|
||||
// • Dynamische Kinderliste (min. 1, max. 10)
|
||||
// • Ein oder zwei Elternteile pro Haushalt
|
||||
// • useActionState → Server-Action-Fehler anzeigen
|
||||
// • Bei Erfolg (state.success) Dialog automatisch schließen
|
||||
// =====================================================================
|
||||
@@ -62,8 +63,8 @@ export function AddFamilyDialog() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Familie hinzufügen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Lege das Elternteil und die zugehörigen Kinder an. Der Elternteil
|
||||
erhält einen Einladungslink per E-Mail, um sein Passwort zu setzen.
|
||||
Lege einen Haushalt mit Elternteilen und Kindern an. Die
|
||||
Elternteile erhalten Einladungslinks per E-Mail.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -71,34 +72,77 @@ export function AddFamilyDialog() {
|
||||
{/* Anzahl Kinder als verstecktes Feld für die Server Action */}
|
||||
<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">
|
||||
<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">
|
||||
<FormField
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
id="parent1FirstName"
|
||||
name="parent1FirstName"
|
||||
label="Vorname"
|
||||
autoComplete="given-name"
|
||||
error={state.errors?.firstName?.[0]}
|
||||
error={state.errors?.parent1FirstName?.[0]}
|
||||
/>
|
||||
<FormField
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
id="parent1LastName"
|
||||
name="parent1LastName"
|
||||
label="Nachname"
|
||||
autoComplete="family-name"
|
||||
error={state.errors?.lastName?.[0]}
|
||||
error={state.errors?.parent1LastName?.[0]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
id="email"
|
||||
name="email"
|
||||
id="parent1Email"
|
||||
name="parent1Email"
|
||||
type="email"
|
||||
label="E-Mail-Adresse"
|
||||
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>
|
||||
|
||||
@@ -199,6 +243,8 @@ function FormField({
|
||||
label,
|
||||
type = "text",
|
||||
autoComplete,
|
||||
placeholder,
|
||||
required = true,
|
||||
error,
|
||||
}: {
|
||||
id: string;
|
||||
@@ -206,6 +252,8 @@ function FormField({
|
||||
label: string;
|
||||
type?: string;
|
||||
autoComplete?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -216,7 +264,8 @@ function FormField({
|
||||
name={name}
|
||||
type={type}
|
||||
autoComplete={autoComplete}
|
||||
required
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
@@ -13,7 +13,12 @@ const dutySchema = z.object({
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
if (!parsed.success) {
|
||||
@@ -23,7 +28,7 @@ export async function createDuty(rawPayload: unknown) {
|
||||
try {
|
||||
await prisma.parentDuty.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId!,
|
||||
kitaId,
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
},
|
||||
@@ -38,13 +43,17 @@ export async function createDuty(rawPayload: unknown) {
|
||||
}
|
||||
|
||||
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 {
|
||||
await prisma.parentDuty.delete({
|
||||
where: {
|
||||
id: dutyId,
|
||||
kitaId: session.user.kitaId!,
|
||||
kitaId,
|
||||
},
|
||||
});
|
||||
revalidatePath("/dashboard/families");
|
||||
@@ -57,9 +66,28 @@ export async function deleteDuty(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 {
|
||||
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
|
||||
const existing = await prisma.parentDutyAssignment.findUnique({
|
||||
where: {
|
||||
@@ -73,7 +101,7 @@ export async function assignDuty(userId: string, dutyId: string) {
|
||||
|
||||
await prisma.parentDutyAssignment.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId!,
|
||||
kitaId,
|
||||
userId: userId,
|
||||
dutyId: dutyId,
|
||||
},
|
||||
@@ -89,13 +117,17 @@ export async function assignDuty(userId: string, dutyId: 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 {
|
||||
await prisma.parentDutyAssignment.delete({
|
||||
where: {
|
||||
id: assignmentId,
|
||||
kitaId: session.user.kitaId!,
|
||||
kitaId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Plus, X, Shield, Trash2 } from "lucide-react";
|
||||
import { ParentDuty, ParentDutyAssignment, User } from "@prisma/client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -15,13 +14,30 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
import { createDuty, assignDuty, removeDutyAssignment, deleteDuty } from "./duty-actions";
|
||||
|
||||
type DutyWithAssignments = ParentDuty & {
|
||||
assignments: ParentDutyAssignment[];
|
||||
type DutyWithAssignments = {
|
||||
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({
|
||||
@@ -29,9 +45,9 @@ export function DutyManager({
|
||||
allDuties,
|
||||
userAssignments,
|
||||
}: {
|
||||
user: User;
|
||||
user: DutyUser;
|
||||
allDuties: DutyWithAssignments[];
|
||||
userAssignments: (ParentDutyAssignment & { duty: ParentDuty })[];
|
||||
userAssignments: UserDutyAssignment[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -13,54 +13,79 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { AddFamilyDialog } from "./add-family-dialog";
|
||||
import { DutyManager } from "./duty-manager";
|
||||
import { EditFamilyDialog } from "./edit-family-dialog";
|
||||
|
||||
export const metadata = { title: "Familienverwaltung · Kita-Planer" };
|
||||
|
||||
// =====================================================================
|
||||
// /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.
|
||||
// =====================================================================
|
||||
|
||||
export default async function FamiliesPage() {
|
||||
// Guard: Nur Admins (und Koordinatoren) dürfen diese Seite sehen
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
// Guard: Nur Admins dürfen die vollständige Familienliste sehen.
|
||||
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
|
||||
const families = await prisma.user.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId!,
|
||||
role: UserRole.ELTERN,
|
||||
},
|
||||
const families = await prisma.family.findMany({
|
||||
where: { kitaId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
passwordHash: true, // "" → Invite ausstehend; sonst aktiv
|
||||
emailVerifiedAt: true,
|
||||
childLinks: {
|
||||
select: {
|
||||
child: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
dutyAssignments: {
|
||||
include: { duty: true },
|
||||
select: {
|
||||
id: true,
|
||||
dutyId: true,
|
||||
duty: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||
},
|
||||
children: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
const allDuties = await prisma.parentDuty.findMany({
|
||||
where: { kitaId: session.user.kitaId! },
|
||||
include: { assignments: true },
|
||||
where: { kitaId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
assignments: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
const childCount = families.reduce(
|
||||
(acc, family) => acc + family.children.length,
|
||||
0,
|
||||
);
|
||||
const canManageDuties = session.user.role === UserRole.ADMIN;
|
||||
|
||||
return (
|
||||
<div className="px-8 py-8">
|
||||
{/* Header */}
|
||||
@@ -72,7 +97,7 @@ export default async function FamiliesPage() {
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{families.length === 0
|
||||
? "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>
|
||||
</div>
|
||||
<AddFamilyDialog />
|
||||
@@ -85,8 +110,8 @@ export default async function FamiliesPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Elternteil</TableHead>
|
||||
<TableHead>E-Mail</TableHead>
|
||||
<TableHead>Familie</TableHead>
|
||||
<TableHead>Elternteile</TableHead>
|
||||
<TableHead>Kinder</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Aktionen</TableHead>
|
||||
@@ -94,23 +119,35 @@ export default async function FamiliesPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{families.map((family) => {
|
||||
const isActive = !!family.emailVerifiedAt;
|
||||
const children = family.childLinks.map((l) => l.child);
|
||||
const hasPendingInvite = family.users.some(
|
||||
(user) => !user.emailVerifiedAt,
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={family.id}>
|
||||
<TableCell className="font-medium">
|
||||
{family.firstName} {family.lastName}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{family.email}
|
||||
{family.name}
|
||||
</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>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{children.map((child) => (
|
||||
{family.children.map((child) => (
|
||||
<span
|
||||
key={child.id}
|
||||
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium"
|
||||
@@ -122,16 +159,39 @@ export default async function FamiliesPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={isActive ? "success" : "warning"}>
|
||||
{isActive ? "Aktiv" : "Eingeladen"}
|
||||
<Badge variant={hasPendingInvite ? "warning" : "success"}>
|
||||
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DutyManager
|
||||
user={family as any}
|
||||
allDuties={allDuties as any}
|
||||
userAssignments={family.dutyAssignments}
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
<EditFamilyDialog
|
||||
family={{
|
||||
id: family.id,
|
||||
name: family.name,
|
||||
parents: family.users.map((parent) => ({
|
||||
id: parent.id,
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
email: parent.email,
|
||||
})),
|
||||
children: family.children,
|
||||
}}
|
||||
/>
|
||||
{canManageDuties &&
|
||||
family.users.map((parent) => (
|
||||
<DutyManager
|
||||
key={parent.id}
|
||||
user={{
|
||||
id: parent.id,
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
}}
|
||||
allDuties={allDuties}
|
||||
userAssignments={parent.dutyAssignments}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { MitbringselItem } from "@prisma/client";
|
||||
import { Trash2, Plus, Utensils } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -9,7 +8,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { addMitbringsel, deleteMitbringsel } from "../actions";
|
||||
|
||||
type ItemWithUser = MitbringselItem & {
|
||||
type MitbringselItemDto = {
|
||||
id: string;
|
||||
userId: string;
|
||||
content: string;
|
||||
user: { firstName: string; lastName: string };
|
||||
};
|
||||
|
||||
@@ -20,7 +22,7 @@ export function MitbringselList({
|
||||
isAdmin,
|
||||
}: {
|
||||
terminId: string;
|
||||
items: ItemWithUser[];
|
||||
items: MitbringselItemDto[];
|
||||
currentUserId: string;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
|
||||
@@ -5,17 +5,20 @@ import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { Check, X, Clock, CalendarIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Termin, User } from "@prisma/client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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;
|
||||
};
|
||||
|
||||
export function PendingAnfragen({ termine }: { termine: PendingTermin[] }) {
|
||||
export function PendingAnfragen({ termine }: { termine: PendingTerminDto[] }) {
|
||||
if (termine.length === 0) {
|
||||
return (
|
||||
<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 handleApprove = () => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Termin, MitbringselItem, TerminType, TerminStatus } from "@prisma/client";
|
||||
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
|
||||
import { format } from "date-fns";
|
||||
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 { MitbringselList } from "./mitbringsel-list";
|
||||
import { toggleMitbringselList } from "../actions";
|
||||
@@ -14,18 +14,43 @@ import { Label } from "@/components/ui/label";
|
||||
import { useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type TerminWithItems = Termin & {
|
||||
mitbringselItems?: (MitbringselItem & {
|
||||
export type TerminListItemDto = {
|
||||
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 };
|
||||
})[];
|
||||
}[];
|
||||
};
|
||||
|
||||
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({
|
||||
termine,
|
||||
userId,
|
||||
isAdmin,
|
||||
}: {
|
||||
termine: TerminWithItems[];
|
||||
termine: CalendarListItemDto[];
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
@@ -46,23 +71,58 @@ export function TerminList({
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{termine.map((termin) => (
|
||||
termin.kind === "duty" ? (
|
||||
<DutyCard key={`duty-${termin.id}`} duty={termin} />
|
||||
) : (
|
||||
<TerminCard
|
||||
key={termin.id}
|
||||
key={`termin-${termin.id}`}
|
||||
termin={termin}
|
||||
userId={userId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</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({
|
||||
termin,
|
||||
userId,
|
||||
isAdmin,
|
||||
}: {
|
||||
termin: TerminWithItems;
|
||||
termin: TerminListItemDto;
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
|
||||
@@ -55,6 +55,10 @@ export async function createTerminRequest(rawPayload: unknown) {
|
||||
|
||||
export async function createTerminAdmin(rawPayload: unknown) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
const parsed = terminSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
@@ -66,7 +70,7 @@ export async function createTerminAdmin(rawPayload: unknown) {
|
||||
try {
|
||||
await prisma.termin.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
kitaId,
|
||||
createdById: session.user.id,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
@@ -90,12 +94,16 @@ export async function createTerminAdmin(rawPayload: unknown) {
|
||||
|
||||
export async function approveTermin(terminId: string) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.termin.update({
|
||||
where: {
|
||||
id: terminId,
|
||||
kitaId: session.user.kitaId,
|
||||
kitaId,
|
||||
},
|
||||
data: {
|
||||
status: TerminStatus.CONFIRMED,
|
||||
@@ -114,12 +122,16 @@ export async function approveTermin(terminId: string) {
|
||||
|
||||
export async function rejectTermin(terminId: string, reason?: string) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.termin.update({
|
||||
where: {
|
||||
id: terminId,
|
||||
kitaId: session.user.kitaId,
|
||||
kitaId,
|
||||
},
|
||||
data: {
|
||||
status: TerminStatus.REJECTED,
|
||||
@@ -139,12 +151,16 @@ export async function rejectTermin(terminId: string, reason?: string) {
|
||||
|
||||
export async function toggleMitbringselList(terminId: string, enabled: boolean) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.termin.update({
|
||||
where: {
|
||||
id: terminId,
|
||||
kitaId: session.user.kitaId,
|
||||
kitaId,
|
||||
},
|
||||
data: {
|
||||
mitbringselListEnabled: enabled,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { DutyAssignmentStatus, TerminStatus, UserRole } from "@prisma/client";
|
||||
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
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 { 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({
|
||||
searchParams,
|
||||
@@ -14,51 +14,139 @@ export default async function KalenderPage({
|
||||
}) {
|
||||
const session = await requireKitaSession();
|
||||
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";
|
||||
|
||||
// Fetch confirmed events
|
||||
const confirmedTermine = await prisma.termin.findMany({
|
||||
const confirmedTermineRows = await prisma.termin.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
},
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
type: true,
|
||||
status: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
allDay: true,
|
||||
mitbringselListEnabled: true,
|
||||
mitbringselItems: {
|
||||
include: {
|
||||
user: { select: { firstName: true, lastName: true } },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
content: true,
|
||||
user: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
|
||||
// Fetch user's own pending events
|
||||
const myPendingTermine = await prisma.termin.findMany({
|
||||
const myPendingTermineRows = await prisma.termin.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: TerminStatus.PENDING,
|
||||
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" },
|
||||
});
|
||||
|
||||
// Combine for general view
|
||||
const allUserTermine = [...confirmedTermine, ...myPendingTermine].sort(
|
||||
(a, b) => a.startDate.getTime() - b.startDate.getTime()
|
||||
);
|
||||
const dutyAssignmentRows = await prisma.dutyAssignment.findMany({
|
||||
where: {
|
||||
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
|
||||
let allPendingTermine: any[] = [];
|
||||
const allUserTermine: CalendarListItemDto[] = [
|
||||
...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) {
|
||||
allPendingTermine = await prisma.termin.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: TerminStatus.PENDING,
|
||||
},
|
||||
include: {
|
||||
createdBy: { select: { firstName: true, lastName: true } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
startDate: true,
|
||||
allDay: true,
|
||||
createdBy: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
@@ -94,7 +182,7 @@ export default async function KalenderPage({
|
||||
</a>
|
||||
<a
|
||||
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"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground"
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { Baby, LogOut } from "lucide-react";
|
||||
|
||||
import { auth, signOut } from "@/auth";
|
||||
import { signOut } from "@/auth";
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarNav } from "@/components/dashboard/sidebar-nav";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
|
||||
// =====================================================================
|
||||
// Dashboard-Layout · Linke Sidebar
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -47,7 +54,7 @@ export default async function DashboardLayout({
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
<SidebarNav role={session.user.role as any} />
|
||||
<SidebarNav role={session.user.role} />
|
||||
</div>
|
||||
|
||||
{/* Footer: User-Info + Abmelden */}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,9 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
if (!session.user.familyId) {
|
||||
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
||||
}
|
||||
|
||||
// ── 2. Validierung (Soll-Tage) ─────────────────────────────────────
|
||||
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({
|
||||
where: {
|
||||
id: { in: childrenIds },
|
||||
kitaId,
|
||||
parentLinks: { some: { userId: session.user.id } },
|
||||
familyId: session.user.familyId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
@@ -101,11 +104,10 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
|
||||
// ── 3. Datenbank-Transaktion ───────────────────────────────────────
|
||||
try {
|
||||
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({
|
||||
where: {
|
||||
kitaId,
|
||||
userId: session.user.id,
|
||||
childId: { in: childrenIds },
|
||||
date: {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import Link from "next/link";
|
||||
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 { prisma } from "@/lib/prisma";
|
||||
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" };
|
||||
|
||||
@@ -29,34 +31,51 @@ export default async function NotdienstPage() {
|
||||
select: { notdienstMinPerChildPerMonth: true },
|
||||
});
|
||||
|
||||
// Nur eigene, aktive Kinder laden
|
||||
const children = await prisma.child.findMany({
|
||||
// Nur aktive Kinder des eigenen Haushalts laden.
|
||||
const children = session.user.familyId
|
||||
? await prisma.child.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
familyId: session.user.familyId,
|
||||
active: true,
|
||||
parentLinks: { some: { userId: session.user.id } },
|
||||
},
|
||||
select: { id: true, firstName: true },
|
||||
});
|
||||
})
|
||||
: [];
|
||||
|
||||
const targetData = getTargetMonthData();
|
||||
const { targetYear, targetMonth, monthName, isLocked } = targetData;
|
||||
|
||||
const requiredDaysTotal = children.length * kita.notdienstMinPerChildPerMonth;
|
||||
const childIds = children.map((child) => child.id);
|
||||
|
||||
// Bisherige Einträge für den Zielmonat laden
|
||||
// Wir filtern bewusst nach den eigenen Kindern und dem User
|
||||
const availabilities = await prisma.notdienstAvailability.findMany({
|
||||
// Bisherige Einträge für den Zielmonat laden, haushaltsweit über die Kinder.
|
||||
const [availabilities, availabilityCountForCurrentOrNextMonth] =
|
||||
children.length > 0
|
||||
? await Promise.all([
|
||||
prisma.notdienstAvailability.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
userId: session.user.id,
|
||||
childId: { in: childIds },
|
||||
date: {
|
||||
gte: new Date(targetYear, targetMonth - 1, 1),
|
||||
lt: new Date(targetYear, targetMonth, 1),
|
||||
},
|
||||
},
|
||||
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());
|
||||
|
||||
@@ -86,19 +105,43 @@ export default async function NotdienstPage() {
|
||||
</div>
|
||||
|
||||
{children.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
||||
Deinem Account sind noch keine Kinder zugeordnet.
|
||||
</div>
|
||||
<NotdienstLockout />
|
||||
) : (
|
||||
<NotdienstForm
|
||||
<NotdienstEntry
|
||||
hasAnyAvailability={availabilityCountForCurrentOrNextMonth > 0}
|
||||
targetYear={targetYear}
|
||||
targetMonth={targetMonth}
|
||||
isLocked={isLocked}
|
||||
requiredDaysTotal={requiredDaysTotal}
|
||||
initialSelectedDates={selectedDates}
|
||||
childrenIds={children.map((c) => c.id)}
|
||||
childrenIds={childIds}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function generatePlanAction() {
|
||||
lt: new Date(targetYear, targetMonth, 1),
|
||||
},
|
||||
},
|
||||
include: { child: true, user: true },
|
||||
include: { child: { select: { familyId: true } } },
|
||||
});
|
||||
|
||||
// 2. Werktage holen
|
||||
@@ -63,8 +63,8 @@ export async function generatePlanAction() {
|
||||
// Finde die Familie, die am wenigsten oft dran war
|
||||
// Zufall einbauen bei Gleichstand
|
||||
const sorted = availForDay.sort((a, b) => {
|
||||
const countA = familyUsageCount[a.userId] || 0;
|
||||
const countB = familyUsageCount[b.userId] || 0;
|
||||
const countA = familyUsageCount[a.child.familyId] || 0;
|
||||
const countB = familyUsageCount[b.child.familyId] || 0;
|
||||
if (countA === countB) {
|
||||
return Math.random() - 0.5; // Zufallsmischung
|
||||
}
|
||||
@@ -79,8 +79,8 @@ export async function generatePlanAction() {
|
||||
});
|
||||
|
||||
// Usage-Counter erhöhen
|
||||
familyUsageCount[selected.userId] =
|
||||
(familyUsageCount[selected.userId] || 0) + 1;
|
||||
familyUsageCount[selected.child.familyId] =
|
||||
(familyUsageCount[selected.child.familyId] || 0) + 1;
|
||||
}
|
||||
|
||||
// 4. In der DB speichern
|
||||
@@ -161,6 +161,19 @@ export async function updateAssignmentAction(
|
||||
|
||||
// Neues anlegen, falls ausgewählt
|
||||
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({
|
||||
data: {
|
||||
kitaId,
|
||||
@@ -174,7 +187,9 @@ export async function updateAssignmentAction(
|
||||
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { error: error.message || "Update fehlgeschlagen." };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "Update fehlgeschlagen.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,15 @@ export default async function PlanungsZentralePage() {
|
||||
where: {
|
||||
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
||||
},
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
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.
|
||||
const allChildrenRaw = await prisma.child.findMany({
|
||||
where: { kitaId, active: true },
|
||||
include: {
|
||||
parentLinks: { include: { user: true } },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { lastName: "asc" },
|
||||
});
|
||||
|
||||
const allChildren = allChildrenRaw.map((c) => {
|
||||
// Einfachheit halber nehmen wir den ersten verknüpften Elternteil zur Anzeige
|
||||
const parent = c.parentLinks[0]?.user;
|
||||
const parentName = parent ? `${parent.firstName} ${parent.lastName}` : "Unbekannt";
|
||||
const parentName =
|
||||
c.family.users
|
||||
.map((parent) => `${parent.firstName} ${parent.lastName}`)
|
||||
.join(", ") || c.family.name;
|
||||
return {
|
||||
id: c.id,
|
||||
name: `${c.firstName} ${c.lastName}`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useTransition } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { NotdienstPlanStatus } from "@prisma/client";
|
||||
|
||||
+247
-16
@@ -1,6 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle } from "lucide-react";
|
||||
import { UserRole, NotdienstPlanStatus } from "@prisma/client";
|
||||
import { format } from "date-fns";
|
||||
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 { prisma } from "@/lib/prisma";
|
||||
@@ -13,7 +15,10 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { OnboardingDialog } from "@/components/OnboardingDialog";
|
||||
import { AlertButton } from "./alert-button";
|
||||
import { AbsenceCard } from "./absence-card";
|
||||
import { NewsTicker } from "./news-ticker";
|
||||
|
||||
export const metadata = { title: "Übersicht · Kita-Planer" };
|
||||
|
||||
@@ -30,22 +35,166 @@ export default async function DashboardPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Schnelle Statistiken für die Übersicht
|
||||
const [familyCount, childCount] = await Promise.all([
|
||||
prisma.user.count({
|
||||
where: { kitaId: session.user.kitaId, role: UserRole.ELTERN },
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// 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({
|
||||
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 =
|
||||
session.user.role === UserRole.ADMIN ||
|
||||
session.user.role === UserRole.SUPERADMIN;
|
||||
const canReportAbsences = session.user.role !== UserRole.ERZIEHER;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Willkommen, {session.user.name?.split(" ")[0] ?? "zusammen"}!
|
||||
@@ -55,6 +204,25 @@ export default async function DashboardPage() {
|
||||
</p>
|
||||
</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) */}
|
||||
{isAdmin && (
|
||||
<div className="mb-8">
|
||||
@@ -62,10 +230,53 @@ export default async function DashboardPage() {
|
||||
</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 */}
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatCard
|
||||
label="Elternteile"
|
||||
label="Familien"
|
||||
value={familyCount}
|
||||
description="registrierte Familien"
|
||||
icon={<Users className="h-5 w-5 text-muted-foreground" />}
|
||||
@@ -147,15 +358,38 @@ async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
|
||||
date: today,
|
||||
plan: { status: NotdienstPlanStatus.PUBLISHED },
|
||||
},
|
||||
include: {
|
||||
child: { include: { parentLinks: { include: { user: true } } } },
|
||||
alerts: true, // Um zu sehen, ob schon ein Alarm ausgelöst wurde
|
||||
select: {
|
||||
id: true,
|
||||
child: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
alerts: {
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!assignment) return null;
|
||||
|
||||
const 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
|
||||
|
||||
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">
|
||||
<div>
|
||||
<p className="font-medium text-base">
|
||||
{parent ? `${parent.firstName} ${parent.lastName}` : "Unbekannt"}
|
||||
{parentNames || assignment.child.family.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Kind: {assignment.child.firstName} {assignment.child.lastName}
|
||||
@@ -198,10 +432,7 @@ async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
|
||||
: "Abgebrochen"}
|
||||
</Badge>
|
||||
) : (
|
||||
<AlertButton
|
||||
assignmentId={assignment.id}
|
||||
parentUserId={parent?.id || ""}
|
||||
/>
|
||||
<AlertButton assignmentId={assignment.id} />
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
<DialogDescription className="text-base pt-2">
|
||||
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
|
||||
und Mitbringsel-Einträge werden DSGVO-konform sofort und unwiderruflich aus der Datenbank gelöscht.
|
||||
Wenn du das letzte Elternteil im Haushalt bist, wird die gesamte
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,264 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
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() {
|
||||
const session = await requireKitaSession();
|
||||
|
||||
try {
|
||||
// Da wir onDelete: Cascade überall konfiguriert haben,
|
||||
// löscht dieser eine Befehl den User, seine ChildParent-Links,
|
||||
// seine MitbringselItems, seine NotdienstAvailabilities etc.
|
||||
await prisma.user.delete({
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
kitaId: session.user.kitaId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
familyId: true,
|
||||
},
|
||||
});
|
||||
|
||||
// The client component will trigger the actual NextAuth client signOut,
|
||||
// but returning success indicates the DB deletion was successful.
|
||||
if (!user) {
|
||||
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 };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Löschen des Accounts:", error);
|
||||
|
||||
@@ -2,11 +2,22 @@ import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { format } from "date-fns";
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
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" };
|
||||
|
||||
@@ -15,12 +26,41 @@ export default async function ProfilPage() {
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: session.user.id },
|
||||
include: {
|
||||
childLinks: {
|
||||
include: { child: true },
|
||||
select: {
|
||||
id: 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: {
|
||||
include: { duty: true },
|
||||
select: {
|
||||
id: true,
|
||||
duty: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -56,12 +96,39 @@ export default async function ProfilPage() {
|
||||
</div>
|
||||
<div className="font-medium">{user.email}</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 className="text-sm text-muted-foreground">Rolle im Verein</div>
|
||||
<Badge variant={user.role === "ELTERN" ? "secondary" : "default"} className="mt-1">
|
||||
{user.role}
|
||||
</Badge>
|
||||
</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="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<CalendarHeart className="h-3.5 w-3.5" /> Mitglied seit
|
||||
@@ -74,6 +141,29 @@ export default async function ProfilPage() {
|
||||
</Card>
|
||||
|
||||
<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 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -83,19 +173,17 @@ export default async function ProfilPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user.childLinks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Kinder verknüpft.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{user.childLinks.map((link) => (
|
||||
<div key={link.child.id} className="flex items-center justify-between p-2 rounded-md bg-muted/50">
|
||||
<span className="font-medium">
|
||||
{link.child.firstName} {link.child.lastName}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<MyChildrenManager
|
||||
items={(user.family?.children ?? []).map((child) => ({
|
||||
id: child.id,
|
||||
firstName: child.firstName,
|
||||
lastName: child.lastName,
|
||||
dateOfBirth: child.dateOfBirth
|
||||
? child.dateOfBirth.toISOString().slice(0, 10)
|
||||
: null,
|
||||
}))}
|
||||
canManage={!!user.familyId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -124,12 +212,11 @@ export default async function ProfilPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive/20 mt-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -48,8 +48,7 @@ export default async function InvitePage({
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
passwordHash: true,
|
||||
emailVerifiedAt: true,
|
||||
kita: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
@@ -57,7 +56,7 @@ export default async function InvitePage({
|
||||
if (!user) notFound();
|
||||
|
||||
const userName = `${user.firstName} ${user.lastName}`;
|
||||
const alreadyAccepted = user.passwordHash !== "";
|
||||
const alreadyAccepted = !!user.emailVerifiedAt;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-muted/30">
|
||||
|
||||
+31
-2
@@ -14,8 +14,37 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Kita-Planer",
|
||||
description: "Der digitale Kita-Planer für Elterninitiativen",
|
||||
metadataBase: new URL(
|
||||
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({
|
||||
|
||||
@@ -16,7 +16,7 @@ export const metadata = { title: "Anmelden · Kita-Planer" };
|
||||
|
||||
export default async function LoginPage() {
|
||||
const session = await auth();
|
||||
if (session?.user) {
|
||||
if (session?.user?.id) {
|
||||
redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
|
||||
}
|
||||
|
||||
|
||||
+388
-66
@@ -1,104 +1,426 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
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-
|
||||
// Logik selbst (Onboarding vs. Dashboard) übernimmt `requireKitaSession`.
|
||||
export const metadata: Metadata = {
|
||||
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() {
|
||||
const session = await auth();
|
||||
if (session?.user) {
|
||||
if (session?.user?.id) {
|
||||
redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="border-b">
|
||||
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-6">
|
||||
<span className="text-lg font-semibold">Kita-Planer</span>
|
||||
<nav className="flex items-center gap-3">
|
||||
<Button asChild variant="ghost">
|
||||
<Link href="/login">Anmelden</Link>
|
||||
<div className="min-h-screen bg-[#f8faf8] text-slate-950">
|
||||
<header className="absolute left-0 right-0 top-0 z-20">
|
||||
<div className="mx-auto flex h-20 w-full max-w-7xl items-center justify-between px-5 sm:px-6">
|
||||
<Link href="/" className="flex items-center gap-3 text-white">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/12 ring-1 ring-white/30">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="text-base font-semibold tracking-tight">
|
||||
Kita-Planer
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="text-white hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<Link href="/login">Login</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Button
|
||||
asChild
|
||||
className="bg-white text-slate-950 shadow-sm hover:bg-white/90"
|
||||
>
|
||||
<Link href="/register">Kita registrieren</Link>
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1">
|
||||
<section className="mx-auto w-full max-w-6xl px-6 py-24">
|
||||
<div className="max-w-3xl">
|
||||
<p className="mb-4 inline-block rounded-full bg-secondary px-3 py-1 text-xs font-medium text-secondary-foreground">
|
||||
Für Elterninitiativen & Elternvereine
|
||||
</p>
|
||||
<h1 className="text-balance text-4xl font-semibold tracking-tight sm:text-5xl">
|
||||
Der digitale Kita-Planer für Elterninitiativen.
|
||||
<main>
|
||||
<section
|
||||
className="relative min-h-[calc(100svh-56px)] overflow-hidden bg-slate-950"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(90deg, rgba(2, 6, 23, 0.9), rgba(15, 23, 42, 0.68), rgba(15, 23, 42, 0.18)), url(${heroImage})`,
|
||||
backgroundPosition: "center",
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-[#f8faf8] to-transparent" />
|
||||
|
||||
<div className="relative z-10 mx-auto flex min-h-[calc(100svh-56px)] w-full max-w-7xl items-center px-5 pb-20 pt-28 sm:px-6">
|
||||
<div className="max-w-4xl text-white">
|
||||
<Badge className="mb-6 border-white/20 bg-white/12 text-white hover:bg-white/12">
|
||||
Kita-Betriebssystem für Elternvereine
|
||||
</Badge>
|
||||
<h1 className="max-w-4xl text-balance text-5xl font-semibold leading-[1.02] sm:text-6xl lg:text-7xl">
|
||||
Die einfache Plattform für Elternvereine, Dienste und
|
||||
Kommunikation.
|
||||
</h1>
|
||||
<p className="mt-6 text-lg text-muted-foreground">
|
||||
Notdienst-Planung, Terminkalender und Stammdaten — endlich
|
||||
an einem Ort. Schluss mit Excel-Tabellen, WhatsApp-Listen und
|
||||
vergessenen Geburtstagen.
|
||||
<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-8 flex flex-wrap gap-3">
|
||||
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto w-full max-w-7xl px-5 py-24 sm:px-6">
|
||||
<div className="mb-12 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
|
||||
Die 5 Säulen
|
||||
</p>
|
||||
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
|
||||
Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam
|
||||
im Blick.
|
||||
</h2>
|
||||
</div>
|
||||
<p className="max-w-md text-sm leading-6 text-slate-600">
|
||||
Kita-Planer bündelt operative Aufgaben, sensible Familiendaten und
|
||||
offizielle Kommunikation in einer Oberfläche, die Vorstände
|
||||
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 kostenlos registrieren</Link>
|
||||
<Link href="/register">Kita registrieren</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline">
|
||||
<Link href="/login">Bereits Mitglied? Anmelden</Link>
|
||||
<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>
|
||||
|
||||
<div className="mt-20 grid gap-8 sm:grid-cols-3">
|
||||
<FeatureCard
|
||||
icon={<ShieldCheck className="h-6 w-6" />}
|
||||
title="Notdienst-Planung"
|
||||
description="Verfügbarkeiten erfassen, fairen Plan automatisch generieren, Eltern bei Krankheitsausfall sofort alarmieren."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<CalendarCheck2 className="h-6 w-6" />}
|
||||
title="Terminkalender"
|
||||
description="Kita-Feste, Schließtage und private Anfragen mit Mitbringsel-Listen — übersichtlich für alle Eltern."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Users className="h-6 w-6" />}
|
||||
title="Eltern-Adressbuch"
|
||||
description="Spielverabredungen leichter machen — auf Opt-In-Basis und DSGVO-konform."
|
||||
/>
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="border-t">
|
||||
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-6 text-sm text-muted-foreground">
|
||||
<footer className="border-t border-slate-200 bg-white">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-5 py-8 text-sm text-slate-500 sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||
<span>© {new Date().getFullYear()} Kita-Planer</span>
|
||||
<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>
|
||||
</footer>
|
||||
</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
@@ -8,7 +8,7 @@ import { prisma } from "@/lib/prisma";
|
||||
// =====================================================================
|
||||
// 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
|
||||
// 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(),
|
||||
role: user.role,
|
||||
kitaId: user.kitaId,
|
||||
familyId: user.familyId,
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -97,22 +98,27 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
token.id = user.id;
|
||||
token.role = user.role;
|
||||
token.kitaId = user.kitaId;
|
||||
token.familyId = user.familyId;
|
||||
return token;
|
||||
}
|
||||
|
||||
if (token.id) {
|
||||
const tokenUserId = token.id ?? token.sub;
|
||||
if (tokenUserId) {
|
||||
const fresh = await prisma.user.findUnique({
|
||||
where: { id: token.id },
|
||||
select: { role: true, kitaId: true },
|
||||
where: { id: tokenUserId },
|
||||
select: { role: true, kitaId: true, familyId: true },
|
||||
});
|
||||
if (!fresh) {
|
||||
// User wurde gelöscht → Token entwerten.
|
||||
// (Auth.js erkennt den fehlenden `sub`/`id` und meldet ab.)
|
||||
delete (token as Partial<typeof token>).id;
|
||||
delete (token as Partial<typeof token>).sub;
|
||||
return token;
|
||||
}
|
||||
token.id = tokenUserId;
|
||||
token.role = fresh.role;
|
||||
token.kitaId = fresh.kitaId;
|
||||
token.familyId = fresh.familyId;
|
||||
}
|
||||
|
||||
return token;
|
||||
@@ -125,9 +131,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
*/
|
||||
async session({ session, token }) {
|
||||
if (token && session.user) {
|
||||
session.user.id = token.id;
|
||||
session.user.id = token.id ?? token.sub;
|
||||
session.user.role = token.role;
|
||||
session.user.kitaId = token.kitaId;
|
||||
session.user.familyId = token.familyId;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,18 @@
|
||||
|
||||
import Link from "next/link";
|
||||
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";
|
||||
|
||||
@@ -45,7 +56,28 @@ const navItems = [
|
||||
label: "Familienverwaltung",
|
||||
icon: Users,
|
||||
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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -34,7 +34,7 @@ export async function getSession(): Promise<Session | null> {
|
||||
|
||||
export async function requireSession(): Promise<AuthenticatedSession> {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login");
|
||||
}
|
||||
return session as AuthenticatedSession;
|
||||
@@ -81,7 +81,9 @@ export async function requireRole(
|
||||
// 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
|
||||
// (Prisma läuft nur in Node-Runtime).
|
||||
const { prisma } = await import("@/lib/prisma");
|
||||
|
||||
@@ -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
@@ -1,13 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Resend } from "resend";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
let resendClient: Resend | null = null;
|
||||
|
||||
type SendAppEmailInput = {
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
react: ReactNode;
|
||||
from?: string;
|
||||
replyTo?: string | string[];
|
||||
};
|
||||
|
||||
type SendAppEmailResult =
|
||||
@@ -31,6 +32,7 @@ export async function sendAppEmail({
|
||||
subject,
|
||||
react,
|
||||
from = process.env.EMAIL_FROM,
|
||||
replyTo,
|
||||
}: SendAppEmailInput): Promise<SendAppEmailResult> {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
return {
|
||||
@@ -47,11 +49,13 @@ export async function sendAppEmail({
|
||||
}
|
||||
|
||||
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!,
|
||||
to,
|
||||
subject,
|
||||
react,
|
||||
replyTo,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
|
||||
+3
-6
@@ -16,7 +16,6 @@ import { auth } from "@/auth";
|
||||
// Eingeloggt + auf /login oder /register → /
|
||||
// =====================================================================
|
||||
|
||||
const PUBLIC_ROUTES = ["/", "/login", "/register", "/datenschutz"];
|
||||
const ONBOARDING_ROUTE = "/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).
|
||||
const session = await auth();
|
||||
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) =>
|
||||
pathname.startsWith(prefix),
|
||||
);
|
||||
|
||||
// ── 1. Nicht eingeloggt + geschützte Route ──────────────────────────
|
||||
if (!user && isProtectedRoute) {
|
||||
if (!hasValidUser && isProtectedRoute) {
|
||||
const loginUrl = new URL("/login", request.nextUrl);
|
||||
// callbackUrl für spätere Nutzung (optional)
|
||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||
@@ -44,7 +41,7 @@ export async function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
// ── 2. Eingeloggt ───────────────────────────────────────────────────
|
||||
if (user) {
|
||||
if (hasValidUser && user) {
|
||||
// 2a. Eingeloggter User auf Login/Register-Seite → Startseite,
|
||||
// die dann selbst zu /dashboard oder /onboarding redirectet.
|
||||
if (pathname === "/login" || pathname === "/register") {
|
||||
|
||||
Vendored
+5
-1
@@ -5,7 +5,8 @@ import type { UserRole } from "@prisma/client";
|
||||
// Type-Augmentation für NextAuth (Auth.js v5)
|
||||
// ---------------------------------------------------------------------
|
||||
// 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 /
|
||||
// Server Component, die `session.user.kitaId` nutzt, scheitert beim
|
||||
// Build, falls die Eigenschaft je entfernt wird.
|
||||
@@ -16,6 +17,7 @@ declare module "next-auth" {
|
||||
id: string;
|
||||
role: UserRole;
|
||||
kitaId: string | null;
|
||||
familyId: string | null;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
@@ -23,6 +25,7 @@ declare module "next-auth" {
|
||||
id: string;
|
||||
role: UserRole;
|
||||
kitaId: string | null;
|
||||
familyId: string | null;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
}
|
||||
@@ -35,5 +38,6 @@ declare module "@auth/core/jwt" {
|
||||
id: string;
|
||||
role: UserRole;
|
||||
kitaId: string | null;
|
||||
familyId: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user