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
|
# production
|
||||||
/build
|
/build
|
||||||
|
/uploads
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.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": {
|
"scripts": {
|
||||||
"dev": "NODE_ENV=development prisma db push && NODE_ENV=development prisma db seed && next dev",
|
"dev": "NODE_ENV=development prisma db push && NODE_ENV=development prisma db seed && next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
"build:prod": "prisma generate && next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
"start:prod": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
@@ -18,10 +20,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.2",
|
"@auth/prisma-adapter": "^2.11.2",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@prisma/client": "^6.19.3",
|
"@prisma/client": "^6.19.3",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@react-email/components": "^1.0.12",
|
"@react-email/components": "^1.0.12",
|
||||||
@@ -35,6 +40,9 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-hook-form": "^7.75.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"resend": "^6.12.3",
|
"resend": "^6.12.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
|||||||
+179
-23
@@ -32,6 +32,7 @@ enum UserRole {
|
|||||||
SUPERADMIN
|
SUPERADMIN
|
||||||
ADMIN
|
ADMIN
|
||||||
KOORDINATOR
|
KOORDINATOR
|
||||||
|
ERZIEHER
|
||||||
ELTERN
|
ELTERN
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +74,18 @@ enum NotdienstAlertStatus {
|
|||||||
CANCELLED
|
CANCELLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum DutyAssignmentStatus {
|
||||||
|
PLANNED
|
||||||
|
DONE
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AbsenceReason {
|
||||||
|
ILLNESS
|
||||||
|
VACATION
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// TENANT
|
// TENANT
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -96,10 +109,15 @@ model Kita {
|
|||||||
|
|
||||||
// Relations (Cascade auf alle Tenant-Daten)
|
// Relations (Cascade auf alle Tenant-Daten)
|
||||||
users User[]
|
users User[]
|
||||||
|
families Family[]
|
||||||
children Child[]
|
children Child[]
|
||||||
educators Educator[]
|
educators Educator[]
|
||||||
parentDuties ParentDuty[]
|
parentDuties ParentDuty[]
|
||||||
parentDutyAssignments ParentDutyAssignment[]
|
parentDutyAssignments ParentDutyAssignment[]
|
||||||
|
dutyTypes DutyType[]
|
||||||
|
dutyAssignments DutyAssignment[]
|
||||||
|
absences Absence[]
|
||||||
|
announcements Announcement[]
|
||||||
invitations Invitation[]
|
invitations Invitation[]
|
||||||
termine Termin[]
|
termine Termin[]
|
||||||
mitbringselItems MitbringselItem[]
|
mitbringselItems MitbringselItem[]
|
||||||
@@ -107,11 +125,33 @@ model Kita {
|
|||||||
notdienstAvailabilities NotdienstAvailability[]
|
notdienstAvailabilities NotdienstAvailability[]
|
||||||
notdienstAssignments NotdienstAssignment[]
|
notdienstAssignments NotdienstAssignment[]
|
||||||
notdienstAlerts NotdienstAlert[]
|
notdienstAlerts NotdienstAlert[]
|
||||||
childParents ChildParent[]
|
|
||||||
|
|
||||||
@@map("kitas")
|
@@map("kitas")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// FAMILIES / HAUSHALTE
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
model Family {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
kitaId String
|
||||||
|
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
name String
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
users User[]
|
||||||
|
children Child[]
|
||||||
|
dutyAssignments DutyAssignment[]
|
||||||
|
|
||||||
|
@@index([kitaId])
|
||||||
|
@@map("families")
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// USERS
|
// USERS
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -124,6 +164,11 @@ model User {
|
|||||||
kitaId String?
|
kitaId String?
|
||||||
kita Kita? @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
kita Kita? @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Optional, weil Admins/Superadmins keinem Haushalt angehören müssen.
|
||||||
|
/// Eltern-User werden beim Löschen ihrer Familie kaskadiert entfernt.
|
||||||
|
familyId String?
|
||||||
|
family Family? @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
email String @unique
|
email String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
|
|
||||||
@@ -157,13 +202,14 @@ model User {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
childLinks ChildParent[]
|
|
||||||
dutyAssignments ParentDutyAssignment[]
|
dutyAssignments ParentDutyAssignment[]
|
||||||
|
|
||||||
notdienstAvailabilities NotdienstAvailability[]
|
notdienstAvailabilities NotdienstAvailability[]
|
||||||
notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent")
|
notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent")
|
||||||
notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger")
|
notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger")
|
||||||
notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator")
|
notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator")
|
||||||
|
announcementsAuthored Announcement[] @relation("AnnouncementAuthor")
|
||||||
|
announcementReads AnnouncementRead[]
|
||||||
|
|
||||||
termineCreated Termin[] @relation("TerminCreator")
|
termineCreated Termin[] @relation("TerminCreator")
|
||||||
termineApproved Termin[] @relation("TerminApprover")
|
termineApproved Termin[] @relation("TerminApprover")
|
||||||
@@ -172,6 +218,7 @@ model User {
|
|||||||
invitationsCreated Invitation[] @relation("InvitationCreator")
|
invitationsCreated Invitation[] @relation("InvitationCreator")
|
||||||
|
|
||||||
@@index([kitaId])
|
@@index([kitaId])
|
||||||
|
@@index([familyId])
|
||||||
@@index([kitaId, role])
|
@@index([kitaId, role])
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -185,6 +232,9 @@ model Child {
|
|||||||
kitaId String
|
kitaId String
|
||||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
familyId String
|
||||||
|
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
firstName String
|
firstName String
|
||||||
lastName String
|
lastName String
|
||||||
dateOfBirth DateTime?
|
dateOfBirth DateTime?
|
||||||
@@ -195,34 +245,15 @@ model Child {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
parentLinks ChildParent[]
|
|
||||||
notdienstAvailabilities NotdienstAvailability[]
|
notdienstAvailabilities NotdienstAvailability[]
|
||||||
notdienstAssignments NotdienstAssignment[]
|
notdienstAssignments NotdienstAssignment[]
|
||||||
|
absences Absence[]
|
||||||
|
|
||||||
@@index([kitaId])
|
@@index([kitaId])
|
||||||
|
@@index([familyId])
|
||||||
@@map("children")
|
@@map("children")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verknüpfung Kind ↔ Elternteil (m:n).
|
|
||||||
/// Kaskadiert über beide Seiten, damit das Löschen eines Users
|
|
||||||
/// oder Kindes keine Datenleichen hinterlässt.
|
|
||||||
model ChildParent {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
kitaId String
|
|
||||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
|
||||||
childId String
|
|
||||||
child Child @relation(fields: [childId], references: [id], onDelete: Cascade)
|
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@unique([childId, userId])
|
|
||||||
@@index([kitaId])
|
|
||||||
@@index([userId])
|
|
||||||
@@map("child_parents")
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// EDUCATORS (ErzieherInnen — reine Stammdaten, keine Logins, Modul 4)
|
// EDUCATORS (ErzieherInnen — reine Stammdaten, keine Logins, Modul 4)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -285,6 +316,131 @@ model ParentDutyAssignment {
|
|||||||
@@map("parent_duty_assignments")
|
@@map("parent_duty_assignments")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// DUTY PLANNING (Top-Down-Dienstplan fuer Haushalte)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
model DutyType {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kitaId String
|
||||||
|
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
assignments DutyAssignment[]
|
||||||
|
|
||||||
|
@@unique([kitaId, name])
|
||||||
|
@@index([kitaId])
|
||||||
|
@@map("duty_types")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DutyAssignment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kitaId String
|
||||||
|
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
familyId String
|
||||||
|
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
dutyTypeId String
|
||||||
|
dutyType DutyType @relation(fields: [dutyTypeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
startDate DateTime @db.Date
|
||||||
|
endDate DateTime @db.Date
|
||||||
|
status DutyAssignmentStatus @default(PLANNED)
|
||||||
|
|
||||||
|
reminderSentAt DateTime?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([kitaId, dutyTypeId, startDate])
|
||||||
|
@@index([kitaId, startDate])
|
||||||
|
@@index([familyId, startDate])
|
||||||
|
@@map("duty_assignments")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// ABSENCES (Abwesenheits- und Krankmeldungen)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
model Absence {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kitaId String
|
||||||
|
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
childId String
|
||||||
|
child Child @relation(fields: [childId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
startDate DateTime @db.Date
|
||||||
|
endDate DateTime @db.Date
|
||||||
|
reason AbsenceReason
|
||||||
|
note String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([kitaId, startDate, endDate])
|
||||||
|
@@index([childId, startDate])
|
||||||
|
@@map("absences")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// ANNOUNCEMENTS (Digitales Schwarzes Brett)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
model Announcement {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kitaId String
|
||||||
|
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
|
||||||
|
authorId String
|
||||||
|
author User @relation("AnnouncementAuthor", fields: [authorId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
attachments Attachment[]
|
||||||
|
reads AnnouncementRead[]
|
||||||
|
|
||||||
|
@@index([kitaId, createdAt])
|
||||||
|
@@index([authorId])
|
||||||
|
@@map("announcements")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Attachment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
announcementId String
|
||||||
|
announcement Announcement @relation(fields: [announcementId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
fileName String
|
||||||
|
fileUrl String
|
||||||
|
fileType String
|
||||||
|
|
||||||
|
@@index([announcementId])
|
||||||
|
@@map("attachments")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AnnouncementRead {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
announcementId String
|
||||||
|
announcement Announcement @relation(fields: [announcementId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
readAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([userId, announcementId])
|
||||||
|
@@index([announcementId])
|
||||||
|
@@map("announcement_reads")
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// INVITATIONS (Invite-Only Onboarding, Modul 3)
|
// INVITATIONS (Invite-Only Onboarding, Modul 3)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
+182
-22
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
AbsenceReason,
|
||||||
InvitationStatus,
|
InvitationStatus,
|
||||||
NotdienstAlertStatus,
|
NotdienstAlertStatus,
|
||||||
NotdienstPlanStatus,
|
NotdienstPlanStatus,
|
||||||
@@ -29,6 +30,7 @@ const RESET_MODE = process.argv.includes("--reset");
|
|||||||
const DEMO_USER_EMAILS = [
|
const DEMO_USER_EMAILS = [
|
||||||
"super@kita-planer.local",
|
"super@kita-planer.local",
|
||||||
"admin@waldameisen.local",
|
"admin@waldameisen.local",
|
||||||
|
"erzieher@waldameisen.local",
|
||||||
"mueller@waldameisen.local",
|
"mueller@waldameisen.local",
|
||||||
"schmidt@waldameisen.local",
|
"schmidt@waldameisen.local",
|
||||||
"yilmaz@waldameisen.local",
|
"yilmaz@waldameisen.local",
|
||||||
@@ -52,6 +54,16 @@ function addDays(date: Date, days: number) {
|
|||||||
return dateOnly(next);
|
return dateOnly(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addWeeks(date: Date, weeks: number) {
|
||||||
|
return addDays(date, weeks * 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfIsoWeek(date: Date) {
|
||||||
|
const normalized = dateOnly(date);
|
||||||
|
const day = normalized.getDay() || 7;
|
||||||
|
return addDays(normalized, 1 - day);
|
||||||
|
}
|
||||||
|
|
||||||
function startOfMonth(date: Date) {
|
function startOfMonth(date: Date) {
|
||||||
return new Date(date.getFullYear(), date.getMonth(), 1);
|
return new Date(date.getFullYear(), date.getMonth(), 1);
|
||||||
}
|
}
|
||||||
@@ -156,6 +168,27 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const familyMueller = await prisma.family.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
name: "Familie Mueller",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const familySchmidtYilmaz = await prisma.family.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
name: "Familie Schmidt-Yilmaz",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const familyFischer = await prisma.family.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
name: "Familie Fischer",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const admin = await prisma.user.create({
|
const admin = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId: kita.id,
|
kitaId: kita.id,
|
||||||
@@ -175,9 +208,28 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const erzieherUser = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
email: "erzieher@waldameisen.local",
|
||||||
|
passwordHash,
|
||||||
|
firstName: "Eva",
|
||||||
|
lastName: "Erzieherin",
|
||||||
|
role: UserRole.ERZIEHER,
|
||||||
|
privacyPolicyAcceptedAt: consentAt,
|
||||||
|
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
|
||||||
|
emailVerifiedAt: consentAt,
|
||||||
|
phone: "+49 30 1000 2000",
|
||||||
|
street: "Kitaweg 1",
|
||||||
|
postalCode: "10115",
|
||||||
|
city: "Berlin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const koordinator = await prisma.user.create({
|
const koordinator = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId: kita.id,
|
kitaId: kita.id,
|
||||||
|
familyId: familyMueller.id,
|
||||||
email: "mueller@waldameisen.local",
|
email: "mueller@waldameisen.local",
|
||||||
passwordHash,
|
passwordHash,
|
||||||
firstName: "Maria",
|
firstName: "Maria",
|
||||||
@@ -197,6 +249,7 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
|
|||||||
const elternSchmidt = await prisma.user.create({
|
const elternSchmidt = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId: kita.id,
|
kitaId: kita.id,
|
||||||
|
familyId: familySchmidtYilmaz.id,
|
||||||
email: "schmidt@waldameisen.local",
|
email: "schmidt@waldameisen.local",
|
||||||
passwordHash,
|
passwordHash,
|
||||||
firstName: "Lukas",
|
firstName: "Lukas",
|
||||||
@@ -211,6 +264,7 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
|
|||||||
const elternYilmaz = await prisma.user.create({
|
const elternYilmaz = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId: kita.id,
|
kitaId: kita.id,
|
||||||
|
familyId: familySchmidtYilmaz.id,
|
||||||
email: "yilmaz@waldameisen.local",
|
email: "yilmaz@waldameisen.local",
|
||||||
passwordHash,
|
passwordHash,
|
||||||
firstName: "Aylin",
|
firstName: "Aylin",
|
||||||
@@ -230,6 +284,7 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
|
|||||||
const pendingParent = await prisma.user.create({
|
const pendingParent = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId: kita.id,
|
kitaId: kita.id,
|
||||||
|
familyId: familyFischer.id,
|
||||||
email: "pending@waldameisen.local",
|
email: "pending@waldameisen.local",
|
||||||
passwordHash: "",
|
passwordHash: "",
|
||||||
firstName: "Lena",
|
firstName: "Lena",
|
||||||
@@ -240,8 +295,12 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
kita,
|
kita,
|
||||||
|
familyMueller,
|
||||||
|
familySchmidtYilmaz,
|
||||||
|
familyFischer,
|
||||||
superAdmin,
|
superAdmin,
|
||||||
admin,
|
admin,
|
||||||
|
erzieherUser,
|
||||||
koordinator,
|
koordinator,
|
||||||
elternSchmidt,
|
elternSchmidt,
|
||||||
elternYilmaz,
|
elternYilmaz,
|
||||||
@@ -251,72 +310,58 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
|
|||||||
|
|
||||||
async function createChildren({
|
async function createChildren({
|
||||||
kita,
|
kita,
|
||||||
koordinator,
|
familyMueller,
|
||||||
elternSchmidt,
|
familySchmidtYilmaz,
|
||||||
elternYilmaz,
|
familyFischer,
|
||||||
pendingParent,
|
|
||||||
}: SeedContext) {
|
}: SeedContext) {
|
||||||
const anna = await prisma.child.create({
|
const anna = await prisma.child.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId: kita.id,
|
kitaId: kita.id,
|
||||||
|
familyId: familyMueller.id,
|
||||||
firstName: "Anna",
|
firstName: "Anna",
|
||||||
lastName: "Mueller",
|
lastName: "Mueller",
|
||||||
dateOfBirth: new Date("2021-03-15"),
|
dateOfBirth: new Date("2021-03-15"),
|
||||||
parentLinks: {
|
|
||||||
create: { kitaId: kita.id, userId: koordinator.id },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const ben = await prisma.child.create({
|
const ben = await prisma.child.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId: kita.id,
|
kitaId: kita.id,
|
||||||
|
familyId: familyMueller.id,
|
||||||
firstName: "Ben",
|
firstName: "Ben",
|
||||||
lastName: "Mueller",
|
lastName: "Mueller",
|
||||||
dateOfBirth: new Date("2023-07-22"),
|
dateOfBirth: new Date("2023-07-22"),
|
||||||
notes: "Geschwisterkind von Anna.",
|
notes: "Geschwisterkind von Anna.",
|
||||||
parentLinks: {
|
|
||||||
create: { kitaId: kita.id, userId: koordinator.id },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const clara = await prisma.child.create({
|
const clara = await prisma.child.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId: kita.id,
|
kitaId: kita.id,
|
||||||
|
familyId: familySchmidtYilmaz.id,
|
||||||
firstName: "Clara",
|
firstName: "Clara",
|
||||||
lastName: "Schmidt",
|
lastName: "Schmidt",
|
||||||
dateOfBirth: new Date("2022-11-03"),
|
dateOfBirth: new Date("2022-11-03"),
|
||||||
parentLinks: {
|
|
||||||
create: { kitaId: kita.id, userId: elternSchmidt.id },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emil = await prisma.child.create({
|
const emil = await prisma.child.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId: kita.id,
|
kitaId: kita.id,
|
||||||
|
familyId: familySchmidtYilmaz.id,
|
||||||
firstName: "Emil",
|
firstName: "Emil",
|
||||||
lastName: "Yilmaz",
|
lastName: "Yilmaz",
|
||||||
dateOfBirth: new Date("2021-09-09"),
|
dateOfBirth: new Date("2021-09-09"),
|
||||||
parentLinks: {
|
|
||||||
create: [
|
|
||||||
{ kitaId: kita.id, userId: elternYilmaz.id },
|
|
||||||
{ kitaId: kita.id, userId: elternSchmidt.id },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const nina = await prisma.child.create({
|
const nina = await prisma.child.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId: kita.id,
|
kitaId: kita.id,
|
||||||
|
familyId: familyFischer.id,
|
||||||
firstName: "Nina",
|
firstName: "Nina",
|
||||||
lastName: "Fischer",
|
lastName: "Fischer",
|
||||||
dateOfBirth: new Date("2022-05-30"),
|
dateOfBirth: new Date("2022-05-30"),
|
||||||
parentLinks: {
|
|
||||||
create: { kitaId: kita.id, userId: pendingParent.id },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -384,6 +429,83 @@ async function createParentDuties({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createAbsences(
|
||||||
|
{ kita }: SeedContext,
|
||||||
|
children: Awaited<ReturnType<typeof createChildren>>,
|
||||||
|
) {
|
||||||
|
const today = dateOnly(new Date());
|
||||||
|
|
||||||
|
await prisma.absence.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
kitaId: kita.id,
|
||||||
|
childId: children.nina.id,
|
||||||
|
startDate: today,
|
||||||
|
endDate: today,
|
||||||
|
reason: AbsenceReason.ILLNESS,
|
||||||
|
note: "Fieber, bleibt heute zuhause.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kitaId: kita.id,
|
||||||
|
childId: children.emil.id,
|
||||||
|
startDate: addDays(today, 1),
|
||||||
|
endDate: addDays(today, 2),
|
||||||
|
reason: AbsenceReason.VACATION,
|
||||||
|
note: "Familienbesuch.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDutyPlan({
|
||||||
|
kita,
|
||||||
|
familyMueller,
|
||||||
|
familySchmidtYilmaz,
|
||||||
|
familyFischer,
|
||||||
|
}: SeedContext) {
|
||||||
|
const waesche = await prisma.dutyType.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
name: "Waeschedienst",
|
||||||
|
description: "Woechentlicher Dienstplan fuer Kita-Waesche.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const einkauf = await prisma.dutyType.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
name: "Einkauf",
|
||||||
|
description: "Woechentlicher Einkauf nach Kita-Liste.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const families = [familyMueller, familySchmidtYilmaz, familyFischer];
|
||||||
|
const currentWeek = startOfIsoWeek(new Date());
|
||||||
|
|
||||||
|
await prisma.dutyAssignment.createMany({
|
||||||
|
data: Array.from({ length: 8 }).flatMap((_, index) => {
|
||||||
|
const startDate = addWeeks(currentWeek, index);
|
||||||
|
const endDate = addDays(startDate, 6);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
kitaId: kita.id,
|
||||||
|
dutyTypeId: waesche.id,
|
||||||
|
familyId: families[index % families.length].id,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kitaId: kita.id,
|
||||||
|
dutyTypeId: einkauf.id,
|
||||||
|
familyId: families[(index + 1) % families.length].id,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function createInvites({ kita, admin, pendingParent }: SeedContext) {
|
async function createInvites({ kita, admin, pendingParent }: SeedContext) {
|
||||||
const expires = addDays(new Date(), 7);
|
const expires = addDays(new Date(), 7);
|
||||||
|
|
||||||
@@ -408,6 +530,39 @@ async function createInvites({ kita, admin, pendingParent }: SeedContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createAnnouncements({
|
||||||
|
kita,
|
||||||
|
admin,
|
||||||
|
koordinator,
|
||||||
|
}: SeedContext) {
|
||||||
|
const sommerfest = await prisma.announcement.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
title: "Sommerfest: Helferliste und Ablauf",
|
||||||
|
content:
|
||||||
|
"## Liebe Familien,\n\nunser Sommerfest findet naechsten Monat im Kita-Garten statt. Bitte merkt euch den Termin vor. Details zu Aufbau, Kuchen und Getraenken folgen ueber das Schwarze Brett.",
|
||||||
|
authorId: admin.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.announcement.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
title: "Neue Garderoben-Regelung",
|
||||||
|
content:
|
||||||
|
"Ab Montag bitten wir alle Familien, Wechselkleidung wieder in die beschrifteten Boxen zu legen. So bleibt der Morgen fuer Kinder und Team entspannter.",
|
||||||
|
authorId: admin.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.announcementRead.create({
|
||||||
|
data: {
|
||||||
|
userId: koordinator.id,
|
||||||
|
announcementId: sommerfest.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function createTermine({
|
async function createTermine({
|
||||||
kita,
|
kita,
|
||||||
admin,
|
admin,
|
||||||
@@ -601,7 +756,10 @@ async function createDemoData() {
|
|||||||
const educators = await createEducators(context.kita.id);
|
const educators = await createEducators(context.kita.id);
|
||||||
|
|
||||||
await createParentDuties(context);
|
await createParentDuties(context);
|
||||||
|
await createDutyPlan(context);
|
||||||
|
await createAbsences(context, children);
|
||||||
await createInvites(context);
|
await createInvites(context);
|
||||||
|
await createAnnouncements(context);
|
||||||
await createTermine(context);
|
await createTermine(context);
|
||||||
await createNotdienstData(context, children, educators);
|
await createNotdienstData(context, children, educators);
|
||||||
|
|
||||||
@@ -622,6 +780,7 @@ function printSummary(
|
|||||||
kita,
|
kita,
|
||||||
superAdmin,
|
superAdmin,
|
||||||
admin,
|
admin,
|
||||||
|
erzieherUser,
|
||||||
koordinator,
|
koordinator,
|
||||||
elternSchmidt,
|
elternSchmidt,
|
||||||
elternYilmaz,
|
elternYilmaz,
|
||||||
@@ -636,6 +795,7 @@ function printSummary(
|
|||||||
console.log(` Logins (Passwort jeweils: ${DEFAULT_PASSWORD})`);
|
console.log(` Logins (Passwort jeweils: ${DEFAULT_PASSWORD})`);
|
||||||
console.log(` Superadmin: ${superAdmin.email}`);
|
console.log(` Superadmin: ${superAdmin.email}`);
|
||||||
console.log(` Admin: ${admin.email}`);
|
console.log(` Admin: ${admin.email}`);
|
||||||
|
console.log(` Erzieherin: ${erzieherUser.email}`);
|
||||||
console.log(` Koordinator: ${koordinator.email}`);
|
console.log(` Koordinator: ${koordinator.email}`);
|
||||||
console.log(` Eltern: ${elternSchmidt.email}`);
|
console.log(` Eltern: ${elternSchmidt.email}`);
|
||||||
console.log(` Eltern: ${elternYilmaz.email}`);
|
console.log(` Eltern: ${elternYilmaz.email}`);
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 960 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 960 KiB |
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");
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error: any) {
|
} catch {
|
||||||
return { error: "Fehler bei der Bestätigung." };
|
return { error: "Fehler bei der Bestätigung." };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,29 @@ export default async function AlertPage({
|
|||||||
|
|
||||||
const alert = await prisma.notdienstAlert.findUnique({
|
const alert = await prisma.notdienstAlert.findUnique({
|
||||||
where: { confirmationToken: token },
|
where: { confirmationToken: token },
|
||||||
include: {
|
select: {
|
||||||
parentUser: true,
|
status: true,
|
||||||
kita: true,
|
parentUser: {
|
||||||
assignment: { include: { child: true } },
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kita: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
select: {
|
||||||
|
date: true,
|
||||||
|
child: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { readFile } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
function getUploadPath(attachmentId: string) {
|
||||||
|
const uploadDir = path.resolve(
|
||||||
|
process.env.UPLOAD_DIR ?? "uploads",
|
||||||
|
"announcements",
|
||||||
|
);
|
||||||
|
return path.join(uploadDir, attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentDisposition(fileName: string) {
|
||||||
|
const fallback = fileName.replace(/[^\w.\-]/g, "_");
|
||||||
|
return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
const user = session?.user;
|
||||||
|
|
||||||
|
if (!user?.id || !user.kitaId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const attachment = await prisma.attachment.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
announcement: {
|
||||||
|
kitaId: user.kitaId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fileName: true,
|
||||||
|
fileType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await readFile(getUploadPath(attachment.id));
|
||||||
|
return new Response(new Uint8Array(file), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": attachment.fileType,
|
||||||
|
"Content-Disposition": contentDisposition(attachment.fileName),
|
||||||
|
"Cache-Control": "private, max-age=300",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Anhang konnte nicht gelesen werden:", error);
|
||||||
|
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 =
|
const BASE_URL =
|
||||||
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||||
|
|
||||||
export async function triggerAlertAction(
|
export async function triggerAlertAction(assignmentId: string) {
|
||||||
assignmentId: string,
|
|
||||||
parentUserId: string,
|
|
||||||
) {
|
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||||
const kitaId = session.user.kitaId!;
|
const kitaId = session.user.kitaId!;
|
||||||
|
|
||||||
@@ -36,6 +33,19 @@ export async function triggerAlertAction(
|
|||||||
id: true,
|
id: true,
|
||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
|
family: {
|
||||||
|
select: {
|
||||||
|
users: {
|
||||||
|
where: { role: UserRole.ELTERN },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -50,7 +60,9 @@ export async function triggerAlertAction(
|
|||||||
return { error: "Notdienst-Einteilung wurde nicht gefunden." };
|
return { error: "Notdienst-Einteilung wurde nicht gefunden." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parentUserId) {
|
const parents = assignment.child.family.users;
|
||||||
|
|
||||||
|
if (parents.length === 0) {
|
||||||
return { error: "Kein Elternteil für diesen Notdienst hinterlegt." };
|
return { error: "Kein Elternteil für diesen Notdienst hinterlegt." };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,64 +71,50 @@ export async function triggerAlertAction(
|
|||||||
return { error: mailConfigError };
|
return { error: mailConfigError };
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentLink = await prisma.childParent.findFirst({
|
|
||||||
where: {
|
|
||||||
kitaId,
|
|
||||||
childId: assignment.child.id,
|
|
||||||
userId: parentUserId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
email: true,
|
|
||||||
firstName: true,
|
|
||||||
lastName: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!parentLink) {
|
|
||||||
return { error: "Das Elternteil passt nicht zu dieser Einteilung." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = crypto.randomUUID();
|
|
||||||
|
|
||||||
await prisma.notdienstAlert.create({
|
|
||||||
data: {
|
|
||||||
kitaId,
|
|
||||||
assignmentId,
|
|
||||||
parentUserId,
|
|
||||||
triggeredById: session.user.id,
|
|
||||||
status: NotdienstAlertStatus.PENDING,
|
|
||||||
confirmationToken: token,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const alertUrl = `${BASE_URL}/alert/${token}`;
|
|
||||||
const dateLabel = format(assignment.date, "EEEE, dd. MMMM yyyy", {
|
const dateLabel = format(assignment.date, "EEEE, dd. MMMM yyyy", {
|
||||||
locale: de,
|
locale: de,
|
||||||
});
|
});
|
||||||
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
|
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
|
||||||
|
const alertJobs: { email: string; token: string }[] = [];
|
||||||
|
|
||||||
const emailResult = await sendAppEmail({
|
await prisma.$transaction(
|
||||||
to: parentLink.user.email,
|
parents.map((parent) => {
|
||||||
subject: `Dringender Notdienst-Alarm für ${dateLabel}`,
|
const token = crypto.randomUUID();
|
||||||
react: createElement(AlertEmail, {
|
alertJobs.push({ email: parent.email, token });
|
||||||
date: dateLabel,
|
return prisma.notdienstAlert.create({
|
||||||
childName,
|
data: {
|
||||||
confirmLink: alertUrl,
|
kitaId,
|
||||||
|
assignmentId,
|
||||||
|
parentUserId: parent.id,
|
||||||
|
triggeredById: session.user.id,
|
||||||
|
status: NotdienstAlertStatus.PENDING,
|
||||||
|
confirmationToken: token,
|
||||||
|
},
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
|
for (const job of alertJobs) {
|
||||||
|
const emailResult = await sendAppEmail({
|
||||||
|
to: job.email,
|
||||||
|
subject: `Dringender Notdienst-Alarm für ${dateLabel}`,
|
||||||
|
react: createElement(AlertEmail, {
|
||||||
|
date: dateLabel,
|
||||||
|
childName,
|
||||||
|
confirmLink: `${BASE_URL}/alert/${job.token}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!emailResult.success) {
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
return {
|
||||||
|
error: `Alarm wurde angelegt, aber mindestens eine E-Mail konnte nicht versendet werden: ${emailResult.error}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
|
|
||||||
if (!emailResult.success) {
|
|
||||||
return {
|
|
||||||
error: `Alarm wurde angelegt, aber die E-Mail konnte nicht versendet werden: ${emailResult.error}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { addDays, format } from "date-fns";
|
||||||
|
import { de } from "date-fns/locale";
|
||||||
|
import { ArrowLeft, ArrowRight, CalendarDays, Stethoscope } from "lucide-react";
|
||||||
|
import { AbsenceReason } from "@prisma/client";
|
||||||
|
|
||||||
|
import { getDailyAbsences } from "@/actions/absences";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export const metadata = { title: "Abwesenheiten · Kita-Planer" };
|
||||||
|
|
||||||
|
export default async function AdminAbwesenheitenPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: { date?: string };
|
||||||
|
}) {
|
||||||
|
const selectedDate = parseDateParam(searchParams.date);
|
||||||
|
const dateKey = format(selectedDate, "yyyy-MM-dd");
|
||||||
|
const result = await getDailyAbsences({ date: dateKey });
|
||||||
|
const absences = result.absences ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full bg-slate-50 px-4 py-6 sm:px-8">
|
||||||
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 rounded-lg border bg-background p-5 shadow-sm lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||||
|
<Stethoscope className="h-4 w-4" />
|
||||||
|
Tagesübersicht
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-2 text-3xl font-bold tracking-tight">
|
||||||
|
Heute fehlen ({absences.length} Kinder)
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{format(selectedDate, "EEEE, dd. MMMM yyyy", { locale: de })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href={hrefForDate(addDays(selectedDate, -1))}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Zurück
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href="/dashboard/admin/abwesenheiten">Heute</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href={hrefForDate(addDays(selectedDate, 1))}>
|
||||||
|
Vor
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<form className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
name="date"
|
||||||
|
defaultValue={dateKey}
|
||||||
|
className="h-9 w-40"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
Anzeigen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.error ? (
|
||||||
|
<Card className="border-destructive/30">
|
||||||
|
<CardContent className="p-6 text-sm text-destructive">
|
||||||
|
{result.error}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : absences.length === 0 ? (
|
||||||
|
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-background p-8 text-center">
|
||||||
|
<div className="mx-auto max-w-md">
|
||||||
|
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||||
|
<CalendarDays className="h-10 w-10" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
|
||||||
|
Keine Abwesenheiten
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Für diesen Tag sind aktuell keine Kinder abgemeldet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{absences.map((absence) => (
|
||||||
|
<Card key={absence.id} className="overflow-hidden">
|
||||||
|
<CardHeader className="flex flex-col gap-3 p-5 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl">
|
||||||
|
{absence.childName}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
{absence.familyName}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<ReasonBadge reason={absence.reason} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 px-5 pb-5 sm:grid-cols-[180px_1fr]">
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium">Zeitraum</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{formatDateRange(absence.startDate, absence.endDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium">Notiz</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{absence.note || "Keine Bemerkung hinterlegt."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateParam(value: string | undefined) {
|
||||||
|
if (!value) return new Date();
|
||||||
|
const parsed = new Date(`${value}T00:00:00`);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hrefForDate(date: Date) {
|
||||||
|
return `/dashboard/admin/abwesenheiten?date=${format(date, "yyyy-MM-dd")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateRange(startDate: string, endDate: string) {
|
||||||
|
const start = new Date(`${startDate}T00:00:00`);
|
||||||
|
const end = new Date(`${endDate}T00:00:00`);
|
||||||
|
const startLabel = format(start, "dd.MM.", { locale: de });
|
||||||
|
const endLabel = format(end, "dd.MM.yyyy", { locale: de });
|
||||||
|
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReasonBadge({ reason }: { reason: AbsenceReason }) {
|
||||||
|
if (reason === AbsenceReason.ILLNESS) {
|
||||||
|
return <Badge variant="destructive">Krank</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason === AbsenceReason.VACATION) {
|
||||||
|
return <Badge variant="warning">Urlaub</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Badge variant="outline">Sonstiges</Badge>;
|
||||||
|
}
|
||||||
@@ -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 { requireKitaSession } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { Contact, Mail, Phone, Baby } from "lucide-react";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
export const metadata = { title: "Adressbuch · Kita-Planer" };
|
||||||
|
|
||||||
|
type DirectoryFamilyDto = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
children: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}[];
|
||||||
|
parents: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
hasDirectoryOptIn: boolean;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
duties: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
export default async function AdressbuchPage() {
|
export default async function AdressbuchPage() {
|
||||||
const session = await requireKitaSession();
|
const session = await requireKitaSession();
|
||||||
|
|
||||||
// Fetch only users who opted in to the directory
|
const familyRows = await prisma.family.findMany({
|
||||||
const users = await prisma.user.findMany({
|
where: {
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
users: {
|
||||||
|
some: {
|
||||||
|
directoryOptInAt: { not: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
children: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
directoryOptInAt: true,
|
||||||
|
dutyAssignments: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
duty: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleContactRows = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
kitaId: session.user.kitaId,
|
kitaId: session.user.kitaId,
|
||||||
directoryOptInAt: { not: null },
|
directoryOptInAt: { not: null },
|
||||||
|
familyId: { in: familyRows.map((family) => family.id) },
|
||||||
},
|
},
|
||||||
include: {
|
select: {
|
||||||
childLinks: {
|
id: true,
|
||||||
include: { child: true },
|
email: true,
|
||||||
},
|
phone: true,
|
||||||
dutyAssignments: {
|
|
||||||
include: { duty: true },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
orderBy: { lastName: "asc" },
|
|
||||||
});
|
});
|
||||||
|
const visibleContactsByUserId = new Map(
|
||||||
|
visibleContactRows.map((user) => [user.id, user]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const families: DirectoryFamilyDto[] = familyRows.map((family) => ({
|
||||||
|
id: family.id,
|
||||||
|
name: family.name,
|
||||||
|
children: family.children,
|
||||||
|
parents: family.users.map((user) => {
|
||||||
|
const hasDirectoryOptIn = !!user.directoryOptInAt;
|
||||||
|
const contact = visibleContactsByUserId.get(user.id);
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
hasDirectoryOptIn,
|
||||||
|
email: contact?.email,
|
||||||
|
phone: contact?.phone ?? undefined,
|
||||||
|
duties: user.dutyAssignments.map((assignment) => ({
|
||||||
|
id: assignment.id,
|
||||||
|
name: assignment.duty.name,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-6 p-6">
|
<div className="flex h-full flex-col gap-6 p-6">
|
||||||
<div>
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Adressbuch</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Adressbuch</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
Kontaktinformationen aller Eltern, die der Freigabe zugestimmt haben.
|
Haushalte und Kontaktdaten, die explizit fuer das interne
|
||||||
|
Kita-Adressbuch freigegeben wurden.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{users.length === 0 ? (
|
{families.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-background p-10 text-center">
|
||||||
<Contact className="h-10 w-10 text-muted-foreground mb-4" />
|
<Contact className="mb-4 h-10 w-10 text-muted-foreground" />
|
||||||
<h3 className="mt-4 text-lg font-semibold">Keine Kontakte</h3>
|
<h3 className="text-lg font-semibold">Keine freigegebenen Kontakte</h3>
|
||||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||||
Bisher hat niemand der Veröffentlichung im Adressbuch zugestimmt.
|
Sobald mindestens ein Elternteil eines Haushalts zustimmt,
|
||||||
|
erscheint die Familie hier.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
{users.map((u) => (
|
{families.map((family) => (
|
||||||
<Card key={u.id} className="flex flex-col">
|
<Card key={family.id} className="overflow-hidden">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="border-b bg-muted/30 pb-4">
|
||||||
<CardTitle className="flex justify-between items-start">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<span className="text-lg">
|
<div>
|
||||||
{u.firstName} {u.lastName}
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
</span>
|
<UsersRound className="h-4 w-4 text-primary" />
|
||||||
{u.role === "ADMIN" || u.role === "KOORDINATOR" ? (
|
{family.name}
|
||||||
<Badge variant="secondary" className="text-[10px]">Vorstand</Badge>
|
</CardTitle>
|
||||||
) : null}
|
<CardDescription className="mt-1">
|
||||||
</CardTitle>
|
{family.parents.filter((user) => user.hasDirectoryOptIn).length}{" "}
|
||||||
</CardHeader>
|
von {family.parents.length} Elternteilen sichtbar
|
||||||
<CardContent className="flex flex-col gap-3 text-sm flex-1">
|
</CardDescription>
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
</div>
|
||||||
<Mail className="h-4 w-4 shrink-0" />
|
<Badge variant="success">Freigegeben</Badge>
|
||||||
<a href={`mailto:${u.email}`} className="hover:text-primary transition-colors truncate">
|
|
||||||
{u.email}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
{u.phone && (
|
</CardHeader>
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
|
||||||
<Phone className="h-4 w-4 shrink-0" />
|
|
||||||
<a href={`tel:${u.phone}`} className="hover:text-primary transition-colors">
|
|
||||||
{u.phone}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{u.childLinks.length > 0 && (
|
<CardContent className="grid gap-5 pt-5">
|
||||||
<div className="flex items-start gap-2 text-muted-foreground mt-1">
|
<section className="space-y-2">
|
||||||
<Baby className="h-4 w-4 shrink-0 mt-0.5" />
|
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
<div className="flex flex-wrap gap-1">
|
<Baby className="h-3.5 w-3.5" />
|
||||||
{u.childLinks.map(link => (
|
Kinder
|
||||||
<span key={link.child.id} className="bg-muted px-1.5 py-0.5 rounded-md text-xs">
|
|
||||||
{link.child.firstName}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{family.children.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
{u.dutyAssignments.length > 0 && (
|
Keine Kinder hinterlegt.
|
||||||
<div className="mt-auto pt-3 border-t">
|
</p>
|
||||||
<span className="text-xs font-medium text-muted-foreground mb-1 block">Ämter / Dienste:</span>
|
) : (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{u.dutyAssignments.map((assignment) => (
|
{family.children.map((child) => (
|
||||||
<Badge key={assignment.duty.id} variant="outline" className="bg-primary/5 text-primary border-primary/20">
|
<Badge key={child.id} variant="secondary">
|
||||||
{assignment.duty.name}
|
{child.firstName} {child.lastName}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5" />
|
||||||
|
Kontakte
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="grid gap-3">
|
||||||
|
{family.parents.map((user) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className="rounded-md border bg-background p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{user.firstName} {user.lastName}
|
||||||
|
</p>
|
||||||
|
{!user.hasDirectoryOptIn && (
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
Kontakt nicht freigegeben.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{user.hasDirectoryOptIn ? (
|
||||||
|
<Badge variant="outline">Opt-in</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.hasDirectoryOptIn && (
|
||||||
|
<div className="mt-3 grid gap-2 text-sm text-muted-foreground">
|
||||||
|
{user.email && (
|
||||||
|
<a
|
||||||
|
href={`mailto:${user.email}`}
|
||||||
|
className="flex min-w-0 items-center gap-2 hover:text-primary"
|
||||||
|
>
|
||||||
|
<Mail className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">{user.email}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{user.phone && (
|
||||||
|
<a
|
||||||
|
href={`tel:${user.phone}`}
|
||||||
|
className="flex items-center gap-2 hover:text-primary"
|
||||||
|
>
|
||||||
|
<Phone className="h-4 w-4 shrink-0" />
|
||||||
|
{user.phone}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user.duties.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5 border-t pt-3">
|
||||||
|
{user.duties.map((duty) => (
|
||||||
|
<Badge
|
||||||
|
key={duty.id}
|
||||||
|
variant="outline"
|
||||||
|
className="bg-primary/5 text-primary"
|
||||||
|
>
|
||||||
|
{duty.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ import { triggerAlertAction } from "./actions";
|
|||||||
|
|
||||||
export function AlertButton({
|
export function AlertButton({
|
||||||
assignmentId,
|
assignmentId,
|
||||||
parentUserId,
|
|
||||||
}: {
|
}: {
|
||||||
assignmentId: string;
|
assignmentId: string;
|
||||||
parentUserId: string;
|
|
||||||
}) {
|
}) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -20,7 +18,7 @@ export function AlertButton({
|
|||||||
if (!confirm("Bist du sicher? Dies löst den Notdienst-Alarm aus.")) return;
|
if (!confirm("Bist du sicher? Dies löst den Notdienst-Alarm aus.")) return;
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await triggerAlertAction(assignmentId, parentUserId);
|
const result = await triggerAlertAction(assignmentId);
|
||||||
if ("error" in result && result.error) {
|
if ("error" in result && result.error) {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { Educator } from "@prisma/client";
|
|
||||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -30,9 +29,16 @@ import {
|
|||||||
|
|
||||||
import { createEducator, updateEducator, deleteEducator } from "../actions";
|
import { createEducator, updateEducator, deleteEducator } from "../actions";
|
||||||
|
|
||||||
export function ErzieherList({ educators }: { educators: Educator[] }) {
|
type EducatorDto = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||||
const [openCreate, setOpenCreate] = useState(false);
|
const [openCreate, setOpenCreate] = useState(false);
|
||||||
const [editingEducator, setEditingEducator] = useState<Educator | null>(null);
|
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const handleCreate = (formData: FormData) => {
|
const handleCreate = (formData: FormData) => {
|
||||||
|
|||||||
@@ -8,13 +8,17 @@ import { requireRole } from "@/lib/auth-utils";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
const educatorSchema = z.object({
|
const educatorSchema = z.object({
|
||||||
firstName: z.string().min(1, "Vorname ist erforderlich"),
|
firstName: z.string().min(1, "Vorname ist erforderlich").max(100).trim(),
|
||||||
lastName: z.string().min(1, "Nachname ist erforderlich"),
|
lastName: z.string().min(1, "Nachname ist erforderlich").max(100).trim(),
|
||||||
active: z.boolean().default(true),
|
active: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function createEducator(rawPayload: unknown) {
|
export async function createEducator(rawPayload: unknown) {
|
||||||
const session = await requireRole([UserRole.ADMIN]);
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
|
const kitaId = session.user.kitaId;
|
||||||
|
if (!kitaId) {
|
||||||
|
return { error: "Kein Mandant zugeordnet." };
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = educatorSchema.safeParse(rawPayload);
|
const parsed = educatorSchema.safeParse(rawPayload);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -24,7 +28,7 @@ export async function createEducator(rawPayload: unknown) {
|
|||||||
try {
|
try {
|
||||||
await prisma.educator.create({
|
await prisma.educator.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId: session.user.kitaId!,
|
kitaId,
|
||||||
firstName: parsed.data.firstName,
|
firstName: parsed.data.firstName,
|
||||||
lastName: parsed.data.lastName,
|
lastName: parsed.data.lastName,
|
||||||
active: parsed.data.active,
|
active: parsed.data.active,
|
||||||
@@ -41,6 +45,10 @@ export async function createEducator(rawPayload: unknown) {
|
|||||||
|
|
||||||
export async function updateEducator(id: string, rawPayload: unknown) {
|
export async function updateEducator(id: string, rawPayload: unknown) {
|
||||||
const session = await requireRole([UserRole.ADMIN]);
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
|
const kitaId = session.user.kitaId;
|
||||||
|
if (!kitaId) {
|
||||||
|
return { error: "Kein Mandant zugeordnet." };
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = educatorSchema.safeParse(rawPayload);
|
const parsed = educatorSchema.safeParse(rawPayload);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -51,7 +59,7 @@ export async function updateEducator(id: string, rawPayload: unknown) {
|
|||||||
await prisma.educator.update({
|
await prisma.educator.update({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
kitaId: session.user.kitaId!,
|
kitaId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
firstName: parsed.data.firstName,
|
firstName: parsed.data.firstName,
|
||||||
@@ -70,12 +78,16 @@ export async function updateEducator(id: string, rawPayload: unknown) {
|
|||||||
|
|
||||||
export async function deleteEducator(id: string) {
|
export async function deleteEducator(id: string) {
|
||||||
const session = await requireRole([UserRole.ADMIN]);
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
|
const kitaId = session.user.kitaId;
|
||||||
|
if (!kitaId) {
|
||||||
|
return { error: "Kein Mandant zugeordnet." };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.educator.delete({
|
await prisma.educator.delete({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
kitaId: session.user.kitaId!,
|
kitaId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { GraduationCap } from "lucide-react";
|
||||||
import { UserRole } from "@prisma/client";
|
import { UserRole } from "@prisma/client";
|
||||||
|
|
||||||
import { requireRole } from "@/lib/auth-utils";
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
@@ -11,16 +12,28 @@ export default async function ErzieherPage() {
|
|||||||
|
|
||||||
const educators = await prisma.educator.findMany({
|
const educators = await prisma.educator.findMany({
|
||||||
where: { kitaId: session.user.kitaId! },
|
where: { kitaId: session.user.kitaId! },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-6 p-6">
|
<div className="flex h-full flex-col gap-6 p-6">
|
||||||
<div>
|
<div className="flex items-start justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">ErzieherInnen-Verwaltung</h1>
|
<div>
|
||||||
<p className="text-muted-foreground">
|
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||||
Verwalte die Stammdaten des Kita-Personals (nur für den Vorstand sichtbar).
|
<GraduationCap className="h-6 w-6 text-primary" />
|
||||||
</p>
|
ErzieherInnen-Verwaltung
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Stammdaten des Kita-Personals für interne Planung und spätere
|
||||||
|
Notdienst-Alarmierung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ErzieherList educators={educators} />
|
<ErzieherList educators={educators} />
|
||||||
|
|||||||
@@ -2,30 +2,46 @@
|
|||||||
|
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { Prisma, UserRole } from "@prisma/client";
|
import { Prisma, UserRole } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { requireRole } from "@/lib/auth-utils";
|
|
||||||
import { InviteEmail } from "@/emails/InviteEmail";
|
import { InviteEmail } from "@/emails/InviteEmail";
|
||||||
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
// =====================================================================
|
const INVITE_TOKEN_TTL_DAYS = 7;
|
||||||
// /dashboard/families · Server Actions
|
const BASE_URL =
|
||||||
// ---------------------------------------------------------------------
|
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||||
// addFamilyAction: Erstellt Elternteil + Kinder + VerificationToken in
|
|
||||||
// einer atomaren Prisma-Transaktion. Die kitaId kommt ausschließlich
|
|
||||||
// aus der validierten Session — nie aus dem Formular-Input.
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
const parentSchema = z.object({
|
const childSchema = z.object({
|
||||||
firstName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
|
firstName: z.string().min(1, "Vorname des Kindes fehlt.").max(100).trim(),
|
||||||
lastName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
|
lastName: z.string().min(1, "Nachname des Kindes fehlt.").max(100).trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const parentInputSchema = z.object({
|
||||||
|
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
|
||||||
|
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.string()
|
||||||
.email("Bitte eine gültige E-Mail-Adresse angeben.")
|
.email("Bitte eine gültige E-Mail-Adresse angeben.")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim(),
|
.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addFamilySchema = z.object({
|
||||||
|
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
|
||||||
|
parent1FirstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
|
||||||
|
parent1LastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
|
||||||
|
parent1Email: z
|
||||||
|
.string()
|
||||||
|
.email("Bitte eine gültige E-Mail-Adresse angeben.")
|
||||||
|
.toLowerCase()
|
||||||
|
.trim(),
|
||||||
|
parent2FirstName: z.string().max(100).trim().optional(),
|
||||||
|
parent2LastName: z.string().max(100).trim().optional(),
|
||||||
|
parent2Email: z.string().trim().optional(),
|
||||||
childCount: z.coerce
|
childCount: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
@@ -33,65 +49,139 @@ const parentSchema = z.object({
|
|||||||
.max(10),
|
.max(10),
|
||||||
});
|
});
|
||||||
|
|
||||||
const childSchema = z.object({
|
const updateFamilySchema = z.object({
|
||||||
firstName: z.string().min(1, "Vorname des Kindes fehlt.").max(100).trim(),
|
familyId: z.string().min(1),
|
||||||
lastName: z.string().min(1, "Nachname des Kindes fehlt.").max(100).trim(),
|
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
|
||||||
|
parents: z.array(
|
||||||
|
parentInputSchema.extend({
|
||||||
|
id: z.string().min(1),
|
||||||
|
}),
|
||||||
|
).min(1).max(2),
|
||||||
|
newParents: z.array(parentInputSchema).max(1).default([]),
|
||||||
|
children: z
|
||||||
|
.array(
|
||||||
|
childSchema.extend({
|
||||||
|
id: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.max(20),
|
||||||
|
newChildren: z.array(childSchema).max(10).default([]),
|
||||||
|
removedChildIds: z.array(z.string().min(1)).max(20).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AddFamilyState = {
|
export type AddFamilyState = {
|
||||||
errors?: {
|
errors?: {
|
||||||
firstName?: string[];
|
familyName?: string[];
|
||||||
lastName?: string[];
|
parent1FirstName?: string[];
|
||||||
email?: string[];
|
parent1LastName?: string[];
|
||||||
|
parent1Email?: string[];
|
||||||
|
parent2FirstName?: string[];
|
||||||
|
parent2LastName?: string[];
|
||||||
|
parent2Email?: string[];
|
||||||
children?: string[];
|
children?: string[];
|
||||||
_form?: string[];
|
_form?: string[];
|
||||||
};
|
};
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PRIVACY_POLICY_VERSION = "2026-05-01";
|
type InviteJob = {
|
||||||
const INVITE_TOKEN_TTL_DAYS = 7;
|
to: string;
|
||||||
const BASE_URL =
|
parentName: string;
|
||||||
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
inviteUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
|
||||||
|
const firstName = data.parent2FirstName?.trim() ?? "";
|
||||||
|
const lastName = data.parent2LastName?.trim() ?? "";
|
||||||
|
const email = data.parent2Email?.trim() ?? "";
|
||||||
|
|
||||||
|
if (!firstName && !lastName && !email) {
|
||||||
|
return { ok: true as const, parent: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parentInputSchema.safeParse({ firstName, lastName, email });
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
errors: {
|
||||||
|
parent2FirstName: parsed.error.flatten().fieldErrors.firstName,
|
||||||
|
parent2LastName: parsed.error.flatten().fieldErrors.lastName,
|
||||||
|
parent2Email: parsed.error.flatten().fieldErrors.email,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true as const, parent: parsed.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInviteToken() {
|
||||||
|
const token = crypto.randomUUID();
|
||||||
|
const expires = new Date(
|
||||||
|
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
|
||||||
|
);
|
||||||
|
return { token, expires, inviteUrl: `${BASE_URL}/invite/${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendInviteJobs(kitaName: string, jobs: InviteJob[]) {
|
||||||
|
for (const job of jobs) {
|
||||||
|
const result = await sendAppEmail({
|
||||||
|
to: job.to,
|
||||||
|
subject: `Einladung zu ${kitaName} im Kita-Planer`,
|
||||||
|
react: createElement(InviteEmail, {
|
||||||
|
parentName: job.parentName,
|
||||||
|
kitaName,
|
||||||
|
inviteLink: job.inviteUrl,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return result.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function addFamilyAction(
|
export async function addFamilyAction(
|
||||||
_prev: AddFamilyState,
|
_prev: AddFamilyState,
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<AddFamilyState> {
|
): Promise<AddFamilyState> {
|
||||||
// ── 1. Nur Admins dürfen Familien anlegen ──────────────────────────
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
|
const kitaId = session.user.kitaId;
|
||||||
|
|
||||||
// ── 2. Parent-Felder validieren ────────────────────────────────────
|
if (!kitaId) {
|
||||||
const parsedParent = parentSchema.safeParse(Object.fromEntries(formData));
|
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
|
||||||
if (!parsedParent.success) {
|
|
||||||
return { errors: parsedParent.error.flatten().fieldErrors };
|
|
||||||
}
|
}
|
||||||
const { firstName, lastName, email, childCount } = parsedParent.data;
|
|
||||||
|
|
||||||
// ── 3. Kinder-Felder validieren ────────────────────────────────────
|
const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
|
||||||
const childrenRaw: { firstName: string; lastName: string }[] = [];
|
if (!parsedFamily.success) {
|
||||||
for (let i = 0; i < childCount; i++) {
|
return { errors: parsedFamily.error.flatten().fieldErrors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionalParent = optionalParentFromForm(parsedFamily.data);
|
||||||
|
if (!optionalParent.ok) {
|
||||||
|
return { errors: optionalParent.errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const childrenRaw: z.infer<typeof childSchema>[] = [];
|
||||||
|
for (let i = 0; i < parsedFamily.data.childCount; i++) {
|
||||||
const parsed = childSchema.safeParse({
|
const parsed = childSchema.safeParse({
|
||||||
firstName: formData.get(`childFirstName_${i}`),
|
firstName: formData.get(`childFirstName_${i}`),
|
||||||
lastName: formData.get(`childLastName_${i}`),
|
lastName: formData.get(`childLastName_${i}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return {
|
return {
|
||||||
errors: { children: [`Kind ${i + 1}: ${Object.values(parsed.error.flatten().fieldErrors).flat().join(", ")}`] },
|
errors: {
|
||||||
|
children: [
|
||||||
|
`Kind ${i + 1}: ${Object.values(parsed.error.flatten().fieldErrors).flat().join(", ")}`,
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
childrenRaw.push(parsed.data);
|
childrenRaw.push(parsed.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 4. Datenbank-Transaktion ───────────────────────────────────────
|
|
||||||
// kitaId kommt ausschließlich aus der Session (Mandanten-Isolation!).
|
|
||||||
// SUPERADMIN hat keine kitaId → dieser Pfad sollte nie erreicht werden,
|
|
||||||
// aber wir prüfen explizit, um den Typ zu narrowen.
|
|
||||||
const kitaId = session.user.kitaId;
|
|
||||||
if (!kitaId) {
|
|
||||||
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const mailConfigError = getAppEmailConfigError();
|
const mailConfigError = getAppEmailConfigError();
|
||||||
if (mailConfigError) {
|
if (mailConfigError) {
|
||||||
return { errors: { _form: [mailConfigError] } };
|
return { errors: { _form: [mailConfigError] } };
|
||||||
@@ -106,54 +196,68 @@ export async function addFamilyAction(
|
|||||||
return { errors: { _form: ["Kita wurde nicht gefunden."] } };
|
return { errors: { _form: ["Kita wurde nicht gefunden."] } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentName = `${firstName} ${lastName}`;
|
const parents = [
|
||||||
const token = crypto.randomUUID();
|
{
|
||||||
const inviteUrl = `${BASE_URL}/invite/${token}`;
|
firstName: parsedFamily.data.parent1FirstName,
|
||||||
const expires = new Date(
|
lastName: parsedFamily.data.parent1LastName,
|
||||||
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
|
email: parsedFamily.data.parent1Email,
|
||||||
);
|
},
|
||||||
|
...(optionalParent.parent ? [optionalParent.parent] : []),
|
||||||
|
];
|
||||||
|
const emailSet = new Set(parents.map((parent) => parent.email));
|
||||||
|
if (emailSet.size !== parents.length) {
|
||||||
|
return {
|
||||||
|
errors: {
|
||||||
|
_form: ["Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein."],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteJobs: InviteJob[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
// 4a. Elternteil anlegen (kein Passwort → leerer passwordHash)
|
const family = await tx.family.create({
|
||||||
const parent = await tx.user.create({
|
|
||||||
data: {
|
data: {
|
||||||
email,
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
passwordHash: "", // wird beim Invite-Einlösen gesetzt
|
|
||||||
role: UserRole.ELTERN,
|
|
||||||
kitaId,
|
kitaId,
|
||||||
|
name: parsedFamily.data.familyName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4b. Kinder anlegen + mit Elternteil verknüpfen
|
for (const parent of parents) {
|
||||||
for (const child of childrenRaw) {
|
const createdParent = await tx.user.create({
|
||||||
const createdChild = await tx.child.create({
|
|
||||||
data: {
|
data: {
|
||||||
kitaId,
|
kitaId,
|
||||||
firstName: child.firstName,
|
familyId: family.id,
|
||||||
lastName: child.lastName,
|
email: parent.email,
|
||||||
|
firstName: parent.firstName,
|
||||||
|
lastName: parent.lastName,
|
||||||
|
passwordHash: "",
|
||||||
|
role: UserRole.ELTERN,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const invite = createInviteToken();
|
||||||
await tx.childParent.create({
|
inviteJobs.push({
|
||||||
|
to: parent.email,
|
||||||
|
parentName: `${parent.firstName} ${parent.lastName}`,
|
||||||
|
inviteUrl: invite.inviteUrl,
|
||||||
|
});
|
||||||
|
await tx.verificationToken.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId,
|
identifier: createdParent.id,
|
||||||
childId: createdChild.id,
|
token: invite.token,
|
||||||
userId: parent.id,
|
expires: invite.expires,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4c. Einladungs-Token erstellen
|
await tx.child.createMany({
|
||||||
// identifier = userId (kein PII im Token selbst)
|
data: childrenRaw.map((child) => ({
|
||||||
await tx.verificationToken.create({
|
kitaId,
|
||||||
data: {
|
familyId: family.id,
|
||||||
identifier: parent.id,
|
firstName: child.firstName,
|
||||||
token,
|
lastName: child.lastName,
|
||||||
expires,
|
})),
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -163,32 +267,205 @@ export async function addFamilyAction(
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
errors: {
|
errors: {
|
||||||
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
|
_form: ["Mindestens eine E-Mail-Adresse existiert bereits."],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailResult = await sendAppEmail({
|
const emailError = await sendInviteJobs(kita.name, inviteJobs);
|
||||||
to: email,
|
if (emailError) {
|
||||||
subject: `Einladung zu ${kita.name} im Kita-Planer`,
|
|
||||||
react: createElement(InviteEmail, {
|
|
||||||
parentName,
|
|
||||||
kitaName: kita.name,
|
|
||||||
inviteLink: inviteUrl,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!emailResult.success) {
|
|
||||||
return {
|
return {
|
||||||
errors: {
|
errors: {
|
||||||
_form: [
|
_form: [
|
||||||
`Familie wurde angelegt, aber die Einladung konnte nicht versendet werden: ${emailResult.error}`,
|
`Familie wurde angelegt, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/families");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFamilyAction(rawPayload: unknown) {
|
||||||
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
|
const kitaId = session.user.kitaId;
|
||||||
|
|
||||||
|
if (!kitaId) {
|
||||||
|
return { error: "Kein Mandant zugeordnet." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = updateFamilySchema.safeParse(rawPayload);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { error: "Ungültige Eingabedaten." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
familyId,
|
||||||
|
familyName,
|
||||||
|
parents,
|
||||||
|
newParents,
|
||||||
|
children,
|
||||||
|
newChildren,
|
||||||
|
removedChildIds,
|
||||||
|
} = parsed.data;
|
||||||
|
|
||||||
|
const parentEmails = [...parents, ...newParents].map((parent) => parent.email);
|
||||||
|
if (new Set(parentEmails).size !== parentEmails.length) {
|
||||||
|
return { error: "Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedChildIdSet = new Set(removedChildIds);
|
||||||
|
const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id));
|
||||||
|
if (activeChildren.length + newChildren.length === 0) {
|
||||||
|
return { error: "Eine Familie benötigt mindestens ein Kind." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newParents.length > 0) {
|
||||||
|
const mailConfigError = getAppEmailConfigError();
|
||||||
|
if (mailConfigError) {
|
||||||
|
return { error: mailConfigError };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kita = await prisma.kita.findUnique({
|
||||||
|
where: { id: kitaId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!kita) {
|
||||||
|
return { error: "Kita wurde nicht gefunden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteJobs: InviteJob[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingFamily = await prisma.family.findFirst({
|
||||||
|
where: { id: familyId, kitaId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
users: { select: { id: true } },
|
||||||
|
children: { select: { id: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingFamily) {
|
||||||
|
return { error: "Familie wurde nicht gefunden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingParentIds = new Set(existingFamily.users.map((user) => user.id));
|
||||||
|
const existingChildIds = new Set(existingFamily.children.map((child) => child.id));
|
||||||
|
|
||||||
|
if (!parents.every((parent) => existingParentIds.has(parent.id))) {
|
||||||
|
return { error: "Mindestens ein Elternteil gehört nicht zu dieser Familie." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!children.every((child) => existingChildIds.has(child.id))) {
|
||||||
|
return { error: "Mindestens ein Kind gehört nicht zu dieser Familie." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!removedChildIds.every((childId) => existingChildIds.has(childId))) {
|
||||||
|
return { error: "Mindestens ein Kind gehört nicht zu dieser Familie." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingParentIds.size + newParents.length > 2) {
|
||||||
|
return { error: "Pro Familie sind maximal zwei Elternteile vorgesehen." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.family.update({
|
||||||
|
where: { id: familyId },
|
||||||
|
data: { name: familyName },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const parent of parents) {
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: parent.id },
|
||||||
|
data: {
|
||||||
|
firstName: parent.firstName,
|
||||||
|
lastName: parent.lastName,
|
||||||
|
email: parent.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const parent of newParents) {
|
||||||
|
const createdParent = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
kitaId,
|
||||||
|
familyId,
|
||||||
|
email: parent.email,
|
||||||
|
firstName: parent.firstName,
|
||||||
|
lastName: parent.lastName,
|
||||||
|
passwordHash: "",
|
||||||
|
role: UserRole.ELTERN,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const invite = createInviteToken();
|
||||||
|
inviteJobs.push({
|
||||||
|
to: parent.email,
|
||||||
|
parentName: `${parent.firstName} ${parent.lastName}`,
|
||||||
|
inviteUrl: invite.inviteUrl,
|
||||||
|
});
|
||||||
|
await tx.verificationToken.create({
|
||||||
|
data: {
|
||||||
|
identifier: createdParent.id,
|
||||||
|
token: invite.token,
|
||||||
|
expires: invite.expires,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of activeChildren) {
|
||||||
|
await tx.child.update({
|
||||||
|
where: { id: child.id },
|
||||||
|
data: {
|
||||||
|
firstName: child.firstName,
|
||||||
|
lastName: child.lastName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const childId of removedChildIds) {
|
||||||
|
await tx.child.delete({ where: { id: childId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of newChildren) {
|
||||||
|
await tx.child.create({
|
||||||
|
data: {
|
||||||
|
kitaId,
|
||||||
|
familyId,
|
||||||
|
firstName: child.firstName,
|
||||||
|
lastName: child.lastName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === "P2002"
|
||||||
|
) {
|
||||||
|
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("Fehler beim Aktualisieren der Familie:", error);
|
||||||
|
return { error: "Familie konnte nicht aktualisiert werden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailError = await sendInviteJobs(kita.name, inviteJobs);
|
||||||
|
if (emailError) {
|
||||||
|
return {
|
||||||
|
error: `Familie wurde aktualisiert, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/families");
|
||||||
|
revalidatePath("/dashboard/profil");
|
||||||
|
revalidatePath("/dashboard/notdienst");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const initialState: AddFamilyState = {};
|
|||||||
// Verwaltet:
|
// Verwaltet:
|
||||||
// • Dialog-Open/Close-State
|
// • Dialog-Open/Close-State
|
||||||
// • Dynamische Kinderliste (min. 1, max. 10)
|
// • Dynamische Kinderliste (min. 1, max. 10)
|
||||||
|
// • Ein oder zwei Elternteile pro Haushalt
|
||||||
// • useActionState → Server-Action-Fehler anzeigen
|
// • useActionState → Server-Action-Fehler anzeigen
|
||||||
// • Bei Erfolg (state.success) Dialog automatisch schließen
|
// • Bei Erfolg (state.success) Dialog automatisch schließen
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -62,8 +63,8 @@ export function AddFamilyDialog() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Familie hinzufügen</DialogTitle>
|
<DialogTitle>Familie hinzufügen</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Lege das Elternteil und die zugehörigen Kinder an. Der Elternteil
|
Lege einen Haushalt mit Elternteilen und Kindern an. Die
|
||||||
erhält einen Einladungslink per E-Mail, um sein Passwort zu setzen.
|
Elternteile erhalten Einladungslinks per E-Mail.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -71,34 +72,77 @@ export function AddFamilyDialog() {
|
|||||||
{/* Anzahl Kinder als verstecktes Feld für die Server Action */}
|
{/* Anzahl Kinder als verstecktes Feld für die Server Action */}
|
||||||
<input type="hidden" name="childCount" value={childCount} />
|
<input type="hidden" name="childCount" value={childCount} />
|
||||||
|
|
||||||
{/* ── Elternteil ─────────────────────────────────────────── */}
|
<FormField
|
||||||
|
id="familyName"
|
||||||
|
name="familyName"
|
||||||
|
label="Familienname"
|
||||||
|
placeholder="Familie Schmidt"
|
||||||
|
error={state.errors?.familyName?.[0]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Elternteile ────────────────────────────────────────── */}
|
||||||
<fieldset className="space-y-4">
|
<fieldset className="space-y-4">
|
||||||
<legend className="text-sm font-semibold">Elternteil</legend>
|
<legend className="text-sm font-semibold">Elternteil 1</legend>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<FormField
|
<FormField
|
||||||
id="firstName"
|
id="parent1FirstName"
|
||||||
name="firstName"
|
name="parent1FirstName"
|
||||||
label="Vorname"
|
label="Vorname"
|
||||||
autoComplete="given-name"
|
autoComplete="given-name"
|
||||||
error={state.errors?.firstName?.[0]}
|
error={state.errors?.parent1FirstName?.[0]}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
id="lastName"
|
id="parent1LastName"
|
||||||
name="lastName"
|
name="parent1LastName"
|
||||||
label="Nachname"
|
label="Nachname"
|
||||||
autoComplete="family-name"
|
autoComplete="family-name"
|
||||||
error={state.errors?.lastName?.[0]}
|
error={state.errors?.parent1LastName?.[0]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
id="email"
|
id="parent1Email"
|
||||||
name="email"
|
name="parent1Email"
|
||||||
type="email"
|
type="email"
|
||||||
label="E-Mail-Adresse"
|
label="E-Mail-Adresse"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
error={state.errors?.email?.[0]}
|
error={state.errors?.parent1Email?.[0]}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="space-y-4">
|
||||||
|
<legend className="text-sm font-semibold">
|
||||||
|
Elternteil 2 <span className="font-normal text-muted-foreground">(optional)</span>
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField
|
||||||
|
id="parent2FirstName"
|
||||||
|
name="parent2FirstName"
|
||||||
|
label="Vorname"
|
||||||
|
autoComplete="given-name"
|
||||||
|
required={false}
|
||||||
|
error={state.errors?.parent2FirstName?.[0]}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
id="parent2LastName"
|
||||||
|
name="parent2LastName"
|
||||||
|
label="Nachname"
|
||||||
|
autoComplete="family-name"
|
||||||
|
required={false}
|
||||||
|
error={state.errors?.parent2LastName?.[0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="parent2Email"
|
||||||
|
name="parent2Email"
|
||||||
|
type="email"
|
||||||
|
label="E-Mail-Adresse"
|
||||||
|
autoComplete="email"
|
||||||
|
required={false}
|
||||||
|
error={state.errors?.parent2Email?.[0]}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@@ -199,6 +243,8 @@ function FormField({
|
|||||||
label,
|
label,
|
||||||
type = "text",
|
type = "text",
|
||||||
autoComplete,
|
autoComplete,
|
||||||
|
placeholder,
|
||||||
|
required = true,
|
||||||
error,
|
error,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -206,6 +252,8 @@ function FormField({
|
|||||||
label: string;
|
label: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
autoComplete?: string;
|
autoComplete?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -216,7 +264,8 @@ function FormField({
|
|||||||
name={name}
|
name={name}
|
||||||
type={type}
|
type={type}
|
||||||
autoComplete={autoComplete}
|
autoComplete={autoComplete}
|
||||||
required
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
aria-invalid={!!error}
|
aria-invalid={!!error}
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ const dutySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function createDuty(rawPayload: unknown) {
|
export async function createDuty(rawPayload: unknown) {
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
|
const kitaId = session.user.kitaId;
|
||||||
|
if (!kitaId) {
|
||||||
|
return { error: "Kein Mandant zugeordnet." };
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = dutySchema.safeParse(rawPayload);
|
const parsed = dutySchema.safeParse(rawPayload);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -23,7 +28,7 @@ export async function createDuty(rawPayload: unknown) {
|
|||||||
try {
|
try {
|
||||||
await prisma.parentDuty.create({
|
await prisma.parentDuty.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId: session.user.kitaId!,
|
kitaId,
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
description: parsed.data.description,
|
description: parsed.data.description,
|
||||||
},
|
},
|
||||||
@@ -38,13 +43,17 @@ export async function createDuty(rawPayload: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDuty(dutyId: string) {
|
export async function deleteDuty(dutyId: string) {
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
|
const kitaId = session.user.kitaId;
|
||||||
|
if (!kitaId) {
|
||||||
|
return { error: "Kein Mandant zugeordnet." };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.parentDuty.delete({
|
await prisma.parentDuty.delete({
|
||||||
where: {
|
where: {
|
||||||
id: dutyId,
|
id: dutyId,
|
||||||
kitaId: session.user.kitaId!,
|
kitaId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
revalidatePath("/dashboard/families");
|
revalidatePath("/dashboard/families");
|
||||||
@@ -57,9 +66,28 @@ export async function deleteDuty(dutyId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function assignDuty(userId: string, dutyId: string) {
|
export async function assignDuty(userId: string, dutyId: string) {
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
|
const kitaId = session.user.kitaId;
|
||||||
|
if (!kitaId) {
|
||||||
|
return { error: "Kein Mandant zugeordnet." };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const [user, duty] = await Promise.all([
|
||||||
|
prisma.user.findFirst({
|
||||||
|
where: { id: userId, kitaId },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
prisma.parentDuty.findFirst({
|
||||||
|
where: { id: dutyId, kitaId },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!user || !duty) {
|
||||||
|
return { error: "Nutzer oder Amt gehört nicht zu dieser Kita." };
|
||||||
|
}
|
||||||
|
|
||||||
// Check if assignment already exists
|
// Check if assignment already exists
|
||||||
const existing = await prisma.parentDutyAssignment.findUnique({
|
const existing = await prisma.parentDutyAssignment.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -73,7 +101,7 @@ export async function assignDuty(userId: string, dutyId: string) {
|
|||||||
|
|
||||||
await prisma.parentDutyAssignment.create({
|
await prisma.parentDutyAssignment.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId: session.user.kitaId!,
|
kitaId,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
dutyId: dutyId,
|
dutyId: dutyId,
|
||||||
},
|
},
|
||||||
@@ -89,13 +117,17 @@ export async function assignDuty(userId: string, dutyId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function removeDutyAssignment(assignmentId: string) {
|
export async function removeDutyAssignment(assignmentId: string) {
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
|
const kitaId = session.user.kitaId;
|
||||||
|
if (!kitaId) {
|
||||||
|
return { error: "Kein Mandant zugeordnet." };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.parentDutyAssignment.delete({
|
await prisma.parentDutyAssignment.delete({
|
||||||
where: {
|
where: {
|
||||||
id: assignmentId,
|
id: assignmentId,
|
||||||
kitaId: session.user.kitaId!,
|
kitaId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { Plus, X, Shield, Trash2 } from "lucide-react";
|
import { Plus, X, Shield, Trash2 } from "lucide-react";
|
||||||
import { ParentDuty, ParentDutyAssignment, User } from "@prisma/client";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -15,13 +14,30 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
import { createDuty, assignDuty, removeDutyAssignment, deleteDuty } from "./duty-actions";
|
import { createDuty, assignDuty, removeDutyAssignment, deleteDuty } from "./duty-actions";
|
||||||
|
|
||||||
type DutyWithAssignments = ParentDuty & {
|
type DutyWithAssignments = {
|
||||||
assignments: ParentDutyAssignment[];
|
id: string;
|
||||||
|
name: string;
|
||||||
|
assignments: {
|
||||||
|
id: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type DutyUser = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserDutyAssignment = {
|
||||||
|
id: string;
|
||||||
|
dutyId: string;
|
||||||
|
duty: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DutyManager({
|
export function DutyManager({
|
||||||
@@ -29,9 +45,9 @@ export function DutyManager({
|
|||||||
allDuties,
|
allDuties,
|
||||||
userAssignments,
|
userAssignments,
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: DutyUser;
|
||||||
allDuties: DutyWithAssignments[];
|
allDuties: DutyWithAssignments[];
|
||||||
userAssignments: (ParentDutyAssignment & { duty: ParentDuty })[];
|
userAssignments: UserDutyAssignment[];
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { updateFamilyAction } from "./actions";
|
||||||
|
|
||||||
|
type EditableParent = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditableChild = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DraftParent = EditableParent & {
|
||||||
|
kind: "existing" | "new";
|
||||||
|
};
|
||||||
|
|
||||||
|
type DraftChild = EditableChild & {
|
||||||
|
kind: "existing" | "new";
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditableFamily = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parents: EditableParent[];
|
||||||
|
children: EditableChild[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const initialParents = useMemo(
|
||||||
|
() =>
|
||||||
|
family.parents.map((parent) => ({
|
||||||
|
...parent,
|
||||||
|
kind: "existing" as const,
|
||||||
|
})),
|
||||||
|
[family.parents],
|
||||||
|
);
|
||||||
|
const initialChildren = useMemo(
|
||||||
|
() =>
|
||||||
|
family.children.map((child) => ({
|
||||||
|
...child,
|
||||||
|
kind: "existing" as const,
|
||||||
|
})),
|
||||||
|
[family.children],
|
||||||
|
);
|
||||||
|
const [parents, setParents] = useState<DraftParent[]>(initialParents);
|
||||||
|
const [children, setChildren] = useState<DraftChild[]>(initialChildren);
|
||||||
|
const [removedChildIds, setRemovedChildIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
setParents(initialParents);
|
||||||
|
setChildren(initialChildren);
|
||||||
|
setRemovedChildIds([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addParent() {
|
||||||
|
if (parents.length >= 2) return;
|
||||||
|
setParents((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `new-parent-${crypto.randomUUID()}`,
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
kind: "new",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNewParent(parent: DraftParent) {
|
||||||
|
if (parent.kind !== "new") return;
|
||||||
|
setParents((current) => current.filter((item) => item.id !== parent.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addChild() {
|
||||||
|
setChildren((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `new-child-${crypto.randomUUID()}`,
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
kind: "new",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeChild(child: DraftChild) {
|
||||||
|
setChildren((current) => current.filter((item) => item.id !== child.id));
|
||||||
|
if (child.kind === "existing") {
|
||||||
|
setRemovedChildIds((current) => [...current, child.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(formData: FormData) {
|
||||||
|
const payload = {
|
||||||
|
familyId: family.id,
|
||||||
|
familyName: String(formData.get("familyName") ?? ""),
|
||||||
|
parents: parents
|
||||||
|
.filter((parent) => parent.kind === "existing")
|
||||||
|
.map((parent) => ({
|
||||||
|
id: parent.id,
|
||||||
|
firstName: String(formData.get(`parentFirstName_${parent.id}`) ?? ""),
|
||||||
|
lastName: String(formData.get(`parentLastName_${parent.id}`) ?? ""),
|
||||||
|
email: String(formData.get(`parentEmail_${parent.id}`) ?? ""),
|
||||||
|
})),
|
||||||
|
newParents: parents
|
||||||
|
.filter((parent) => parent.kind === "new")
|
||||||
|
.map((parent) => ({
|
||||||
|
firstName: String(formData.get(`parentFirstName_${parent.id}`) ?? ""),
|
||||||
|
lastName: String(formData.get(`parentLastName_${parent.id}`) ?? ""),
|
||||||
|
email: String(formData.get(`parentEmail_${parent.id}`) ?? ""),
|
||||||
|
})),
|
||||||
|
children: children
|
||||||
|
.filter((child) => child.kind === "existing")
|
||||||
|
.map((child) => ({
|
||||||
|
id: child.id,
|
||||||
|
firstName: String(formData.get(`childFirstName_${child.id}`) ?? ""),
|
||||||
|
lastName: String(formData.get(`childLastName_${child.id}`) ?? ""),
|
||||||
|
})),
|
||||||
|
newChildren: children
|
||||||
|
.filter((child) => child.kind === "new")
|
||||||
|
.map((child) => ({
|
||||||
|
firstName: String(formData.get(`childFirstName_${child.id}`) ?? ""),
|
||||||
|
lastName: String(formData.get(`childLastName_${child.id}`) ?? ""),
|
||||||
|
})),
|
||||||
|
removedChildIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateFamilyAction(payload);
|
||||||
|
if (result.error) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Familie aktualisiert.");
|
||||||
|
setRemovedChildIds([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog onOpenChange={(open) => !open && resetState()}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 gap-1">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Details</span>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||||
|
<form action={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Familie bearbeiten</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Aktualisiere Haushalt, Elternteile und Kinder.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-6 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={`familyName-${family.id}`}>Familienname</Label>
|
||||||
|
<Input
|
||||||
|
id={`familyName-${family.id}`}
|
||||||
|
name="familyName"
|
||||||
|
defaultValue={family.name}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="grid gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<legend className="text-sm font-semibold">Elternteile</legend>
|
||||||
|
{parents.length < 2 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1"
|
||||||
|
onClick={addParent}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
Elternteil hinzufügen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{parents.map((parent, index) => (
|
||||||
|
<div key={parent.id} className="rounded-md border p-3">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
Elternteil {index + 1}
|
||||||
|
</p>
|
||||||
|
{parent.kind === "new" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 px-2 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => removeNewParent(parent)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field
|
||||||
|
id={`parentFirstName-${parent.id}`}
|
||||||
|
name={`parentFirstName_${parent.id}`}
|
||||||
|
label="Vorname"
|
||||||
|
defaultValue={parent.firstName}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
id={`parentLastName-${parent.id}`}
|
||||||
|
name={`parentLastName_${parent.id}`}
|
||||||
|
label="Nachname"
|
||||||
|
defaultValue={parent.lastName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Field
|
||||||
|
id={`parentEmail-${parent.id}`}
|
||||||
|
name={`parentEmail_${parent.id}`}
|
||||||
|
label="E-Mail-Adresse"
|
||||||
|
type="email"
|
||||||
|
defaultValue={parent.email}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="grid gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<legend className="text-sm font-semibold">Kinder</legend>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1"
|
||||||
|
onClick={addChild}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
Kind hinzufügen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Keine Kinder verknüpft.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
children.map((child, index) => (
|
||||||
|
<div key={child.id} className="rounded-md border p-3">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
Kind {index + 1}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 px-2 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => removeChild(child)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field
|
||||||
|
id={`childFirstName-${child.id}`}
|
||||||
|
name={`childFirstName_${child.id}`}
|
||||||
|
label="Vorname"
|
||||||
|
defaultValue={child.firstName}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
id={`childLastName-${child.id}`}
|
||||||
|
name={`childLastName_${child.id}`}
|
||||||
|
label="Nachname"
|
||||||
|
defaultValue={child.lastName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? "Speichert..." : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
defaultValue,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
defaultValue: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,54 +13,79 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { AddFamilyDialog } from "./add-family-dialog";
|
import { AddFamilyDialog } from "./add-family-dialog";
|
||||||
import { DutyManager } from "./duty-manager";
|
import { DutyManager } from "./duty-manager";
|
||||||
|
import { EditFamilyDialog } from "./edit-family-dialog";
|
||||||
|
|
||||||
export const metadata = { title: "Familienverwaltung · Kita-Planer" };
|
export const metadata = { title: "Familienverwaltung · Kita-Planer" };
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// /dashboard/families · Server Component
|
// /dashboard/families · Server Component
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Zeigt alle Elternteile (ELTERN) und deren Kinder für die aktuelle
|
// Zeigt alle Haushalte und deren Eltern/Kinder für die aktuelle
|
||||||
// kitaId. Tenant-Filter ist garantiert: kitaId aus requireRole-Session.
|
// kitaId. Tenant-Filter ist garantiert: kitaId aus requireRole-Session.
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
export default async function FamiliesPage() {
|
export default async function FamiliesPage() {
|
||||||
// Guard: Nur Admins (und Koordinatoren) dürfen diese Seite sehen
|
// Guard: Nur Admins dürfen die vollständige Familienliste sehen.
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
|
|
||||||
// Alle ELTERN-User der Kita mit ihren verknüpften Kindern laden
|
const kitaId = session.user.kitaId!;
|
||||||
|
|
||||||
|
// Alle Haushalte der Kita mit Elternteilen und Kindern laden.
|
||||||
// kitaId IMMER aus der Session — nie aus URL/Params
|
// kitaId IMMER aus der Session — nie aus URL/Params
|
||||||
const families = await prisma.user.findMany({
|
const families = await prisma.family.findMany({
|
||||||
where: {
|
where: { kitaId },
|
||||||
kitaId: session.user.kitaId!,
|
|
||||||
role: UserRole.ELTERN,
|
|
||||||
},
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
firstName: true,
|
name: true,
|
||||||
lastName: true,
|
users: {
|
||||||
email: true,
|
|
||||||
passwordHash: true, // "" → Invite ausstehend; sonst aktiv
|
|
||||||
emailVerifiedAt: true,
|
|
||||||
childLinks: {
|
|
||||||
select: {
|
select: {
|
||||||
child: {
|
id: true,
|
||||||
select: { id: true, firstName: true, lastName: true },
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
emailVerifiedAt: true,
|
||||||
|
dutyAssignments: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
dutyId: true,
|
||||||
|
duty: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||||
},
|
},
|
||||||
dutyAssignments: {
|
children: {
|
||||||
include: { duty: true },
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
orderBy: { name: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const allDuties = await prisma.parentDuty.findMany({
|
const allDuties = await prisma.parentDuty.findMany({
|
||||||
where: { kitaId: session.user.kitaId! },
|
where: { kitaId },
|
||||||
include: { assignments: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
assignments: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const childCount = families.reduce(
|
||||||
|
(acc, family) => acc + family.children.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const canManageDuties = session.user.role === UserRole.ADMIN;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-8 py-8">
|
<div className="px-8 py-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -72,7 +97,7 @@ export default async function FamiliesPage() {
|
|||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{families.length === 0
|
{families.length === 0
|
||||||
? "Noch keine Familien angelegt."
|
? "Noch keine Familien angelegt."
|
||||||
: `${families.length} ${families.length === 1 ? "Familie" : "Familien"} · ${families.reduce((acc, f) => acc + f.childLinks.length, 0)} Kinder`}
|
: `${families.length} ${families.length === 1 ? "Familie" : "Familien"} · ${childCount} Kinder`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AddFamilyDialog />
|
<AddFamilyDialog />
|
||||||
@@ -85,8 +110,8 @@ export default async function FamiliesPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Elternteil</TableHead>
|
<TableHead>Familie</TableHead>
|
||||||
<TableHead>E-Mail</TableHead>
|
<TableHead>Elternteile</TableHead>
|
||||||
<TableHead>Kinder</TableHead>
|
<TableHead>Kinder</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="text-right">Aktionen</TableHead>
|
<TableHead className="text-right">Aktionen</TableHead>
|
||||||
@@ -94,23 +119,35 @@ export default async function FamiliesPage() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{families.map((family) => {
|
{families.map((family) => {
|
||||||
const isActive = !!family.emailVerifiedAt;
|
const hasPendingInvite = family.users.some(
|
||||||
const children = family.childLinks.map((l) => l.child);
|
(user) => !user.emailVerifiedAt,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={family.id}>
|
<TableRow key={family.id}>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
{family.firstName} {family.lastName}
|
{family.name}
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{family.email}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{children.length === 0 ? (
|
<div className="space-y-1.5">
|
||||||
|
{family.users.map((parent) => (
|
||||||
|
<div key={parent.id}>
|
||||||
|
<div className="font-medium">
|
||||||
|
{parent.firstName} {parent.lastName}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{parent.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{family.children.length === 0 ? (
|
||||||
<span className="text-sm text-muted-foreground">—</span>
|
<span className="text-sm text-muted-foreground">—</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{children.map((child) => (
|
{family.children.map((child) => (
|
||||||
<span
|
<span
|
||||||
key={child.id}
|
key={child.id}
|
||||||
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium"
|
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium"
|
||||||
@@ -122,16 +159,39 @@ export default async function FamiliesPage() {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={isActive ? "success" : "warning"}>
|
<Badge variant={hasPendingInvite ? "warning" : "success"}>
|
||||||
{isActive ? "Aktiv" : "Eingeladen"}
|
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<DutyManager
|
<div className="flex flex-wrap justify-end gap-1">
|
||||||
user={family as any}
|
<EditFamilyDialog
|
||||||
allDuties={allDuties as any}
|
family={{
|
||||||
userAssignments={family.dutyAssignments}
|
id: family.id,
|
||||||
/>
|
name: family.name,
|
||||||
|
parents: family.users.map((parent) => ({
|
||||||
|
id: parent.id,
|
||||||
|
firstName: parent.firstName,
|
||||||
|
lastName: parent.lastName,
|
||||||
|
email: parent.email,
|
||||||
|
})),
|
||||||
|
children: family.children,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{canManageDuties &&
|
||||||
|
family.users.map((parent) => (
|
||||||
|
<DutyManager
|
||||||
|
key={parent.id}
|
||||||
|
user={{
|
||||||
|
id: parent.id,
|
||||||
|
firstName: parent.firstName,
|
||||||
|
lastName: parent.lastName,
|
||||||
|
}}
|
||||||
|
allDuties={allDuties}
|
||||||
|
userAssignments={parent.dutyAssignments}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { MitbringselItem } from "@prisma/client";
|
|
||||||
import { Trash2, Plus, Utensils } from "lucide-react";
|
import { Trash2, Plus, Utensils } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -9,7 +8,10 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { addMitbringsel, deleteMitbringsel } from "../actions";
|
import { addMitbringsel, deleteMitbringsel } from "../actions";
|
||||||
|
|
||||||
type ItemWithUser = MitbringselItem & {
|
type MitbringselItemDto = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
content: string;
|
||||||
user: { firstName: string; lastName: string };
|
user: { firstName: string; lastName: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,7 +22,7 @@ export function MitbringselList({
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
}: {
|
}: {
|
||||||
terminId: string;
|
terminId: string;
|
||||||
items: ItemWithUser[];
|
items: MitbringselItemDto[];
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -5,17 +5,20 @@ import { format } from "date-fns";
|
|||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
import { Check, X, Clock, CalendarIcon } from "lucide-react";
|
import { Check, X, Clock, CalendarIcon } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Termin, User } from "@prisma/client";
|
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { approveTermin, rejectTermin } from "../actions";
|
import { approveTermin, rejectTermin } from "../actions";
|
||||||
|
|
||||||
type PendingTermin = Termin & {
|
export type PendingTerminDto = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startDate: Date;
|
||||||
|
allDay: boolean;
|
||||||
createdBy: { firstName: string; lastName: string } | null;
|
createdBy: { firstName: string; lastName: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PendingAnfragen({ termine }: { termine: PendingTermin[] }) {
|
export function PendingAnfragen({ termine }: { termine: PendingTerminDto[] }) {
|
||||||
if (termine.length === 0) {
|
if (termine.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
|
||||||
@@ -37,7 +40,7 @@ export function PendingAnfragen({ termine }: { termine: PendingTermin[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PendingTerminCard({ termin }: { termin: PendingTermin }) {
|
function PendingTerminCard({ termin }: { termin: PendingTerminDto }) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const handleApprove = () => {
|
const handleApprove = () => {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Termin, MitbringselItem, TerminType, TerminStatus } from "@prisma/client";
|
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
import { Calendar, Clock, MapPin, AlignLeft } from "lucide-react";
|
import { AlignLeft, Calendar, Clock, WashingMachine } from "lucide-react";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { MitbringselList } from "./mitbringsel-list";
|
import { MitbringselList } from "./mitbringsel-list";
|
||||||
import { toggleMitbringselList } from "../actions";
|
import { toggleMitbringselList } from "../actions";
|
||||||
@@ -14,18 +14,43 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
type TerminWithItems = Termin & {
|
export type TerminListItemDto = {
|
||||||
mitbringselItems?: (MitbringselItem & {
|
kind: "termin";
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
type: TerminType;
|
||||||
|
status: TerminStatus;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
allDay: boolean;
|
||||||
|
mitbringselListEnabled: boolean;
|
||||||
|
mitbringselItems?: {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
content: string;
|
||||||
user: { firstName: string; lastName: string };
|
user: { firstName: string; lastName: string };
|
||||||
})[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DutyCalendarItemDto = {
|
||||||
|
kind: "duty";
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
familyName: string;
|
||||||
|
status: DutyAssignmentStatus;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto;
|
||||||
|
|
||||||
export function TerminList({
|
export function TerminList({
|
||||||
termine,
|
termine,
|
||||||
userId,
|
userId,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
}: {
|
}: {
|
||||||
termine: TerminWithItems[];
|
termine: CalendarListItemDto[];
|
||||||
userId: string;
|
userId: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -46,23 +71,58 @@ export function TerminList({
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{termine.map((termin) => (
|
{termine.map((termin) => (
|
||||||
<TerminCard
|
termin.kind === "duty" ? (
|
||||||
key={termin.id}
|
<DutyCard key={`duty-${termin.id}`} duty={termin} />
|
||||||
termin={termin}
|
) : (
|
||||||
userId={userId}
|
<TerminCard
|
||||||
isAdmin={isAdmin}
|
key={`termin-${termin.id}`}
|
||||||
/>
|
termin={termin}
|
||||||
|
userId={userId}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
/>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DutyCard({ duty }: { duty: DutyCalendarItemDto }) {
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col overflow-hidden border-emerald-200 bg-emerald-50/60">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
|
<Badge className="border-transparent bg-emerald-600 text-white hover:bg-emerald-700">
|
||||||
|
Elterndienst
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="line-clamp-2 leading-tight">
|
||||||
|
{duty.title}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1 flex items-center gap-1 text-sm">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
{format(duty.startDate, "dd.MM.", { locale: de })} -{" "}
|
||||||
|
{format(duty.endDate, "dd.MM.yyyy", { locale: de })}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex-1 pb-4">
|
||||||
|
<div className="flex items-start gap-2 text-sm text-emerald-900">
|
||||||
|
<WashingMachine className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<p>
|
||||||
|
Eingeteilt: <span className="font-medium">{duty.familyName}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TerminCard({
|
function TerminCard({
|
||||||
termin,
|
termin,
|
||||||
userId,
|
userId,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
}: {
|
}: {
|
||||||
termin: TerminWithItems;
|
termin: TerminListItemDto;
|
||||||
userId: string;
|
userId: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ export async function createTerminRequest(rawPayload: unknown) {
|
|||||||
|
|
||||||
export async function createTerminAdmin(rawPayload: unknown) {
|
export async function createTerminAdmin(rawPayload: unknown) {
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||||
|
const kitaId = session.user.kitaId;
|
||||||
|
if (!kitaId) {
|
||||||
|
return { error: "Kein Mandant zugeordnet." };
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = terminSchema.safeParse(rawPayload);
|
const parsed = terminSchema.safeParse(rawPayload);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -66,7 +70,7 @@ export async function createTerminAdmin(rawPayload: unknown) {
|
|||||||
try {
|
try {
|
||||||
await prisma.termin.create({
|
await prisma.termin.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId: session.user.kitaId,
|
kitaId,
|
||||||
createdById: session.user.id,
|
createdById: session.user.id,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
@@ -90,12 +94,16 @@ export async function createTerminAdmin(rawPayload: unknown) {
|
|||||||
|
|
||||||
export async function approveTermin(terminId: string) {
|
export async function approveTermin(terminId: string) {
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||||
|
const kitaId = session.user.kitaId;
|
||||||
|
if (!kitaId) {
|
||||||
|
return { error: "Kein Mandant zugeordnet." };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.termin.update({
|
await prisma.termin.update({
|
||||||
where: {
|
where: {
|
||||||
id: terminId,
|
id: terminId,
|
||||||
kitaId: session.user.kitaId,
|
kitaId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
status: TerminStatus.CONFIRMED,
|
status: TerminStatus.CONFIRMED,
|
||||||
@@ -114,12 +122,16 @@ export async function approveTermin(terminId: string) {
|
|||||||
|
|
||||||
export async function rejectTermin(terminId: string, reason?: string) {
|
export async function rejectTermin(terminId: string, reason?: string) {
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||||
|
const kitaId = session.user.kitaId;
|
||||||
|
if (!kitaId) {
|
||||||
|
return { error: "Kein Mandant zugeordnet." };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.termin.update({
|
await prisma.termin.update({
|
||||||
where: {
|
where: {
|
||||||
id: terminId,
|
id: terminId,
|
||||||
kitaId: session.user.kitaId,
|
kitaId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
status: TerminStatus.REJECTED,
|
status: TerminStatus.REJECTED,
|
||||||
@@ -139,12 +151,16 @@ export async function rejectTermin(terminId: string, reason?: string) {
|
|||||||
|
|
||||||
export async function toggleMitbringselList(terminId: string, enabled: boolean) {
|
export async function toggleMitbringselList(terminId: string, enabled: boolean) {
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||||
|
const kitaId = session.user.kitaId;
|
||||||
|
if (!kitaId) {
|
||||||
|
return { error: "Kein Mandant zugeordnet." };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.termin.update({
|
await prisma.termin.update({
|
||||||
where: {
|
where: {
|
||||||
id: terminId,
|
id: terminId,
|
||||||
kitaId: session.user.kitaId,
|
kitaId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
mitbringselListEnabled: enabled,
|
mitbringselListEnabled: enabled,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
|
import { DutyAssignmentStatus, TerminStatus, UserRole } from "@prisma/client";
|
||||||
|
|
||||||
import { requireKitaSession } from "@/lib/auth-utils";
|
import { requireKitaSession } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { UserRole, TerminStatus } from "@prisma/client";
|
|
||||||
import { TerminList } from "./_components/termin-list";
|
|
||||||
import { PendingAnfragen } from "./_components/pending-anfragen";
|
|
||||||
import { TerminRequestModal } from "./_components/termin-request-modal";
|
|
||||||
import { AdminTerminModal } from "./_components/admin-termin-modal";
|
import { AdminTerminModal } from "./_components/admin-termin-modal";
|
||||||
import { CalendarDays } from "lucide-react";
|
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
|
||||||
|
import { TerminList, type CalendarListItemDto } from "./_components/termin-list";
|
||||||
|
import { TerminRequestModal } from "./_components/termin-request-modal";
|
||||||
|
|
||||||
export default async function KalenderPage({
|
export default async function KalenderPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -14,51 +14,139 @@ export default async function KalenderPage({
|
|||||||
}) {
|
}) {
|
||||||
const session = await requireKitaSession();
|
const session = await requireKitaSession();
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
session.user.role === UserRole.ADMIN || session.user.role === UserRole.KOORDINATOR;
|
session.user.role === UserRole.ADMIN ||
|
||||||
|
session.user.role === UserRole.KOORDINATOR;
|
||||||
|
|
||||||
const currentTab = searchParams.tab || "übersicht";
|
const currentTab = searchParams.tab || "übersicht";
|
||||||
|
|
||||||
// Fetch confirmed events
|
const confirmedTermineRows = await prisma.termin.findMany({
|
||||||
const confirmedTermine = await prisma.termin.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
kitaId: session.user.kitaId,
|
kitaId: session.user.kitaId,
|
||||||
status: TerminStatus.CONFIRMED,
|
status: TerminStatus.CONFIRMED,
|
||||||
},
|
},
|
||||||
include: {
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
type: true,
|
||||||
|
status: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
allDay: true,
|
||||||
|
mitbringselListEnabled: true,
|
||||||
mitbringselItems: {
|
mitbringselItems: {
|
||||||
include: {
|
select: {
|
||||||
user: { select: { firstName: true, lastName: true } },
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
content: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { startDate: "asc" },
|
orderBy: { startDate: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch user's own pending events
|
const myPendingTermineRows = await prisma.termin.findMany({
|
||||||
const myPendingTermine = await prisma.termin.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
kitaId: session.user.kitaId,
|
kitaId: session.user.kitaId,
|
||||||
status: TerminStatus.PENDING,
|
status: TerminStatus.PENDING,
|
||||||
createdById: session.user.id,
|
createdById: session.user.id,
|
||||||
},
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
type: true,
|
||||||
|
status: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
allDay: true,
|
||||||
|
mitbringselListEnabled: true,
|
||||||
|
mitbringselItems: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
content: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: { startDate: "asc" },
|
orderBy: { startDate: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Combine for general view
|
const dutyAssignmentRows = await prisma.dutyAssignment.findMany({
|
||||||
const allUserTermine = [...confirmedTermine, ...myPendingTermine].sort(
|
where: {
|
||||||
(a, b) => a.startDate.getTime() - b.startDate.getTime()
|
kitaId: session.user.kitaId,
|
||||||
);
|
status: DutyAssignmentStatus.PLANNED,
|
||||||
|
endDate: { gte: new Date() },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
status: true,
|
||||||
|
dutyType: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
family: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { startDate: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
// If admin, fetch all pending events
|
const allUserTermine: CalendarListItemDto[] = [
|
||||||
let allPendingTermine: any[] = [];
|
...confirmedTermineRows.map((termin) => ({
|
||||||
|
...termin,
|
||||||
|
kind: "termin" as const,
|
||||||
|
})),
|
||||||
|
...myPendingTermineRows.map((termin) => ({
|
||||||
|
...termin,
|
||||||
|
kind: "termin" as const,
|
||||||
|
})),
|
||||||
|
...dutyAssignmentRows.map((assignment) => ({
|
||||||
|
kind: "duty" as const,
|
||||||
|
id: assignment.id,
|
||||||
|
title: assignment.dutyType.name,
|
||||||
|
familyName: assignment.family.name,
|
||||||
|
status: assignment.status,
|
||||||
|
startDate: assignment.startDate,
|
||||||
|
endDate: assignment.endDate,
|
||||||
|
})),
|
||||||
|
].sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
|
||||||
|
|
||||||
|
let allPendingTermine: PendingTerminDto[] = [];
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
allPendingTermine = await prisma.termin.findMany({
|
allPendingTermine = await prisma.termin.findMany({
|
||||||
where: {
|
where: {
|
||||||
kitaId: session.user.kitaId,
|
kitaId: session.user.kitaId,
|
||||||
status: TerminStatus.PENDING,
|
status: TerminStatus.PENDING,
|
||||||
},
|
},
|
||||||
include: {
|
select: {
|
||||||
createdBy: { select: { firstName: true, lastName: true } },
|
id: true,
|
||||||
|
title: true,
|
||||||
|
startDate: true,
|
||||||
|
allDay: true,
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
@@ -94,7 +182,7 @@ export default async function KalenderPage({
|
|||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/dashboard/kalender?tab=anfragen"
|
href="/dashboard/kalender?tab=anfragen"
|
||||||
className={`pb-2 text-sm font-medium transition-colors hover:text-primary flex items-center gap-2 ${
|
className={`flex items-center gap-2 pb-2 text-sm font-medium transition-colors hover:text-primary ${
|
||||||
currentTab === "anfragen"
|
currentTab === "anfragen"
|
||||||
? "border-b-2 border-primary text-primary"
|
? "border-b-2 border-primary text-primary"
|
||||||
: "text-muted-foreground"
|
: "text-muted-foreground"
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import Link from "next/link";
|
import type { Metadata } from "next";
|
||||||
import { Baby, LogOut } from "lucide-react";
|
import { Baby, LogOut } from "lucide-react";
|
||||||
|
|
||||||
import { auth, signOut } from "@/auth";
|
import { signOut } from "@/auth";
|
||||||
import { requireKitaSession } from "@/lib/auth-utils";
|
import { requireKitaSession } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { SidebarNav } from "@/components/dashboard/sidebar-nav";
|
import { SidebarNav } from "@/components/dashboard/sidebar-nav";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
robots: {
|
||||||
|
index: false,
|
||||||
|
follow: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Dashboard-Layout · Linke Sidebar
|
// Dashboard-Layout · Linke Sidebar
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -47,7 +54,7 @@ export default async function DashboardLayout({
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className="flex-1 overflow-y-auto py-4">
|
<div className="flex-1 overflow-y-auto py-4">
|
||||||
<SidebarNav role={session.user.role as any} />
|
<SidebarNav role={session.user.role} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer: User-Info + Abmelden */}
|
{/* Footer: User-Info + Abmelden */}
|
||||||
|
|||||||
@@ -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) {
|
if (!kitaId) {
|
||||||
return { error: "Kein Mandant zugeordnet." };
|
return { error: "Kein Mandant zugeordnet." };
|
||||||
}
|
}
|
||||||
|
if (!session.user.familyId) {
|
||||||
|
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
||||||
|
}
|
||||||
|
|
||||||
// ── 2. Validierung (Soll-Tage) ─────────────────────────────────────
|
// ── 2. Validierung (Soll-Tage) ─────────────────────────────────────
|
||||||
const kita = await prisma.kita.findUniqueOrThrow({
|
const kita = await prisma.kita.findUniqueOrThrow({
|
||||||
@@ -61,12 +64,12 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sicherstellen, dass alle Kinder zum angemeldeten User gehören und zur kitaId passen
|
// Sicherstellen, dass alle Kinder zum Haushalt des angemeldeten Users gehören.
|
||||||
const validChildren = await prisma.child.findMany({
|
const validChildren = await prisma.child.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: childrenIds },
|
id: { in: childrenIds },
|
||||||
kitaId,
|
kitaId,
|
||||||
parentLinks: { some: { userId: session.user.id } },
|
familyId: session.user.familyId,
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
@@ -101,11 +104,10 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
|
|||||||
// ── 3. Datenbank-Transaktion ───────────────────────────────────────
|
// ── 3. Datenbank-Transaktion ───────────────────────────────────────
|
||||||
try {
|
try {
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
// a) Alle *eigenen* bestehenden Einträge im Zielmonat für diese Kinder löschen
|
// a) Alle bestehenden Haushaltseinträge im Zielmonat für diese Kinder löschen
|
||||||
await tx.notdienstAvailability.deleteMany({
|
await tx.notdienstAvailability.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
kitaId,
|
kitaId,
|
||||||
userId: session.user.id,
|
|
||||||
childId: { in: childrenIds },
|
childId: { in: childrenIds },
|
||||||
date: {
|
date: {
|
||||||
gte: new Date(targetYear, targetMonth - 1, 1),
|
gte: new Date(targetYear, targetMonth - 1, 1),
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ArrowRight, CalendarHeart } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { NotdienstForm } from "./notdienst-form";
|
||||||
|
|
||||||
|
type NotdienstEntryProps = {
|
||||||
|
hasAnyAvailability: boolean;
|
||||||
|
targetYear: number;
|
||||||
|
targetMonth: number;
|
||||||
|
isLocked: boolean;
|
||||||
|
requiredDaysTotal: number;
|
||||||
|
initialSelectedDates: string[];
|
||||||
|
childrenIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotdienstEntry({
|
||||||
|
hasAnyAvailability,
|
||||||
|
targetYear,
|
||||||
|
targetMonth,
|
||||||
|
isLocked,
|
||||||
|
requiredDaysTotal,
|
||||||
|
initialSelectedDates,
|
||||||
|
childrenIds,
|
||||||
|
}: NotdienstEntryProps) {
|
||||||
|
const [showForm, setShowForm] = useState(hasAnyAvailability);
|
||||||
|
|
||||||
|
if (!showForm) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-gradient-to-b from-emerald-50/70 to-background p-8 text-center">
|
||||||
|
<div className="mx-auto max-w-lg">
|
||||||
|
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 shadow-sm">
|
||||||
|
<CalendarHeart className="h-10 w-10" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
|
||||||
|
Willkommen beim Notdienst!
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||||
|
Es sieht so aus, als hättest du noch keine Termine eingetragen.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="mt-6"
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
>
|
||||||
|
Jetzt erste Pflicht-Termine eintragen
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotdienstForm
|
||||||
|
targetYear={targetYear}
|
||||||
|
targetMonth={targetMonth}
|
||||||
|
isLocked={isLocked}
|
||||||
|
requiredDaysTotal={requiredDaysTotal}
|
||||||
|
initialSelectedDates={initialSelectedDates}
|
||||||
|
childrenIds={childrenIds}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { UserRole } from "@prisma/client";
|
import { UserRole } from "@prisma/client";
|
||||||
import { Info, Calendar } from "lucide-react";
|
import { Info, Calendar, ArrowRight, ShieldAlert } from "lucide-react";
|
||||||
|
|
||||||
import { requireRole } from "@/lib/auth-utils";
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getTargetMonthData } from "@/lib/date-utils";
|
import { getTargetMonthData } from "@/lib/date-utils";
|
||||||
import { NotdienstForm } from "./notdienst-form";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { NotdienstEntry } from "./notdienst-entry";
|
||||||
|
|
||||||
export const metadata = { title: "Notdienst · Kita-Planer" };
|
export const metadata = { title: "Notdienst · Kita-Planer" };
|
||||||
|
|
||||||
@@ -29,34 +31,51 @@ export default async function NotdienstPage() {
|
|||||||
select: { notdienstMinPerChildPerMonth: true },
|
select: { notdienstMinPerChildPerMonth: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Nur eigene, aktive Kinder laden
|
// Nur aktive Kinder des eigenen Haushalts laden.
|
||||||
const children = await prisma.child.findMany({
|
const children = session.user.familyId
|
||||||
where: {
|
? await prisma.child.findMany({
|
||||||
kitaId,
|
where: {
|
||||||
active: true,
|
kitaId,
|
||||||
parentLinks: { some: { userId: session.user.id } },
|
familyId: session.user.familyId,
|
||||||
},
|
active: true,
|
||||||
select: { id: true, firstName: true },
|
},
|
||||||
});
|
select: { id: true, firstName: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
const targetData = getTargetMonthData();
|
const targetData = getTargetMonthData();
|
||||||
const { targetYear, targetMonth, monthName, isLocked } = targetData;
|
const { targetYear, targetMonth, monthName, isLocked } = targetData;
|
||||||
|
|
||||||
const requiredDaysTotal = children.length * kita.notdienstMinPerChildPerMonth;
|
const requiredDaysTotal = children.length * kita.notdienstMinPerChildPerMonth;
|
||||||
|
const childIds = children.map((child) => child.id);
|
||||||
|
|
||||||
// Bisherige Einträge für den Zielmonat laden
|
// Bisherige Einträge für den Zielmonat laden, haushaltsweit über die Kinder.
|
||||||
// Wir filtern bewusst nach den eigenen Kindern und dem User
|
const [availabilities, availabilityCountForCurrentOrNextMonth] =
|
||||||
const availabilities = await prisma.notdienstAvailability.findMany({
|
children.length > 0
|
||||||
where: {
|
? await Promise.all([
|
||||||
kitaId,
|
prisma.notdienstAvailability.findMany({
|
||||||
userId: session.user.id,
|
where: {
|
||||||
date: {
|
kitaId,
|
||||||
gte: new Date(targetYear, targetMonth - 1, 1),
|
childId: { in: childIds },
|
||||||
lt: new Date(targetYear, targetMonth, 1),
|
date: {
|
||||||
},
|
gte: new Date(targetYear, targetMonth - 1, 1),
|
||||||
},
|
lt: new Date(targetYear, targetMonth, 1),
|
||||||
select: { date: true },
|
},
|
||||||
});
|
},
|
||||||
|
select: { date: true },
|
||||||
|
}),
|
||||||
|
prisma.notdienstAvailability.count({
|
||||||
|
where: {
|
||||||
|
kitaId,
|
||||||
|
childId: { in: childIds },
|
||||||
|
date: {
|
||||||
|
gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
|
||||||
|
lt: new Date(new Date().getFullYear(), new Date().getMonth() + 2, 1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
: [[], 0];
|
||||||
|
|
||||||
const selectedDates = availabilities.map((a) => a.date.toISOString());
|
const selectedDates = availabilities.map((a) => a.date.toISOString());
|
||||||
|
|
||||||
@@ -86,19 +105,43 @@ export default async function NotdienstPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{children.length === 0 ? (
|
{children.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
<NotdienstLockout />
|
||||||
Deinem Account sind noch keine Kinder zugeordnet.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<NotdienstForm
|
<NotdienstEntry
|
||||||
|
hasAnyAvailability={availabilityCountForCurrentOrNextMonth > 0}
|
||||||
targetYear={targetYear}
|
targetYear={targetYear}
|
||||||
targetMonth={targetMonth}
|
targetMonth={targetMonth}
|
||||||
isLocked={isLocked}
|
isLocked={isLocked}
|
||||||
requiredDaysTotal={requiredDaysTotal}
|
requiredDaysTotal={requiredDaysTotal}
|
||||||
initialSelectedDates={selectedDates}
|
initialSelectedDates={selectedDates}
|
||||||
childrenIds={children.map((c) => c.id)}
|
childrenIds={childIds}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotdienstLockout() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-muted/20 p-8 text-center">
|
||||||
|
<div className="mx-auto max-w-lg">
|
||||||
|
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-amber-100 text-amber-700 shadow-sm">
|
||||||
|
<ShieldAlert className="h-10 w-10" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
|
||||||
|
Erst Kinder hinterlegen
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||||
|
Bitte lege zuerst deine Kinder im Profil an, bevor du Notdienste
|
||||||
|
übernimmst.
|
||||||
|
</p>
|
||||||
|
<Button asChild className="mt-6">
|
||||||
|
<Link href="/dashboard/profil">
|
||||||
|
Zum Profil
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export async function generatePlanAction() {
|
|||||||
lt: new Date(targetYear, targetMonth, 1),
|
lt: new Date(targetYear, targetMonth, 1),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: { child: true, user: true },
|
include: { child: { select: { familyId: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Werktage holen
|
// 2. Werktage holen
|
||||||
@@ -63,8 +63,8 @@ export async function generatePlanAction() {
|
|||||||
// Finde die Familie, die am wenigsten oft dran war
|
// Finde die Familie, die am wenigsten oft dran war
|
||||||
// Zufall einbauen bei Gleichstand
|
// Zufall einbauen bei Gleichstand
|
||||||
const sorted = availForDay.sort((a, b) => {
|
const sorted = availForDay.sort((a, b) => {
|
||||||
const countA = familyUsageCount[a.userId] || 0;
|
const countA = familyUsageCount[a.child.familyId] || 0;
|
||||||
const countB = familyUsageCount[b.userId] || 0;
|
const countB = familyUsageCount[b.child.familyId] || 0;
|
||||||
if (countA === countB) {
|
if (countA === countB) {
|
||||||
return Math.random() - 0.5; // Zufallsmischung
|
return Math.random() - 0.5; // Zufallsmischung
|
||||||
}
|
}
|
||||||
@@ -79,8 +79,8 @@ export async function generatePlanAction() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Usage-Counter erhöhen
|
// Usage-Counter erhöhen
|
||||||
familyUsageCount[selected.userId] =
|
familyUsageCount[selected.child.familyId] =
|
||||||
(familyUsageCount[selected.userId] || 0) + 1;
|
(familyUsageCount[selected.child.familyId] || 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. In der DB speichern
|
// 4. In der DB speichern
|
||||||
@@ -161,6 +161,19 @@ export async function updateAssignmentAction(
|
|||||||
|
|
||||||
// Neues anlegen, falls ausgewählt
|
// Neues anlegen, falls ausgewählt
|
||||||
if (newChildId) {
|
if (newChildId) {
|
||||||
|
const child = await tx.child.findFirst({
|
||||||
|
where: {
|
||||||
|
id: newChildId,
|
||||||
|
kitaId,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!child) {
|
||||||
|
throw new Error("Kind gehört nicht zu dieser Kita.");
|
||||||
|
}
|
||||||
|
|
||||||
await tx.notdienstAssignment.create({
|
await tx.notdienstAssignment.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId,
|
kitaId,
|
||||||
@@ -174,7 +187,9 @@ export async function updateAssignmentAction(
|
|||||||
|
|
||||||
revalidatePath("/dashboard/notdienst/plan");
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
return { error: error.message || "Update fehlgeschlagen." };
|
return {
|
||||||
|
error: error instanceof Error ? error.message : "Update fehlgeschlagen.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,15 @@ export default async function PlanungsZentralePage() {
|
|||||||
where: {
|
where: {
|
||||||
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
||||||
},
|
},
|
||||||
include: {
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
assignments: {
|
assignments: {
|
||||||
include: { child: { include: { parentLinks: { include: { user: true } } } } },
|
select: {
|
||||||
|
id: true,
|
||||||
|
childId: true,
|
||||||
|
date: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -34,16 +40,30 @@ export default async function PlanungsZentralePage() {
|
|||||||
// aber für MVP reichen alle aktiven Kinder.
|
// aber für MVP reichen alle aktiven Kinder.
|
||||||
const allChildrenRaw = await prisma.child.findMany({
|
const allChildrenRaw = await prisma.child.findMany({
|
||||||
where: { kitaId, active: true },
|
where: { kitaId, active: true },
|
||||||
include: {
|
select: {
|
||||||
parentLinks: { include: { user: true } },
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
family: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
users: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: { lastName: "asc" },
|
orderBy: { lastName: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const allChildren = allChildrenRaw.map((c) => {
|
const allChildren = allChildrenRaw.map((c) => {
|
||||||
// Einfachheit halber nehmen wir den ersten verknüpften Elternteil zur Anzeige
|
const parentName =
|
||||||
const parent = c.parentLinks[0]?.user;
|
c.family.users
|
||||||
const parentName = parent ? `${parent.firstName} ${parent.lastName}` : "Unbekannt";
|
.map((parent) => `${parent.firstName} ${parent.lastName}`)
|
||||||
|
.join(", ") || c.family.name;
|
||||||
return {
|
return {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
name: `${c.firstName} ${c.lastName}`,
|
name: `${c.firstName} ${c.lastName}`,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
import { NotdienstPlanStatus } from "@prisma/client";
|
import { NotdienstPlanStatus } from "@prisma/client";
|
||||||
|
|||||||
+247
-16
@@ -1,6 +1,8 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle } from "lucide-react";
|
import { format } from "date-fns";
|
||||||
import { UserRole, NotdienstPlanStatus } from "@prisma/client";
|
import { de } from "date-fns/locale";
|
||||||
|
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle, ClipboardList } from "lucide-react";
|
||||||
|
import { DutyAssignmentStatus, UserRole, NotdienstPlanStatus } from "@prisma/client";
|
||||||
|
|
||||||
import { requireKitaSession } from "@/lib/auth-utils";
|
import { requireKitaSession } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -13,7 +15,10 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { OnboardingDialog } from "@/components/OnboardingDialog";
|
||||||
import { AlertButton } from "./alert-button";
|
import { AlertButton } from "./alert-button";
|
||||||
|
import { AbsenceCard } from "./absence-card";
|
||||||
|
import { NewsTicker } from "./news-ticker";
|
||||||
|
|
||||||
export const metadata = { title: "Übersicht · Kita-Planer" };
|
export const metadata = { title: "Übersicht · Kita-Planer" };
|
||||||
|
|
||||||
@@ -30,22 +35,166 @@ export default async function DashboardPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Schnelle Statistiken für die Übersicht
|
const today = new Date();
|
||||||
const [familyCount, childCount] = await Promise.all([
|
today.setHours(0, 0, 0, 0);
|
||||||
prisma.user.count({
|
|
||||||
where: { kitaId: session.user.kitaId, role: UserRole.ELTERN },
|
// Schnelle Statistiken und persönliche Dienste für die Übersicht.
|
||||||
|
const [
|
||||||
|
familyCount,
|
||||||
|
childCount,
|
||||||
|
upcomingDuties,
|
||||||
|
onboardingUser,
|
||||||
|
absenceChildren,
|
||||||
|
upcomingAbsences,
|
||||||
|
latestAnnouncements,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.family.count({
|
||||||
|
where: { kitaId: session.user.kitaId },
|
||||||
}),
|
}),
|
||||||
prisma.child.count({
|
prisma.child.count({
|
||||||
where: { kitaId: session.user.kitaId },
|
where: { kitaId: session.user.kitaId },
|
||||||
}),
|
}),
|
||||||
|
session.user.familyId
|
||||||
|
? prisma.dutyAssignment.findMany({
|
||||||
|
where: {
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
familyId: session.user.familyId,
|
||||||
|
status: DutyAssignmentStatus.PLANNED,
|
||||||
|
endDate: { gte: today },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
dutyType: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { startDate: "asc" },
|
||||||
|
take: 5,
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: session.user.id,
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
phone: true,
|
||||||
|
street: true,
|
||||||
|
postalCode: true,
|
||||||
|
city: true,
|
||||||
|
familyId: true,
|
||||||
|
role: true,
|
||||||
|
family: {
|
||||||
|
select: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
children: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
session.user.familyId
|
||||||
|
? prisma.child.findMany({
|
||||||
|
where: {
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
familyId: session.user.familyId,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
session.user.familyId
|
||||||
|
? prisma.absence.findMany({
|
||||||
|
where: {
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
endDate: { gte: today },
|
||||||
|
child: {
|
||||||
|
familyId: session.user.familyId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
reason: true,
|
||||||
|
note: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
child: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ startDate: "asc" }],
|
||||||
|
take: 6,
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
prisma.announcement.findMany({
|
||||||
|
where: { kitaId: session.user.kitaId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
createdAt: true,
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
attachments: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fileName: true,
|
||||||
|
fileUrl: true,
|
||||||
|
fileType: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reads: {
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 3,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
session.user.role === UserRole.ADMIN ||
|
session.user.role === UserRole.ADMIN ||
|
||||||
session.user.role === UserRole.SUPERADMIN;
|
session.user.role === UserRole.SUPERADMIN;
|
||||||
|
const canReportAbsences = session.user.role !== UserRole.ERZIEHER;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-8 py-8">
|
<div className="px-8 py-8">
|
||||||
|
<OnboardingDialog
|
||||||
|
user={{
|
||||||
|
phone: onboardingUser.phone,
|
||||||
|
street: onboardingUser.street,
|
||||||
|
postalCode: onboardingUser.postalCode,
|
||||||
|
city: onboardingUser.city,
|
||||||
|
familyId: onboardingUser.familyId,
|
||||||
|
role: onboardingUser.role,
|
||||||
|
}}
|
||||||
|
family={
|
||||||
|
onboardingUser.family
|
||||||
|
? { childrenCount: onboardingUser.family._count.children }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
Willkommen, {session.user.name?.split(" ")[0] ?? "zusammen"}!
|
Willkommen, {session.user.name?.split(" ")[0] ?? "zusammen"}!
|
||||||
@@ -55,6 +204,25 @@ export default async function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NewsTicker
|
||||||
|
items={latestAnnouncements.map((announcement) => ({
|
||||||
|
id: announcement.id,
|
||||||
|
title: announcement.title,
|
||||||
|
content: announcement.content,
|
||||||
|
createdAt: format(announcement.createdAt, "dd.MM.yyyy", {
|
||||||
|
locale: de,
|
||||||
|
}),
|
||||||
|
authorName: `${announcement.author.firstName} ${announcement.author.lastName}`,
|
||||||
|
isUnread: announcement.reads.length === 0,
|
||||||
|
attachments: announcement.attachments.map((attachment) => ({
|
||||||
|
id: attachment.id,
|
||||||
|
fileName: attachment.fileName,
|
||||||
|
fileUrl: attachment.fileUrl,
|
||||||
|
fileType: attachment.fileType,
|
||||||
|
})),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Heutiger Notdienst (Nur für Admins/Koordinatoren) */}
|
{/* Heutiger Notdienst (Nur für Admins/Koordinatoren) */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -62,10 +230,53 @@ export default async function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canReportAbsences && (
|
||||||
|
<AbsenceCard
|
||||||
|
today={format(today, "yyyy-MM-dd")}
|
||||||
|
childOptions={absenceChildren.map((child) => ({
|
||||||
|
id: child.id,
|
||||||
|
name: `${child.firstName} ${child.lastName}`,
|
||||||
|
}))}
|
||||||
|
absences={upcomingAbsences.map((absence) => ({
|
||||||
|
id: absence.id,
|
||||||
|
childName: `${absence.child.firstName} ${absence.child.lastName}`,
|
||||||
|
reason: absence.reason,
|
||||||
|
note: absence.note,
|
||||||
|
startDate: format(absence.startDate, "yyyy-MM-dd"),
|
||||||
|
endDate: format(absence.endDate, "yyyy-MM-dd"),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{upcomingDuties.length > 0 && (
|
||||||
|
<Card className="mb-8 border-emerald-200 bg-emerald-50/50">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<ClipboardList className="h-5 w-5 text-emerald-700" />
|
||||||
|
Deine anstehenden Dienste
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Die nächsten Einteilungen für deinen Haushalt.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{upcomingDuties.map((duty) => (
|
||||||
|
<div key={duty.id} className="rounded-md border bg-background p-3">
|
||||||
|
<p className="font-medium">{duty.dutyType.name}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{format(duty.startDate, "dd.MM.", { locale: de })} bis{" "}
|
||||||
|
{format(duty.endDate, "dd.MM.yyyy", { locale: de })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Statistik-Kacheln */}
|
{/* Statistik-Kacheln */}
|
||||||
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Elternteile"
|
label="Familien"
|
||||||
value={familyCount}
|
value={familyCount}
|
||||||
description="registrierte Familien"
|
description="registrierte Familien"
|
||||||
icon={<Users className="h-5 w-5 text-muted-foreground" />}
|
icon={<Users className="h-5 w-5 text-muted-foreground" />}
|
||||||
@@ -147,15 +358,38 @@ async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
|
|||||||
date: today,
|
date: today,
|
||||||
plan: { status: NotdienstPlanStatus.PUBLISHED },
|
plan: { status: NotdienstPlanStatus.PUBLISHED },
|
||||||
},
|
},
|
||||||
include: {
|
select: {
|
||||||
child: { include: { parentLinks: { include: { user: true } } } },
|
id: true,
|
||||||
alerts: true, // Um zu sehen, ob schon ein Alarm ausgelöst wurde
|
child: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
family: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
users: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
alerts: {
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!assignment) return null;
|
if (!assignment) return null;
|
||||||
|
|
||||||
const parent = assignment.child.parentLinks[0]?.user;
|
const parentNames = assignment.child.family.users
|
||||||
|
.map((parent) => `${parent.firstName} ${parent.lastName}`)
|
||||||
|
.join(", ");
|
||||||
const existingAlert = assignment.alerts[0]; // Kann PENDING, CONFIRMED oder CANCELLED sein
|
const existingAlert = assignment.alerts[0]; // Kann PENDING, CONFIRMED oder CANCELLED sein
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -172,7 +406,7 @@ async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
|
|||||||
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-base">
|
<p className="font-medium text-base">
|
||||||
{parent ? `${parent.firstName} ${parent.lastName}` : "Unbekannt"}
|
{parentNames || assignment.child.family.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Kind: {assignment.child.firstName} {assignment.child.lastName}
|
Kind: {assignment.child.firstName} {assignment.child.lastName}
|
||||||
@@ -198,10 +432,7 @@ async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
|
|||||||
: "Abgebrochen"}
|
: "Abgebrochen"}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<AlertButton
|
<AlertButton assignmentId={assignment.id} />
|
||||||
assignmentId={assignment.id}
|
|
||||||
parentUserId={parent?.id || ""}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { Loader2, Save } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { updateMyContact } from "../actions";
|
||||||
|
|
||||||
|
type ContactFormProps = {
|
||||||
|
contact: {
|
||||||
|
phone: string | null;
|
||||||
|
street: string | null;
|
||||||
|
postalCode: string | null;
|
||||||
|
city: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContactForm({ contact }: ContactFormProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function handleSubmit(formData: FormData) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateMyContact({
|
||||||
|
phone: String(formData.get("phone") ?? ""),
|
||||||
|
street: String(formData.get("street") ?? ""),
|
||||||
|
postalCode: String(formData.get("postalCode") ?? ""),
|
||||||
|
city: String(formData.get("city") ?? ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Kontaktdaten gespeichert.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={handleSubmit} className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="phone">Telefonnummer</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
type="tel"
|
||||||
|
defaultValue={contact.phone ?? ""}
|
||||||
|
placeholder="+49 30 123456"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="street">Straße und Hausnummer</Label>
|
||||||
|
<Input
|
||||||
|
id="street"
|
||||||
|
name="street"
|
||||||
|
defaultValue={contact.street ?? ""}
|
||||||
|
placeholder="Beispielweg 12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[120px_1fr]">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="postalCode">PLZ</Label>
|
||||||
|
<Input
|
||||||
|
id="postalCode"
|
||||||
|
name="postalCode"
|
||||||
|
defaultValue={contact.postalCode ?? ""}
|
||||||
|
placeholder="10115"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="city">Ort</Label>
|
||||||
|
<Input
|
||||||
|
id="city"
|
||||||
|
name="city"
|
||||||
|
defaultValue={contact.city ?? ""}
|
||||||
|
placeholder="Berlin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Home, Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { createMyFamily } from "../actions";
|
||||||
|
|
||||||
|
export function CreateFamilyDialog({
|
||||||
|
defaultFamilyName,
|
||||||
|
}: {
|
||||||
|
defaultFamilyName: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function handleSubmit(formData: FormData) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createMyFamily({
|
||||||
|
familyName: String(formData.get("familyName") ?? ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Haushalt angelegt.");
|
||||||
|
setOpen(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" size="sm" className="gap-2">
|
||||||
|
<Home className="h-4 w-4" />
|
||||||
|
Haushalt anlegen
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<form action={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Eigenen Haushalt anlegen</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Dein Account wird diesem Haushalt zugeordnet. Danach kannst du
|
||||||
|
deine Kinder im Profil verwalten.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-2 py-4">
|
||||||
|
<Label htmlFor="familyName">Haushaltsname</Label>
|
||||||
|
<Input
|
||||||
|
id="familyName"
|
||||||
|
name="familyName"
|
||||||
|
defaultValue={defaultFamilyName}
|
||||||
|
placeholder="Familie Beispiel"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Haushalt speichern
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,8 +56,10 @@ export function DeleteAccountDialog() {
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-base pt-2">
|
<DialogDescription className="text-base pt-2">
|
||||||
Diese Aktion kann <strong className="text-foreground">nicht</strong> rückgängig gemacht werden.
|
Diese Aktion kann <strong className="text-foreground">nicht</strong> rückgängig gemacht werden.
|
||||||
Alle deine personenbezogenen Daten, Verknüpfungen zu deinen Kindern, gebuchten Notdienste
|
Wenn du das letzte Elternteil im Haushalt bist, wird die gesamte
|
||||||
und Mitbringsel-Einträge werden DSGVO-konform sofort und unwiderruflich aus der Datenbank gelöscht.
|
Familie inklusive Kinder gelöscht. Gibt es ein weiteres Elternteil,
|
||||||
|
bleibt der Haushalt für diese Person erhalten und nur dein Account
|
||||||
|
wird entfernt.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { createMyChild, deleteMyChild, updateMyChild } from "../actions";
|
||||||
|
|
||||||
|
export type MyChild = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
dateOfBirth: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MyChildrenManager({
|
||||||
|
items,
|
||||||
|
canManage,
|
||||||
|
}: {
|
||||||
|
items: MyChild[];
|
||||||
|
canManage: boolean;
|
||||||
|
}) {
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editingChild, setEditingChild] = useState<MyChild | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function payloadFromForm(formData: FormData) {
|
||||||
|
return {
|
||||||
|
firstName: String(formData.get("firstName") ?? ""),
|
||||||
|
lastName: String(formData.get("lastName") ?? ""),
|
||||||
|
dateOfBirth: String(formData.get("dateOfBirth") ?? ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreate(formData: FormData) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createMyChild(payloadFromForm(formData));
|
||||||
|
if (result.error) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Kind angelegt.");
|
||||||
|
setCreateOpen(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdate(formData: FormData) {
|
||||||
|
if (!editingChild) return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateMyChild(
|
||||||
|
editingChild.id,
|
||||||
|
payloadFromForm(formData),
|
||||||
|
);
|
||||||
|
if (result.error) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Kind aktualisiert.");
|
||||||
|
setEditingChild(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(child: MyChild) {
|
||||||
|
const confirmed = confirm(
|
||||||
|
`${child.firstName} ${child.lastName} wirklich entfernen?`,
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteMyChild(child.id);
|
||||||
|
if (result.error) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Kind entfernt.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Keine Kinder verknüpft.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{items.map((child) => (
|
||||||
|
<div
|
||||||
|
key={child.id}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-md bg-muted/50 p-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{child.firstName} {child.lastName}
|
||||||
|
</p>
|
||||||
|
{child.dateOfBirth && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Geboren am {new Date(child.dateOfBirth).toLocaleDateString("de-DE")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setEditingChild(child)}
|
||||||
|
disabled={isPending || !canManage}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => handleDelete(child)}
|
||||||
|
disabled={isPending || !canManage}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canManage ? (
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" variant="outline" size="sm" className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Kind hinzufügen
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<form action={handleCreate}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Kind hinzufügen</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Dieses Kind wird mit deinem Haushalt verknüpft.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ChildFields />
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setCreateOpen(false)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Dein Account ist noch keinem Haushalt zugeordnet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={!!editingChild}
|
||||||
|
onOpenChange={(open) => !open && setEditingChild(null)}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<form action={handleUpdate}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Kind bearbeiten</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{editingChild && <ChildFields child={editingChild} />}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setEditingChild(null)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
Aktualisieren
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChildFields({ child }: { child?: MyChild }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="child-firstName">Vorname</Label>
|
||||||
|
<Input
|
||||||
|
id="child-firstName"
|
||||||
|
name="firstName"
|
||||||
|
defaultValue={child?.firstName ?? ""}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="child-lastName">Nachname</Label>
|
||||||
|
<Input
|
||||||
|
id="child-lastName"
|
||||||
|
name="lastName"
|
||||||
|
defaultValue={child?.lastName ?? ""}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="child-dateOfBirth">Geburtsdatum</Label>
|
||||||
|
<Input
|
||||||
|
id="child-dateOfBirth"
|
||||||
|
name="dateOfBirth"
|
||||||
|
type="date"
|
||||||
|
defaultValue={child?.dateOfBirth ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,264 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { requireKitaSession } from "@/lib/auth-utils";
|
import { requireKitaSession } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { signOut } from "@/auth"; // Note: this is NextAuth v5 server side signOut if needed, but we do client side
|
|
||||||
|
const childSchema = z.object({
|
||||||
|
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
|
||||||
|
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
|
||||||
|
dateOfBirth: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const familySchema = z.object({
|
||||||
|
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const contactSchema = z.object({
|
||||||
|
phone: z.string().trim().max(50).optional(),
|
||||||
|
street: z.string().trim().max(120).optional(),
|
||||||
|
postalCode: z.string().trim().max(20).optional(),
|
||||||
|
city: z.string().trim().max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseDateInput(value?: string) {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const date = new Date(`${value}T00:00:00`);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireOwnFamilyChild(
|
||||||
|
childId: string,
|
||||||
|
familyId: string | null,
|
||||||
|
kitaId: string,
|
||||||
|
) {
|
||||||
|
if (!familyId) return null;
|
||||||
|
|
||||||
|
return prisma.child.findFirst({
|
||||||
|
where: { id: childId, familyId, kitaId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMyChild(rawPayload: unknown) {
|
||||||
|
const session = await requireKitaSession();
|
||||||
|
const parsed = childSchema.safeParse(rawPayload);
|
||||||
|
|
||||||
|
if (!session.user.familyId) {
|
||||||
|
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { error: "Ungültige Eingabedaten." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.child.create({
|
||||||
|
data: {
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
familyId: session.user.familyId,
|
||||||
|
firstName: parsed.data.firstName,
|
||||||
|
lastName: parsed.data.lastName,
|
||||||
|
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/profil");
|
||||||
|
revalidatePath("/dashboard/families");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler beim Anlegen des Kindes:", error);
|
||||||
|
return { error: "Das Kind konnte nicht angelegt werden." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMyFamily(rawPayload: unknown) {
|
||||||
|
const session = await requireKitaSession();
|
||||||
|
const parsed = familySchema.safeParse(rawPayload);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { error: "Bitte gib einen gültigen Familiennamen ein." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.familyId) {
|
||||||
|
return { error: "Dein Account ist bereits einem Haushalt zugeordnet." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const family = await tx.family.create({
|
||||||
|
data: {
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
name: parsed.data.familyName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: { familyId: family.id },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/profil");
|
||||||
|
revalidatePath("/dashboard/families");
|
||||||
|
revalidatePath("/dashboard/adressbuch");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
|
||||||
|
return { error: "Der Haushalt konnte nicht angelegt werden." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMyContact(rawPayload: unknown) {
|
||||||
|
const session = await requireKitaSession();
|
||||||
|
const parsed = contactSchema.safeParse(rawPayload);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { error: "Ungültige Kontaktdaten." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: {
|
||||||
|
phone: parsed.data.phone || null,
|
||||||
|
street: parsed.data.street || null,
|
||||||
|
postalCode: parsed.data.postalCode || null,
|
||||||
|
city: parsed.data.city || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/profil");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/dashboard/adressbuch");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler beim Aktualisieren der Kontaktdaten:", error);
|
||||||
|
return { error: "Kontaktdaten konnten nicht gespeichert werden." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMyChild(childId: string, rawPayload: unknown) {
|
||||||
|
const session = await requireKitaSession();
|
||||||
|
const parsed = childSchema.safeParse(rawPayload);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { error: "Ungültige Eingabedaten." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const child = await requireOwnFamilyChild(
|
||||||
|
childId,
|
||||||
|
session.user.familyId,
|
||||||
|
session.user.kitaId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!child) {
|
||||||
|
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.child.update({
|
||||||
|
where: { id: childId },
|
||||||
|
data: {
|
||||||
|
firstName: parsed.data.firstName,
|
||||||
|
lastName: parsed.data.lastName,
|
||||||
|
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/profil");
|
||||||
|
revalidatePath("/dashboard/families");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler beim Aktualisieren des Kindes:", error);
|
||||||
|
return { error: "Das Kind konnte nicht aktualisiert werden." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMyChild(childId: string) {
|
||||||
|
const session = await requireKitaSession();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const child = await requireOwnFamilyChild(
|
||||||
|
childId,
|
||||||
|
session.user.familyId,
|
||||||
|
session.user.kitaId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!child) {
|
||||||
|
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.child.delete({
|
||||||
|
where: { id: childId },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/profil");
|
||||||
|
revalidatePath("/dashboard/families");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler beim Entfernen des Kindes:", error);
|
||||||
|
return { error: "Das Kind konnte nicht entfernt werden." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteMyAccount() {
|
export async function deleteMyAccount() {
|
||||||
const session = await requireKitaSession();
|
const session = await requireKitaSession();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Da wir onDelete: Cascade überall konfiguriert haben,
|
const user = await prisma.user.findFirst({
|
||||||
// löscht dieser eine Befehl den User, seine ChildParent-Links,
|
|
||||||
// seine MitbringselItems, seine NotdienstAvailabilities etc.
|
|
||||||
await prisma.user.delete({
|
|
||||||
where: {
|
where: {
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
familyId: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// The client component will trigger the actual NextAuth client signOut,
|
if (!user) {
|
||||||
// but returning success indicates the DB deletion was successful.
|
return { error: "Account wurde nicht gefunden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.familyId) {
|
||||||
|
await prisma.user.delete({
|
||||||
|
where: { id: user.id },
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const family = await prisma.family.findFirst({
|
||||||
|
where: {
|
||||||
|
id: user.familyId,
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
users: {
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!family) {
|
||||||
|
return { error: "Haushalt wurde nicht gefunden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family.users.length <= 1) {
|
||||||
|
await prisma.family.delete({
|
||||||
|
where: { id: family.id },
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.delete({
|
||||||
|
where: { id: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Löschen des Accounts:", error);
|
console.error("Fehler beim Löschen des Accounts:", error);
|
||||||
|
|||||||
@@ -2,11 +2,22 @@ import { requireKitaSession } from "@/lib/auth-utils";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
import { UserCircle, Mail, Baby, Shield, CalendarHeart } from "lucide-react";
|
import {
|
||||||
|
Baby,
|
||||||
|
CalendarHeart,
|
||||||
|
Home,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
Shield,
|
||||||
|
UserCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { DeleteAccountDialog } from "./_components/delete-account-dialog";
|
import { DeleteAccountDialog } from "./_components/delete-account-dialog";
|
||||||
|
import { MyChildrenManager } from "./_components/my-children-manager";
|
||||||
|
import { CreateFamilyDialog } from "./_components/create-family-dialog";
|
||||||
|
import { ContactForm } from "./_components/contact-form";
|
||||||
|
|
||||||
export const metadata = { title: "Mein Profil · Kita-Planer" };
|
export const metadata = { title: "Mein Profil · Kita-Planer" };
|
||||||
|
|
||||||
@@ -15,12 +26,41 @@ export default async function ProfilPage() {
|
|||||||
|
|
||||||
const user = await prisma.user.findUniqueOrThrow({
|
const user = await prisma.user.findUniqueOrThrow({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
include: {
|
select: {
|
||||||
childLinks: {
|
id: true,
|
||||||
include: { child: true },
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
role: true,
|
||||||
|
familyId: true,
|
||||||
|
phone: true,
|
||||||
|
street: true,
|
||||||
|
postalCode: true,
|
||||||
|
city: true,
|
||||||
|
createdAt: true,
|
||||||
|
family: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
children: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
dateOfBirth: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
dutyAssignments: {
|
dutyAssignments: {
|
||||||
include: { duty: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
duty: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -56,12 +96,39 @@ export default async function ProfilPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="font-medium">{user.email}</div>
|
<div className="font-medium">{user.email}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
|
<Phone className="h-3.5 w-3.5" /> Telefon
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{user.phone ?? "Noch nicht hinterlegt"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Rolle im Verein</div>
|
<div className="text-sm text-muted-foreground">Rolle im Verein</div>
|
||||||
<Badge variant={user.role === "ELTERN" ? "secondary" : "default"} className="mt-1">
|
<Badge variant={user.role === "ELTERN" ? "secondary" : "default"} className="mt-1">
|
||||||
{user.role}
|
{user.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
|
<Home className="h-3.5 w-3.5" /> Haushalt
|
||||||
|
</div>
|
||||||
|
{user.family ? (
|
||||||
|
<div className="font-medium">{user.family.name}</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 space-y-3 rounded-md border border-dashed p-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Dein Account ist noch keinem Haushalt zugeordnet. Lege hier
|
||||||
|
deinen eigenen Haushalt an, wenn du selbst Elternteil in der
|
||||||
|
Kita bist.
|
||||||
|
</p>
|
||||||
|
<CreateFamilyDialog
|
||||||
|
defaultFamilyName={`Familie ${user.lastName}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="pt-2 border-t mt-2">
|
<div className="pt-2 border-t mt-2">
|
||||||
<div className="text-sm text-muted-foreground flex items-center gap-1">
|
<div className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
<CalendarHeart className="h-3.5 w-3.5" /> Mitglied seit
|
<CalendarHeart className="h-3.5 w-3.5" /> Mitglied seit
|
||||||
@@ -74,6 +141,29 @@ export default async function ProfilPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Phone className="h-5 w-5" />
|
||||||
|
Kontaktdaten
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Diese Angaben helfen bei Notdienst, Adressbuch und Abstimmung in
|
||||||
|
der Kita.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ContactForm
|
||||||
|
contact={{
|
||||||
|
phone: user.phone,
|
||||||
|
street: user.street,
|
||||||
|
postalCode: user.postalCode,
|
||||||
|
city: user.city,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Kinder */}
|
{/* Kinder */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -83,19 +173,17 @@ export default async function ProfilPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{user.childLinks.length === 0 ? (
|
<MyChildrenManager
|
||||||
<p className="text-sm text-muted-foreground">Keine Kinder verknüpft.</p>
|
items={(user.family?.children ?? []).map((child) => ({
|
||||||
) : (
|
id: child.id,
|
||||||
<div className="flex flex-col gap-2">
|
firstName: child.firstName,
|
||||||
{user.childLinks.map((link) => (
|
lastName: child.lastName,
|
||||||
<div key={link.child.id} className="flex items-center justify-between p-2 rounded-md bg-muted/50">
|
dateOfBirth: child.dateOfBirth
|
||||||
<span className="font-medium">
|
? child.dateOfBirth.toISOString().slice(0, 10)
|
||||||
{link.child.firstName} {link.child.lastName}
|
: null,
|
||||||
</span>
|
}))}
|
||||||
</div>
|
canManage={!!user.familyId}
|
||||||
))}
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -124,12 +212,11 @@ export default async function ProfilPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Danger Zone */}
|
|
||||||
<Card className="border-destructive/20 mt-8">
|
<Card className="border-destructive/20 mt-8">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Hier kannst du dein Profil und alle zugehörigen Daten DSGVO-konform löschen.
|
Hier kannst du dein Profil und je nach Haushaltssituation auch die
|
||||||
|
zugehörigen Familiendaten DSGVO-konform löschen.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -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: {
|
select: {
|
||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
email: true,
|
emailVerifiedAt: true,
|
||||||
passwordHash: true,
|
|
||||||
kita: { select: { name: true } },
|
kita: { select: { name: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -57,7 +56,7 @@ export default async function InvitePage({
|
|||||||
if (!user) notFound();
|
if (!user) notFound();
|
||||||
|
|
||||||
const userName = `${user.firstName} ${user.lastName}`;
|
const userName = `${user.firstName} ${user.lastName}`;
|
||||||
const alreadyAccepted = user.passwordHash !== "";
|
const alreadyAccepted = !!user.emailVerifiedAt;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-muted/30">
|
<div className="flex min-h-screen flex-col bg-muted/30">
|
||||||
|
|||||||
+31
-2
@@ -14,8 +14,37 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Kita-Planer",
|
metadataBase: new URL(
|
||||||
description: "Der digitale Kita-Planer für Elterninitiativen",
|
process.env.NEXT_PUBLIC_SITE_URL ?? "https://kita-planer.example.com",
|
||||||
|
),
|
||||||
|
title: {
|
||||||
|
default: "Der digitale Kita-Planer für Elternvereine",
|
||||||
|
template: "%s | Kita-Planer",
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Kita-Planer bündelt Dienste, Termine, Notdienste, Abwesenheiten und sichere Kommunikation für Elternvereine an einem Ort.",
|
||||||
|
openGraph: {
|
||||||
|
title: "Der digitale Kita-Planer für Elternvereine",
|
||||||
|
description:
|
||||||
|
"Organisiert Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.",
|
||||||
|
type: "website",
|
||||||
|
locale: "de_DE",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "/og-image.png",
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: "Ein Kind spielt mit bunten Holzklötzen.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: "Der digitale Kita-Planer für Elternvereine",
|
||||||
|
description:
|
||||||
|
"Organisiert Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.",
|
||||||
|
images: ["/og-image.png"],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const metadata = { title: "Anmelden · Kita-Planer" };
|
|||||||
|
|
||||||
export default async function LoginPage() {
|
export default async function LoginPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session?.user) {
|
if (session?.user?.id) {
|
||||||
redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
|
redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+394
-72
@@ -1,104 +1,426 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { CalendarCheck2, ShieldCheck, Users } from "lucide-react";
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
CalendarDays,
|
||||||
|
CheckCircle2,
|
||||||
|
DatabaseZap,
|
||||||
|
Fingerprint,
|
||||||
|
LockKeyhole,
|
||||||
|
Mail,
|
||||||
|
Megaphone,
|
||||||
|
MessageSquareText,
|
||||||
|
ShieldAlert,
|
||||||
|
ShieldCheck,
|
||||||
|
Stethoscope,
|
||||||
|
Trash2,
|
||||||
|
UsersRound,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { ContactForm } from "./contact-form";
|
||||||
|
|
||||||
// Eingeloggte User von der Landingpage direkt weiterleiten — die Routing-
|
export const metadata: Metadata = {
|
||||||
// Logik selbst (Onboarding vs. Dashboard) übernimmt `requireKitaSession`.
|
title: "Kita-Planer für Elternvereine",
|
||||||
|
description:
|
||||||
|
"Die einfache Plattform für Elternvereine, Dienste und Kommunikation: plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.",
|
||||||
|
openGraph: {
|
||||||
|
title: "Kita-Planer für Elternvereine",
|
||||||
|
description:
|
||||||
|
"Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.",
|
||||||
|
type: "website",
|
||||||
|
locale: "de_DE",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "/og-image.png",
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: "Ein Kind spielt mit bunten Holzklötzen.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: "Kita-Planer für Elternvereine",
|
||||||
|
description:
|
||||||
|
"Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.",
|
||||||
|
images: ["/og-image.png"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const heroImage =
|
||||||
|
"https://images.unsplash.com/photo-1503454537195-1dcabb73ffb9?auto=format&fit=crop&w=2400&q=86";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: ShieldAlert,
|
||||||
|
title: "Fairer Notdienst-Planer",
|
||||||
|
description:
|
||||||
|
"Automatische Generierung, gerechte Verteilung und 1-Klick-Alarmierung, wenn morgens Ersatz gebraucht wird.",
|
||||||
|
accent: "bg-red-50 text-red-700 ring-red-100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: CalendarDays,
|
||||||
|
title: "Smarter Kalender",
|
||||||
|
description:
|
||||||
|
"Feste, Termine, Raumbuchungen und digitale Mitbring-Listen in einem verbindlichen Kalender für alle Familien.",
|
||||||
|
accent: "bg-sky-50 text-sky-700 ring-sky-100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: UsersRound,
|
||||||
|
title: "Sicheres Adressbuch",
|
||||||
|
description:
|
||||||
|
"Haushaltsbasiert, DSGVO-konform und mit strengem Opt-In: Kontaktdaten werden nur sichtbar, wenn Eltern zustimmen.",
|
||||||
|
accent: "bg-emerald-50 text-emerald-700 ring-emerald-100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Stethoscope,
|
||||||
|
title: "Digitale Krankmeldung",
|
||||||
|
description:
|
||||||
|
"Ein Klick statt Dauerläuten am Morgen. Eltern melden Kinder digital ab, ErzieherInnen sehen auf dem Tablet sofort den Überblick für den Morgenkreis.",
|
||||||
|
accent: "bg-amber-50 text-amber-700 ring-amber-100",
|
||||||
|
badge: "Neu",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Megaphone,
|
||||||
|
title: "Schwarzes Brett",
|
||||||
|
description:
|
||||||
|
"Der WhatsApp-Killer für Vorstände: offizielle Ankündigungen mit Dateianhängen, Lesebestätigung und optionalem E-Mail-Push.",
|
||||||
|
accent: "bg-indigo-50 text-indigo-700 ring-indigo-100",
|
||||||
|
badge: "Neu",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const privacyPoints = [
|
||||||
|
{
|
||||||
|
icon: DatabaseZap,
|
||||||
|
title: "Strikt isolierte Mandanten",
|
||||||
|
text: "Jede Kita arbeitet innerhalb ihrer eigenen kitaId-Grenze. Datenabfragen sind konsequent tenant-isoliert.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Fingerprint,
|
||||||
|
title: "Privacy by Design",
|
||||||
|
text: "Adressbuchdaten erscheinen nur nach aktivem Opt-In pro Elternteil. Nicht freigegebene Kontaktdaten verlassen den Server nicht.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Trash2,
|
||||||
|
title: "Recht auf Vergessenwerden",
|
||||||
|
text: "Eltern können ihren Account selbst löschen. Wenn sie der letzte Haushaltspartner sind, werden die Familiendaten sauber entfernt.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: LockKeyhole,
|
||||||
|
title: "Geschützte Anhänge",
|
||||||
|
text: "Dokumente vom Schwarzen Brett liegen nicht öffentlich und werden nur nach Session- und Kita-Prüfung ausgeliefert.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const proofPoints = [
|
||||||
|
"Notdienst, Kalender und Abwesenheiten in einem System",
|
||||||
|
"Gebaut für Vorstände, Eltern und ErzieherInnen",
|
||||||
|
"Klare Rollenrechte statt Chat-Chaos",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Eingeloggte User von der Landingpage direkt weiterleiten.
|
||||||
export default async function LandingPage() {
|
export default async function LandingPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session?.user) {
|
if (session?.user?.id) {
|
||||||
redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
|
redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="min-h-screen bg-[#f8faf8] text-slate-950">
|
||||||
<header className="border-b">
|
<header className="absolute left-0 right-0 top-0 z-20">
|
||||||
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-6">
|
<div className="mx-auto flex h-20 w-full max-w-7xl items-center justify-between px-5 sm:px-6">
|
||||||
<span className="text-lg font-semibold">Kita-Planer</span>
|
<Link href="/" className="flex items-center gap-3 text-white">
|
||||||
<nav className="flex items-center gap-3">
|
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/12 ring-1 ring-white/30">
|
||||||
<Button asChild variant="ghost">
|
<ShieldCheck className="h-5 w-5" />
|
||||||
<Link href="/login">Anmelden</Link>
|
</span>
|
||||||
|
<span className="text-base font-semibold tracking-tight">
|
||||||
|
Kita-Planer
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="ghost"
|
||||||
|
className="text-white hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<Link href="/login">Login</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild>
|
<Button
|
||||||
|
asChild
|
||||||
|
className="bg-white text-slate-950 shadow-sm hover:bg-white/90"
|
||||||
|
>
|
||||||
<Link href="/register">Kita registrieren</Link>
|
<Link href="/register">Kita registrieren</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1">
|
<main>
|
||||||
<section className="mx-auto w-full max-w-6xl px-6 py-24">
|
<section
|
||||||
<div className="max-w-3xl">
|
className="relative min-h-[calc(100svh-56px)] overflow-hidden bg-slate-950"
|
||||||
<p className="mb-4 inline-block rounded-full bg-secondary px-3 py-1 text-xs font-medium text-secondary-foreground">
|
style={{
|
||||||
Für Elterninitiativen & Elternvereine
|
backgroundImage: `linear-gradient(90deg, rgba(2, 6, 23, 0.9), rgba(15, 23, 42, 0.68), rgba(15, 23, 42, 0.18)), url(${heroImage})`,
|
||||||
</p>
|
backgroundPosition: "center",
|
||||||
<h1 className="text-balance text-4xl font-semibold tracking-tight sm:text-5xl">
|
backgroundSize: "cover",
|
||||||
Der digitale Kita-Planer für Elterninitiativen.
|
}}
|
||||||
</h1>
|
>
|
||||||
<p className="mt-6 text-lg text-muted-foreground">
|
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-[#f8faf8] to-transparent" />
|
||||||
Notdienst-Planung, Terminkalender und Stammdaten — endlich
|
|
||||||
an einem Ort. Schluss mit Excel-Tabellen, WhatsApp-Listen und
|
<div className="relative z-10 mx-auto flex min-h-[calc(100svh-56px)] w-full max-w-7xl items-center px-5 pb-20 pt-28 sm:px-6">
|
||||||
vergessenen Geburtstagen.
|
<div className="max-w-4xl text-white">
|
||||||
</p>
|
<Badge className="mb-6 border-white/20 bg-white/12 text-white hover:bg-white/12">
|
||||||
<div className="mt-8 flex flex-wrap gap-3">
|
Kita-Betriebssystem für Elternvereine
|
||||||
<Button asChild size="lg">
|
</Badge>
|
||||||
<Link href="/register">Kita kostenlos registrieren</Link>
|
<h1 className="max-w-4xl text-balance text-5xl font-semibold leading-[1.02] sm:text-6xl lg:text-7xl">
|
||||||
</Button>
|
Die einfache Plattform für Elternvereine, Dienste und
|
||||||
<Button asChild size="lg" variant="outline">
|
Kommunikation.
|
||||||
<Link href="/login">Bereits Mitglied? Anmelden</Link>
|
</h1>
|
||||||
</Button>
|
<p className="mt-6 max-w-2xl text-lg leading-8 text-white/82 sm:text-xl">
|
||||||
|
Organisiert Notdienste, Termine, Krankmeldungen und offizielle
|
||||||
|
Kommunikation an einem Ort. Schluss mit Zettelwirtschaft und
|
||||||
|
WhatsApp-Chaos.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="lg"
|
||||||
|
className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90"
|
||||||
|
>
|
||||||
|
<Link href="/register">
|
||||||
|
Kita registrieren
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
className="h-12 border-white/35 bg-white/10 px-6 text-white hover:bg-white/20 hover:text-white"
|
||||||
|
>
|
||||||
|
<Link href="/login">Login</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 grid max-w-3xl gap-3 text-sm text-white/78 md:grid-cols-3">
|
||||||
|
{proofPoints.map((item) => (
|
||||||
|
<div key={item} className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-300" />
|
||||||
|
<span>{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="mt-20 grid gap-8 sm:grid-cols-3">
|
<section className="mx-auto w-full max-w-7xl px-5 py-24 sm:px-6">
|
||||||
<FeatureCard
|
<div className="mb-12 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
icon={<ShieldCheck className="h-6 w-6" />}
|
<div className="max-w-3xl">
|
||||||
title="Notdienst-Planung"
|
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
|
||||||
description="Verfügbarkeiten erfassen, fairen Plan automatisch generieren, Eltern bei Krankheitsausfall sofort alarmieren."
|
Die 5 Säulen
|
||||||
/>
|
</p>
|
||||||
<FeatureCard
|
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
|
||||||
icon={<CalendarCheck2 className="h-6 w-6" />}
|
Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam
|
||||||
title="Terminkalender"
|
im Blick.
|
||||||
description="Kita-Feste, Schließtage und private Anfragen mit Mitbringsel-Listen — übersichtlich für alle Eltern."
|
</h2>
|
||||||
/>
|
</div>
|
||||||
<FeatureCard
|
<p className="max-w-md text-sm leading-6 text-slate-600">
|
||||||
icon={<Users className="h-6 w-6" />}
|
Kita-Planer bündelt operative Aufgaben, sensible Familiendaten und
|
||||||
title="Eltern-Adressbuch"
|
offizielle Kommunikation in einer Oberfläche, die Vorstände
|
||||||
description="Spielverabredungen leichter machen — auf Opt-In-Basis und DSGVO-konform."
|
kontrollieren und Eltern verstehen.
|
||||||
/>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-6">
|
||||||
|
{features.map((feature, index) => {
|
||||||
|
const Icon = feature.icon;
|
||||||
|
const isWide = index > 2;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={feature.title}
|
||||||
|
className={`group border-slate-200 bg-white/90 shadow-sm transition duration-200 hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg ${
|
||||||
|
isWide ? "xl:col-span-3" : "xl:col-span-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CardHeader className="space-y-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div
|
||||||
|
className={`flex h-11 w-11 items-center justify-center rounded-lg ring-1 ${feature.accent}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
{feature.badge && (
|
||||||
|
<Badge className="bg-slate-950 text-white hover:bg-slate-950">
|
||||||
|
{feature.badge}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl tracking-tight">
|
||||||
|
{feature.title}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-3 text-sm leading-6 text-slate-600">
|
||||||
|
{feature.description}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-slate-950 px-5 py-24 text-white sm:px-6">
|
||||||
|
<div className="mx-auto grid w-full max-w-7xl gap-12 lg:grid-cols-[0.92fr_1.08fr] lg:items-start">
|
||||||
|
<div>
|
||||||
|
<div className="mb-7 flex h-16 w-16 items-center justify-center rounded-lg bg-emerald-400/12 text-emerald-200 ring-1 ring-emerald-300/20">
|
||||||
|
<ShieldCheck className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<Badge className="mb-5 border-emerald-300/20 bg-emerald-300/10 text-emerald-100 hover:bg-emerald-300/10">
|
||||||
|
Das Vorstands-Argument
|
||||||
|
</Badge>
|
||||||
|
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
|
||||||
|
Datenschutz ist nicht die Fußnote. Er ist die Architektur.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-5 max-w-xl text-base leading-7 text-slate-300">
|
||||||
|
Elternvereine verwalten besonders sensible Daten. Deshalb ist
|
||||||
|
Kita-Planer so gebaut, dass Sichtbarkeit, Löschung und
|
||||||
|
Mandantentrennung nicht vom guten Willen einzelner Chats
|
||||||
|
abhängen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{privacyPoints.map((point) => {
|
||||||
|
const Icon = point.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={point.title}
|
||||||
|
className="rounded-lg border border-white/10 bg-white/[0.06] p-5 shadow-sm transition hover:bg-white/[0.09]"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-white/10 text-emerald-100">
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold">{point.title}</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-300">
|
||||||
|
{point.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6">
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-8 shadow-sm sm:p-10">
|
||||||
|
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Bereit für eine Kita-Organisation ohne Nebenkanäle?
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-slate-600">
|
||||||
|
Registriere eure Kita, lade den Vorstand ein und starte mit
|
||||||
|
einem System, das für Elternvereine und Datenschutz gebaut ist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Button asChild size="lg">
|
||||||
|
<Link href="/register">Kita registrieren</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="lg" variant="outline">
|
||||||
|
<Link href="/login">Login</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="kontakt" className="px-5 pb-24 sm:px-6">
|
||||||
|
<div className="mx-auto grid w-full max-w-7xl gap-6 rounded-lg border border-slate-200 bg-[#eef6f1] p-5 shadow-sm lg:grid-cols-[0.9fr_1.1fr] lg:p-8">
|
||||||
|
<div className="flex flex-col justify-between rounded-lg bg-slate-950 p-8 text-white">
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-lg bg-emerald-300/12 text-emerald-200 ring-1 ring-emerald-300/20">
|
||||||
|
<MessageSquareText className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<Badge className="mb-5 border-white/15 bg-white/10 text-white hover:bg-white/10">
|
||||||
|
Kontakt & Demo
|
||||||
|
</Badge>
|
||||||
|
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
|
||||||
|
Ihr wollt sehen, ob Kita-Planer zu eurem Verein passt?
|
||||||
|
</h2>
|
||||||
|
<p className="mt-5 text-sm leading-6 text-slate-300">
|
||||||
|
Schreibt uns kurz, wie ihr aktuell organisiert seid. Wir
|
||||||
|
melden uns mit einer passenden Demo, beantworten
|
||||||
|
Datenschutzfragen oder sprechen über ein individuelles Setup.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-3 text-sm text-slate-200">
|
||||||
|
{[
|
||||||
|
"Demo für Vorstand und ErzieherInnen",
|
||||||
|
"Einordnung eurer bestehenden Abläufe",
|
||||||
|
"Antworten auf Datenschutz- und Hosting-Fragen",
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item} className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-300" />
|
||||||
|
<span>{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-white/70 bg-white shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
|
||||||
|
<Mail className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl tracking-tight">
|
||||||
|
Anfrage senden
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-sm leading-6">
|
||||||
|
Seriös, unverbindlich und ohne Sales-Theater. Wir melden uns
|
||||||
|
in der Regel zeitnah mit einem konkreten Vorschlag.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ContactForm />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="border-t">
|
<footer className="border-t border-slate-200 bg-white">
|
||||||
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-6 text-sm text-muted-foreground">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-5 py-8 text-sm text-slate-500 sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||||
<span>© {new Date().getFullYear()} Kita-Planer</span>
|
<span>© {new Date().getFullYear()} Kita-Planer</span>
|
||||||
<span>Made with ❤️ für Elternvereine</span>
|
<nav className="flex gap-5">
|
||||||
|
<Link href="/impressum" className="hover:text-slate-950">
|
||||||
|
Impressum
|
||||||
|
</Link>
|
||||||
|
<Link href="/datenschutz" className="hover:text-slate-950">
|
||||||
|
Datenschutz
|
||||||
|
</Link>
|
||||||
|
<Link href="#kontakt" className="hover:text-slate-950">
|
||||||
|
Kontakt
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeatureCard({
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-card p-6">
|
|
||||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-md bg-secondary text-secondary-foreground">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">{description}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
+12
-5
@@ -8,7 +8,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
// =====================================================================
|
// =====================================================================
|
||||||
// NextAuth.js (Auth.js v5) · Credentials-Provider mit JWT-Strategie
|
// NextAuth.js (Auth.js v5) · Credentials-Provider mit JWT-Strategie
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Mandantenfähigkeit: `id`, `role`, `kitaId` werden über die JWT-/Session-
|
// Mandantenfähigkeit: `id`, `role`, `kitaId`, `familyId` werden über die JWT-/Session-
|
||||||
// Callbacks aus der DB in jede Session durchgeschleift, damit jede
|
// Callbacks aus der DB in jede Session durchgeschleift, damit jede
|
||||||
// Server Action / API-Route den Tenant-Filter setzen kann.
|
// Server Action / API-Route den Tenant-Filter setzen kann.
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -80,6 +80,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
name: `${user.firstName} ${user.lastName}`.trim(),
|
name: `${user.firstName} ${user.lastName}`.trim(),
|
||||||
role: user.role,
|
role: user.role,
|
||||||
kitaId: user.kitaId,
|
kitaId: user.kitaId,
|
||||||
|
familyId: user.familyId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -97,22 +98,27 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
token.id = user.id;
|
token.id = user.id;
|
||||||
token.role = user.role;
|
token.role = user.role;
|
||||||
token.kitaId = user.kitaId;
|
token.kitaId = user.kitaId;
|
||||||
|
token.familyId = user.familyId;
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token.id) {
|
const tokenUserId = token.id ?? token.sub;
|
||||||
|
if (tokenUserId) {
|
||||||
const fresh = await prisma.user.findUnique({
|
const fresh = await prisma.user.findUnique({
|
||||||
where: { id: token.id },
|
where: { id: tokenUserId },
|
||||||
select: { role: true, kitaId: true },
|
select: { role: true, kitaId: true, familyId: true },
|
||||||
});
|
});
|
||||||
if (!fresh) {
|
if (!fresh) {
|
||||||
// User wurde gelöscht → Token entwerten.
|
// User wurde gelöscht → Token entwerten.
|
||||||
// (Auth.js erkennt den fehlenden `sub`/`id` und meldet ab.)
|
// (Auth.js erkennt den fehlenden `sub`/`id` und meldet ab.)
|
||||||
delete (token as Partial<typeof token>).id;
|
delete (token as Partial<typeof token>).id;
|
||||||
|
delete (token as Partial<typeof token>).sub;
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
token.id = tokenUserId;
|
||||||
token.role = fresh.role;
|
token.role = fresh.role;
|
||||||
token.kitaId = fresh.kitaId;
|
token.kitaId = fresh.kitaId;
|
||||||
|
token.familyId = fresh.familyId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
@@ -125,9 +131,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
*/
|
*/
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
if (token && session.user) {
|
if (token && session.user) {
|
||||||
session.user.id = token.id;
|
session.user.id = token.id ?? token.sub;
|
||||||
session.user.role = token.role;
|
session.user.role = token.role;
|
||||||
session.user.kitaId = token.kitaId;
|
session.user.kitaId = token.kitaId;
|
||||||
|
session.user.familyId = token.familyId;
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { LayoutDashboard, Users, CalendarCheck2, CalendarDays, Contact, GraduationCap, UserCircle } from "lucide-react";
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
CalendarCheck2,
|
||||||
|
CalendarDays,
|
||||||
|
Contact,
|
||||||
|
GraduationCap,
|
||||||
|
UserCircle,
|
||||||
|
ClipboardList,
|
||||||
|
Stethoscope,
|
||||||
|
Megaphone,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -45,7 +56,28 @@ const navItems = [
|
|||||||
label: "Familienverwaltung",
|
label: "Familienverwaltung",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
exact: false,
|
exact: false,
|
||||||
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
|
allowedRoles: [UserRole.ADMIN],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard/admin/dienste",
|
||||||
|
label: "Dienstplan",
|
||||||
|
icon: ClipboardList,
|
||||||
|
exact: false,
|
||||||
|
allowedRoles: [UserRole.ADMIN],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard/admin/news",
|
||||||
|
label: "Schwarzes Brett",
|
||||||
|
icon: Megaphone,
|
||||||
|
exact: false,
|
||||||
|
allowedRoles: [UserRole.ADMIN],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard/admin/abwesenheiten",
|
||||||
|
label: "Abwesenheiten",
|
||||||
|
icon: Stethoscope,
|
||||||
|
exact: false,
|
||||||
|
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ERZIEHER],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/dashboard/erzieher",
|
href: "/dashboard/erzieher",
|
||||||
|
|||||||
@@ -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> {
|
export async function requireSession(): Promise<AuthenticatedSession> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) {
|
if (!session?.user?.id) {
|
||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
return session as AuthenticatedSession;
|
return session as AuthenticatedSession;
|
||||||
@@ -81,7 +81,9 @@ export async function requireRole(
|
|||||||
// interne Helfer
|
// interne Helfer
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
async function consentCheck(userId: string): Promise<boolean> {
|
async function consentCheck(userId: string | undefined): Promise<boolean> {
|
||||||
|
if (!userId) return false;
|
||||||
|
|
||||||
// Lazy-Import, damit `auth-utils` selbst Edge-kompatibel bleibt
|
// Lazy-Import, damit `auth-utils` selbst Edge-kompatibel bleibt
|
||||||
// (Prisma läuft nur in Node-Runtime).
|
// (Prisma läuft nur in Node-Runtime).
|
||||||
const { prisma } = await import("@/lib/prisma");
|
const { prisma } = await import("@/lib/prisma");
|
||||||
|
|||||||
@@ -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 type { ReactNode } from "react";
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
let resendClient: Resend | null = null;
|
||||||
|
|
||||||
type SendAppEmailInput = {
|
type SendAppEmailInput = {
|
||||||
to: string | string[];
|
to: string | string[];
|
||||||
subject: string;
|
subject: string;
|
||||||
react: ReactNode;
|
react: ReactNode;
|
||||||
from?: string;
|
from?: string;
|
||||||
|
replyTo?: string | string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type SendAppEmailResult =
|
type SendAppEmailResult =
|
||||||
@@ -31,6 +32,7 @@ export async function sendAppEmail({
|
|||||||
subject,
|
subject,
|
||||||
react,
|
react,
|
||||||
from = process.env.EMAIL_FROM,
|
from = process.env.EMAIL_FROM,
|
||||||
|
replyTo,
|
||||||
}: SendAppEmailInput): Promise<SendAppEmailResult> {
|
}: SendAppEmailInput): Promise<SendAppEmailResult> {
|
||||||
if (!process.env.RESEND_API_KEY) {
|
if (!process.env.RESEND_API_KEY) {
|
||||||
return {
|
return {
|
||||||
@@ -47,11 +49,13 @@ export async function sendAppEmail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await resend.emails.send({
|
resendClient ??= new Resend(process.env.RESEND_API_KEY);
|
||||||
|
const { data, error } = await resendClient.emails.send({
|
||||||
from: from!,
|
from: from!,
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
react,
|
react,
|
||||||
|
replyTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
+3
-6
@@ -16,7 +16,6 @@ import { auth } from "@/auth";
|
|||||||
// Eingeloggt + auf /login oder /register → /
|
// Eingeloggt + auf /login oder /register → /
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
const PUBLIC_ROUTES = ["/", "/login", "/register", "/datenschutz"];
|
|
||||||
const ONBOARDING_ROUTE = "/onboarding";
|
const ONBOARDING_ROUTE = "/onboarding";
|
||||||
const PROTECTED_PREFIX = ["/dashboard", "/admin", "/onboarding"];
|
const PROTECTED_PREFIX = ["/dashboard", "/admin", "/onboarding"];
|
||||||
|
|
||||||
@@ -27,16 +26,14 @@ export async function proxy(request: NextRequest) {
|
|||||||
// kein DB-Call, pure Token-Verifikation (Edge-sicher).
|
// kein DB-Call, pure Token-Verifikation (Edge-sicher).
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const user = session?.user;
|
const user = session?.user;
|
||||||
|
const hasValidUser = !!user?.id;
|
||||||
|
|
||||||
const isPublicRoute = PUBLIC_ROUTES.some(
|
|
||||||
(route) => pathname === route || pathname.startsWith(route + "/"),
|
|
||||||
);
|
|
||||||
const isProtectedRoute = PROTECTED_PREFIX.some((prefix) =>
|
const isProtectedRoute = PROTECTED_PREFIX.some((prefix) =>
|
||||||
pathname.startsWith(prefix),
|
pathname.startsWith(prefix),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 1. Nicht eingeloggt + geschützte Route ──────────────────────────
|
// ── 1. Nicht eingeloggt + geschützte Route ──────────────────────────
|
||||||
if (!user && isProtectedRoute) {
|
if (!hasValidUser && isProtectedRoute) {
|
||||||
const loginUrl = new URL("/login", request.nextUrl);
|
const loginUrl = new URL("/login", request.nextUrl);
|
||||||
// callbackUrl für spätere Nutzung (optional)
|
// callbackUrl für spätere Nutzung (optional)
|
||||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||||
@@ -44,7 +41,7 @@ export async function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Eingeloggt ───────────────────────────────────────────────────
|
// ── 2. Eingeloggt ───────────────────────────────────────────────────
|
||||||
if (user) {
|
if (hasValidUser && user) {
|
||||||
// 2a. Eingeloggter User auf Login/Register-Seite → Startseite,
|
// 2a. Eingeloggter User auf Login/Register-Seite → Startseite,
|
||||||
// die dann selbst zu /dashboard oder /onboarding redirectet.
|
// die dann selbst zu /dashboard oder /onboarding redirectet.
|
||||||
if (pathname === "/login" || pathname === "/register") {
|
if (pathname === "/login" || pathname === "/register") {
|
||||||
|
|||||||
Vendored
+5
-1
@@ -5,7 +5,8 @@ import type { UserRole } from "@prisma/client";
|
|||||||
// Type-Augmentation für NextAuth (Auth.js v5)
|
// Type-Augmentation für NextAuth (Auth.js v5)
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Erzwingt zur Compile-Zeit, dass `id`, `role` und `kitaId` in
|
// Erzwingt zur Compile-Zeit, dass `id`, `role` und `kitaId` in
|
||||||
// `Session.user` und im JWT verfügbar sind. Damit ist die
|
// `Session.user` und im JWT verfügbar sind. `familyId` transportiert den
|
||||||
|
// optionalen Haushalt fuer Eltern- und Notdienst-Flows. Damit ist die
|
||||||
// Mandanten-Isolation auf Typ-Ebene abgesichert: Jede Server Action /
|
// Mandanten-Isolation auf Typ-Ebene abgesichert: Jede Server Action /
|
||||||
// Server Component, die `session.user.kitaId` nutzt, scheitert beim
|
// Server Component, die `session.user.kitaId` nutzt, scheitert beim
|
||||||
// Build, falls die Eigenschaft je entfernt wird.
|
// Build, falls die Eigenschaft je entfernt wird.
|
||||||
@@ -16,6 +17,7 @@ declare module "next-auth" {
|
|||||||
id: string;
|
id: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
kitaId: string | null;
|
kitaId: string | null;
|
||||||
|
familyId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
@@ -23,6 +25,7 @@ declare module "next-auth" {
|
|||||||
id: string;
|
id: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
kitaId: string | null;
|
kitaId: string | null;
|
||||||
|
familyId: string | null;
|
||||||
} & DefaultSession["user"];
|
} & DefaultSession["user"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,5 +38,6 @@ declare module "@auth/core/jwt" {
|
|||||||
id: string;
|
id: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
kitaId: string | null;
|
kitaId: string | null;
|
||||||
|
familyId: string | null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Reference in New Issue
Block a user