Compare commits

...

11 Commits

Author SHA1 Message Date
t.indorf 5dca7c76f8 testplan 2026-05-17 23:55:35 +02:00
t.indorf 211bb9e300 Add Codex branch workflow rule 2026-05-17 23:14:06 +02:00
t.indorf e8b62f6008 Tighten landing page positioning 2026-05-17 20:43:26 +02:00
t.indorf 380c67963b Add superadmin audit and Kita management 2026-05-16 12:02:59 +02:00
t.indorf 0b80c62beb Add contact email confirmation and abuse protection 2026-05-16 11:38:30 +02:00
t.indorf 9542def530 Add RSVP events and Unsplash image support 2026-05-15 21:28:36 +02:00
t.indorf ed6786b6ea added missing packages 2026-05-11 16:29:32 +02:00
t.indorf 89cf1dfe84 added more page content 2026-05-11 16:06:33 +02:00
t.indorf 70a50d89ab added robots udn sitemap 2026-05-11 12:23:43 +02:00
t.indorf 928ddac5b3 added postgres to the stack 2026-05-11 11:19:24 +02:00
t.indorf b0cf3adce6 removed the coolify network 2026-05-11 11:09:27 +02:00
43 changed files with 4887 additions and 143 deletions
+6
View File
@@ -3,3 +3,9 @@
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules --> <!-- END:nextjs-agent-rules -->
<!-- BEGIN:codex-workflow-rules -->
# Codex workflow
Before making repository changes, Codex should work from a dedicated branch in the Codex worktree. Prefer a `codex/...` branch name when Git refs allow it; if an existing ref blocks that namespace, use a `codex-...` branch name instead.
<!-- END:codex-workflow-rules -->
+8 -4
View File
@@ -6,10 +6,14 @@
# Keine echten Secrets committen. # Keine echten Secrets committen.
# ===================================================================== # =====================================================================
# PostgreSQL # PostgreSQL im Coolify-Compose-Stack
# In Coolify am besten die interne URL deiner bestehenden Postgres-DB verwenden. POSTGRES_DB="kita_planer"
# Beispiel: postgresql://USER:PASSWORD@HOST:5432/kita_planer?schema=public POSTGRES_USER="kita_planer"
DATABASE_URL="postgresql://[USER]:[PASSWORD]@[HOST]:[PORT]/[DATABASE]?schema=public" POSTGRES_PASSWORD="[GENERATE_ME]"
# pgAdmin im Coolify-Compose-Stack
PGADMIN_DEFAULT_EMAIL="[admin@deine-domain.de]"
PGADMIN_DEFAULT_PASSWORD="[GENERATE_ME]"
# Auth.js / NextAuth # Auth.js / NextAuth
# Generieren mit: openssl rand -base64 32 # Generieren mit: openssl rand -base64 32
+38 -9
View File
@@ -1,4 +1,19 @@
services: services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-kita_planer}
POSTGRES_USER: ${POSTGRES_USER:-kita_planer}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
volumes:
- kita_planer_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
app: app:
build: build:
context: . context: .
@@ -12,7 +27,7 @@ services:
NODE_ENV: production NODE_ENV: production
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
DATABASE_URL: ${DATABASE_URL:?} DATABASE_URL: postgresql://${POSTGRES_USER:-kita_planer}:${POSTGRES_PASSWORD:?}@postgres:5432/${POSTGRES_DB:-kita_planer}?schema=public
AUTH_SECRET: ${AUTH_SECRET:?} AUTH_SECRET: ${AUTH_SECRET:?}
AUTH_URL: ${AUTH_URL:?} AUTH_URL: ${AUTH_URL:?}
NEXTAUTH_URL: ${NEXTAUTH_URL:?} NEXTAUTH_URL: ${NEXTAUTH_URL:?}
@@ -26,16 +41,30 @@ services:
UPLOAD_DIR: /app/uploads/announcements UPLOAD_DIR: /app/uploads/announcements
volumes: volumes:
- kita_planer_uploads:/app/uploads - kita_planer_uploads:/app/uploads
networks: depends_on:
- default postgres:
- coolify condition: service_healthy
ports: ports:
- "3000" - "3000"
restart: unless-stopped restart: unless-stopped
volumes: pgadmin:
kita_planer_uploads: image: dpage/pgadmin4:latest
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:?}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:?}
PGADMIN_CONFIG_ENHANCED_COOKIE_PROTECTION: "True"
PGADMIN_CONFIG_CONSOLE_LOG_LEVEL: 30
volumes:
- kita_planer_pgadmin_data:/var/lib/pgadmin
depends_on:
postgres:
condition: service_healthy
ports:
- "80"
restart: unless-stopped
networks: volumes:
coolify: kita_planer_postgres_data:
external: true kita_planer_uploads:
kita_planer_pgadmin_data:
+85
View File
@@ -0,0 +1,85 @@
# Live-Testplan mein-kitaplaner.de
## Kopfbereich
- Zielsystem: `https://mein-kitaplaner.de`
- Testdatum: `2026-05-17`
- Strategie: echte Testdaten auf Produktion verwenden und anschließend bereinigen
- Testkennzeichnung: alle Testnamen und Test-E-Mails mit `Codex Live-Test` kennzeichnen
- Statuslegende:
- `[ ]` offen
- `[x]` erledigt
- `[!]` Blocker / Beobachtung
## Testdaten
- Gründer/Admin: `hanivan790@acanok.com`
- Test-Kita: `Codex Live-Test Kita-Verein 20260517-2343`
- Elternteil: `kitatester1233@mailaddress.de`
- Passwort: `<TEST_PASSWORD>`
Keine echten Passwörter, Secrets, Session-Werte, Tokens oder Invite-Links in diese Datei schreiben.
## Core Journey
- [x] Landingpage erreichbar.
- Erwartung: `https://mein-kitaplaner.de` lädt mit aktuellem Landingpage-Content.
- [x] Demo-CTA führt zum Kontaktformular.
- Erwartung: CTA öffnet `/#kontakt` und das Formular ist sichtbar.
- [x] Kontaktformular versendet Bestätigungsmail.
- Bestätigt: Testmail an `nolip18234@acanok.com` ist angekommen.
- [x] Registrierung: neuer Gründer/Admin kann Account erstellen.
- Testdaten: eindeutige Admin-Testmail und `<TEST_PASSWORD>`.
- Erwartung: Account wird erstellt, keine Fehlermeldung.
- [x] Redirect nach Registrierung führt zu `/onboarding`.
- Erwartung: Gründer landet nach Registrierung direkt im Onboarding.
- [x] Onboarding: Kita wird mit Standardmodulen angelegt.
- Testdaten: `Codex Live-Test Kita-Verein YYYYMMDD-HHMM`.
- Erwartung: Notdienst, Kalender und Adressbuch bleiben standardmäßig aktiv.
- [x] Redirect nach Onboarding führt zu `/dashboard`.
- Erwartung: Dashboard lädt ohne erneute Anmeldung.
- [x] Dashboard zeigt Kita-Namen und Admin-Navigation.
- Erwartung: Sidebar zeigt Test-Kita sowie Admin-Menüpunkte wie Familienverwaltung, Dienstplan, Schwarzes Brett und ErzieherInnen.
- [x] Familienverwaltung: Familie mit Kind und Elternteil anlegen.
- Testdaten: Familie, Elternteil und Kind mit `Codex Live-Test` kennzeichnen.
- Erwartung: Familie erscheint in der Liste.
- [ ] Einladung wird per E-Mail versendet.
- Erwartung: Eltern-Testadresse erhält eine Einladung.
- Erst als erledigt markieren, wenn die Mail im Postfach bestätigt wurde.
- [ ] Invite-Link öffnet Aktivierungsseite.
- Erwartung: Seite `/invite/[token]` zeigt personalisierte Einladung und Kita-Namen.
- Invite-Link nicht in diese Datei schreiben.
- [ ] Invite-Validierung: Passwort-Pflicht, Passwort-Mismatch, Datenschutz-Pflicht.
- Erwartung: fehlende oder unterschiedliche Passwörter und fehlende Datenschutzerklärung erzeugen sichtbare Fehler.
- [ ] Elternteil kann Einladung annehmen und wird eingeloggt.
- Erwartung: Passwort setzen, Datenschutzerklärung akzeptieren, optional Adressbuch-Opt-in wählen, danach Redirect zu `/dashboard`.
- [ ] Elternrolle sieht nur erlaubte Navigation.
- Erwartung: Eltern sehen keine Admin-only Punkte wie Familienverwaltung, Dienstplan, Schwarzes Brett-Admin, ErzieherInnen.
- [ ] Logout/Login für Admin und Elternteil funktionieren.
- Erwartung: beide Testaccounts können sich abmelden und mit `<TEST_PASSWORD>` erneut anmelden.
- [x] Zugriffsschutz: `/dashboard` ohne Session leitet zu `/login`.
- Erwartung: ausgeloggter Aufruf von `/dashboard` führt zu `/login?callbackUrl=%2Fdashboard` oder entsprechender Login-Route.
- [ ] Cleanup: Test-Kita/User/Familie über Superadmin löschen.
- Erwartung: Test-Kita wird über Superadmin-Diagnose mit exakter Namensbestätigung gelöscht.
- Erst als erledigt markieren, wenn Superadmin-Ansicht keine Test-Kita/Test-User mehr zeigt.
- [ ] Nach Cleanup: Test-Login funktioniert nicht mehr / Daten nicht sichtbar.
- Erwartung: Admin- und Eltern-Testlogin schlagen fehl; Test-Kita erscheint nicht mehr.
## Beobachtungen
- [x] `2026-05-17`: Kontaktformular auf Produktion getestet. Bestätigungsmail an `nolip18234@acanok.com` ist angekommen.
- [x] `2026-05-17`: Registrierung mit `hanivan790@acanok.com`, Redirect nach `/onboarding`, Onboarding mit `Codex Live-Test Kita-Verein 20260517-2343` und Dashboard/Admin-Navigation erfolgreich geprüft.
- [!] `2026-05-17`: Familienanlage mit Elternadresse `nolip18234@acanok.com` blockiert, weil die E-Mail-Adresse bereits existiert. Mit `kitatester1233@mailaddress.de` erfolgreich fortgesetzt.
- [x] `2026-05-17`: Admin-Logout, Zugriffsschutz für `/dashboard` ohne Session und Admin-Login mit temporärem Passwort erfolgreich geprüft. Kombinierter Login-Check bleibt offen, bis ein Elternaccount aktiviert werden kann.
- [x] `2026-05-17`: Familie `Codex Live-Test Familie 20260517-2343` mit Kind und Elternteil `kitatester1233@mailaddress.de` erfolgreich angelegt; Status in der Familienverwaltung: `Einladung offen`.
- [ ] `2026-05-17`: Im Familien-Details-Dialog ist kein Invite-Link sichtbar; Fortsetzung der Invite-Tests benötigt Mailbestätigung und Invite-Link aus dem Postfach.
- [ ] Mailzeit Einladung Elternteil: offen.
- [ ] Cleanup-Ergebnis: offen.
## Pflege-Regeln
- Erledigte Schritte während des Tests direkt von `[ ]` auf `[x]` setzen.
- Blocker mit `[!]` markieren und kurz im Abschnitt Beobachtungen beschreiben.
- Mailversand nur als erledigt markieren, wenn die Mail im jeweiligen Postfach bestätigt wurde.
- Keine echten Passwörter, Secrets, Tokens oder vollständigen Invite-Links dokumentieren.
- Cleanup erst als erledigt markieren, wenn die Testdaten in der Superadmin-Ansicht nicht mehr sichtbar sind.
+9 -1
View File
@@ -1,7 +1,15 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ images: {
remotePatterns: [
{
protocol: "https",
hostname: "images.unsplash.com",
pathname: "/**",
},
],
},
}; };
export default nextConfig; export default nextConfig;
+19
View File
@@ -19,6 +19,7 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@react-email/components": "^1.0.12", "@react-email/components": "^1.0.12",
"@react-email/render": "^2.0.8",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -2973,6 +2974,24 @@
"react": "^18.0 || ^19.0 || ^19.0.0-rc" "react": "^18.0 || ^19.0 || ^19.0.0-rc"
} }
}, },
"node_modules/@react-email/render": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.8.tgz",
"integrity": "sha512-5udvVr3U/WuGJZfLdLBOhkzrqRWd2Q5ZYmF7ppcy7FzWcwgshdqLMNqJOXcVzAXJXg/2bm7D+WGJzTtZOZMQnQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"html-to-text": "^9.0.5",
"prettier": "^3.5.3"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": "^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
}
},
"node_modules/@react-email/row": { "node_modules/@react-email/row": {
"version": "0.0.13", "version": "0.0.13",
"resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.13.tgz", "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.13.tgz",
+1
View File
@@ -30,6 +30,7 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@react-email/components": "^1.0.12", "@react-email/components": "^1.0.12",
"@react-email/render": "^2.0.8",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+71
View File
@@ -63,6 +63,16 @@ enum TerminStatus {
CANCELLED CANCELLED
} }
enum EventParticipationStatus {
ATTENDING
NOT_ATTENDING
}
enum AuditTargetType {
KITA
USER
}
enum NotdienstPlanStatus { enum NotdienstPlanStatus {
DRAFT DRAFT
PUBLISHED PUBLISHED
@@ -121,6 +131,7 @@ model Kita {
invitations Invitation[] invitations Invitation[]
termine Termin[] termine Termin[]
mitbringselItems MitbringselItem[] mitbringselItems MitbringselItem[]
eventParticipations EventParticipation[]
notdienstPlans NotdienstPlan[] notdienstPlans NotdienstPlan[]
notdienstAvailabilities NotdienstAvailability[] notdienstAvailabilities NotdienstAvailability[]
notdienstAssignments NotdienstAssignment[] notdienstAssignments NotdienstAssignment[]
@@ -216,6 +227,7 @@ model User {
mitbringselItems MitbringselItem[] mitbringselItems MitbringselItem[]
invitationsCreated Invitation[] @relation("InvitationCreator") invitationsCreated Invitation[] @relation("InvitationCreator")
auditLogsAuthored AuditLog[] @relation("AuditLogActor")
@@index([kitaId]) @@index([kitaId])
@@index([familyId]) @@index([familyId])
@@ -248,6 +260,7 @@ model Child {
notdienstAvailabilities NotdienstAvailability[] notdienstAvailabilities NotdienstAvailability[]
notdienstAssignments NotdienstAssignment[] notdienstAssignments NotdienstAssignment[]
absences Absence[] absences Absence[]
eventParticipations EventParticipation[]
@@index([kitaId]) @@index([kitaId])
@@index([familyId]) @@index([familyId])
@@ -490,6 +503,11 @@ model Termin {
/// Mitbringliste pro Termin aktivierbar (Modul 2, "Flexible Mitbring-Listen") /// Mitbringliste pro Termin aktivierbar (Modul 2, "Flexible Mitbring-Listen")
mitbringselListEnabled Boolean @default(false) mitbringselListEnabled Boolean @default(false)
/// Teilnahme-Abfrage für Ausflüge und besondere Events.
requiresRsvp Boolean @default(false)
rsvpDeadline DateTime?
participantInfo String?
createdById String? createdById String?
createdBy User? @relation("TerminCreator", fields: [createdById], references: [id], onDelete: SetNull) createdBy User? @relation("TerminCreator", fields: [createdById], references: [id], onDelete: SetNull)
@@ -502,12 +520,35 @@ model Termin {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
mitbringselItems MitbringselItem[] mitbringselItems MitbringselItem[]
participations EventParticipation[]
@@index([kitaId, startDate]) @@index([kitaId, startDate])
@@index([kitaId, status]) @@index([kitaId, status])
@@map("termine") @@map("termine")
} }
model EventParticipation {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
eventId String
event Termin @relation(fields: [eventId], references: [id], onDelete: Cascade)
childId String
child Child @relation(fields: [childId], references: [id], onDelete: Cascade)
status EventParticipationStatus
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([eventId, childId])
@@index([kitaId])
@@index([childId])
@@map("event_participations")
}
model MitbringselItem { model MitbringselItem {
id String @id @default(cuid()) id String @id @default(cuid())
kitaId String kitaId String
@@ -652,6 +693,36 @@ model NotdienstAlert {
@@map("notdienst_alerts") @@map("notdienst_alerts")
} }
// =====================================================================
// AUDIT LOGS (Systemweite Superadmin-Aktionen)
// =====================================================================
/// Persistente Spur fuer Superadmin-Support-Aktionen.
/// Zielobjekte werden bewusst nur als Snapshot gespeichert, damit Logs auch
/// nach einer Kita- oder Benutzerloeschung erhalten bleiben.
model AuditLog {
id String @id @default(cuid())
actorUserId String?
actorUser User? @relation("AuditLogActor", fields: [actorUserId], references: [id], onDelete: SetNull)
actorEmail String
action String
targetType AuditTargetType
targetId String
targetLabel String
targetKitaId String?
metadata Json?
createdAt DateTime @default(now())
@@index([actorUserId])
@@index([targetType, targetId])
@@index([targetKitaId])
@@index([createdAt])
@@map("audit_logs")
}
// ===================================================================== // =====================================================================
// AUTH-HILFSTABELLE // AUTH-HILFSTABELLE
// ===================================================================== // =====================================================================
+55 -7
View File
@@ -1,20 +1,52 @@
"use server"; "use server";
import { createElement } from "react"; import { createElement } from "react";
import { headers } from "next/headers";
import { ContactConfirmationEmail } from "@/emails/ContactConfirmationEmail";
import { ContactRequestEmail } from "@/emails/ContactRequestEmail"; import { ContactRequestEmail } from "@/emails/ContactRequestEmail";
import { import {
contactRequestSchema, contactRequestSchema,
contactRequestTypeLabels, contactRequestTypeLabels,
type ContactRequestInput, type ContactSubmissionPayload,
} from "@/lib/contact-schema"; } from "@/lib/contact-schema";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { rateLimit } from "@/lib/rate-limit";
const DEFAULT_ADMIN_EMAIL = "kontakt@kita-planer.local"; const DEFAULT_ADMIN_EMAIL = "kontakt@kita-planer.local";
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_SECONDS = 60 * 60;
async function resolveClientIp(): Promise<string> {
const h = await headers();
const forwarded = h.get("x-forwarded-for");
if (forwarded) {
return forwarded.split(",")[0]?.trim() || "unknown";
}
return h.get("x-real-ip") ?? "unknown";
}
export async function submitContactRequest(data: ContactSubmissionPayload) {
// Honeypot: silently absorb bot submissions without informing them.
if (data.website && data.website.trim().length > 0) {
return { success: true };
}
const ip = await resolveClientIp();
const limit = rateLimit(
`contact:${ip}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_SECONDS,
);
if (!limit.allowed) {
const minutes = Math.max(1, Math.ceil(limit.retryAfterSeconds / 60));
return {
success: false,
error: `Zu viele Anfragen. Bitte versuche es in ${minutes} Minuten erneut.`,
};
}
export async function submitContactRequest(data: ContactRequestInput) {
const parsed = contactRequestSchema.safeParse(data); const parsed = contactRequestSchema.safeParse(data);
if (!parsed.success) { if (!parsed.success) {
return { return {
success: false, success: false,
@@ -32,19 +64,35 @@ export async function submitContactRequest(data: ContactRequestInput) {
const adminEmail = process.env.ADMIN_EMAIL || DEFAULT_ADMIN_EMAIL; const adminEmail = process.env.ADMIN_EMAIL || DEFAULT_ADMIN_EMAIL;
const inquiryLabel = contactRequestTypeLabels[parsed.data.requestType]; const inquiryLabel = contactRequestTypeLabels[parsed.data.requestType];
const result = await sendAppEmail({
const [adminResult, confirmationResult] = await Promise.all([
sendAppEmail({
to: adminEmail, to: adminEmail,
subject: `Kontaktanfrage: ${inquiryLabel} von ${parsed.data.name}`, subject: `Kontaktanfrage: ${inquiryLabel} von ${parsed.data.name}`,
replyTo: parsed.data.email, replyTo: parsed.data.email,
react: createElement(ContactRequestEmail, parsed.data), react: createElement(ContactRequestEmail, parsed.data),
}); }),
sendAppEmail({
to: parsed.data.email,
subject: "Wir haben deine Anfrage erhalten — Kita-Planer",
replyTo: adminEmail,
react: createElement(ContactConfirmationEmail, parsed.data),
}),
]);
if (!result.success) { if (!adminResult.success) {
return { return {
success: false, success: false,
error: result.error, error: adminResult.error,
}; };
} }
if (!confirmationResult.success) {
console.error(
"Bestätigungs-E-Mail konnte nicht zugestellt werden:",
confirmationResult.error,
);
}
return { success: true }; return { success: true };
} }
+156
View File
@@ -0,0 +1,156 @@
"use server";
import { revalidatePath } from "next/cache";
import {
EventParticipationStatus,
TerminStatus,
UserRole,
} from "@prisma/client";
import { z } from "zod";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
const submitRsvpSchema = z.object({
eventId: z.string().min(1),
childId: z.string().min(1),
status: z.nativeEnum(EventParticipationStatus),
});
export type EventParticipantDto = {
childId: string;
childName: string;
familyName: string;
status: EventParticipationStatus | null;
};
export async function submitEventRsvp(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = submitRsvpSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
}
const { eventId, childId, status } = parsed.data;
const event = await prisma.termin.findFirst({
where: {
id: eventId,
kitaId: session.user.kitaId,
},
select: {
id: true,
status: true,
requiresRsvp: true,
rsvpDeadline: true,
startDate: true,
},
});
if (!event || !event.requiresRsvp || event.status !== TerminStatus.CONFIRMED) {
return { error: "Für diesen Termin ist keine Rückmeldung aktiv." };
}
const deadline = event.rsvpDeadline ?? event.startDate;
if (deadline < new Date()) {
return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
}
const child = await prisma.child.findFirst({
where: {
id: childId,
kitaId: session.user.kitaId,
familyId: session.user.familyId,
active: true,
},
select: { id: true },
});
if (!child) {
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
}
await prisma.eventParticipation.upsert({
where: {
eventId_childId: {
eventId,
childId,
},
},
create: {
kitaId: session.user.kitaId,
eventId,
childId,
status,
},
update: {
status,
},
});
revalidatePath("/dashboard");
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true };
}
export async function getEventParticipants(
eventId: string,
): Promise<{ participants?: EventParticipantDto[]; error?: string }> {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const event = await prisma.termin.findFirst({
where: { id: eventId, kitaId },
select: { id: true },
});
if (!event) {
return { error: "Termin wurde nicht gefunden." };
}
const children = await prisma.child.findMany({
where: {
kitaId,
active: true,
},
select: {
id: true,
firstName: true,
lastName: true,
family: {
select: {
name: true,
},
},
eventParticipations: {
where: { eventId },
select: { status: true },
take: 1,
},
},
orderBy: [
{ family: { name: "asc" } },
{ lastName: "asc" },
{ firstName: "asc" },
],
});
return {
participants: children.map((child) => ({
childId: child.id,
childName: `${child.firstName} ${child.lastName}`,
familyName: child.family.name,
status: child.eventParticipations[0]?.status ?? null,
})),
};
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Activity, Building2, ScrollText } from "lucide-react";
import { cn } from "@/lib/utils";
const items = [
{
href: "/admin",
label: "Kitas",
icon: Building2,
exact: true,
},
{
href: "/admin/audit",
label: "AuditLog",
icon: ScrollText,
exact: false,
},
];
export function AdminNav() {
const pathname = usePathname();
return (
<nav className="flex flex-col gap-1 px-3">
{items.map(({ href, label, icon: Icon, exact }) => {
const isActive = exact ? pathname === href : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
);
})}
<div className="mt-3 border-t px-3 pt-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Diagnose
</div>
<div className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground">
<Activity className="h-4 w-4 shrink-0" />
Read-only Support
</div>
</nav>
);
}
@@ -0,0 +1,129 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { AlertTriangle, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { deleteKita } from "../actions";
type DeleteCounts = {
users: number;
families: number;
activeChildren: number;
termine: number;
absences: number;
announcements: number;
};
export function KitaDeleteDialog({
kitaId,
kitaName,
counts,
}: {
kitaId: string;
kitaName: string;
counts: DeleteCounts;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [confirmationName, setConfirmationName] = useState("");
const [isPending, startTransition] = useTransition();
const canSubmit = confirmationName === kitaName && !isPending;
function onSubmit() {
startTransition(async () => {
const result = await deleteKita({ kitaId, confirmationName });
if (!result.ok) {
toast.error(result.error ?? "Kita konnte nicht gelöscht werden.");
return;
}
toast.success("Kita wurde gelöscht.");
setOpen(false);
router.push(result.redirectTo ?? "/admin");
router.refresh();
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2 className="h-4 w-4" />
Kita löschen
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Kita endgültig löschen</DialogTitle>
<DialogDescription>
Diese Aktion löscht alle mandantengebundenen Daten dieser Kita
unwiderruflich. Das AuditLog bleibt erhalten.
</DialogDescription>
</DialogHeader>
<div className="rounded-md border border-destructive/25 bg-destructive/5 p-4">
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-destructive">
<AlertTriangle className="h-4 w-4" />
Betroffene Daten
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<span>Benutzer: {counts.users}</span>
<span>Familien: {counts.families}</span>
<span>Aktive Kinder: {counts.activeChildren}</span>
<span>Termine: {counts.termine}</span>
<span>Abwesenheiten: {counts.absences}</span>
<span>News: {counts.announcements}</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`confirm-kita-${kitaId}`}>
Zur Bestätigung Kita-Namen eingeben
</Label>
<Input
id={`confirm-kita-${kitaId}`}
value={confirmationName}
onChange={(event) => setConfirmationName(event.target.value)}
placeholder={kitaName}
autoComplete="off"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button
type="button"
variant="destructive"
onClick={onSubmit}
disabled={!canSubmit}
>
{isPending ? "Lösche..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,211 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Lock, RotateCcw, Trash2, Unlock } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
deleteUser,
lockUser,
resetFailedLoginAttempts,
unlockUser,
} from "../actions";
type UserActionResult = {
ok: boolean;
error?: string;
};
async function runSupportAction(
action: () => Promise<UserActionResult>,
successMessage: string,
router: ReturnType<typeof useRouter>,
) {
const result = await action();
if (!result.ok) {
toast.error(result.error ?? "Aktion konnte nicht ausgeführt werden.");
return;
}
toast.success(successMessage);
router.refresh();
}
export function UserSupportActions({
user,
}: {
user: {
id: string;
email: string;
name: string;
isLocked: boolean;
failedLoginAttempts: number;
};
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
return (
<div className="flex flex-wrap justify-end gap-1">
{user.isLocked ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() =>
startTransition(() =>
runSupportAction(
() => unlockUser(user.id),
"Benutzer wurde entsperrt.",
router,
),
)
}
>
<Unlock className="h-4 w-4" />
Entsperren
</Button>
) : (
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() =>
startTransition(() =>
runSupportAction(
() => lockUser(user.id),
"Benutzer wurde gesperrt.",
router,
),
)
}
>
<Lock className="h-4 w-4" />
Sperren
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending || user.failedLoginAttempts === 0}
onClick={() =>
startTransition(() =>
runSupportAction(
() => resetFailedLoginAttempts(user.id),
"Fehlversuche wurden zurückgesetzt.",
router,
),
)
}
>
<RotateCcw className="h-4 w-4" />
Reset
</Button>
<UserDeleteDialog user={user} />
</div>
);
}
function UserDeleteDialog({
user,
}: {
user: {
id: string;
email: string;
name: string;
};
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [confirmationEmail, setConfirmationEmail] = useState("");
const [isPending, startTransition] = useTransition();
const canSubmit = confirmationEmail === user.email && !isPending;
function onSubmit() {
startTransition(async () => {
const result = await deleteUser({
userId: user.id,
confirmationEmail,
});
if (!result.ok) {
toast.error(result.error ?? "Benutzer konnte nicht gelöscht werden.");
return;
}
toast.success("Benutzer wurde gelöscht.");
setOpen(false);
router.refresh();
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" variant="destructive" size="sm">
<Trash2 className="h-4 w-4" />
Löschen
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Benutzer endgültig löschen</DialogTitle>
<DialogDescription>
Löscht nur das Benutzerkonto von {user.name}. Familie und Kinder
bleiben erhalten.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor={`confirm-user-${user.id}`}>
Zur Bestätigung E-Mail-Adresse eingeben
</Label>
<Input
id={`confirm-user-${user.id}`}
value={confirmationEmail}
onChange={(event) => setConfirmationEmail(event.target.value)}
placeholder={user.email}
autoComplete="off"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button
type="button"
variant="destructive"
onClick={onSubmit}
disabled={!canSubmit}
>
{isPending ? "Lösche..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+438
View File
@@ -0,0 +1,438 @@
import "server-only";
import { AuditTargetType, InvitationStatus, UserRole } from "@prisma/client";
import { prisma } from "@/lib/prisma";
const roleOrder: UserRole[] = [
UserRole.ADMIN,
UserRole.KOORDINATOR,
UserRole.ERZIEHER,
UserRole.ELTERN,
];
export type KitaOverviewRow = {
id: string;
name: string;
slug: string;
createdAt: Date;
updatedAt: Date;
modules: {
notdienst: boolean;
termine: boolean;
adressbuch: boolean;
};
counts: {
users: number;
families: number;
activeChildren: number;
termine: number;
absences: number;
announcements: number;
openInvitations: number;
lockedUsers: number;
missingConsent: number;
familiesWithoutUsers: number;
};
lastLoginAt: Date | null;
roleCounts: Record<UserRole, number>;
};
export type GlobalAdminHealth = {
orphanTenantUsers: number;
lockedTenantUsers: number;
openInvitations: number;
missingConsent: number;
kitas: number;
};
export async function getGlobalAdminHealth(): Promise<GlobalAdminHealth> {
const now = new Date();
const [
orphanTenantUsers,
lockedTenantUsers,
openInvitations,
missingConsent,
kitas,
] = await Promise.all([
prisma.user.count({
where: {
kitaId: null,
role: { not: UserRole.SUPERADMIN },
},
}),
prisma.user.count({
where: {
role: { not: UserRole.SUPERADMIN },
lockedUntil: { gt: now },
},
}),
prisma.invitation.count({
where: {
status: InvitationStatus.PENDING,
expiresAt: { gt: now },
},
}),
prisma.user.count({
where: {
role: { not: UserRole.SUPERADMIN },
privacyPolicyAcceptedAt: null,
},
}),
prisma.kita.count(),
]);
return {
orphanTenantUsers,
lockedTenantUsers,
openInvitations,
missingConsent,
kitas,
};
}
export async function getKitaOverviewRows(): Promise<KitaOverviewRow[]> {
const now = new Date();
const kitas = await prisma.kita.findMany({
select: {
id: true,
name: true,
slug: true,
notdienstModuleEnabled: true,
terminModuleEnabled: true,
adressbuchModuleEnabled: true,
createdAt: true,
updatedAt: true,
users: {
select: {
role: true,
lastLoginAt: true,
lockedUntil: true,
privacyPolicyAcceptedAt: true,
},
},
families: {
select: {
users: {
select: { id: true },
},
},
},
children: {
select: { active: true },
},
invitations: {
select: {
status: true,
expiresAt: true,
},
},
_count: {
select: {
termine: true,
absences: true,
announcements: true,
},
},
},
orderBy: { name: "asc" },
});
return kitas.map((kita) => {
const roleCounts = Object.fromEntries(
Object.values(UserRole).map((role) => [role, 0]),
) as Record<UserRole, number>;
let lastLoginAt: Date | null = null;
let lockedUsers = 0;
let missingConsent = 0;
for (const user of kita.users) {
roleCounts[user.role] += 1;
if (user.lastLoginAt && (!lastLoginAt || user.lastLoginAt > lastLoginAt)) {
lastLoginAt = user.lastLoginAt;
}
if (user.lockedUntil && user.lockedUntil > now) {
lockedUsers += 1;
}
if (user.role !== UserRole.SUPERADMIN && !user.privacyPolicyAcceptedAt) {
missingConsent += 1;
}
}
return {
id: kita.id,
name: kita.name,
slug: kita.slug,
createdAt: kita.createdAt,
updatedAt: kita.updatedAt,
modules: {
notdienst: kita.notdienstModuleEnabled,
termine: kita.terminModuleEnabled,
adressbuch: kita.adressbuchModuleEnabled,
},
counts: {
users: kita.users.length,
families: kita.families.length,
activeChildren: kita.children.filter((child) => child.active).length,
termine: kita._count.termine,
absences: kita._count.absences,
announcements: kita._count.announcements,
openInvitations: kita.invitations.filter(
(invitation) =>
invitation.status === InvitationStatus.PENDING &&
invitation.expiresAt > now,
).length,
lockedUsers,
missingConsent,
familiesWithoutUsers: kita.families.filter(
(family) => family.users.length === 0,
).length,
},
lastLoginAt,
roleCounts,
};
});
}
export type KitaDiagnostics = {
kita: {
id: string;
name: string;
slug: string;
createdAt: Date;
updatedAt: Date;
modules: KitaOverviewRow["modules"];
};
counts: KitaOverviewRow["counts"] & {
inactiveChildren: number;
expiredPendingInvitations: number;
};
roleCounts: Record<UserRole, number>;
users: {
id: string;
email: string;
firstName: string;
lastName: string;
role: UserRole;
familyName: string | null;
emailVerifiedAt: Date | null;
privacyPolicyAcceptedAt: Date | null;
lastLoginAt: Date | null;
failedLoginAttempts: number;
lockedUntil: Date | null;
createdAt: Date;
}[];
diagnostics: {
lockedUsers: number;
missingConsent: number;
unverifiedUsers: number;
familiesWithoutUsers: number;
usersWithEmptyPassword: number;
};
};
export async function getKitaDiagnostics(
kitaId: string,
): Promise<KitaDiagnostics | null> {
const now = new Date();
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: {
id: true,
name: true,
slug: true,
notdienstModuleEnabled: true,
terminModuleEnabled: true,
adressbuchModuleEnabled: true,
createdAt: true,
updatedAt: true,
users: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
passwordHash: true,
emailVerifiedAt: true,
privacyPolicyAcceptedAt: true,
lastLoginAt: true,
failedLoginAttempts: true,
lockedUntil: true,
createdAt: true,
family: {
select: { name: true },
},
},
orderBy: [
{ role: "asc" },
{ lastName: "asc" },
{ firstName: "asc" },
],
},
families: {
select: {
users: { select: { id: true } },
},
},
children: {
select: { active: true },
},
invitations: {
select: {
status: true,
expiresAt: true,
},
},
_count: {
select: {
termine: true,
absences: true,
announcements: true,
},
},
},
});
if (!kita) {
return null;
}
const roleCounts = Object.fromEntries(
Object.values(UserRole).map((role) => [role, 0]),
) as Record<UserRole, number>;
let lockedUsers = 0;
let missingConsent = 0;
let unverifiedUsers = 0;
let usersWithEmptyPassword = 0;
for (const user of kita.users) {
roleCounts[user.role] += 1;
if (user.lockedUntil && user.lockedUntil > now) {
lockedUsers += 1;
}
if (user.role !== UserRole.SUPERADMIN && !user.privacyPolicyAcceptedAt) {
missingConsent += 1;
}
if (!user.emailVerifiedAt) {
unverifiedUsers += 1;
}
if (!user.passwordHash) {
usersWithEmptyPassword += 1;
}
}
const activeChildren = kita.children.filter((child) => child.active).length;
const inactiveChildren = kita.children.length - activeChildren;
const familiesWithoutUsers = kita.families.filter(
(family) => family.users.length === 0,
).length;
return {
kita: {
id: kita.id,
name: kita.name,
slug: kita.slug,
createdAt: kita.createdAt,
updatedAt: kita.updatedAt,
modules: {
notdienst: kita.notdienstModuleEnabled,
termine: kita.terminModuleEnabled,
adressbuch: kita.adressbuchModuleEnabled,
},
},
counts: {
users: kita.users.length,
families: kita.families.length,
activeChildren,
inactiveChildren,
termine: kita._count.termine,
absences: kita._count.absences,
announcements: kita._count.announcements,
openInvitations: kita.invitations.filter(
(invitation) =>
invitation.status === InvitationStatus.PENDING &&
invitation.expiresAt > now,
).length,
expiredPendingInvitations: kita.invitations.filter(
(invitation) =>
invitation.status === InvitationStatus.PENDING &&
invitation.expiresAt <= now,
).length,
lockedUsers,
missingConsent,
familiesWithoutUsers,
},
roleCounts,
users: kita.users.map((user) => ({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
familyName: user.family?.name ?? null,
emailVerifiedAt: user.emailVerifiedAt,
privacyPolicyAcceptedAt: user.privacyPolicyAcceptedAt,
lastLoginAt: user.lastLoginAt,
failedLoginAttempts: user.failedLoginAttempts,
lockedUntil: user.lockedUntil,
createdAt: user.createdAt,
})),
diagnostics: {
lockedUsers,
missingConsent,
unverifiedUsers,
familiesWithoutUsers,
usersWithEmptyPassword,
},
};
}
export async function recordKitaDiagnosticView({
actorUserId,
actorEmail,
kitaId,
kitaName,
kitaSlug,
}: {
actorUserId: string;
actorEmail: string;
kitaId: string;
kitaName: string;
kitaSlug: string;
}) {
await prisma.auditLog.create({
data: {
actorUserId,
actorEmail,
action: "KITA_DIAGNOSTIC_VIEW",
targetType: AuditTargetType.KITA,
targetId: kitaId,
targetLabel: `${kitaName} (${kitaSlug})`,
targetKitaId: kitaId,
},
});
}
export async function getAuditLogRows() {
return prisma.auditLog.findMany({
select: {
id: true,
actorEmail: true,
action: true,
targetType: true,
targetId: true,
targetLabel: true,
targetKitaId: true,
metadata: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
take: 200,
});
}
export { roleOrder };
+287
View File
@@ -0,0 +1,287 @@
"use server";
import { revalidatePath } from "next/cache";
import { AuditTargetType, UserRole } from "@prisma/client";
import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
const deleteKitaSchema = z.object({
kitaId: z.string().min(1),
confirmationName: z.string().min(1).trim(),
});
const deleteUserSchema = z.object({
userId: z.string().min(1),
confirmationEmail: z.string().email().toLowerCase().trim(),
});
const userIdSchema = z.string().min(1);
type ActionResult = {
ok: boolean;
error?: string;
};
async function requireSuperadminActor() {
const session = await requireRole([UserRole.SUPERADMIN]);
return {
id: session.user.id,
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
};
}
async function getKitaDeleteCounts(kitaId: string) {
const [
users,
families,
children,
termine,
absences,
announcements,
invitations,
eventParticipations,
] = await Promise.all([
prisma.user.count({ where: { kitaId } }),
prisma.family.count({ where: { kitaId } }),
prisma.child.count({ where: { kitaId } }),
prisma.termin.count({ where: { kitaId } }),
prisma.absence.count({ where: { kitaId } }),
prisma.announcement.count({ where: { kitaId } }),
prisma.invitation.count({ where: { kitaId } }),
prisma.eventParticipation.count({ where: { kitaId } }),
]);
return {
users,
families,
children,
termine,
absences,
announcements,
invitations,
eventParticipations,
};
}
export async function deleteKita(
rawPayload: unknown,
): Promise<ActionResult & { redirectTo?: string }> {
const actor = await requireSuperadminActor();
const parsed = deleteKitaSchema.safeParse(rawPayload);
if (!parsed.success) {
return { ok: false, error: "Ungültige Eingabedaten." };
}
const kita = await prisma.kita.findUnique({
where: { id: parsed.data.kitaId },
select: { id: true, name: true, slug: true },
});
if (!kita) {
return { ok: false, error: "Kita wurde nicht gefunden." };
}
if (parsed.data.confirmationName !== kita.name) {
return {
ok: false,
error: "Die Bestätigung muss exakt dem Kita-Namen entsprechen.",
};
}
const counts = await getKitaDeleteCounts(kita.id);
await prisma.$transaction(async (tx) => {
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: actor.email,
action: "KITA_DELETE",
targetType: AuditTargetType.KITA,
targetId: kita.id,
targetLabel: `${kita.name} (${kita.slug})`,
targetKitaId: kita.id,
metadata: counts,
},
});
await tx.kita.delete({ where: { id: kita.id } });
});
revalidatePath("/admin");
revalidatePath("/admin/audit");
return { ok: true, redirectTo: "/admin" };
}
async function findMutableTenantUser(userId: string, actorId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
kitaId: true,
familyId: true,
failedLoginAttempts: true,
lockedUntil: true,
kita: {
select: {
name: true,
slug: true,
},
},
family: {
select: {
name: true,
_count: {
select: {
users: true,
},
},
},
},
},
});
if (!user) {
return { error: "Benutzer wurde nicht gefunden." } as const;
}
if (user.id === actorId) {
return { error: "Das eigene Superadmin-Konto kann hier nicht geändert werden." } as const;
}
if (user.role === UserRole.SUPERADMIN) {
return { error: "Andere Superadmins werden in V1 nicht verwaltet." } as const;
}
if (!user.kitaId) {
return { error: "Nur tenant-gebundene Benutzer können verwaltet werden." } as const;
}
return { user } as const;
}
export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
const actor = await requireSuperadminActor();
const parsed = deleteUserSchema.safeParse(rawPayload);
if (!parsed.success) {
return { ok: false, error: "Ungültige Eingabedaten." };
}
const lookup = await findMutableTenantUser(parsed.data.userId, actor.id);
if ("error" in lookup) {
return { ok: false, error: lookup.error };
}
const { user } = lookup;
if (parsed.data.confirmationEmail !== user.email) {
return {
ok: false,
error: "Die Bestätigung muss exakt der Benutzer-E-Mail entsprechen.",
};
}
await prisma.$transaction(async (tx) => {
await tx.verificationToken.deleteMany({
where: { identifier: user.id },
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: actor.email,
action: "USER_DELETE",
targetType: AuditTargetType.USER,
targetId: user.id,
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
targetKitaId: user.kitaId,
metadata: {
role: user.role,
kitaName: user.kita?.name ?? null,
kitaSlug: user.kita?.slug ?? null,
familyId: user.familyId,
familyName: user.family?.name ?? null,
familyUserCountBeforeDelete: user.family?._count.users ?? null,
},
},
});
await tx.user.delete({ where: { id: user.id } });
});
revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`);
revalidatePath("/admin/audit");
return { ok: true };
}
async function updateUserSupportState(
userId: string,
action: "USER_LOCK" | "USER_UNLOCK" | "USER_FAILED_LOGIN_RESET",
data: {
lockedUntil?: Date | null;
failedLoginAttempts?: number;
},
): Promise<ActionResult> {
const actor = await requireSuperadminActor();
const parsed = userIdSchema.safeParse(userId);
if (!parsed.success) {
return { ok: false, error: "Ungültige Eingabedaten." };
}
const lookup = await findMutableTenantUser(parsed.data, actor.id);
if ("error" in lookup) {
return { ok: false, error: lookup.error };
}
const { user } = lookup;
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: user.id },
data,
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: actor.email,
action,
targetType: AuditTargetType.USER,
targetId: user.id,
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
targetKitaId: user.kitaId,
metadata: {
role: user.role,
previousLockedUntil: user.lockedUntil?.toISOString() ?? null,
previousFailedLoginAttempts: user.failedLoginAttempts,
},
},
});
});
revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`);
revalidatePath("/admin/audit");
return { ok: true };
}
export async function lockUser(userId: string): Promise<ActionResult> {
return updateUserSupportState(userId, "USER_LOCK", {
lockedUntil: new Date("2099-12-31T23:59:59.000Z"),
});
}
export async function unlockUser(userId: string): Promise<ActionResult> {
return updateUserSupportState(userId, "USER_UNLOCK", {
lockedUntil: null,
failedLoginAttempts: 0,
});
}
export async function resetFailedLoginAttempts(
userId: string,
): Promise<ActionResult> {
return updateUserSupportState(userId, "USER_FAILED_LOGIN_RESET", {
failedLoginAttempts: 0,
});
}
+153
View File
@@ -0,0 +1,153 @@
import Link from "next/link";
import type { Metadata } from "next";
import { ArrowLeft, ScrollText } from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getAuditLogRows } from "../_lib/data";
export const metadata: Metadata = {
title: "AuditLog · Kita-Planer",
};
function formatDate(date: Date) {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
function actionLabel(action: string) {
switch (action) {
case "KITA_DIAGNOSTIC_VIEW":
return "Diagnose geöffnet";
case "KITA_DELETE":
return "Kita gelöscht";
case "USER_DELETE":
return "Benutzer gelöscht";
case "USER_LOCK":
return "Benutzer gesperrt";
case "USER_UNLOCK":
return "Benutzer entsperrt";
case "USER_FAILED_LOGIN_RESET":
return "Fehlversuche zurückgesetzt";
default:
return action;
}
}
function metadataPreview(metadata: unknown) {
if (!metadata) return "—";
const preview = JSON.stringify(metadata);
if (!preview) return "—";
return preview.length > 140 ? `${preview.slice(0, 140)}...` : preview;
}
export default async function AuditPage() {
await requireRole([UserRole.SUPERADMIN]);
const rows = await getAuditLogRows();
return (
<div className="px-8 py-8">
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
<div>
<Button asChild variant="ghost" className="-ml-3 mb-3">
<Link href="/admin">
<ArrowLeft className="h-4 w-4" />
Zur Kita-Übersicht
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">AuditLog</h1>
<p className="mt-1 text-sm text-muted-foreground">
Die letzten 200 Superadmin-Diagnose- und Support-Aktionen.
</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
<ScrollText className="h-5 w-5" />
</div>
</div>
<Card>
<CardHeader>
<CardTitle as="h2" className="text-xl">
Systemweite Aktionen
</CardTitle>
<CardDescription>
Audit-Einträge sind unabhängig von gelöschten Kitas und Benutzern.
</CardDescription>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
Noch keine Audit-Einträge vorhanden.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Zeitpunkt</TableHead>
<TableHead>Aktion</TableHead>
<TableHead>Akteur</TableHead>
<TableHead>Ziel</TableHead>
<TableHead>Metadaten</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="whitespace-nowrap">
{formatDate(row.createdAt)}
</TableCell>
<TableCell>
<Badge
variant={
row.action.endsWith("_DELETE")
? "destructive"
: "secondary"
}
>
{actionLabel(row.action)}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm">{row.actorEmail}</span>
</TableCell>
<TableCell>
<div className="font-medium">{row.targetLabel}</div>
<div className="text-xs text-muted-foreground">
{row.targetType} · {row.targetId}
</div>
</TableCell>
<TableCell className="max-w-sm">
<code className="block truncate rounded bg-muted px-2 py-1 text-xs">
{metadataPreview(row.metadata)}
</code>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
+359
View File
@@ -0,0 +1,359 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import {
AlertTriangle,
ArrowLeft,
Baby,
CheckCircle2,
Clock,
Lock,
MailCheck,
MailWarning,
ShieldAlert,
Users,
} from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
getKitaDiagnostics,
recordKitaDiagnosticView,
roleOrder,
} from "../../_lib/data";
import { KitaDeleteDialog } from "../../_components/kita-delete-dialog";
import { UserSupportActions } from "../../_components/user-support-actions";
type PageProps = {
params: Promise<{ kitaId: string }>;
};
export const metadata: Metadata = {
title: "Kita-Diagnose · Kita-Planer",
};
function formatDate(date: Date | null) {
if (!date) return "Nie";
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
function isLocked(lockedUntil: Date | null) {
return !!lockedUntil && lockedUntil > new Date();
}
export default async function KitaDiagnosticsPage({ params }: PageProps) {
const [{ kitaId }, session] = await Promise.all([
params,
requireRole([UserRole.SUPERADMIN]),
]);
const diagnostics = await getKitaDiagnostics(kitaId);
if (!diagnostics) {
notFound();
}
await recordKitaDiagnosticView({
actorUserId: session.user.id,
actorEmail: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
kitaId: diagnostics.kita.id,
kitaName: diagnostics.kita.name,
kitaSlug: diagnostics.kita.slug,
});
const issueCards = [
{
label: "Gesperrte Konten",
value: diagnostics.diagnostics.lockedUsers,
icon: Lock,
},
{
label: "Fehlender Consent",
value: diagnostics.diagnostics.missingConsent,
icon: ShieldAlert,
},
{
label: "Unverifizierte E-Mails",
value: diagnostics.diagnostics.unverifiedUsers,
icon: MailWarning,
},
{
label: "Familien ohne Benutzer",
value: diagnostics.diagnostics.familiesWithoutUsers,
icon: Users,
},
];
return (
<div className="px-8 py-8">
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
<div>
<Button asChild variant="ghost" className="-ml-3 mb-3">
<Link href="/admin">
<ArrowLeft className="h-4 w-4" />
Zur Kita-Übersicht
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">
{diagnostics.kita.name}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{diagnostics.kita.slug} · angelegt am{" "}
{formatDate(diagnostics.kita.createdAt)}
</p>
</div>
<KitaDeleteDialog
kitaId={diagnostics.kita.id}
kitaName={diagnostics.kita.name}
counts={diagnostics.counts}
/>
</div>
<div className="mb-6 grid gap-4 md:grid-cols-4">
<MetricCard
icon={Users}
label="Benutzer"
value={diagnostics.counts.users}
description={`${diagnostics.counts.families} Familien`}
/>
<MetricCard
icon={Baby}
label="Aktive Kinder"
value={diagnostics.counts.activeChildren}
description={`${diagnostics.counts.inactiveChildren} inaktiv`}
/>
<MetricCard
icon={Clock}
label="Termine"
value={diagnostics.counts.termine}
description={`${diagnostics.counts.absences} Abwesenheiten`}
/>
<MetricCard
icon={MailCheck}
label="Offene Einladungen"
value={diagnostics.counts.openInvitations}
description={`${diagnostics.counts.expiredPendingInvitations} abgelaufen`}
attention={diagnostics.counts.openInvitations > 0}
/>
</div>
<div className="mb-6 grid gap-4 md:grid-cols-4">
{issueCards.map(({ label, value, icon: Icon }) => (
<Card
key={label}
className={value > 0 ? "border-amber-200 bg-amber-50/70" : ""}
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardDescription>{label}</CardDescription>
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle className="text-3xl">{value}</CardTitle>
</CardHeader>
</Card>
))}
</div>
<div className="mb-6 grid gap-4 lg:grid-cols-[1fr_1.4fr]">
<Card>
<CardHeader>
<CardTitle as="h2" className="text-xl">
Rollenverteilung
</CardTitle>
<CardDescription>
Nur tenant-gebundene Benutzer dieser Kita.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{roleOrder.map((role) => (
<Badge key={role} variant="secondary">
{role}: {diagnostics.roleCounts[role]}
</Badge>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle as="h2" className="text-xl">
Modulstatus
</CardTitle>
<CardDescription>
V1 zeigt Module nur read-only, keine Konfigurationsänderungen.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<ModuleBadge label="Notdienst" enabled={diagnostics.kita.modules.notdienst} />
<ModuleBadge label="Termine" enabled={diagnostics.kita.modules.termine} />
<ModuleBadge
label="Adressbuch"
enabled={diagnostics.kita.modules.adressbuch}
/>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle as="h2" className="text-xl">
Benutzer
</CardTitle>
<CardDescription>
Support-Aktionen sind auf Löschen, Sperren, Entsperren und
Fehlversuche zurücksetzen begrenzt.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Benutzer</TableHead>
<TableHead>Rolle / Familie</TableHead>
<TableHead>Status</TableHead>
<TableHead>Login</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{diagnostics.users.map((user) => {
const userIsLocked = isLocked(user.lockedUntil);
const userName = `${user.firstName} ${user.lastName}`.trim();
return (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">{userName}</div>
<div className="text-xs text-muted-foreground">
{user.email}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<Badge variant="secondary">{user.role}</Badge>
<span className="text-xs text-muted-foreground">
{user.familyName ?? "Keine Familie"}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1.5">
{userIsLocked ? (
<Badge variant="destructive">
<Lock className="mr-1 h-3 w-3" />
Gesperrt
</Badge>
) : (
<Badge variant="success">
<CheckCircle2 className="mr-1 h-3 w-3" />
Aktiv
</Badge>
)}
{!user.emailVerifiedAt && (
<Badge variant="warning">E-Mail offen</Badge>
)}
{!user.privacyPolicyAcceptedAt && (
<Badge variant="warning">Consent fehlt</Badge>
)}
{user.failedLoginAttempts > 0 && (
<Badge variant="outline">
{user.failedLoginAttempts} Fehlversuche
</Badge>
)}
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div>{formatDate(user.lastLoginAt)}</div>
{userIsLocked && (
<div className="text-xs text-muted-foreground">
bis {formatDate(user.lockedUntil)}
</div>
)}
</div>
</TableCell>
<TableCell className="text-right">
<UserSupportActions
user={{
id: user.id,
email: user.email,
name: userName,
isLocked: userIsLocked,
failedLoginAttempts: user.failedLoginAttempts,
}}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
function MetricCard({
icon: Icon,
label,
value,
description,
attention = false,
}: {
icon: typeof Users;
label: string;
value: number;
description: string;
attention?: boolean;
}) {
return (
<Card className={attention ? "border-amber-200 bg-amber-50/70" : ""}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardDescription>{label}</CardDescription>
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle className="text-3xl">{value}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
function ModuleBadge({ label, enabled }: { label: string; enabled: boolean }) {
return (
<Badge variant={enabled ? "success" : "outline"}>
{enabled ? (
<CheckCircle2 className="mr-1 h-3 w-3" />
) : (
<AlertTriangle className="mr-1 h-3 w-3" />
)}
{label}: {enabled ? "aktiv" : "inaktiv"}
</Badge>
);
}
+80
View File
@@ -0,0 +1,80 @@
import type { Metadata } from "next";
import { LogOut, ShieldCheck } from "lucide-react";
import { UserRole } from "@prisma/client";
import { signOut } from "@/auth";
import { requireRole } from "@/lib/auth-utils";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { AdminNav } from "./_components/admin-nav";
export const metadata: Metadata = {
title: "Systemadmin · Kita-Planer",
robots: {
index: false,
follow: false,
},
};
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await requireRole([UserRole.SUPERADMIN]);
return (
<div className="flex min-h-screen bg-muted/30">
<aside className="flex w-64 shrink-0 flex-col border-r bg-background">
<div className="flex h-16 items-center gap-2.5 border-b px-5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground">
<ShieldCheck className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-none">
Systemadmin
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Kita-Planer
</p>
</div>
</div>
<div className="flex-1 overflow-y-auto py-4">
<AdminNav />
</div>
<div className="border-t p-3">
<Separator className="mb-3" />
<div className="mb-2 px-3">
<p className="truncate text-xs font-medium">
{session.user.name ?? session.user.email}
</p>
<p className="truncate text-xs text-muted-foreground">
SUPERADMIN
</p>
</div>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<Button
type="submit"
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground"
>
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</form>
</div>
</aside>
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
);
}
+282
View File
@@ -0,0 +1,282 @@
import Link from "next/link";
import type { Metadata } from "next";
import {
AlertTriangle,
ArrowRight,
Building2,
CheckCircle2,
Clock,
Lock,
MailWarning,
Users,
} from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
getGlobalAdminHealth,
getKitaOverviewRows,
roleOrder,
} from "./_lib/data";
import { KitaDeleteDialog } from "./_components/kita-delete-dialog";
export const metadata: Metadata = {
title: "Superadmin · Kita-Planer",
};
const numberFormat = new Intl.NumberFormat("de-DE");
function formatDate(date: Date | null) {
if (!date) return "Nie";
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
export default async function AdminPage() {
await requireRole([UserRole.SUPERADMIN]);
const [health, kitas] = await Promise.all([
getGlobalAdminHealth(),
getKitaOverviewRows(),
]);
const totalUsers = kitas.reduce((sum, kita) => sum + kita.counts.users, 0);
const totalChildren = kitas.reduce(
(sum, kita) => sum + kita.counts.activeChildren,
0,
);
const needsAttention =
health.lockedTenantUsers +
health.missingConsent +
health.openInvitations +
health.orphanTenantUsers;
return (
<div className="px-8 py-8">
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Kita-Verwaltung
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Systemweite Übersicht, read-only Diagnose und Support-Aktionen.
</p>
</div>
<Button asChild variant="outline">
<Link href="/admin/audit">
AuditLog ansehen
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div>
<div className="mb-6 grid gap-4 md:grid-cols-4">
<MetricCard
icon={Building2}
label="Kitas"
value={health.kitas}
description="Mandanten im System"
/>
<MetricCard
icon={Users}
label="Benutzer"
value={totalUsers}
description={`${numberFormat.format(totalChildren)} aktive Kinder`}
/>
<MetricCard
icon={AlertTriangle}
label="Hinweise"
value={needsAttention}
description="Sperren, Einladungen, Consent"
attention={needsAttention > 0}
/>
<MetricCard
icon={Lock}
label="Gesperrt"
value={health.lockedTenantUsers}
description="Aktive Account-Sperren"
attention={health.lockedTenantUsers > 0}
/>
</div>
{health.orphanTenantUsers > 0 && (
<div className="mb-6 rounded-md border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
<div className="flex items-center gap-2 font-medium">
<MailWarning className="h-4 w-4" />
{health.orphanTenantUsers} tenant-lose Nicht-Superadmin-Benutzer
</div>
<p className="mt-1 text-amber-800 dark:text-amber-300">
Diese Konten sind keiner Kita zugeordnet und sollten separat
bereinigt werden. V1 verwaltet nur tenant-gebundene Benutzer.
</p>
</div>
)}
<Card>
<CardHeader>
<CardTitle as="h2" className="text-xl">
Alle Kitas
</CardTitle>
<CardDescription>
Diagnose-Links sind read-only; Löschungen benötigen eine exakte
Namensbestätigung.
</CardDescription>
</CardHeader>
<CardContent>
{kitas.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
Noch keine Kitas angelegt.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Kita</TableHead>
<TableHead>Status</TableHead>
<TableHead>Counts</TableHead>
<TableHead>Rollen</TableHead>
<TableHead>Letzter Login</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{kitas.map((kita) => {
const warnings =
kita.counts.lockedUsers +
kita.counts.missingConsent +
kita.counts.openInvitations +
kita.counts.familiesWithoutUsers;
return (
<TableRow key={kita.id}>
<TableCell>
<div className="font-medium">{kita.name}</div>
<div className="text-xs text-muted-foreground">
{kita.slug}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1.5">
{warnings === 0 ? (
<Badge variant="success">
<CheckCircle2 className="mr-1 h-3 w-3" />
Unauffällig
</Badge>
) : (
<Badge variant="warning">
<AlertTriangle className="mr-1 h-3 w-3" />
{warnings} Hinweise
</Badge>
)}
{kita.counts.lockedUsers > 0 && (
<Badge variant="outline">
{kita.counts.lockedUsers} gesperrt
</Badge>
)}
{kita.counts.openInvitations > 0 && (
<Badge variant="outline">
{kita.counts.openInvitations} Einladungen
</Badge>
)}
</div>
</TableCell>
<TableCell>
<div className="grid min-w-48 grid-cols-2 gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>{kita.counts.users} Benutzer</span>
<span>{kita.counts.families} Familien</span>
<span>{kita.counts.activeChildren} Kinder</span>
<span>{kita.counts.termine} Termine</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{roleOrder.map((role) =>
kita.roleCounts[role] > 0 ? (
<Badge key={role} variant="secondary">
{role}: {kita.roleCounts[role]}
</Badge>
) : null,
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
{formatDate(kita.lastLoginAt)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex flex-wrap justify-end gap-2">
<Button asChild variant="outline" size="sm">
<Link
href={`/admin/kitas/${kita.id}`}
prefetch={false}
>
Diagnose
</Link>
</Button>
<KitaDeleteDialog
kitaId={kita.id}
kitaName={kita.name}
counts={kita.counts}
/>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function MetricCard({
icon: Icon,
label,
value,
description,
attention = false,
}: {
icon: typeof Building2;
label: string;
value: number;
description: string;
attention?: boolean;
}) {
return (
<Card className={attention ? "border-amber-200 bg-amber-50/70" : ""}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardDescription>{label}</CardDescription>
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle className="text-3xl">{numberFormat.format(value)}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
+41 -3
View File
@@ -11,6 +11,7 @@ import {
contactRequestSchema, contactRequestSchema,
contactRequestTypeLabels, contactRequestTypeLabels,
type ContactRequestInput, type ContactRequestInput,
type ContactSubmissionPayload,
} from "@/lib/contact-schema"; } from "@/lib/contact-schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -44,16 +45,27 @@ export function ContactForm() {
}, },
}); });
function onSubmit(values: ContactRequestInput) { function onSubmit(
values: ContactRequestInput,
event?: React.BaseSyntheticEvent,
) {
const formEl = event?.target as HTMLFormElement | undefined;
const website =
(formEl?.elements.namedItem("website") as HTMLInputElement | null)
?.value ?? "";
startTransition(async () => { startTransition(async () => {
const result = await submitContactRequest(values); const payload: ContactSubmissionPayload = { ...values, website };
const result = await submitContactRequest(payload);
if (!result.success) { if (!result.success) {
toast.error(result.error); toast.error(result.error);
return; return;
} }
toast.success("Danke! Wir melden uns in Kürze bei euch."); toast.success(
"Danke! Wir haben dir eine Bestätigung an deine E-Mail-Adresse geschickt.",
);
form.reset({ form.reset({
name: "", name: "",
kitaName: "", kitaName: "",
@@ -61,6 +73,10 @@ export function ContactForm() {
requestType: "DEMO", requestType: "DEMO",
message: "", message: "",
}); });
const honeypot = formEl?.elements.namedItem(
"website",
) as HTMLInputElement | null;
if (honeypot) honeypot.value = "";
}); });
} }
@@ -171,6 +187,28 @@ export function ContactForm() {
)} )}
/> />
<div
aria-hidden="true"
style={{
position: "absolute",
left: "-10000px",
top: "auto",
width: "1px",
height: "1px",
overflow: "hidden",
}}
>
<label htmlFor="contact-website">Website (bitte leer lassen)</label>
<input
id="contact-website"
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
defaultValue=""
/>
</div>
<Button type="submit" size="lg" disabled={isPending} className="h-12"> <Button type="submit" size="lg" disabled={isPending} className="h-12">
{isPending ? ( {isPending ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -0,0 +1,608 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import {
EventParticipationStatus,
TerminStatus,
TerminType,
} from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import {
CalendarPlus,
Check,
Clock,
HelpCircle,
Pencil,
Users,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import {
createTerminAdmin,
updateTerminAdmin,
} from "../../kalender/actions";
type AdminTerminDto = {
id: string;
title: string;
description: string | null;
type: TerminType;
status: TerminStatus;
startDate: string;
endDate: string;
allDay: boolean;
mitbringselListEnabled: boolean;
requiresRsvp: boolean;
rsvpDeadline: string | null;
participantInfo: string | null;
participations: {
childId: string;
status: EventParticipationStatus;
}[];
};
type ChildDto = {
id: string;
name: string;
familyName: string;
};
export function AdminCalendarManager({
termine,
childRows,
}: {
termine: AdminTerminDto[];
childRows: ChildDto[];
}) {
return (
<div className="flex h-full flex-col gap-6 p-6">
<div className="flex flex-col justify-between gap-4 lg:flex-row lg:items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Event-Anmeldungen & Ausflüge
</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind
verfolgen.
</p>
</div>
<TerminFormDialog mode="create" />
</div>
{termine.length === 0 ? (
<div className="rounded-md border border-dashed p-10 text-center">
<CalendarPlus className="mx-auto h-10 w-10 text-muted-foreground" />
<h2 className="mt-4 text-lg font-semibold">Noch keine Termine</h2>
<p className="mt-1 text-sm text-muted-foreground">
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
</p>
</div>
) : (
<div className="grid gap-4">
{termine.map((termin) => (
<AdminTerminCard
key={termin.id}
termin={termin}
childRows={childRows}
/>
))}
</div>
)}
</div>
);
}
function AdminTerminCard({
termin,
childRows,
}: {
termin: AdminTerminDto;
childRows: ChildDto[];
}) {
const stats = useMemo(() => getStats(termin, childRows), [termin, childRows]);
return (
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="mb-2 flex flex-wrap gap-2">
<Badge className={getTypeBadgeClass(termin.type)}>
{getTypeLabel(termin.type)}
</Badge>
<Badge variant={getStatusVariant(termin.status)}>
{getStatusLabel(termin.status)}
</Badge>
{termin.requiresRsvp && (
<Badge variant="warning">RSVP aktiv</Badge>
)}
</div>
<CardTitle className="text-lg">{termin.title}</CardTitle>
<CardDescription className="mt-1 flex flex-wrap items-center gap-2">
<Clock className="h-3.5 w-3.5" />
{format(new Date(termin.startDate), "dd.MM.yyyy", {
locale: de,
})}
{!termin.allDay && (
<>
<span>·</span>
<span>
{format(new Date(termin.startDate), "HH:mm", {
locale: de,
})}{" "}
bis{" "}
{format(new Date(termin.endDate), "HH:mm", {
locale: de,
})}
</span>
</>
)}
</CardDescription>
</div>
<TerminFormDialog mode="edit" termin={termin} />
</div>
</CardHeader>
<CardContent className="grid gap-5 pt-5">
{termin.description && (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
{termin.description}
</p>
)}
{termin.requiresRsvp ? (
<div className="grid gap-4">
<div className="flex flex-col gap-3 rounded-md border bg-amber-50/70 p-4 text-amber-950 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
{stats.pending} Ausstehend
</p>
<p className="mt-1 text-sm text-amber-900/80">
Frist:{" "}
{termin.rsvpDeadline
? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", {
locale: de,
})
: "bis Terminbeginn"}
</p>
</div>
<Users className="h-5 w-5 shrink-0" />
</div>
{termin.participantInfo && (
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-4 text-sm text-yellow-950">
<span className="font-medium">Hinweis für Teilnehmer: </span>
{termin.participantInfo}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>Kind</TableHead>
<TableHead>Familie</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{childRows.map((child) => {
const status = stats.statusByChildId.get(child.id) ?? null;
return (
<TableRow key={child.id}>
<TableCell className="font-medium">{child.name}</TableCell>
<TableCell>{child.familyName}</TableCell>
<TableCell>
<RsvpStatusBadge status={status} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
) : (
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
Für diesen Termin ist keine Teilnahme-Abfrage aktiv.
</div>
)}
</CardContent>
</Card>
);
}
function TerminFormDialog({
mode,
termin,
}: {
mode: "create" | "edit";
termin?: AdminTerminDto;
}) {
const [open, setOpen] = useState(false);
const [requiresRsvp, setRequiresRsvp] = useState(
termin?.requiresRsvp ?? false,
);
const [isPending, startTransition] = useTransition();
function handleAction(formData: FormData) {
const startDate = new Date(String(formData.get("startDate")));
const endDate = new Date(String(formData.get("endDate")));
const deadlineRaw = String(formData.get("rsvpDeadline") ?? "");
const hasRsvp = formData.get("requiresRsvp") === "on";
const payload = {
title: String(formData.get("title") ?? ""),
description: String(formData.get("description") ?? ""),
type: formData.get("type") as TerminType,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
allDay: formData.get("allDay") === "on",
mitbringselListEnabled: formData.get("mitbringselListEnabled") === "on",
requiresRsvp: hasRsvp,
rsvpDeadline:
hasRsvp && deadlineRaw ? new Date(deadlineRaw).toISOString() : null,
participantInfo: String(formData.get("participantInfo") ?? ""),
};
startTransition(async () => {
const result =
mode === "edit" && termin
? await updateTerminAdmin(termin.id, payload)
: await createTerminAdmin(payload);
if (result.error) {
toast.error(result.error);
return;
}
toast.success(
mode === "edit" ? "Termin aktualisiert." : "Termin angelegt.",
);
setOpen(false);
});
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen) {
setRequiresRsvp(termin?.requiresRsvp ?? false);
}
}}
>
<DialogTrigger asChild>
<Button variant={mode === "edit" ? "outline" : "default"} size="sm">
{mode === "edit" ? (
<Pencil className="h-4 w-4" />
) : (
<CalendarPlus className="h-4 w-4" />
)}
{mode === "edit" ? "Bearbeiten" : "Termin anlegen"}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<form action={handleAction}>
<DialogHeader>
<DialogTitle>
{mode === "edit" ? "Termin bearbeiten" : "Termin anlegen"}
</DialogTitle>
<DialogDescription>
RSVP kann für Ausflüge und besondere Events aktiviert werden.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor={`${mode}-title`}>Titel</Label>
<Input
id={`${mode}-title`}
name="title"
defaultValue={termin?.title ?? ""}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-description`}>Beschreibung</Label>
<Textarea
id={`${mode}-description`}
name="description"
defaultValue={termin?.description ?? ""}
rows={3}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-type`}>Kategorie</Label>
<select
id={`${mode}-type`}
name="type"
defaultValue={termin?.type ?? TerminType.KITA_FEST}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
required
>
{Object.values(TerminType).map((type) => (
<option key={type} value={type}>
{getTypeLabel(type)}
</option>
))}
</select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor={`${mode}-startDate`}>Start</Label>
<Input
id={`${mode}-startDate`}
name="startDate"
type="datetime-local"
defaultValue={
termin ? toDatetimeLocalValue(termin.startDate) : ""
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-endDate`}>Ende</Label>
<Input
id={`${mode}-endDate`}
name="endDate"
type="datetime-local"
defaultValue={
termin ? toDatetimeLocalValue(termin.endDate) : ""
}
required
/>
</div>
</div>
<div className="grid gap-3 rounded-md border p-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="allDay"
defaultChecked={termin?.allDay ?? false}
className="h-4 w-4 rounded border-input"
/>
Ganztägiger Termin
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="mitbringselListEnabled"
defaultChecked={termin?.mitbringselListEnabled ?? false}
className="h-4 w-4 rounded border-input"
/>
Mitbring-Liste aktiv
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="requiresRsvp"
checked={requiresRsvp}
onChange={(event) => setRequiresRsvp(event.target.checked)}
className="h-4 w-4 rounded border-input"
/>
Teilnahme-Abfrage (RSVP) aktivieren
</label>
</div>
{requiresRsvp && (
<div className="grid gap-4 rounded-md border border-amber-200 bg-amber-50/60 p-4">
<div className="grid gap-2">
<Label htmlFor={`${mode}-rsvpDeadline`}>
Rückmeldefrist
</Label>
<Input
id={`${mode}-rsvpDeadline`}
name="rsvpDeadline"
type="datetime-local"
defaultValue={
termin?.rsvpDeadline
? toDatetimeLocalValue(termin.rsvpDeadline)
: ""
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-participantInfo`}>
Hinweise für Teilnehmer
</Label>
<Textarea
id={`${mode}-participantInfo`}
name="participantInfo"
defaultValue={termin?.participantInfo ?? ""}
placeholder="z.B. Zweite Brotdose mitbringen"
rows={3}
/>
</div>
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function RsvpStatusBadge({
status,
}: {
status: EventParticipationStatus | null;
}) {
if (status === EventParticipationStatus.ATTENDING) {
return (
<Badge variant="success" className="gap-1">
<Check className="h-3.5 w-3.5" />
Zugesagt
</Badge>
);
}
if (status === EventParticipationStatus.NOT_ATTENDING) {
return (
<Badge variant="destructive" className="gap-1">
<X className="h-3.5 w-3.5" />
Abgesagt
</Badge>
);
}
return (
<Badge variant="outline" className="gap-1 text-muted-foreground">
<HelpCircle className="h-3.5 w-3.5" />
Ausstehend
</Badge>
);
}
function getStats(termin: AdminTerminDto, children: ChildDto[]) {
const statusByChildId = new Map(
termin.participations.map((participation) => [
participation.childId,
participation.status,
]),
);
let attending = 0;
let notAttending = 0;
children.forEach((child) => {
const status = statusByChildId.get(child.id);
if (status === EventParticipationStatus.ATTENDING) attending += 1;
if (status === EventParticipationStatus.NOT_ATTENDING) notAttending += 1;
});
return {
attending,
notAttending,
pending: Math.max(children.length - attending - notAttending, 0),
statusByChildId,
};
}
function toDatetimeLocalValue(value: string) {
const date = new Date(value);
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return localDate.toISOString().slice(0, 16);
}
function getStatusVariant(
status: TerminStatus,
): "default" | "secondary" | "destructive" | "outline" | "success" | "warning" {
switch (status) {
case TerminStatus.CONFIRMED:
return "success";
case TerminStatus.PENDING:
return "warning";
case TerminStatus.REJECTED:
case TerminStatus.CANCELLED:
return "destructive";
default:
return "outline";
}
}
function getStatusLabel(status: TerminStatus) {
switch (status) {
case TerminStatus.CONFIRMED:
return "Bestätigt";
case TerminStatus.PENDING:
return "Ausstehend";
case TerminStatus.REJECTED:
return "Abgelehnt";
case TerminStatus.CANCELLED:
return "Abgesagt";
}
}
function getTypeBadgeClass(type: TerminType) {
switch (type) {
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "border-transparent bg-amber-500 text-white";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "border-transparent bg-rose-500 text-white";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "border-transparent bg-blue-500 text-white";
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "border-transparent bg-purple-500 text-white";
default:
return "border-slate-300 bg-slate-100 text-slate-800";
}
}
function getTypeLabel(type: TerminType) {
switch (type) {
case TerminType.KITA_FEST:
return "Kita-Fest";
case TerminType.SCHLIESSTAG:
return "Schließtag";
case TerminType.TEAMTAG:
return "Teamtag";
case TerminType.MITMACH_TAG:
return "Mitmach-Tag";
case TerminType.ELTERNABEND:
return "Elternabend";
case TerminType.MITGLIEDERVERSAMMLUNG:
return "Mitgliederversammlung";
case TerminType.ELTERNCAFE:
return "Elterncafe";
case TerminType.GEBURTSTAG_INTERN:
return "Geburtstag Intern";
case TerminType.GEBURTSTAG_EXTERN:
return "Raumanfrage Extern";
case TerminType.SONSTIGES:
return "Sonstiges";
}
}
+80
View File
@@ -0,0 +1,80 @@
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { AdminCalendarManager } from "./admin-calendar-manager";
export const metadata = { title: "Event-Anmeldungen · Kita-Planer" };
export default async function AdminKalenderPage() {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return <div className="p-8">Kein Mandant zugeordnet.</div>;
}
const [termine, children] = await Promise.all([
prisma.termin.findMany({
where: { kitaId },
select: {
id: true,
title: true,
description: true,
type: true,
status: true,
startDate: true,
endDate: true,
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
rsvpDeadline: true,
participantInfo: true,
participations: {
select: {
childId: true,
status: true,
},
},
},
orderBy: { startDate: "desc" },
}),
prisma.child.findMany({
where: {
kitaId,
active: true,
},
select: {
id: true,
firstName: true,
lastName: true,
family: {
select: {
name: true,
},
},
},
orderBy: [
{ family: { name: "asc" } },
{ lastName: "asc" },
{ firstName: "asc" },
],
}),
]);
return (
<AdminCalendarManager
termine={termine.map((termin) => ({
...termin,
startDate: termin.startDate.toISOString(),
endDate: termin.endDate.toISOString(),
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
}))}
childRows={children.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
familyName: child.family.name,
}))}
/>
);
}
@@ -3,7 +3,7 @@
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client"; import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { AlignLeft, Calendar, Clock, WashingMachine } from "lucide-react"; import { AlertTriangle, AlignLeft, Calendar, Clock, WashingMachine } 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";
@@ -13,6 +13,15 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useTransition } from "react"; import { useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export type TerminListItemDto = { export type TerminListItemDto = {
kind: "termin"; kind: "termin";
@@ -25,6 +34,9 @@ export type TerminListItemDto = {
endDate: Date; endDate: Date;
allDay: boolean; allDay: boolean;
mitbringselListEnabled: boolean; mitbringselListEnabled: boolean;
requiresRsvp: boolean;
participantInfo: string | null;
showParticipantInfo: boolean;
mitbringselItems?: { mitbringselItems?: {
id: string; id: string;
userId: string; userId: string;
@@ -178,6 +190,8 @@ function TerminCard({
</div> </div>
)} )}
<TerminDetailDialog termin={termin} />
{/* Admin Toggle for Mitbringsel-List */} {/* Admin Toggle for Mitbringsel-List */}
{isAdmin && termin.status === TerminStatus.CONFIRMED && ( {isAdmin && termin.status === TerminStatus.CONFIRMED && (
<div className="flex items-center space-x-2 mt-6 pt-4 border-t"> <div className="flex items-center space-x-2 mt-6 pt-4 border-t">
@@ -209,6 +223,58 @@ function TerminCard({
); );
} }
function TerminDetailDialog({ termin }: { termin: TerminListItemDto }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="mt-4">
Details
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{termin.title}</DialogTitle>
<DialogDescription>
{format(termin.startDate, "PP", { locale: de })}
{!termin.allDay &&
` · ${format(termin.startDate, "p", { locale: de })} - ${format(
termin.endDate,
"p",
{ locale: de },
)}`}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<Badge variant={getBadgeVariant(termin.type)} className={getBadgeColor(termin.type)}>
{getTypeLabel(termin.type)}
</Badge>
{termin.description ? (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
{termin.description}
</p>
) : (
<p className="text-sm text-muted-foreground">
Keine Beschreibung hinterlegt.
</p>
)}
{termin.participantInfo && termin.showParticipantInfo && (
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-4 text-sm text-yellow-950">
<div className="mb-1 flex items-center gap-2 font-medium">
<AlertTriangle className="h-4 w-4 text-yellow-700" />
Wichtiger Hinweis für Teilnehmer
</div>
<p className="whitespace-pre-wrap">{termin.participantInfo}</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
function getBadgeVariant(type: TerminType): "default" | "secondary" | "destructive" | "outline" { function getBadgeVariant(type: TerminType): "default" | "secondary" | "destructive" | "outline" {
switch (type) { switch (type) {
case TerminType.KITA_FEST: case TerminType.KITA_FEST:
+94 -14
View File
@@ -12,14 +12,48 @@ import { prisma } from "@/lib/prisma";
// ===================================================================== // =====================================================================
const terminSchema = z.object({ const terminSchema = z.object({
title: z.string().min(2, "Titel muss mindestens 2 Zeichen lang sein"), title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein"),
description: z.string().optional(), description: z.string().trim().optional(),
type: z.nativeEnum(TerminType), type: z.nativeEnum(TerminType),
startDate: z.string().datetime(), startDate: z.string().datetime(),
endDate: z.string().datetime(), endDate: z.string().datetime(),
allDay: z.boolean().default(false), allDay: z.boolean().default(false),
mitbringselListEnabled: z.boolean().default(false),
requiresRsvp: z.boolean().default(false),
rsvpDeadline: z.string().datetime().nullable().optional(),
participantInfo: z.string().trim().max(1000).optional(),
}); });
function buildTerminData(data: z.infer<typeof terminSchema>) {
const startDate = new Date(data.startDate);
const endDate = new Date(data.endDate);
if (endDate < startDate) {
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
}
return {
data: {
title: data.title,
description: data.description || null,
type: data.type,
startDate,
endDate,
allDay: data.allDay,
mitbringselListEnabled: data.mitbringselListEnabled,
requiresRsvp: data.requiresRsvp,
rsvpDeadline:
data.requiresRsvp && data.rsvpDeadline
? new Date(data.rsvpDeadline)
: null,
participantInfo:
data.requiresRsvp && data.participantInfo
? data.participantInfo
: null,
},
};
}
export async function createTerminRequest(rawPayload: unknown) { export async function createTerminRequest(rawPayload: unknown) {
const session = await requireKitaSession(); const session = await requireKitaSession();
@@ -29,18 +63,23 @@ export async function createTerminRequest(rawPayload: unknown) {
} }
const data = parsed.data; const data = parsed.data;
const terminData = buildTerminData(data);
if ("error" in terminData) {
return { error: terminData.error };
}
try { try {
await prisma.termin.create({ await prisma.termin.create({
data: { data: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
createdById: session.user.id, createdById: session.user.id,
title: data.title, title: terminData.data.title,
description: data.description, description: terminData.data.description,
type: data.type, type: terminData.data.type,
startDate: new Date(data.startDate), startDate: terminData.data.startDate,
endDate: new Date(data.endDate), endDate: terminData.data.endDate,
allDay: data.allDay, allDay: terminData.data.allDay,
status: TerminStatus.PENDING, status: TerminStatus.PENDING,
}, },
}); });
@@ -66,18 +105,18 @@ export async function createTerminAdmin(rawPayload: unknown) {
} }
const data = parsed.data; const data = parsed.data;
const terminData = buildTerminData(data);
if ("error" in terminData) {
return { error: terminData.error };
}
try { try {
await prisma.termin.create({ await prisma.termin.create({
data: { data: {
kitaId, kitaId,
createdById: session.user.id, createdById: session.user.id,
title: data.title, ...terminData.data,
description: data.description,
type: data.type,
startDate: new Date(data.startDate),
endDate: new Date(data.endDate),
allDay: data.allDay,
status: TerminStatus.CONFIRMED, status: TerminStatus.CONFIRMED,
approvedById: session.user.id, approvedById: session.user.id,
approvedAt: new Date(), approvedAt: new Date(),
@@ -85,6 +124,7 @@ export async function createTerminAdmin(rawPayload: unknown) {
}); });
revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Erstellen des Termins:", error); console.error("Fehler beim Erstellen des Termins:", error);
@@ -92,6 +132,43 @@ export async function createTerminAdmin(rawPayload: unknown) {
} }
} }
export async function updateTerminAdmin(terminId: string, rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const terminData = buildTerminData(parsed.data);
if ("error" in terminData) {
return { error: terminData.error };
}
try {
await prisma.termin.update({
where: {
id: terminId,
kitaId,
},
data: terminData.data,
});
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren des Termins:", error);
return { error: "Termin konnte nicht aktualisiert werden." };
}
}
export async function approveTermin(terminId: string) { export async function approveTermin(terminId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId; const kitaId = session.user.kitaId;
@@ -113,6 +190,7 @@ export async function approveTermin(terminId: string) {
}); });
revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Bestätigen des Termins:", error); console.error("Fehler beim Bestätigen des Termins:", error);
@@ -142,6 +220,7 @@ export async function rejectTermin(terminId: string, reason?: string) {
}); });
revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Ablehnen des Termins:", error); console.error("Fehler beim Ablehnen des Termins:", error);
@@ -168,6 +247,7 @@ export async function toggleMitbringselList(terminId: string, enabled: boolean)
}); });
revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Umschalten der Mitbring-Liste:", error); console.error("Fehler beim Umschalten der Mitbring-Liste:", error);
+45 -1
View File
@@ -1,4 +1,9 @@
import { DutyAssignmentStatus, TerminStatus, UserRole } from "@prisma/client"; import {
DutyAssignmentStatus,
EventParticipationStatus,
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";
@@ -16,6 +21,7 @@ export default async function KalenderPage({
const isAdmin = const isAdmin =
session.user.role === UserRole.ADMIN || session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.KOORDINATOR; session.user.role === UserRole.KOORDINATOR;
const familyIdForParticipation = session.user.familyId ?? "__no_family__";
const currentTab = searchParams.tab || "übersicht"; const currentTab = searchParams.tab || "übersicht";
@@ -34,6 +40,8 @@ export default async function KalenderPage({
endDate: true, endDate: true,
allDay: true, allDay: true,
mitbringselListEnabled: true, mitbringselListEnabled: true,
requiresRsvp: true,
participantInfo: true,
mitbringselItems: { mitbringselItems: {
select: { select: {
id: true, id: true,
@@ -47,6 +55,17 @@ export default async function KalenderPage({
}, },
}, },
}, },
participations: {
where: {
child: {
familyId: familyIdForParticipation,
},
},
select: {
childId: true,
status: true,
},
},
}, },
orderBy: { startDate: "asc" }, orderBy: { startDate: "asc" },
}); });
@@ -67,6 +86,8 @@ export default async function KalenderPage({
endDate: true, endDate: true,
allDay: true, allDay: true,
mitbringselListEnabled: true, mitbringselListEnabled: true,
requiresRsvp: true,
participantInfo: true,
mitbringselItems: { mitbringselItems: {
select: { select: {
id: true, id: true,
@@ -80,6 +101,17 @@ export default async function KalenderPage({
}, },
}, },
}, },
participations: {
where: {
child: {
familyId: familyIdForParticipation,
},
},
select: {
childId: true,
status: true,
},
},
}, },
orderBy: { startDate: "asc" }, orderBy: { startDate: "asc" },
}); });
@@ -113,10 +145,22 @@ export default async function KalenderPage({
...confirmedTermineRows.map((termin) => ({ ...confirmedTermineRows.map((termin) => ({
...termin, ...termin,
kind: "termin" as const, kind: "termin" as const,
showParticipantInfo:
isAdmin ||
termin.participations.some(
(participation) =>
participation.status === EventParticipationStatus.ATTENDING,
),
})), })),
...myPendingTermineRows.map((termin) => ({ ...myPendingTermineRows.map((termin) => ({
...termin, ...termin,
kind: "termin" as const, kind: "termin" as const,
showParticipantInfo:
isAdmin ||
termin.participations.some(
(participation) =>
participation.status === EventParticipationStatus.ATTENDING,
),
})), })),
...dutyAssignmentRows.map((assignment) => ({ ...dutyAssignmentRows.map((assignment) => ({
kind: "duty" as const, kind: "duty" as const,
+63 -3
View File
@@ -2,7 +2,7 @@ import Link from "next/link";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle, ClipboardList } from "lucide-react"; import { Users, CalendarCheck2, ShieldCheck, AlertTriangle, ClipboardList } from "lucide-react";
import { DutyAssignmentStatus, UserRole, NotdienstPlanStatus } from "@prisma/client"; import { DutyAssignmentStatus, UserRole, NotdienstPlanStatus, TerminStatus } 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";
@@ -19,11 +19,13 @@ import { OnboardingDialog } from "@/components/OnboardingDialog";
import { AlertButton } from "./alert-button"; import { AlertButton } from "./alert-button";
import { AbsenceCard } from "./absence-card"; import { AbsenceCard } from "./absence-card";
import { NewsTicker } from "./news-ticker"; import { NewsTicker } from "./news-ticker";
import { RsvpActionCard } from "./rsvp-action-card";
export const metadata = { title: "Übersicht · Kita-Planer" }; export const metadata = { title: "Übersicht · Kita-Planer" };
export default async function DashboardPage() { export default async function DashboardPage() {
const session = await requireKitaSession(); const session = await requireKitaSession();
const now = new Date();
const kita = await prisma.kita.findUniqueOrThrow({ const kita = await prisma.kita.findUniqueOrThrow({
where: { id: session.user.kitaId }, where: { id: session.user.kitaId },
@@ -172,6 +174,62 @@ export default async function DashboardPage() {
}), }),
]); ]);
const familyChildIds = absenceChildren.map((child) => child.id);
const openRsvpTermine =
session.user.familyId && familyChildIds.length > 0
? await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.CONFIRMED,
requiresRsvp: true,
OR: [
{ rsvpDeadline: { gt: now } },
{ rsvpDeadline: null, startDate: { gt: now } },
],
},
select: {
id: true,
title: true,
startDate: true,
rsvpDeadline: true,
participations: {
where: {
childId: { in: familyChildIds },
},
select: {
childId: true,
status: true,
},
},
},
orderBy: { startDate: "asc" },
take: 5,
})
: [];
const rsvpActionEvents = openRsvpTermine
.map((termin) => {
const statusByChildId = new Map(
termin.participations.map((participation) => [
participation.childId,
participation.status,
]),
);
return {
id: termin.id,
title: termin.title,
startDate: termin.startDate.toISOString(),
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
children: absenceChildren.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
status: statusByChildId.get(child.id) ?? null,
})),
};
})
.filter((termin) => termin.children.some((child) => !child.status));
const isAdmin = const isAdmin =
session.user.role === UserRole.ADMIN || session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.SUPERADMIN; session.user.role === UserRole.SUPERADMIN;
@@ -204,6 +262,8 @@ export default async function DashboardPage() {
</p> </p>
</div> </div>
<RsvpActionCard events={rsvpActionEvents} />
<NewsTicker <NewsTicker
items={latestAnnouncements.map((announcement) => ({ items={latestAnnouncements.map((announcement) => ({
id: announcement.id, id: announcement.id,
@@ -307,9 +367,9 @@ export default async function DashboardPage() {
</Button> </Button>
{kita.terminModuleEnabled && ( {kita.terminModuleEnabled && (
<Button asChild size="sm" variant="outline"> <Button asChild size="sm" variant="outline">
<Link href="/dashboard/termine"> <Link href="/dashboard/kalender">
<CalendarCheck2 className="mr-2 h-4 w-4" /> <CalendarCheck2 className="mr-2 h-4 w-4" />
Termine (bald verfügbar) Terminkalender
</Link> </Link>
</Button> </Button>
)} )}
+204
View File
@@ -0,0 +1,204 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { EventParticipationStatus } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { AlertTriangle, Check, X } from "lucide-react";
import { toast } from "sonner";
import { submitEventRsvp } from "@/actions/rsvp";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export type DashboardRsvpEventDto = {
id: string;
title: string;
startDate: string;
rsvpDeadline: string | null;
children: {
id: string;
name: string;
status: EventParticipationStatus | null;
}[];
};
export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] }) {
if (events.length === 0) return null;
return (
<Card className="mb-8 border-amber-200 bg-amber-50/70">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-amber-950">
<AlertTriangle className="h-5 w-5 text-amber-700" />
Aktion erforderlich
</CardTitle>
<CardDescription className="text-amber-900/80">
Für diese Termine fehlt noch mindestens eine Rückmeldung deiner
Familie.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
{events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-3 rounded-md border bg-background p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="font-medium">{event.title}</p>
<p className="mt-1 text-sm text-muted-foreground">
Rückmeldung bis{" "}
{format(
new Date(event.rsvpDeadline ?? event.startDate),
"dd.MM.yyyy HH:mm",
{ locale: de },
)}
</p>
</div>
<RsvpDialog event={event} />
</div>
))}
</CardContent>
</Card>
);
}
function RsvpDialog({ event }: { event: DashboardRsvpEventDto }) {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const initialSelection = useMemo(
() =>
Object.fromEntries(
event.children.map((child) => [child.id, child.status ?? ""]),
) as Record<string, EventParticipationStatus | "">,
[event.children],
);
const [selection, setSelection] = useState(initialSelection);
function submit() {
const missingChild = event.children.find((child) => !selection[child.id]);
if (missingChild) {
toast.error(`Bitte Rückmeldung für ${missingChild.name} auswählen.`);
return;
}
startTransition(async () => {
for (const child of event.children) {
const status = selection[child.id];
if (!status) continue;
const result = await submitEventRsvp({
eventId: event.id,
childId: child.id,
status,
});
if (result.error) {
toast.error(result.error);
return;
}
}
toast.success("Rückmeldung gespeichert.");
setOpen(false);
});
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen) setSelection(initialSelection);
}}
>
<DialogTrigger asChild>
<Button size="sm">Jetzt abstimmen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Rückmeldung für {event.title}</DialogTitle>
<DialogDescription>
Wähle pro Kind aus, ob es am Termin teilnimmt.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
{event.children.map((child) => (
<div key={child.id} className="rounded-md border p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="font-medium">{child.name}</p>
{child.status && (
<Badge variant="outline">bereits gemeldet</Badge>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<label className="flex cursor-pointer items-center gap-2 rounded-md border p-3 text-sm hover:bg-muted/50">
<input
type="radio"
name={`rsvp-${child.id}`}
checked={
selection[child.id] ===
EventParticipationStatus.ATTENDING
}
onChange={() =>
setSelection((current) => ({
...current,
[child.id]: EventParticipationStatus.ATTENDING,
}))
}
/>
<Check className="h-4 w-4 text-emerald-600" />
Nimmt teil
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-md border p-3 text-sm hover:bg-muted/50">
<input
type="radio"
name={`rsvp-${child.id}`}
checked={
selection[child.id] ===
EventParticipationStatus.NOT_ATTENDING
}
onChange={() =>
setSelection((current) => ({
...current,
[child.id]: EventParticipationStatus.NOT_ATTENDING,
}))
}
/>
<X className="h-4 w-4 text-rose-600" />
Nimmt nicht teil
</label>
</div>
</div>
))}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Abbrechen
</Button>
<Button type="button" disabled={isPending} onClick={submit}>
{isPending ? "Speichern..." : "Rückmeldung speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+327
View File
@@ -0,0 +1,327 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
ArrowLeft,
ArrowRight,
CheckCircle2,
HelpCircle,
ShieldCheck,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
getMarketingFeature,
marketingFeatures,
} from "@/lib/marketing/features";
type FeaturePageProps = {
params: Promise<{
slug: string;
}>;
};
export const dynamicParams = false;
export function generateStaticParams() {
return marketingFeatures.map((feature) => ({
slug: feature.slug,
}));
}
export async function generateMetadata({
params,
}: FeaturePageProps): Promise<Metadata> {
const { slug } = await params;
const feature = getMarketingFeature(slug);
if (!feature) {
return {
title: "Feature nicht gefunden",
};
}
return {
title: feature.seoTitle,
description: feature.seoDescription,
openGraph: {
title: feature.seoTitle,
description: feature.seoDescription,
type: "website",
locale: "de_DE",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Ein Kind spielt mit bunten Holzklötzen.",
},
],
},
twitter: {
card: "summary_large_image",
title: feature.seoTitle,
description: feature.seoDescription,
images: ["/og-image.png"],
},
};
}
export default async function FeaturePage({ params }: FeaturePageProps) {
const { slug } = await params;
const feature = getMarketingFeature(slug);
if (!feature) {
notFound();
}
const Icon = feature.icon;
return (
<div className="min-h-screen bg-[#f8faf8] text-slate-950">
<header className="border-b border-slate-200 bg-white/95">
<div className="mx-auto flex h-20 w-full max-w-7xl items-center justify-between px-5 sm:px-6">
<Link href="/" className="flex items-center gap-3 text-slate-950">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-950 text-white">
<ShieldCheck className="h-5 w-5" />
</span>
<span className="text-base font-semibold tracking-tight">
Kita-Planer
</span>
</Link>
<nav className="flex items-center gap-2">
<Button asChild variant="ghost">
<Link href="/login">Login</Link>
</Button>
<Button asChild>
<Link href="/#kontakt">Demo anfragen</Link>
</Button>
</nav>
</div>
</header>
<main>
<section className="bg-slate-950 px-5 py-20 text-white sm:px-6 lg:py-24">
<div className="mx-auto grid w-full max-w-7xl gap-12 lg:grid-cols-[0.95fr_1.05fr] lg:items-center">
<div>
<Button
asChild
variant="ghost"
className="-ml-3 mb-8 text-slate-300 hover:bg-white/10 hover:text-white"
>
<Link href="/">
<ArrowLeft className="h-4 w-4" />
Zur Startseite
</Link>
</Button>
<Badge className="mb-6 border-white/15 bg-white/10 text-white hover:bg-white/10">
{feature.eyebrow}
</Badge>
<h1 className="max-w-4xl text-balance text-4xl font-semibold leading-tight sm:text-5xl lg:text-6xl">
{feature.heroTitle}
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-300">
{feature.heroDescription}
</p>
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
<Button
asChild
size="lg"
className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90"
>
<Link href="/#kontakt">
Demo anfragen
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<Button
asChild
size="lg"
variant="outline"
className="h-12 border-white/35 bg-white/10 px-6 text-white hover:bg-white/20 hover:text-white"
>
<Link href="/register">Kita registrieren</Link>
</Button>
</div>
</div>
<div className="rounded-lg border border-white/10 bg-white/[0.06] p-6 shadow-2xl shadow-slate-950/30">
<div
className={`mb-6 flex h-14 w-14 items-center justify-center rounded-lg ring-1 ${feature.accent}`}
>
<Icon className="h-7 w-7" />
</div>
<p className="text-sm font-semibold uppercase tracking-wide text-emerald-200">
In einem Satz
</p>
<p className="mt-4 text-2xl font-semibold leading-snug">
{feature.description}
</p>
<div className="mt-8 grid gap-3 text-sm text-slate-300">
{feature.benefits.slice(0, 3).map((benefit) => (
<div key={benefit.title} className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-300" />
<span>{benefit.title}</span>
</div>
))}
</div>
</div>
</div>
</section>
<section className="mx-auto grid w-full max-w-7xl gap-6 px-5 py-20 sm:px-6 lg:grid-cols-2">
<Card className="border-slate-200 bg-white">
<CardHeader>
<CardTitle className="text-2xl tracking-tight">
{feature.problemTitle}
</CardTitle>
<CardDescription className="text-base leading-7 text-slate-600">
{feature.problemText}
</CardDescription>
</CardHeader>
</Card>
<Card className="border-slate-200 bg-white">
<CardHeader>
<CardTitle className="text-2xl tracking-tight">
{feature.solutionTitle}
</CardTitle>
<CardDescription className="text-base leading-7 text-slate-600">
{feature.solutionText}
</CardDescription>
</CardHeader>
</Card>
</section>
<section className="px-5 pb-20 sm:px-6">
<div className="mx-auto w-full max-w-7xl">
<div className="mb-10 max-w-3xl">
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
Was das konkret bringt
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Weniger Abstimmungslast, mehr Klarheit im Kita-Alltag.
</h2>
</div>
<div className="grid gap-5 md:grid-cols-2">
{feature.benefits.map((benefit) => (
<Card key={benefit.title} className="border-slate-200 bg-white">
<CardHeader>
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<CheckCircle2 className="h-5 w-5" />
</div>
<CardTitle className="text-xl tracking-tight">
{benefit.title}
</CardTitle>
<CardDescription className="text-sm leading-6 text-slate-600">
{benefit.text}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
</section>
<section className="bg-slate-950 px-5 py-18 text-white sm:px-6">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<div className="max-w-3xl">
<Badge className="mb-5 border-emerald-300/20 bg-emerald-300/10 text-emerald-100 hover:bg-emerald-300/10">
Vertrauen & Datenschutz
</Badge>
<h2 className="text-3xl font-semibold tracking-tight">
{feature.trustTitle}
</h2>
<p className="mt-4 text-base leading-7 text-slate-300">
{feature.trustText}
</p>
</div>
<Button
asChild
size="lg"
className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90 lg:shrink-0"
>
<Link href="/datenschutz">Datenschutz ansehen</Link>
</Button>
</div>
</section>
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6">
<div className="mb-10 max-w-3xl">
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
Fragen aus dem Vorstand
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Kurz beantwortet.
</h2>
</div>
<div className="grid gap-4 lg:grid-cols-3">
{feature.faqs.map((faq) => (
<Card key={faq.question} className="border-slate-200 bg-white">
<CardHeader>
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-slate-100 text-slate-700">
<HelpCircle className="h-5 w-5" />
</div>
<CardTitle className="text-lg leading-snug">
{faq.question}
</CardTitle>
<CardDescription className="text-sm leading-6 text-slate-600">
{faq.answer}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</section>
<section className="px-5 pb-20 sm:px-6">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 rounded-lg border border-slate-200 bg-white p-8 shadow-sm sm:p-10 md:flex-row md:items-center md:justify-between">
<div className="max-w-2xl">
<h2 className="text-2xl font-semibold tracking-tight">
Wollt ihr {feature.shortTitle} im echten Kita-Alltag sehen?
</h2>
<p className="mt-3 text-sm leading-6 text-slate-600">
Schreibt uns kurz, wie ihr heute organisiert seid. Wir zeigen
euch, wie Kita-Planer zu eurem Elternverein passen kann.
</p>
</div>
<Button asChild size="lg">
<Link href="/#kontakt">
Demo anfragen
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div>
</section>
</main>
<footer className="border-t border-slate-200 bg-white">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-5 py-8 text-sm text-slate-500 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<span>© {new Date().getFullYear()} Kita-Planer</span>
<nav className="flex gap-5">
<Link href="/impressum" className="hover:text-slate-950">
Impressum
</Link>
<Link href="/datenschutz" className="hover:text-slate-950">
Datenschutz
</Link>
<Link href="/#kontakt" className="hover:text-slate-950">
Kontakt
</Link>
</nav>
</div>
</footer>
</div>
);
}
+2 -1
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getPostLoginRedirect } from "@/lib/post-login-redirect";
import { import {
Card, Card,
CardContent, CardContent,
@@ -17,7 +18,7 @@ export const metadata = { title: "Anmelden · Kita-Planer" };
export default async function LoginPage() { export default async function LoginPage() {
const session = await auth(); const session = await auth();
if (session?.user?.id) { if (session?.user?.id) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding"); redirect(getPostLoginRedirect(session.user));
} }
return ( return (
+265 -90
View File
@@ -1,24 +1,30 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { import {
ArrowRight, ArrowRight,
CalendarDays,
CheckCircle2, CheckCircle2,
ClipboardList,
DatabaseZap, DatabaseZap,
FileStack,
Fingerprint, Fingerprint,
HeartHandshake,
LockKeyhole, LockKeyhole,
Mail, Mail,
Megaphone,
MessageSquareText, MessageSquareText,
ShieldAlert, MessagesSquare,
PhoneCall,
Route,
ShieldCheck, ShieldCheck,
Stethoscope, Table2,
Trash2, Trash2,
UserCog,
UsersRound, UsersRound,
} from "lucide-react"; } from "lucide-react";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getPostLoginRedirect } from "@/lib/post-login-redirect";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -28,16 +34,17 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { marketingFeatures } from "@/lib/marketing/features";
import { ContactForm } from "./contact-form"; import { ContactForm } from "./contact-form";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Kita-Planer für Elternvereine", title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description: description:
"Die einfache Plattform für Elternvereine, Dienste und Kommunikation: plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.", "Die einfache Plattform für Elterninitiativen, freie Kitas und Kita-Vereine: organisiert Dienste, Termine, Krankmeldungen und offizielle Kommunikation an einem Ort.",
openGraph: { openGraph: {
title: "Kita-Planer für Elternvereine", title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description: description:
"Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.", "Weniger Chat-Chaos, weniger Excel-Listen, mehr Ruhe im ehrenamtlichen Kita-Alltag.",
type: "website", type: "website",
locale: "de_DE", locale: "de_DE",
images: [ images: [
@@ -51,9 +58,9 @@ export const metadata: Metadata = {
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "Kita-Planer für Elternvereine", title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description: description:
"Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.", "Organisiert Dienste, Termine, Krankmeldungen und offizielle Kommunikation an einem Ort.",
images: ["/og-image.png"], images: ["/og-image.png"],
}, },
}; };
@@ -61,55 +68,15 @@ export const metadata: Metadata = {
const heroImage = const heroImage =
"https://images.unsplash.com/photo-1503454537195-1dcabb73ffb9?auto=format&fit=crop&w=2400&q=86"; "https://images.unsplash.com/photo-1503454537195-1dcabb73ffb9?auto=format&fit=crop&w=2400&q=86";
const features = [
{
icon: ShieldAlert,
title: "Fairer Notdienst-Planer",
description:
"Automatische Generierung, gerechte Verteilung und 1-Klick-Alarmierung, wenn morgens Ersatz gebraucht wird.",
accent: "bg-red-50 text-red-700 ring-red-100",
},
{
icon: CalendarDays,
title: "Smarter Kalender",
description:
"Feste, Termine, Raumbuchungen und digitale Mitbring-Listen in einem verbindlichen Kalender für alle Familien.",
accent: "bg-sky-50 text-sky-700 ring-sky-100",
},
{
icon: UsersRound,
title: "Sicheres Adressbuch",
description:
"Haushaltsbasiert, DSGVO-konform und mit strengem Opt-In: Kontaktdaten werden nur sichtbar, wenn Eltern zustimmen.",
accent: "bg-emerald-50 text-emerald-700 ring-emerald-100",
},
{
icon: Stethoscope,
title: "Digitale Krankmeldung",
description:
"Ein Klick statt Dauerläuten am Morgen. Eltern melden Kinder digital ab, ErzieherInnen sehen auf dem Tablet sofort den Überblick für den Morgenkreis.",
accent: "bg-amber-50 text-amber-700 ring-amber-100",
badge: "Neu",
},
{
icon: Megaphone,
title: "Schwarzes Brett",
description:
"Der WhatsApp-Killer für Vorstände: offizielle Ankündigungen mit Dateianhängen, Lesebestätigung und optionalem E-Mail-Push.",
accent: "bg-indigo-50 text-indigo-700 ring-indigo-100",
badge: "Neu",
},
];
const privacyPoints = [ const privacyPoints = [
{ {
icon: DatabaseZap, icon: DatabaseZap,
title: "Strikt isolierte Mandanten", title: "Strikt isolierte Mandanten",
text: "Jede Kita arbeitet innerhalb ihrer eigenen kitaId-Grenze. Datenabfragen sind konsequent tenant-isoliert.", text: "Jede Kita arbeitet innerhalb ihrer eigenen kitaId-Grenze. Rollen und Datenabfragen bleiben konsequent tenant-isoliert.",
}, },
{ {
icon: Fingerprint, icon: Fingerprint,
title: "Privacy by Design", title: "Kontaktfreigabe per Opt-in",
text: "Adressbuchdaten erscheinen nur nach aktivem Opt-In pro Elternteil. Nicht freigegebene Kontaktdaten verlassen den Server nicht.", text: "Adressbuchdaten erscheinen nur nach aktivem Opt-In pro Elternteil. Nicht freigegebene Kontaktdaten verlassen den Server nicht.",
}, },
{ {
@@ -122,25 +89,100 @@ const privacyPoints = [
title: "Geschützte Anhänge", title: "Geschützte Anhänge",
text: "Dokumente vom Schwarzen Brett liegen nicht öffentlich und werden nur nach Session- und Kita-Prüfung ausgeliefert.", text: "Dokumente vom Schwarzen Brett liegen nicht öffentlich und werden nur nach Session- und Kita-Prüfung ausgeliefert.",
}, },
{
icon: UserCog,
title: "Rollenrechte pro Kita",
text: "Vorstand, Koordination, Eltern und Team sehen nur die Bereiche, die zu ihrer Aufgabe und Kita-Zugehörigkeit passen.",
},
{
icon: FileStack,
title: "Klare Datenpfade",
text: "Löschungen sind bewusst vorgesehen; Fragen zu Export und Übergabe werden im Setup sauber geklärt.",
},
]; ];
const proofPoints = [ const proofPoints = [
"Notdienst, Kalender und Abwesenheiten in einem System", "Für Elterninitiativen, freie Kitas und Kita-Vereine",
"Gebaut für Vorstände, Eltern und ErzieherInnen", "Dienste, Kalender, Abwesenheiten und offizielle Infos in einem System",
"Klare Rollenrechte statt Chat-Chaos", "Gebaut für Vorstände, Eltern, Koordination und Team",
];
const replacedTools = [
{
icon: MessagesSquare,
title: "WhatsApp für offizielle Infos",
text: "Ankündigungen, Anhänge und Lesestatus gehören in einen verbindlichen Kanal, nicht zwischen spontane Rückfragen.",
},
{
icon: Table2,
title: "Excel-Listen für Dienste",
text: "Verfügbarkeiten, Mindesttage und Dienstpläne werden strukturierter als in Tabellen mit Version 17-final-final.",
},
{
icon: FileStack,
title: "Verstreute PDFs und Mail-Anhänge",
text: "Wichtige Dokumente hängen am passenden Kontext und bleiben geschützt abrufbar.",
},
{
icon: ClipboardList,
title: "Manuelle Kontaktlisten",
text: "Familien, Kinder und freigegebene Kontaktdaten werden kontrolliert statt lose herumgereicht.",
},
{
icon: PhoneCall,
title: "Telefonchaos bei Krankmeldungen",
text: "Eltern melden Abwesenheiten digital; das Team sieht morgens schneller, wer fehlt.",
},
];
const audienceRoles = [
{
icon: UserCog,
title: "Vorstand & Admin",
text: "Kita, Familien, Termine, Einladungen und Rechte verwalten, ohne dass alles an einer privaten Ablage hängt.",
},
{
icon: UsersRound,
title: "Eltern",
text: "Termine sehen, Kinder krankmelden, Verfügbarkeiten eintragen und freigegebene Kontakte finden.",
},
{
icon: HeartHandshake,
title: "ErzieherInnen & Koordination",
text: "Tagesüberblick, Abwesenheiten, Dienste und relevante Informationen an einem Ort sehen.",
},
];
const onboardingSteps = [
{
title: "Demo anfragen",
text: "Ihr beschreibt kurz eure aktuelle Organisation und die größten Reibungspunkte.",
},
{
title: "Setup mit Kita und Vorstand klären",
text: "Wir schauen gemeinsam auf Rollen, Module, Datenschutzfragen und den sinnvollen Einstieg.",
},
{
title: "Familien einladen oder anlegen",
text: "Der Vorstand bringt Eltern, Kinder und Zuständigkeiten in eine saubere Struktur.",
},
{
title: "Module im Alltag nutzen",
text: "Dienste, Kalender, Krankmeldungen, Adressbuch und Schwarzes Brett ersetzen nach und nach Nebenkanäle.",
},
]; ];
// Eingeloggte User von der Landingpage direkt weiterleiten. // 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?.id) { if (session?.user?.id) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding"); redirect(getPostLoginRedirect(session.user));
} }
return ( return (
<div className="min-h-screen bg-[#f8faf8] text-slate-950"> <div className="min-h-screen bg-[#f8faf8] text-slate-950">
<header className="absolute left-0 right-0 top-0 z-20"> <header className="absolute left-0 right-0 top-0 z-20">
<div className="mx-auto flex h-20 w-full max-w-7xl items-center justify-between px-5 sm:px-6"> <div className="mx-auto flex min-h-20 w-full max-w-7xl items-center justify-between gap-3 px-5 py-4 sm:px-6">
<Link href="/" className="flex items-center gap-3 text-white"> <Link href="/" className="flex items-center gap-3 text-white">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/12 ring-1 ring-white/30"> <span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/12 ring-1 ring-white/30">
<ShieldCheck className="h-5 w-5" /> <ShieldCheck className="h-5 w-5" />
@@ -150,7 +192,7 @@ export default async function LandingPage() {
</span> </span>
</Link> </Link>
<nav className="flex items-center gap-2"> <nav className="flex flex-wrap items-center justify-end gap-2">
<Button <Button
asChild asChild
variant="ghost" variant="ghost"
@@ -162,36 +204,40 @@ export default async function LandingPage() {
asChild asChild
className="bg-white text-slate-950 shadow-sm hover:bg-white/90" className="bg-white text-slate-950 shadow-sm hover:bg-white/90"
> >
<Link href="/register">Kita registrieren</Link> <Link href="/#kontakt">Demo anfragen</Link>
</Button> </Button>
</nav> </nav>
</div> </div>
</header> </header>
<main> <main>
<section <section className="relative min-h-[calc(100svh-56px)] overflow-hidden bg-slate-950">
className="relative min-h-[calc(100svh-56px)] overflow-hidden bg-slate-950" <Image
style={{ src={heroImage}
backgroundImage: `linear-gradient(90deg, rgba(2, 6, 23, 0.9), rgba(15, 23, 42, 0.68), rgba(15, 23, 42, 0.18)), url(${heroImage})`, alt=""
backgroundPosition: "center", fill
backgroundSize: "cover", preload
}} sizes="100vw"
> className="object-cover object-center"
/>
<div
aria-hidden
className="absolute inset-0 bg-[linear-gradient(90deg,rgba(2,6,23,0.92),rgba(15,23,42,0.72)_45%,rgba(15,23,42,0.35)_75%,rgba(15,23,42,0.55))] sm:bg-[linear-gradient(90deg,rgba(2,6,23,0.9),rgba(15,23,42,0.68),rgba(15,23,42,0.18))]"
/>
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-[#f8faf8] to-transparent" /> <div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-[#f8faf8] to-transparent" />
<div className="relative z-10 mx-auto flex min-h-[calc(100svh-56px)] w-full max-w-7xl items-center px-5 pb-20 pt-28 sm:px-6"> <div className="relative z-10 mx-auto flex min-h-[calc(100svh-56px)] w-full max-w-7xl items-center px-5 pb-20 pt-28 sm:px-6">
<div className="max-w-4xl text-white"> <div className="max-w-4xl text-white">
<Badge className="mb-6 border-white/20 bg-white/12 text-white hover:bg-white/12"> <Badge className="mb-6 border-white/20 bg-white/12 text-white hover:bg-white/12">
Kita-Betriebssystem für Elternvereine Kita-Betriebssystem für Elterninitiativen und Kita-Vereine
</Badge> </Badge>
<h1 className="max-w-4xl text-balance text-5xl font-semibold leading-[1.02] sm:text-6xl lg:text-7xl"> <h1 className="max-w-4xl text-balance text-5xl font-semibold leading-[1.02] sm:text-6xl lg:text-7xl">
Die einfache Plattform für Elternvereine, Dienste und Weniger Chat-Chaos. Weniger Excel. Mehr Ruhe im Kita-Verein.
Kommunikation.
</h1> </h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-white/82 sm:text-xl"> <p className="mt-6 max-w-2xl text-lg leading-8 text-white/82 sm:text-xl">
Organisiert Notdienste, Termine, Krankmeldungen und offizielle Für Elterninitiativen, freie Kitas und Kita-Vereine, die
Kommunikation an einem Ort. Schluss mit Zettelwirtschaft und Dienste, Termine, Krankmeldungen und offizielle Kommunikation
WhatsApp-Chaos. ehrenamtlich organisieren.
</p> </p>
<div className="mt-9 flex flex-col gap-3 sm:flex-row"> <div className="mt-9 flex flex-col gap-3 sm:flex-row">
@@ -200,8 +246,8 @@ export default async function LandingPage() {
size="lg" size="lg"
className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90" className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90"
> >
<Link href="/register"> <Link href="/#kontakt">
Kita registrieren Demo anfragen
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Link> </Link>
</Button> </Button>
@@ -211,11 +257,11 @@ export default async function LandingPage() {
variant="outline" variant="outline"
className="h-12 border-white/35 bg-white/10 px-6 text-white hover:bg-white/20 hover:text-white" className="h-12 border-white/35 bg-white/10 px-6 text-white hover:bg-white/20 hover:text-white"
> >
<Link href="/login">Login</Link> <Link href="/register">Kita kostenlos registrieren</Link>
</Button> </Button>
</div> </div>
<div className="mt-10 grid max-w-3xl gap-3 text-sm text-white/78 md:grid-cols-3"> <div className="mt-10 grid max-w-4xl gap-3 text-sm text-white/78 md:grid-cols-3">
{proofPoints.map((item) => ( {proofPoints.map((item) => (
<div key={item} className="flex items-center gap-2"> <div key={item} className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-300" /> <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-300" />
@@ -227,6 +273,87 @@ export default async function LandingPage() {
</div> </div>
</section> </section>
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6 lg:py-24">
<div className="mb-10 grid gap-5 lg:grid-cols-[0.85fr_1.15fr] lg:items-end">
<div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
Raus aus den Nebenkanälen
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Kita-Planer ersetzt die Dinge, die Vorstände abends noch
zusammenhalten müssen.
</h2>
</div>
<p className="max-w-2xl text-sm leading-6 text-slate-600 lg:justify-self-end">
Elternvereine funktionieren nur, wenn Informationen zuverlässig
ankommen. Deshalb bündelt Kita-Planer die wiederkehrenden
Alltagslisten, Anfragen und offiziellen Nachrichten an einem Ort.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
{replacedTools.map((item) => {
const Icon = item.icon;
return (
<div
key={item.title}
className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm"
>
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<Icon className="h-5 w-5" />
</div>
<h3 className="font-semibold tracking-tight">{item.title}</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
{item.text}
</p>
</div>
);
})}
</div>
</section>
<section className="border-y border-slate-200 bg-white px-5 py-20 sm:px-6">
<div className="mx-auto grid w-full max-w-7xl gap-10 lg:grid-cols-[0.9fr_1.1fr] lg:items-start">
<div>
<Badge className="mb-5 bg-slate-950 text-white hover:bg-slate-950">
Für den Vereinsalltag
</Badge>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Eine Oberfläche für Vorstand, Eltern und Kita-Team.
</h2>
<p className="mt-5 max-w-xl text-sm leading-6 text-slate-600">
Kita-Planer trennt Zuständigkeiten, ohne neue Silos zu bauen.
Jede Rolle sieht die Aufgaben und Informationen, die sie im
gemeinsamen Kita-Alltag wirklich braucht.
</p>
</div>
<div className="grid gap-4">
{audienceRoles.map((role) => {
const Icon = role.icon;
return (
<div
key={role.title}
className="grid gap-4 rounded-lg border border-slate-200 bg-[#f8faf8] p-5 shadow-sm sm:grid-cols-[auto_1fr] sm:items-start"
>
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-white text-emerald-700 ring-1 ring-emerald-100">
<Icon className="h-5 w-5" />
</div>
<div>
<h3 className="font-semibold tracking-tight">
{role.title}
</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
{role.text}
</p>
</div>
</div>
);
})}
</div>
</div>
</section>
<section className="mx-auto w-full max-w-7xl px-5 py-24 sm:px-6"> <section className="mx-auto w-full max-w-7xl px-5 py-24 sm:px-6">
<div className="mb-12 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between"> <div className="mb-12 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl"> <div className="max-w-3xl">
@@ -246,7 +373,7 @@ export default async function LandingPage() {
</div> </div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-6"> <div className="grid gap-5 md:grid-cols-2 xl:grid-cols-6">
{features.map((feature, index) => { {marketingFeatures.map((feature, index) => {
const Icon = feature.icon; const Icon = feature.icon;
const isWide = index > 2; const isWide = index > 2;
return ( return (
@@ -270,12 +397,22 @@ export default async function LandingPage() {
)} )}
</div> </div>
<div> <div>
<CardTitle className="text-xl tracking-tight"> <CardTitle as="h3" className="text-xl tracking-tight">
{feature.title} {feature.title}
</CardTitle> </CardTitle>
<CardDescription className="mt-3 text-sm leading-6 text-slate-600"> <CardDescription className="mt-3 text-sm leading-6 text-slate-600">
{feature.description} {feature.description}
</CardDescription> </CardDescription>
<Button
asChild
variant="link"
className="mt-4 h-auto p-0 text-slate-950"
>
<Link href={`/features/${feature.slug}`}>
Mehr erfahren
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div> </div>
</CardHeader> </CardHeader>
</Card> </Card>
@@ -284,6 +421,44 @@ export default async function LandingPage() {
</div> </div>
</section> </section>
<section className="mx-auto w-full max-w-7xl px-5 pb-24 sm:px-6">
<div className="grid gap-10 rounded-lg border border-slate-200 bg-white p-6 shadow-sm lg:grid-cols-[0.85fr_1.15fr] lg:p-10">
<div>
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<Route className="h-6 w-6" />
</div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
So startet ihr
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Erst verstehen, dann sauber einführen.
</h2>
<p className="mt-5 text-sm leading-6 text-slate-600">
Elternvereine haben wenig Zeit für Tool-Einführung. Der Einstieg
soll deshalb mit euren echten Abläufen beginnen, nicht mit einer
langen Konfigurationsliste.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{onboardingSteps.map((step, index) => (
<div
key={step.title}
className="rounded-lg border border-slate-200 bg-[#f8faf8] p-5"
>
<div className="mb-4 flex h-8 w-8 items-center justify-center rounded-full bg-slate-950 text-sm font-semibold text-white">
{index + 1}
</div>
<h3 className="font-semibold tracking-tight">{step.title}</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
{step.text}
</p>
</div>
))}
</div>
</div>
</section>
<section className="bg-slate-950 px-5 py-24 text-white sm:px-6"> <section className="bg-slate-950 px-5 py-24 text-white sm:px-6">
<div className="mx-auto grid w-full max-w-7xl gap-12 lg:grid-cols-[0.92fr_1.08fr] lg:items-start"> <div className="mx-auto grid w-full max-w-7xl gap-12 lg:grid-cols-[0.92fr_1.08fr] lg:items-start">
<div> <div>
@@ -297,14 +472,14 @@ export default async function LandingPage() {
Datenschutz ist nicht die Fußnote. Er ist die Architektur. Datenschutz ist nicht die Fußnote. Er ist die Architektur.
</h2> </h2>
<p className="mt-5 max-w-xl text-base leading-7 text-slate-300"> <p className="mt-5 max-w-xl text-base leading-7 text-slate-300">
Elternvereine verwalten besonders sensible Daten. Deshalb ist Kita-Vereine verwalten besonders sensible Daten. Deshalb ist
Kita-Planer so gebaut, dass Sichtbarkeit, Löschung und Kita-Planer so gebaut, dass Sichtbarkeit, Rollenrechte,
Mandantentrennung nicht vom guten Willen einzelner Chats Löschung und Mandantentrennung nicht vom guten Willen einzelner
abhängen. Chats abhängen.
</p> </p>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{privacyPoints.map((point) => { {privacyPoints.map((point) => {
const Icon = point.icon; const Icon = point.icon;
return ( return (
@@ -331,19 +506,19 @@ export default async function LandingPage() {
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div className="max-w-2xl"> <div className="max-w-2xl">
<h2 className="text-2xl font-semibold tracking-tight"> <h2 className="text-2xl font-semibold tracking-tight">
Bereit für eine Kita-Organisation ohne Nebenkanäle? Bereit für weniger Nebenkanäle im Kita-Verein?
</h2> </h2>
<p className="mt-3 text-sm leading-6 text-slate-600"> <p className="mt-3 text-sm leading-6 text-slate-600">
Registriere eure Kita, lade den Vorstand ein und starte mit Fragt eine Demo an und prüft gemeinsam, welche Abläufe zuerst
einem System, das für Elternvereine und Datenschutz gebaut ist. aus Chat, Tabelle und Telefonliste herauswandern sollten.
</p> </p>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row"> <div className="flex flex-col gap-3 sm:flex-row">
<Button asChild size="lg"> <Button asChild size="lg">
<Link href="/register">Kita registrieren</Link> <Link href="/#kontakt">Demo anfragen</Link>
</Button> </Button>
<Button asChild size="lg" variant="outline"> <Button asChild size="lg" variant="outline">
<Link href="/login">Login</Link> <Link href="/register">Kita kostenlos registrieren</Link>
</Button> </Button>
</div> </div>
</div> </div>
@@ -389,7 +564,7 @@ export default async function LandingPage() {
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100"> <div className="mb-2 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<Mail className="h-5 w-5" /> <Mail className="h-5 w-5" />
</div> </div>
<CardTitle className="text-2xl tracking-tight"> <CardTitle as="h3" className="text-2xl tracking-tight">
Anfrage senden Anfrage senden
</CardTitle> </CardTitle>
<CardDescription className="text-sm leading-6"> <CardDescription className="text-sm leading-6">
+2 -1
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getPostLoginRedirect } from "@/lib/post-login-redirect";
import { import {
Card, Card,
CardContent, CardContent,
@@ -17,7 +18,7 @@ export const metadata = { title: "Registrieren · Kita-Planer" };
export default async function RegisterPage() { export default async function RegisterPage() {
const session = await auth(); const session = await auth();
if (session?.user) { if (session?.user) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding"); redirect(getPostLoginRedirect(session.user));
} }
return ( return (
+28
View File
@@ -0,0 +1,28 @@
import type { MetadataRoute } from "next";
function getSiteUrl() {
return process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de";
}
export default function robots(): MetadataRoute.Robots {
const siteUrl = getSiteUrl();
return {
rules: {
userAgent: "*",
allow: ["/", "/features/", "/impressum", "/datenschutz"],
disallow: [
"/api/",
"/dashboard/",
"/invite/",
"/alert/",
"/login",
"/register",
"/onboarding/",
"/forbidden",
],
},
sitemap: `${siteUrl}/sitemap.xml`,
host: siteUrl,
};
}
+39
View File
@@ -0,0 +1,39 @@
import type { MetadataRoute } from "next";
import { marketingFeatures } from "@/lib/marketing/features";
function getSiteUrl() {
return process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de";
}
export default function sitemap(): MetadataRoute.Sitemap {
const siteUrl = getSiteUrl();
const lastModified = new Date();
return [
{
url: siteUrl,
lastModified,
changeFrequency: "weekly",
priority: 1,
},
{
url: `${siteUrl}/impressum`,
lastModified,
changeFrequency: "yearly",
priority: 0.2,
},
{
url: `${siteUrl}/datenschutz`,
lastModified,
changeFrequency: "yearly",
priority: 0.2,
},
...marketingFeatures.map((feature) => ({
url: `${siteUrl}/features/${feature.slug}`,
lastModified,
changeFrequency: "monthly" as const,
priority: 0.8,
})),
];
}
@@ -1,11 +1,14 @@
import Script from "next/script"; import Script from "next/script";
import { marketingFeatureSlugs } from "@/lib/marketing/features";
const publicAnalyticsPaths = new Set([ const publicAnalyticsPaths = new Set([
"/", "/",
"/impressum", "/impressum",
"/datenschutz", "/datenschutz",
"/login", "/login",
"/register", "/register",
...marketingFeatureSlugs.map((slug) => `/features/${slug}`),
]); ]);
export function UmamiAnalytics() { export function UmamiAnalytics() {
+7
View File
@@ -72,6 +72,13 @@ const navItems = [
exact: false, exact: false,
allowedRoles: [UserRole.ADMIN], allowedRoles: [UserRole.ADMIN],
}, },
{
href: "/dashboard/admin/kalender",
label: "Event-Anmeldungen",
icon: CalendarCheck2,
exact: false,
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
},
{ {
href: "/dashboard/admin/abwesenheiten", href: "/dashboard/admin/abwesenheiten",
label: "Abwesenheiten", label: "Abwesenheiten",
+8 -4
View File
@@ -23,10 +23,14 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
); );
CardHeader.displayName = "CardHeader"; CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( type CardTitleProps = React.HTMLAttributes<HTMLElement> & {
({ className, ...props }, ref) => ( as?: "div" | "h2" | "h3" | "h4" | "h5" | "h6";
<div };
ref={ref}
const CardTitle = React.forwardRef<HTMLElement, CardTitleProps>(
({ className, as: Comp = "div", ...props }, ref) => (
<Comp
ref={ref as React.Ref<HTMLDivElement>}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)} className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props} {...props}
/> />
+2
View File
@@ -140,6 +140,8 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
return ( return (
<p <p
id={formMessageId} id={formMessageId}
role="alert"
aria-live="polite"
className={cn("text-sm text-destructive", className)} className={cn("text-sm text-destructive", className)}
{...props} {...props}
> >
+141
View File
@@ -0,0 +1,141 @@
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
import {
contactRequestTypeLabels,
type ContactRequestInput,
} from "@/lib/contact-schema";
type ContactConfirmationEmailProps = ContactRequestInput;
export function ContactConfirmationEmail({
name,
kitaName,
requestType,
message,
}: ContactConfirmationEmailProps) {
const inquiryLabel = contactRequestTypeLabels[requestType];
const firstName = name.split(" ")[0] || name;
return (
<Html>
<Head />
<Preview>
Wir haben deine Anfrage bei Kita-Planer erhalten.
</Preview>
<Body style={body}>
<Container style={container}>
<Heading style={heading}>Danke, {firstName}!</Heading>
<Text style={intro}>
Wir haben deine Anfrage erhalten und melden uns in der Regel
innerhalb von ein bis zwei Werktagen mit einem konkreten Vorschlag
bei dir.
</Text>
<Section style={summaryBox}>
<Text style={label}>Zusammenfassung deiner Anfrage</Text>
<InfoRow label="Anliegen" value={inquiryLabel} />
{kitaName ? <InfoRow label="Kita / Initiative" value={kitaName} /> : null}
<Text style={messageLabel}>Deine Nachricht</Text>
<Text style={messageText}>{message}</Text>
</Section>
<Text style={footer}>
Falls du etwas ergänzen möchtest, antworte einfach auf diese
E-Mail. Bis bald das Kita-Planer Team.
</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 summaryBox = {
border: "1px solid #d7e5dc",
borderRadius: "10px",
backgroundColor: "#ffffff",
padding: "18px",
};
const label = {
margin: "0 0 12px",
color: "#64748b",
fontSize: "12px",
fontWeight: 700,
letterSpacing: "0.04em",
textTransform: "uppercase" as const,
};
const row = {
margin: "0 0 10px",
color: "#0f172a",
fontSize: "15px",
lineHeight: "22px",
};
const messageLabel = {
margin: "14px 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",
};
+4
View File
@@ -19,3 +19,7 @@ export const contactRequestSchema = z.object({
}); });
export type ContactRequestInput = z.infer<typeof contactRequestSchema>; export type ContactRequestInput = z.infer<typeof contactRequestSchema>;
export type ContactSubmissionPayload = ContactRequestInput & {
website?: string;
};
+350
View File
@@ -0,0 +1,350 @@
import type { LucideIcon } from "lucide-react";
import {
CalendarDays,
Megaphone,
ShieldAlert,
Stethoscope,
UsersRound,
} from "lucide-react";
export type MarketingFeature = {
slug: string;
icon: LucideIcon;
title: string;
shortTitle: string;
description: string;
accent: string;
badge?: string;
eyebrow: string;
heroTitle: string;
heroDescription: string;
seoTitle: string;
seoDescription: string;
problemTitle: string;
problemText: string;
solutionTitle: string;
solutionText: string;
benefits: {
title: string;
text: string;
}[];
trustTitle: string;
trustText: string;
faqs: {
question: string;
answer: string;
}[];
};
export const marketingFeatures: MarketingFeature[] = [
{
slug: "notdienst-planer",
icon: ShieldAlert,
title: "Fairer Notdienst-Planer",
shortTitle: "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",
eyebrow: "Notdienst ohne Excel-Lotterie",
heroTitle: "Faire Notdienstplanung für Elterninitiativen.",
heroDescription:
"Eltern tragen ihre Verfügbarkeiten ein, Vorstände behalten den Überblick und der Monatsplan entsteht nachvollziehbar statt in endlosen Chatverläufen.",
seoTitle: "Notdienst-Planer für Kita-Elternvereine",
seoDescription:
"Plant Notdienste fair, digital und nachvollziehbar: Verfügbarkeiten erfassen, Mindesttage berücksichtigen und Ersatz schneller finden.",
problemTitle: "Wenn morgens plötzlich Ersatz gebraucht wird",
problemText:
"In vielen Elterninitiativen hängt der Notdienst an Tabellen, Bauchgefühl und der Hoffnung, dass irgendwer im Chat rechtzeitig reagiert. Das ist für Vorstände anstrengend und für Eltern oft schwer nachvollziehbar.",
solutionTitle: "Verfügbarkeiten einsammeln, Plan erzeugen, reagieren",
solutionText:
"Kita-Planer bündelt die Verfügbarkeiten der Familien, berücksichtigt Mindesttage pro Kind und unterstützt Koordinatoren dabei, den Monatsplan zu erstellen und bei Ausfällen schneller Ersatz zu finden.",
benefits: [
{
title: "Verfügbarkeiten pro Familie",
text: "Eltern geben digital an, an welchen Tagen sie unterstützen können. Der Vorstand arbeitet mit strukturierten Daten statt verstreuten Nachrichten.",
},
{
title: "Nachvollziehbare Verteilung",
text: "Mindesttage pro Kind und aktive Kinder werden berücksichtigt, damit die Planung transparent und fairer erklärbar bleibt.",
},
{
title: "Planung für Koordinatoren",
text: "Koordinatoren sehen den Zielmonat, können Pläne erzeugen und bei Bedarf manuell nachsteuern.",
},
{
title: "Schneller reagieren",
text: "Wenn morgens Ersatz nötig ist, hilft eine strukturierte Übersicht deutlich mehr als ein hektischer Chat.",
},
],
trustTitle: "Gemacht für sensible Kita-Abläufe",
trustText:
"Notdienst-Daten bleiben im geschützten Kita-Kontext und sind an Rollenrechte sowie die jeweilige Kita-Zugehörigkeit gebunden.",
faqs: [
{
question: "Können Eltern selbst eintragen, wann sie verfügbar sind?",
answer:
"Ja. Eltern können ihre möglichen Notdiensttage für den Zielmonat digital hinterlegen.",
},
{
question: "Kann der Vorstand den Plan noch anpassen?",
answer:
"Ja. Koordinatoren und Admins behalten die Übersicht und können bei Bedarf manuell nachsteuern.",
},
{
question: "Ersetzt das alle Abstimmungen im Notfall?",
answer:
"Nein, aber es schafft eine deutlich bessere Grundlage, um schnell passende Familien zu finden.",
},
],
},
{
slug: "kita-kalender",
icon: CalendarDays,
title: "Smarter Kalender",
shortTitle: "Kita-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",
eyebrow: "Termine, Dienste und Mitbringlisten",
heroTitle: "Ein Kita-Kalender, den Eltern wirklich nutzen.",
heroDescription:
"Alle wichtigen Termine, Dienstzuordnungen und Mitbringlisten an einem Ort, damit niemand nach der richtigen Nachricht im Chat suchen muss.",
seoTitle: "Kita-Kalender für Elternvereine",
seoDescription:
"Ein gemeinsamer Kalender für Elterninitiativen: Termine, Anfragen, Dienste und Mitbringlisten übersichtlich organisieren.",
problemTitle: "Termine verschwinden zu schnell",
problemText:
"Elternabende, Feste, Schließtage, Dienste und Mitbringlisten landen oft in unterschiedlichen Kanälen. Wer später nachschaut, findet nicht mehr sicher die aktuelle Information.",
solutionTitle: "Ein verbindlicher Ort für den Kita-Alltag",
solutionText:
"Kita-Planer zeigt bestätigte Termine, eigene Anfragen, Dienstzuordnungen und Mitbringlisten gemeinsam. Admins können Anfragen prüfen und offizielle Termine sauber veröffentlichen.",
benefits: [
{
title: "Termine im Überblick",
text: "Feste, Schließtage, Elternabende und andere Ereignisse werden zentral sichtbar.",
},
{
title: "Anfragen statt Nebenabsprachen",
text: "Eltern können Terminwünsche einreichen, die von Verantwortlichen geprüft werden.",
},
{
title: "Mitbringlisten direkt am Termin",
text: "Was gebraucht wird, steht dort, wo Eltern es erwarten: am passenden Termin.",
},
{
title: "Dienste sichtbar machen",
text: "Geplante Dienste werden im Kontext der kommenden Termine und Aufgaben angezeigt.",
},
],
trustTitle: "Verbindlichkeit statt Chat-Suche",
trustText:
"Der Kalender ist Teil des geschützten Kita-Bereichs und zeigt Informationen rollen- und mandantenbezogen an.",
faqs: [
{
question: "Können Eltern eigene Termine vorschlagen?",
answer:
"Ja. Terminanfragen können eingereicht und von berechtigten Personen geprüft werden.",
},
{
question: "Sind Mitbringlisten enthalten?",
answer:
"Ja. Termine können mit digitalen Mitbringlisten verbunden werden.",
},
{
question: "Wer sieht welche Termine?",
answer:
"Termine werden innerhalb der jeweiligen Kita angezeigt; Admins und Koordinatoren haben zusätzliche Prüf- und Verwaltungsfunktionen.",
},
],
},
{
slug: "eltern-adressbuch",
icon: UsersRound,
title: "Sicheres Adressbuch",
shortTitle: "Eltern-Adressbuch",
description:
"Haushaltsbasiert, DSGVO-konform gedacht und mit strengem Opt-In: Kontaktdaten werden nur sichtbar, wenn Eltern zustimmen.",
accent: "bg-emerald-50 text-emerald-700 ring-emerald-100",
eyebrow: "Kontakte nur mit Zustimmung",
heroTitle: "Ein Eltern-Adressbuch mit klarer Freigabe.",
heroDescription:
"Kontaktdaten werden nicht einfach herumgereicht, sondern nur sichtbar, wenn Eltern aktiv zustimmen.",
seoTitle: "Sicheres Eltern-Adressbuch für Kitas",
seoDescription:
"Ein digitales Kita-Adressbuch mit Opt-in, Haushalten und freigegebenen Kontaktdaten für Elternvereine und Elterninitiativen.",
problemTitle: "Kontaktlisten sind praktisch, aber sensibel",
problemText:
"Spielverabredungen und schnelle Rückfragen brauchen Kontaktdaten. Gleichzeitig möchten Eltern selbst entscheiden, welche Daten sichtbar sind.",
solutionTitle: "Haushalte, Kinder und Freigaben sauber trennen",
solutionText:
"Kita-Planer zeigt Familien und Kontaktdaten nur, wenn mindestens ein Elternteil aktiv zugestimmt hat. Nicht freigegebene Daten bleiben verborgen.",
benefits: [
{
title: "Opt-in statt Rundmail",
text: "Eltern geben bewusst frei, ob ihre Kontaktdaten im internen Adressbuch erscheinen.",
},
{
title: "Haushalte im Blick",
text: "Familien, Kinder und Elternteile werden gemeinsam verständlich dargestellt.",
},
{
title: "Nur freigegebene Daten",
text: "E-Mail und Telefonnummer werden nur dort angezeigt, wo eine Freigabe vorliegt.",
},
{
title: "Hilfreich für Dienste",
text: "Zugeordnete Elternaufgaben und Familieninformationen können im passenden Kontext sichtbar werden.",
},
],
trustTitle: "Datenschutz ist Teil der Funktion",
trustText:
"Das Adressbuch ist bewusst als Opt-in-Funktion gestaltet. Es ersetzt lose Listen durch kontrollierte Sichtbarkeit.",
faqs: [
{
question: "Sind alle Eltern automatisch sichtbar?",
answer:
"Nein. Kontaktdaten erscheinen nur nach aktiver Freigabe.",
},
{
question: "Kann eine Familie teilweise sichtbar sein?",
answer:
"Ja. Sichtbarkeit hängt an den freigegebenen Elternteilen und deren Daten.",
},
{
question: "Ist das eine Rechtsberatung zur DSGVO?",
answer:
"Nein. Kita-Planer ist datensparsam gedacht, ersetzt aber keine rechtliche Prüfung eurer konkreten Prozesse.",
},
],
},
{
slug: "krankmeldung",
icon: Stethoscope,
title: "Digitale Krankmeldung",
shortTitle: "Krankmeldung",
description:
"Ein Klick statt Dauerläuten am Morgen. Eltern melden Kinder digital ab, ErzieherInnen sehen sofort den Überblick.",
accent: "bg-amber-50 text-amber-700 ring-amber-100",
badge: "Neu",
eyebrow: "Abwesenheiten ohne Telefonkette",
heroTitle: "Krankmeldungen digital und morgens sofort sichtbar.",
heroDescription:
"Eltern melden Abwesenheiten digital, das Team sieht den Tagesüberblick und der Morgen startet mit weniger Telefonchaos.",
seoTitle: "Digitale Krankmeldung für Kitas",
seoDescription:
"Eltern melden Kinder digital krank oder abwesend. Kita-Teams behalten den Tagesüberblick ohne Telefonchaos am Morgen.",
problemTitle: "Der Morgen beginnt oft mit Dauerläuten",
problemText:
"Wenn mehrere Kinder fehlen, sammeln sich Telefonate, Chatnachrichten und Zurufe genau dann, wenn das Team eigentlich den Tag vorbereiten muss.",
solutionTitle: "Abwesenheiten dort erfassen, wo sie gebraucht werden",
solutionText:
"Kita-Planer bündelt digitale Abwesenheitsmeldungen je Kita und zeigt sie im Dashboard, damit Team und Vorstand schneller den Überblick haben.",
benefits: [
{
title: "Eltern melden digital",
text: "Abwesenheiten können strukturiert mit Zeitraum und Grund hinterlegt werden.",
},
{
title: "Tagesüberblick fürs Team",
text: "ErzieherInnen sehen, welche Kinder fehlen, ohne Telefonnotizen zusammenzutragen.",
},
{
title: "Weniger Missverständnisse",
text: "Zeiträume und Hinweise sind dokumentiert, statt in einzelnen Chatnachrichten zu verschwinden.",
},
{
title: "Passt zum Familienprofil",
text: "Abwesenheiten sind mit den Kindern und der jeweiligen Kita-Zugehörigkeit verbunden.",
},
],
trustTitle: "Sensible Informationen geschützt behandeln",
trustText:
"Abwesenheiten sind personenbezogene Informationen. Kita-Planer behandelt sie innerhalb des geschützten Kita-Bereichs und nicht als öffentliche Nachricht.",
faqs: [
{
question: "Können Eltern mehrere Tage eintragen?",
answer:
"Ja. Abwesenheiten können für einen Zeitraum erfasst werden.",
},
{
question: "Sieht das Kita-Team die Meldungen sofort?",
answer:
"Die Meldungen werden im System sichtbar und können im Dashboard ausgewertet werden.",
},
{
question: "Ersetzt das jede telefonische Rückfrage?",
answer:
"Nicht jede. Bei besonderen Situationen kann weiterhin persönlich nachgefragt werden.",
},
],
},
{
slug: "schwarzes-brett",
icon: Megaphone,
title: "Schwarzes Brett",
shortTitle: "Schwarzes Brett",
description:
"Offizielle Ankündigungen mit Dateianhängen, Lesebestätigung und optionalem E-Mail-Push statt verstreuter Chatposts.",
accent: "bg-indigo-50 text-indigo-700 ring-indigo-100",
badge: "Neu",
eyebrow: "Verbindliche Kita-Kommunikation",
heroTitle: "Ein digitales Schwarzes Brett für offizielle Nachrichten.",
heroDescription:
"Vorstände veröffentlichen Ankündigungen, hängen Dokumente an und erreichen Eltern über einen klaren Kanal statt über mehrere Chatgruppen.",
seoTitle: "Digitales Schwarzes Brett für Kitas",
seoDescription:
"Offizielle Ankündigungen für Elternvereine: Nachrichten, Anhänge, Lesestatus und optionaler E-Mail-Push in einem geschützten System.",
problemTitle: "Wichtige Infos gehen im Chat unter",
problemText:
"Zwischen Fotos, Rückfragen und spontanen Abstimmungen verlieren offizielle Ankündigungen schnell ihre Sichtbarkeit.",
solutionTitle: "Ein Kanal für das, was verbindlich sein soll",
solutionText:
"Kita-Planer trennt offizielle Nachrichten vom Chatrauschen. Ankündigungen können Anhänge enthalten und sind für die passende Kita geschützt abrufbar.",
benefits: [
{
title: "Offizielle Ankündigungen",
text: "Vorstände und Admins veröffentlichen zentrale Nachrichten an Eltern oder Team.",
},
{
title: "Geschützte Anhänge",
text: "PDF, JPG und PNG werden nicht öffentlich ausgeliefert, sondern nach Session und Kita geprüft.",
},
{
title: "Lesestatus im Blick",
text: "Das System kann sichtbar machen, welche Ankündigungen für Nutzer noch ungelesen sind.",
},
{
title: "Optional per E-Mail",
text: "Wichtige Meldungen können zusätzlich per E-Mail angestoßen werden.",
},
],
trustTitle: "Nicht öffentlich, nicht im Chat verloren",
trustText:
"Anhänge und Ankündigungen bleiben an Login, Rolle und Kita-Kontext gebunden. Das ist ruhiger und kontrollierter als öffentliche Links.",
faqs: [
{
question: "Welche Dateitypen sind möglich?",
answer:
"Aktuell sind PDF, JPG und PNG vorgesehen.",
},
{
question: "Sind Anhänge öffentlich erreichbar?",
answer:
"Nein. Anhänge werden nur nach Session- und Kita-Prüfung ausgeliefert.",
},
{
question: "Kann eine Ankündigung per E-Mail versendet werden?",
answer:
"Ja. Für wichtige Meldungen ist optional ein E-Mail-Push vorgesehen.",
},
],
},
];
export const marketingFeatureSlugs = marketingFeatures.map(
(feature) => feature.slug,
);
export function getMarketingFeature(slug: string) {
return marketingFeatures.find((feature) => feature.slug === slug);
}
+14
View File
@@ -0,0 +1,14 @@
import { UserRole } from "@prisma/client";
type RedirectUser = {
role?: UserRole | string;
kitaId?: string | null;
};
export function getPostLoginRedirect(user: RedirectUser) {
if (user.role === UserRole.SUPERADMIN || user.role === "SUPERADMIN") {
return "/admin";
}
return user.kitaId ? "/dashboard" : "/onboarding";
}
+39
View File
@@ -0,0 +1,39 @@
type Bucket = {
count: number;
resetAt: number;
};
const buckets = new Map<string, Bucket>();
export type RateLimitResult =
| { allowed: true; remaining: number; resetAt: number }
| { allowed: false; retryAfterSeconds: number };
export function rateLimit(
key: string,
limit: number,
windowSeconds: number,
): RateLimitResult {
const now = Date.now();
const windowMs = windowSeconds * 1000;
const existing = buckets.get(key);
if (!existing || existing.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true, remaining: limit - 1, resetAt: now + windowMs };
}
if (existing.count >= limit) {
return {
allowed: false,
retryAfterSeconds: Math.ceil((existing.resetAt - now) / 1000),
};
}
existing.count += 1;
return {
allowed: true,
remaining: limit - existing.count,
resetAt: existing.resetAt,
};
}
+5
View File
@@ -12,6 +12,7 @@ import { auth } from "@/auth";
// Routing-Logik: // Routing-Logik:
// Nicht eingeloggt + geschützte Route → /login // Nicht eingeloggt + geschützte Route → /login
// Eingeloggt + kein kitaId + nicht /onboarding → /onboarding // Eingeloggt + kein kitaId + nicht /onboarding → /onboarding
// Eingeloggt + SUPERADMIN → /admin
// Eingeloggt + hat kitaId + auf /onboarding → /dashboard // Eingeloggt + hat kitaId + auf /onboarding → /dashboard
// Eingeloggt + auf /login oder /register → / // Eingeloggt + auf /login oder /register → /
// ===================================================================== // =====================================================================
@@ -48,6 +49,10 @@ export async function proxy(request: NextRequest) {
return NextResponse.redirect(new URL("/", request.nextUrl)); return NextResponse.redirect(new URL("/", request.nextUrl));
} }
if (user.role === "SUPERADMIN" && pathname === ONBOARDING_ROUTE) {
return NextResponse.redirect(new URL("/admin", request.nextUrl));
}
// 2b. Kein kitaId (heimatloser Admin) → muss Onboarding abschließen. // 2b. Kein kitaId (heimatloser Admin) → muss Onboarding abschließen.
// Ausnahme: Superadmin hat nie eine kitaId. // Ausnahme: Superadmin hat nie eine kitaId.
if ( if (