Compare commits

...

24 Commits

Author SHA1 Message Date
t.indorf 1c5d2f10ac Apply linen design system across app 2026-05-20 22:47:52 +02:00
t.indorf 99ff978116 Fix live test follow-up issues 2026-05-19 14:28:32 +02:00
t.indorf 3fa3b9b108 Merge branch 'codex/fix-invite-activation' 2026-05-19 11:14:28 +02:00
t.indorf 84714b8b32 Fix server action state exports 2026-05-19 11:09:53 +02:00
t.indorf 811468a22b Merge branch 'codex/improve-invite-email' 2026-05-19 10:51:57 +02:00
t.indorf 968d108c28 Improve invite email layout 2026-05-19 10:29:42 +02:00
t.indorf b5eb288104 Merge branch 'codex/fix-public-landing-clean' 2026-05-18 16:56:45 +02:00
t.indorf ce1a5c9e42 Make landing page public and redirect auth users directly 2026-05-18 16:55:21 +02:00
t.indorf 6c79e0a8e1 Merge claude/profil-and-mail-fixes into main
Fix mail layouts, /invite form-reset, profile validation
2026-05-17 23:54:07 +02:00
t.indorf 935845ce56 Merge claude/onboarding-fixes into main
Persist values, tighten validation, and a11y on /onboarding
2026-05-17 23:53:57 +02:00
t.indorf cb4f7409bc Merge claude/register-fixes into main
Persist values, a11y errors, and aria-invalid on /register
2026-05-17 23:53:38 +02:00
t.indorf fa06da324c Fix mail layouts, /invite form-reset, profile validation
Mail templates (6 templates touched):
- New shared `_styles.ts` exports a robust font stack starting with
  Arial/Helvetica so mail readers that lack -apple-system and
  BlinkMacSystemFont don't fall back to Monospace (observed in at least
  one webmail client).
- `fontFamily` is now set on every text-bearing element, not only on
  `<Body>`, because some clients (Outlook, several webmail readers) do
  not inherit body CSS into nested sections.
- ASCII substitutions are gone: "Ueber" → "Über", "fuer" → "für",
  "bestaetigen" → "bestätigen", "uebernehmen" → "übernehmen",
  "verlaesslich" → "verlässlich".
- InviteEmail + NewsEmail buttons switch from the off-brand yellow
  (#f0b84b) to the brand dark green so the CTA matches the header.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:55:28 +02:00
t.indorf 0b80c62beb Add contact email confirmation and abuse protection 2026-05-16 11:38:30 +02:00
t.indorf 9542def530 Add RSVP events and Unsplash image support 2026-05-15 21:28:36 +02:00
t.indorf ed6786b6ea added missing packages 2026-05-11 16:29:32 +02:00
t.indorf 89cf1dfe84 added more page content 2026-05-11 16:06:33 +02:00
t.indorf 70a50d89ab added robots udn sitemap 2026-05-11 12:23:43 +02:00
t.indorf 928ddac5b3 added postgres to the stack 2026-05-11 11:19:24 +02:00
t.indorf b0cf3adce6 removed the coolify network 2026-05-11 11:09:27 +02:00
106 changed files with 10041 additions and 2341 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:
+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
// ===================================================================== // =====================================================================
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

+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" />
+197 -132
View File
@@ -3,10 +3,10 @@
import Link from "next/link"; import Link from "next/link";
import { useMemo, useTransition } from "react"; import { useMemo, useTransition } from "react";
import { AbsenceReason } from "@prisma/client"; import { AbsenceReason } from "@prisma/client";
import { Baby, CalendarX2, Loader2, Trash2 } from "lucide-react"; import { Baby, CalendarX2, Check, ChevronRight, Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteAbsence, reportAbsence } from "@/actions/absences"; import { reportAbsence } from "@/actions/absences";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -43,7 +43,6 @@ type AbsenceListItem = {
export function AbsenceCard({ export function AbsenceCard({
childOptions, childOptions,
absences,
today, today,
}: { }: {
childOptions: ChildOption[]; childOptions: ChildOption[];
@@ -69,39 +68,32 @@ export function AbsenceCard({
return; return;
} }
toast.success("Abwesenheit eingetragen."); toast.success("Abwesenheit gemeldet. Die Kita ist informiert.");
});
}
function handleDelete(absenceId: string) {
startTransition(async () => {
const result = await deleteAbsence(absenceId);
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Abwesenheit storniert.");
}); });
} }
return ( return (
<Card className="mb-8 border-amber-200 bg-amber-50/50"> <Card variant="featured" className="min-w-0">
<CardHeader> <CardHeader className="pb-5">
<CardTitle className="flex items-center gap-2 text-lg"> <div className="flex items-start gap-3">
<CalendarX2 className="h-5 w-5 text-amber-700" /> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
Kind abmelden <CalendarX2 className="h-4 w-4 [stroke-width:1.7]" />
</CardTitle> </div>
<CardDescription> <div>
Melde dein Kind für ganze Tage ab. Die Kita sieht die Meldung direkt <CardTitle>Kind abmelden</CardTitle>
in der Tagesübersicht. <CardDescription className="mt-2 max-w-xl">
Melde dein Kind für ganze Tage ab. Die Kita sieht die Meldung
sofort in der Tagesübersicht keine zusätzliche E-Mail nötig.
</CardDescription> </CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
<CardContent>
{hasChildren ? ( {hasChildren ? (
<form action={handleSubmit} className="grid gap-4"> <form action={handleSubmit}>
<div className="grid gap-2"> <div className="grid gap-4 md:grid-cols-2">
<Label>Welches Kind?</Label> <FieldLabel label="Welches Kind?">
<Select name="childId" defaultValue={defaultChildId} required> <Select name="childId" defaultValue={defaultChildId} required>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Kind auswählen" /> <SelectValue placeholder="Kind auswählen" />
@@ -114,11 +106,30 @@ export function AbsenceCard({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </FieldLabel>
<div className="grid gap-3 sm:grid-cols-2"> <FieldLabel label="Grund">
<div className="grid gap-2"> <Select
<Label htmlFor="absence-startDate">Von</Label> name="reason"
defaultValue={AbsenceReason.ILLNESS}
required
>
<SelectTrigger>
<SelectValue placeholder="Grund auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value={AbsenceReason.ILLNESS}>Krank</SelectItem>
<SelectItem value={AbsenceReason.VACATION}>
Urlaub
</SelectItem>
<SelectItem value={AbsenceReason.OTHER}>
Sonstiges
</SelectItem>
</SelectContent>
</Select>
</FieldLabel>
<FieldLabel label="Von" htmlFor="absence-startDate">
<Input <Input
id="absence-startDate" id="absence-startDate"
name="startDate" name="startDate"
@@ -126,9 +137,9 @@ export function AbsenceCard({
defaultValue={today} defaultValue={today}
required required
/> />
</div> </FieldLabel>
<div className="grid gap-2">
<Label htmlFor="absence-endDate">Bis</Label> <FieldLabel label="Bis" htmlFor="absence-endDate">
<Input <Input
id="absence-endDate" id="absence-endDate"
name="endDate" name="endDate"
@@ -136,144 +147,198 @@ export function AbsenceCard({
defaultValue={today} defaultValue={today}
required required
/> />
</div> </FieldLabel>
</div>
<div className="grid gap-2"> <div className="grid gap-2 md:col-span-2">
<Label>Grund</Label> <Label
<Select name="reason" defaultValue={AbsenceReason.ILLNESS} required> htmlFor="absence-note"
<SelectTrigger> className="text-xs font-semibold text-[var(--ink-soft)]"
<SelectValue placeholder="Grund auswählen" /> >
</SelectTrigger> Bemerkung{" "}
<SelectContent> <span className="font-medium text-[var(--ink-faint)]">
<SelectItem value={AbsenceReason.ILLNESS}>Krank</SelectItem> optional
<SelectItem value={AbsenceReason.VACATION}>Urlaub</SelectItem> </span>
<SelectItem value={AbsenceReason.OTHER}>Sonstiges</SelectItem> </Label>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="absence-note">Bemerkung</Label>
<Textarea <Textarea
id="absence-note" id="absence-note"
name="note" name="note"
rows={3} rows={4}
placeholder="Optional, z.B. voraussichtlich wieder da am Freitag." placeholder="z. B. voraussichtlich wieder da am Freitag."
/> />
</div> </div>
</div>
<div className="flex justify-end"> <div className="mt-7 flex items-center justify-between border-t border-[var(--hairline-soft)] pt-5">
<Button type="reset" variant="ghost">
Zurücksetzen
</Button>
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />} {isPending ? <Loader2 className="animate-spin" /> : <Check />}
Abwesenheit melden Abwesenheit melden
</Button> </Button>
</div> </div>
</form> </form>
) : ( ) : (
<div className="flex min-h-72 flex-col items-center justify-center rounded-md border border-dashed bg-background/70 p-6 text-center"> <div className="flex min-h-72 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-center">
<Baby className="h-10 w-10 text-muted-foreground" /> <Baby className="h-10 w-10 text-[var(--ink-muted)]" />
<p className="mt-4 font-medium">Noch keine Kinder hinterlegt</p> <p className="mt-4 font-medium text-[var(--ink)]">
<p className="mt-1 text-sm text-muted-foreground"> Noch keine Kinder hinterlegt
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege zuerst deine Kinder im Profil an, dann kannst du sie hier Lege zuerst deine Kinder im Profil an, dann kannst du sie hier
abmelden. abmelden.
</p> </p>
<Button asChild className="mt-4" variant="outline"> <Button asChild className="mt-4" variant="ghost">
<Link href="/dashboard/profil">Zum Profil</Link> <Link href="/dashboard/profil">Zum Profil</Link>
</Button> </Button>
</div> </div>
)} )}
<div className="rounded-md border bg-background/80 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="font-medium">Aktuelle & anstehende Meldungen</p>
<Badge variant="secondary">{absences.length}</Badge>
</div>
{absences.length === 0 ? (
<p className="text-sm text-muted-foreground">
Es sind keine aktuellen oder kommenden Abwesenheiten hinterlegt.
</p>
) : (
<div className="space-y-3">
{absences.map((absence) => (
<AbsenceRow
key={absence.id}
absence={absence}
disabled={isPending}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
function AbsenceRow({ export function UpcomingAbsencesCard({
absence, absences,
disabled,
onDelete,
}: { }: {
absence: AbsenceListItem; absences: AbsenceListItem[];
disabled: boolean;
onDelete: (id: string) => void;
}) { }) {
const range = useMemo(() => { return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle>Anstehende Meldungen</CardTitle>
<CardDescription className="mt-2">
Aktuelle und kommende Abwesenheiten deiner Kinder.
</CardDescription>
</div>
<Badge variant="secondary">{absences.length}</Badge>
</div>
</CardHeader>
<CardContent>
{absences.length === 0 ? (
<div className="flex min-h-36 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--good-soft)] text-[var(--good)]">
<Check className="h-4 w-4" />
</div>
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
Keine offenen Meldungen
</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">
Für die nächsten Tage ist alles ruhig.
</p>
</div>
) : (
<div className="divide-y divide-[var(--hairline-soft)]">
{absences.map((absence) => (
<AbsenceRow key={absence.id} absence={absence} />
))}
</div>
)}
<Link
href="/dashboard/admin/abwesenheiten"
className="mt-4 inline-flex items-center gap-1 text-[13px] font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
Alle Abwesenheiten
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</CardContent>
</Card>
);
}
function FieldLabel({
label,
htmlFor,
children,
}: {
label: string;
htmlFor?: string;
children: React.ReactNode;
}) {
return (
<div className="grid gap-2">
<Label
htmlFor={htmlFor}
className="text-xs font-semibold text-[var(--ink-soft)]"
>
{label}
</Label>
{children}
</div>
);
}
function AbsenceRow({ absence }: { absence: AbsenceListItem }) {
const { day, month, detail } = useMemo(() => {
const start = new Date(`${absence.startDate}T00:00:00`); const start = new Date(`${absence.startDate}T00:00:00`);
const end = new Date(`${absence.endDate}T00:00:00`); const end = new Date(`${absence.endDate}T00:00:00`);
const startLabel = start.toLocaleDateString("de-DE", { const range =
absence.startDate === absence.endDate
? "ganztägig"
: `${start.toLocaleDateString("de-DE", {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
}); })}-${end.toLocaleDateString("de-DE", {
const endLabel = end.toLocaleDateString("de-DE", {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "numeric", })}`;
});
return absence.startDate === absence.endDate return {
? endLabel day: start.toLocaleDateString("de-DE", { day: "2-digit" }),
: `${startLabel} - ${endLabel}`; month: start
.toLocaleDateString("de-DE", { month: "short" })
.replace(".", ""),
detail: range,
};
}, [absence.endDate, absence.startDate]); }, [absence.endDate, absence.startDate]);
return ( return (
<div className="rounded-md border p-3"> <div className="flex items-center gap-3 py-3">
<div className="flex items-start justify-between gap-3"> <div className="flex h-11 w-11 shrink-0 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] leading-none">
<span className="text-base font-medium text-[var(--ink)] [font-variant-numeric:tabular-nums]">
{day}
</span>
<span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)]">
{month}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium">{absence.childName}</p> <p className="truncate text-sm font-semibold text-[var(--ink)]">
<p className="mt-0.5 text-sm text-muted-foreground">{range}</p> {absence.childName}
</p>
<p className="mt-1 flex items-center gap-1.5 text-xs text-[var(--ink-soft)]">
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: reasonColor(absence.reason) }}
/>
{reasonLabel(absence.reason)} · {detail}
</p>
</div> </div>
<Button <Badge variant="destructive">Bestätigt</Badge>
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
onClick={() => onDelete(absence.id)}
disabled={disabled}
aria-label="Abwesenheit stornieren"
>
<Trash2 className="h-4 w-4" />
</Button>
</div> </div>
<div className="mt-2 flex flex-wrap items-center gap-2"> {absence.note ? (
<ReasonBadge reason={absence.reason} /> <p className="mt-1 truncate text-xs text-[var(--ink-muted)]">
{absence.note && ( {absence.note}
<span className="text-xs text-muted-foreground">{absence.note}</span> </p>
)} ) : null}
</div> </div>
</div> </div>
); );
} }
function ReasonBadge({ reason }: { reason: AbsenceReason }) { function reasonLabel(reason: AbsenceReason) {
if (reason === AbsenceReason.ILLNESS) { if (reason === AbsenceReason.ILLNESS) return "Krank";
return <Badge variant="destructive">Krank</Badge>; if (reason === AbsenceReason.VACATION) return "Urlaub";
} return "Sonstiges";
}
if (reason === AbsenceReason.VACATION) {
return <Badge variant="warning">Urlaub</Badge>; function reasonColor(reason: AbsenceReason) {
} if (reason === AbsenceReason.ILLNESS) return "var(--danger)";
if (reason === AbsenceReason.VACATION) return "var(--warn)";
return <Badge variant="outline">Sonstiges</Badge>; return "var(--accent)";
} }
@@ -0,0 +1,204 @@
"use client";
import { useState } from "react";
import { AbsenceReason } from "@prisma/client";
import { CalendarDays, Circle, MessageSquareText, UserRound } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export type AbsenceTableItem = {
id: string;
childName: string;
familyName: string;
reason: AbsenceReason;
note: string | null;
startDate: string;
endDate: string;
};
export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
const [activeAbsence, setActiveAbsence] = useState<AbsenceTableItem | null>(
null,
);
return (
<>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Kind</TableHead>
<TableHead>Zeitraum</TableHead>
<TableHead>Grund</TableHead>
<TableHead>Bemerkung</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{absences.map((absence) => (
<TableRow
key={absence.id}
className="cursor-pointer border-[var(--hairline-soft)] hover:bg-[var(--surface-2)]"
onClick={() => setActiveAbsence(absence)}
>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-xs font-semibold text-[var(--accent-deep)]">
{getInitials(absence.childName)}
</div>
<div className="min-w-0">
<p className="truncate font-semibold text-[var(--ink)]">
{absence.childName}
</p>
<p className="truncate text-xs text-[var(--ink-soft)]">
{absence.familyName}
</p>
</div>
</div>
</TableCell>
<TableCell className="text-[var(--ink-soft)]">
{formatDateRange(absence.startDate, absence.endDate)}
</TableCell>
<TableCell>
<span className="inline-flex items-center gap-2 text-[var(--ink)]">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(absence.reason) }}
/>
{reasonLabel(absence.reason)}
</span>
</TableCell>
<TableCell className="max-w-[280px] truncate text-[var(--ink-soft)]">
{absence.note || "Keine Bemerkung hinterlegt."}
</TableCell>
<TableCell>
<Badge variant="success">Bestätigt</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Dialog
open={!!activeAbsence}
onOpenChange={(open) => !open && setActiveAbsence(null)}
>
<DialogContent className="left-auto right-0 top-0 h-full max-w-md translate-x-0 translate-y-0 rounded-none border-y-0 border-r-0 border-[var(--hairline)] bg-[var(--surface)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right">
{activeAbsence ? (
<>
<DialogHeader>
<DialogTitle className="text-2xl font-medium tracking-[-0.02em] text-[var(--ink)]">
{activeAbsence.childName}
</DialogTitle>
<DialogDescription>{activeAbsence.familyName}</DialogDescription>
</DialogHeader>
<div className="mt-4 grid gap-4">
<DetailLine
icon={CalendarDays}
label="Zeitraum"
value={formatDateRange(
activeAbsence.startDate,
activeAbsence.endDate,
)}
/>
<DetailLine
icon={Circle}
label="Grund"
value={reasonLabel(activeAbsence.reason)}
dotColor={reasonColor(activeAbsence.reason)}
/>
<DetailLine
icon={MessageSquareText}
label="Bemerkung"
value={activeAbsence.note || "Keine Bemerkung hinterlegt."}
/>
<DetailLine icon={UserRound} label="Status" value="Bestätigt" />
</div>
</>
) : null}
</DialogContent>
</Dialog>
</>
);
}
function DetailLine({
icon: Icon,
label,
value,
dotColor,
}: {
icon: typeof CalendarDays;
label: string;
value: string;
dotColor?: string;
}) {
return (
<div className="rounded-lg border border-[var(--hairline-soft)] bg-[var(--surface-2)] p-3">
<p className="flex items-center gap-2 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
{dotColor ? (
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: dotColor }}
/>
) : (
<Icon className="h-3.5 w-3.5 [stroke-width:1.7]" />
)}
{label}
</p>
<p className="mt-2 text-sm font-medium text-[var(--ink)]">{value}</p>
</div>
);
}
function getInitials(name: string) {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
}
export function reasonLabel(reason: AbsenceReason) {
if (reason === AbsenceReason.ILLNESS) return "Krank";
if (reason === AbsenceReason.VACATION) return "Urlaub";
return "Sonstiges";
}
export function reasonColor(reason: AbsenceReason) {
if (reason === AbsenceReason.ILLNESS) return "var(--danger)";
if (reason === AbsenceReason.VACATION) return "var(--warn)";
return "var(--accent)";
}
export function formatDateRange(startDate: string, endDate: string) {
const start = new Date(`${startDate}T00:00:00`);
const end = new Date(`${endDate}T00:00:00`);
const startLabel = start.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
});
const endLabel = end.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
}
+266 -103
View File
@@ -1,164 +1,327 @@
import Link from "next/link"; import Link from "next/link";
import { addDays, format } from "date-fns"; import { addDays, differenceInCalendarDays, format, isWeekend } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { ArrowLeft, ArrowRight, CalendarDays, Stethoscope } from "lucide-react"; import {
import { AbsenceReason } from "@prisma/client"; ArrowLeft,
ArrowRight,
CalendarCheck2,
CalendarDays,
ChevronDown,
ChevronRight,
Stethoscope,
} from "lucide-react";
import { AbsenceReason, TerminStatus, TerminType, UserRole } from "@prisma/client";
import { getDailyAbsences } from "@/actions/absences"; import { getDailyAbsences } from "@/actions/absences";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
AbsenceTable,
reasonColor,
reasonLabel,
type AbsenceTableItem,
} from "./absence-table";
export const metadata = { title: "Abwesenheiten · Kita-Planer" }; export const metadata = { title: "Abwesenheiten · Kita-Planer" };
export default async function AdminAbwesenheitenPage({ export default async function AdminAbwesenheitenPage({
searchParams, searchParams,
}: { }: {
searchParams: { date?: string }; searchParams: Promise<{ date?: string }>;
}) { }) {
const selectedDate = parseDateParam(searchParams.date); const session = await requireRole([
UserRole.ADMIN,
UserRole.KOORDINATOR,
UserRole.ERZIEHER,
]);
const resolvedSearchParams = await searchParams;
const kitaId = session.user.kitaId!;
const today = startOfLocalDay(new Date());
const selectedDate = parseDateParam(resolvedSearchParams.date, today);
const dateKey = format(selectedDate, "yyyy-MM-dd"); const dateKey = format(selectedDate, "yyyy-MM-dd");
const result = await getDailyAbsences({ date: dateKey }); const result = await getDailyAbsences({ date: dateKey });
const absences = result.absences ?? []; const absences = result.absences ?? [];
const childCount = await prisma.child.count({
where: { kitaId, active: true },
});
const upcomingAbsenceCount = await prisma.absence.count({
where: {
kitaId,
startDate: { gt: selectedDate },
},
});
const closure = await prisma.termin.findFirst({
where: {
kitaId,
status: TerminStatus.CONFIRMED,
type: { in: [TerminType.SCHLIESSTAG, TerminType.TEAMTAG] },
startDate: { lte: selectedDate },
endDate: { gte: selectedDate },
},
select: { title: true, type: true },
orderBy: { startDate: "asc" },
});
const isToday = dateKey === format(today, "yyyy-MM-dd");
const isTooFarFuture = differenceInCalendarDays(selectedDate, today) > 30;
const isClosedDay = isWeekend(selectedDate) || !!closure;
const tableItems: AbsenceTableItem[] = absences.map((absence) => ({
id: absence.id,
childName: absence.childName,
familyName: absence.familyName,
reason: absence.reason,
note: absence.note,
startDate: absence.startDate,
endDate: absence.endDate,
}));
const countsByReason = countByReason(tableItems);
return ( return (
<div className="min-h-full bg-slate-50 px-4 py-6 sm:px-8"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="mx-auto max-w-5xl space-y-6"> <header className="mb-7 flex items-end justify-between gap-8 max-[1100px]:flex-col max-[1100px]:items-start">
<div className="flex flex-col gap-4 rounded-lg border bg-background p-5 shadow-sm lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<Stethoscope className="h-4 w-4" /> TAGESÜBERSICHT
Tagesübersicht </p>
</div> <h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
<h1 className="mt-2 text-3xl font-bold tracking-tight"> Heute fehlen{" "}
Heute fehlen ({absences.length} Kinder) <em className="font-normal italic text-[var(--accent-deep)]">
{absences.length} Kinder
</em>
</h1> </h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-2 text-[15px] leading-6 text-[var(--ink-soft)]">
{format(selectedDate, "EEEE, dd. MMMM yyyy", { locale: de })} {format(selectedDate, "EEEE, d. MMMM yyyy", { locale: de })}
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center justify-end gap-3">
<Button asChild variant="outline" size="sm"> <span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<Link href={hrefForDate(addDays(selectedDate, -1))}> <Stethoscope className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
<ArrowLeft className="h-4 w-4" /> {childCount - absences.length} anwesend
</span>
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<SegmentLink href={hrefForDate(addDays(selectedDate, -1))}>
<ArrowLeft className="h-3.5 w-3.5" />
Zurück Zurück
</Link> </SegmentLink>
</Button> <SegmentLink href={hrefForDate(today)} active={isToday}>
<Button asChild variant="outline" size="sm"> Heute
<Link href="/dashboard/admin/abwesenheiten">Heute</Link> </SegmentLink>
</Button> <SegmentLink href={hrefForDate(addDays(selectedDate, 1))}>
<Button asChild variant="outline" size="sm">
<Link href={hrefForDate(addDays(selectedDate, 1))}>
Vor Vor
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-3.5 w-3.5" />
</Link> </SegmentLink>
</Button> </div>
<form className="flex items-center gap-2"> <form className="flex items-center gap-2">
<Input <Input
type="date" type="date"
name="date" name="date"
defaultValue={dateKey} defaultValue={dateKey}
className="h-9 w-40" className="w-[140px]"
/> />
<Button type="submit" size="sm"> <Button type="submit">Anzeigen</Button>
Anzeigen
</Button>
</form> </form>
</div> </div>
</header>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<FilterChip label="Alle Gruppen" />
<FilterChip label="Alle Gründe" />
</div>
{tableItems.length > 0 ? (
<div className="flex flex-wrap items-center gap-4 text-xs text-[var(--ink-soft)]">
<ReasonInlineStat
reason={AbsenceReason.ILLNESS}
count={countsByReason[AbsenceReason.ILLNESS] ?? 0}
/>
<ReasonInlineStat
reason={AbsenceReason.OTHER}
count={countsByReason[AbsenceReason.OTHER] ?? 0}
/>
<ReasonInlineStat
reason={AbsenceReason.VACATION}
count={countsByReason[AbsenceReason.VACATION] ?? 0}
/>
<span>
letzte Aktualisierung {format(new Date(), "HH:mm", { locale: de })}
</span>
</div>
) : null}
</div> </div>
<div className="mt-4">
{result.error ? ( {result.error ? (
<Card className="border-destructive/30"> <Card className="border-[var(--danger)] bg-[var(--danger-soft)]">
<CardContent className="p-6 text-sm text-destructive"> <CardContent className="pt-[22px] text-sm text-[var(--danger)]">
{result.error} {result.error}
</CardContent> </CardContent>
</Card> </Card>
) : absences.length === 0 ? ( ) : tableItems.length > 0 ? (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-background p-8 text-center"> <Card>
<div className="mx-auto max-w-md"> <CardHeader className="pb-4">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700"> <div className="flex flex-wrap items-center justify-between gap-3">
<CalendarDays className="h-10 w-10" /> <CardTitle>
</div> {tableItems.length} von {childCount} Kindern abwesend ·{" "}
<h2 className="mt-6 text-2xl font-semibold tracking-tight"> {upcomingAbsenceCount} anstehend
Keine Abwesenheiten
</h2>
<p className="mt-2 text-sm text-muted-foreground">
Für diesen Tag sind aktuell keine Kinder abgemeldet.
</p>
</div>
</div>
) : (
<div className="grid gap-3">
{absences.map((absence) => (
<Card key={absence.id} className="overflow-hidden">
<CardHeader className="flex flex-col gap-3 p-5 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="text-xl">
{absence.childName}
</CardTitle> </CardTitle>
<CardDescription className="mt-1"> <Badge variant="secondary">{dateKey}</Badge>
{absence.familyName}
</CardDescription>
</div> </div>
<ReasonBadge reason={absence.reason} />
</CardHeader> </CardHeader>
<CardContent className="grid gap-3 px-5 pb-5 sm:grid-cols-[180px_1fr]"> <CardContent>
<div className="text-sm"> <AbsenceTable absences={tableItems} />
<p className="font-medium">Zeitraum</p>
<p className="text-muted-foreground">
{formatDateRange(absence.startDate, absence.endDate)}
</p>
</div>
<div className="text-sm">
<p className="font-medium">Notiz</p>
<p className="text-muted-foreground">
{absence.note || "Keine Bemerkung hinterlegt."}
</p>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ) : isTooFarFuture ? (
</div> <EmptyState
tone="accent"
title="Noch nicht im Vorschauzeitraum"
description="Abwesenheiten werden erst 30 Tage im Voraus angezeigt."
/>
) : isClosedDay ? (
<EmptyState
tone="accent"
title={`Kita geschlossen am ${format(selectedDate, "d. MMMM", {
locale: de,
})}`}
description={
closure
? closure.title
: "Wochenende · für diesen Tag werden keine Anwesenheiten erwartet."
}
/>
) : (
<EmptyState
tone="good"
title="Keine Abwesenheiten"
description="Für diesen Tag sind aktuell keine Kinder abgemeldet."
/>
)} )}
</div> </div>
</div> </div>
); );
} }
function parseDateParam(value: string | undefined) { function SegmentLink({
if (!value) return new Date(); href,
active,
children,
}: {
href: string;
active?: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className={[
"inline-flex h-10 items-center gap-2 border-r border-[var(--hairline)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition last:border-r-0 hover:bg-[var(--surface-2)] hover:text-[var(--ink)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]",
active ? "bg-[var(--accent-soft)] text-[var(--accent-deep)]" : "",
].join(" ")}
>
{children}
</Link>
);
}
function FilterChip({ label }: { label: string }) {
return (
<button
type="button"
className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-transparent px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--accent-soft)] hover:text-[var(--accent-deep)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
{label}
<ChevronDown className="h-3.5 w-3.5" />
</button>
);
}
function ReasonInlineStat({
reason,
count,
}: {
reason: AbsenceReason;
count: number;
}) {
return (
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(reason) }}
/>
{reasonLabel(reason)} {count}
</span>
);
}
function EmptyState({
tone,
title,
description,
}: {
tone: "good" | "accent";
title: string;
description: string;
}) {
const isGood = tone === "good";
return (
<Card>
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-8 py-12 text-center">
<div
className={[
"flex h-12 w-12 items-center justify-center rounded-2xl",
isGood
? "bg-[var(--good-soft)] text-[var(--good)]"
: "bg-[var(--accent-soft)] text-[var(--accent-deep)]",
].join(" ")}
>
{isGood ? (
<CalendarCheck2 className="h-6 w-6 [stroke-width:1.7]" />
) : (
<CalendarDays className="h-6 w-6 [stroke-width:1.7]" />
)}
</div>
<h2 className="mt-5 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
{title}
</h2>
<p className="mt-2 max-w-md text-sm text-[var(--ink-soft)]">
{description}
</p>
<Link
href="/dashboard/admin/abwesenheiten"
className="mt-5 inline-flex items-center gap-1 text-sm font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
Wochenübersicht öffnen
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</CardContent>
</Card>
);
}
function parseDateParam(value: string | undefined, fallback: Date) {
if (!value) return fallback;
const parsed = new Date(`${value}T00:00:00`); const parsed = new Date(`${value}T00:00:00`);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed; return Number.isNaN(parsed.getTime()) ? fallback : parsed;
} }
function hrefForDate(date: Date) { function hrefForDate(date: Date) {
return `/dashboard/admin/abwesenheiten?date=${format(date, "yyyy-MM-dd")}`; return `/dashboard/admin/abwesenheiten?date=${format(date, "yyyy-MM-dd")}`;
} }
function formatDateRange(startDate: string, endDate: string) { function startOfLocalDay(date: Date) {
const start = new Date(`${startDate}T00:00:00`); const nextDate = new Date(date);
const end = new Date(`${endDate}T00:00:00`); nextDate.setHours(0, 0, 0, 0);
const startLabel = format(start, "dd.MM.", { locale: de }); return nextDate;
const endLabel = format(end, "dd.MM.yyyy", { locale: de });
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
} }
function ReasonBadge({ reason }: { reason: AbsenceReason }) { function countByReason(items: AbsenceTableItem[]) {
if (reason === AbsenceReason.ILLNESS) { return items.reduce<Partial<Record<AbsenceReason, number>>>((acc, item) => {
return <Badge variant="destructive">Krank</Badge>; acc[item.reason] = (acc[item.reason] ?? 0) + 1;
} return acc;
}, {});
if (reason === AbsenceReason.VACATION) {
return <Badge variant="warning">Urlaub</Badge>;
}
return <Badge variant="outline">Sonstiges</Badge>;
} }
@@ -116,9 +116,11 @@ export function DutyPlanner({
if (dutyTypes.length === 0) { if (dutyTypes.length === 0) {
return ( return (
<div className="rounded-md border border-dashed p-8 text-center"> <div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-8 text-center">
<p className="text-sm font-medium">Noch keine Diensttypen</p> <p className="text-sm font-medium text-[var(--ink)]">
<p className="mt-1 text-sm text-muted-foreground"> Noch keine Diensttypen
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege zuerst einen Diensttyp an, danach erscheint hier das Planungsgrid. Lege zuerst einen Diensttyp an, danach erscheint hier das Planungsgrid.
</p> </p>
</div> </div>
@@ -136,29 +138,29 @@ export function DutyPlanner({
disabled={isPending} disabled={isPending}
> >
{isPending ? ( {isPending ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : ( ) : (
<BellRing className="h-4 w-4" /> <BellRing className="h-4 w-4 [stroke-width:1.7]" />
)} )}
Reminder diese Woche senden Reminder diese Woche senden
</Button> </Button>
</div> </div>
<div className="overflow-x-auto rounded-md border"> <div className="overflow-x-auto rounded-lg border border-[var(--hairline)]">
<table className="w-full min-w-[760px] border-collapse text-sm"> <table className="w-full min-w-[760px] border-collapse text-[13.5px] text-[var(--ink)]">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b border-[var(--hairline-soft)] bg-[var(--surface-2)]">
<th className="sticky left-0 z-10 w-56 bg-muted/95 px-4 py-3 text-left font-medium"> <th className="sticky left-0 z-10 w-56 bg-[var(--surface-2)] px-4 py-3 text-left text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Woche Woche
</th> </th>
{dutyTypes.map((dutyType) => ( {dutyTypes.map((dutyType) => (
<th <th
key={dutyType.id} key={dutyType.id}
className="min-w-56 px-4 py-3 text-left font-medium" className="min-w-56 px-4 py-3 text-left font-medium text-[var(--ink)]"
> >
<div>{dutyType.name}</div> <div>{dutyType.name}</div>
{dutyType.description && ( {dutyType.description && (
<div className="mt-1 line-clamp-1 text-xs font-normal text-muted-foreground"> <div className="mt-1 line-clamp-1 text-xs font-normal text-[var(--ink-muted)]">
{dutyType.description} {dutyType.description}
</div> </div>
)} )}
@@ -168,8 +170,11 @@ export function DutyPlanner({
</thead> </thead>
<tbody> <tbody>
{weeks.map((week) => ( {weeks.map((week) => (
<tr key={week.key} className="border-b last:border-b-0"> <tr
<td className="sticky left-0 z-10 bg-background px-4 py-3 font-medium"> key={week.key}
className="border-b border-[var(--hairline-soft)] last:border-b-0 hover:bg-[var(--surface-2)]"
>
<td className="sticky left-0 z-10 bg-[var(--surface)] px-4 py-3 font-medium">
{week.label} {week.label}
</td> </td>
{dutyTypes.map((dutyType) => { {dutyTypes.map((dutyType) => {
+48 -35
View File
@@ -1,7 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { addWeeks, format, getISOWeek, startOfDay, startOfWeek } from "date-fns"; import { addWeeks, format, getISOWeek, startOfDay, startOfWeek } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { CalendarRange, ClipboardList, Plus, Trash2 } from "lucide-react"; import { CalendarRange, Plus, Trash2 } from "lucide-react";
import { UserRole } from "@prisma/client"; import { UserRole } from "@prisma/client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -34,15 +34,16 @@ const MAX_WEEK_COUNT = 104;
export default async function DienstePage({ export default async function DienstePage({
searchParams, searchParams,
}: { }: {
searchParams: { start?: string; weeks?: string }; searchParams: Promise<{ start?: string; weeks?: string }>;
}) { }) {
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]); const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
if (!session.user.kitaId) { if (!session.user.kitaId) {
throw new Error("Kein Mandant zugeordnet."); throw new Error("Kein Mandant zugeordnet.");
} }
const rangeStart = getRangeStart(searchParams.start); const resolvedSearchParams = await searchParams;
const weekCount = getWeekCount(searchParams.weeks); const rangeStart = getRangeStart(resolvedSearchParams.start);
const weekCount = getWeekCount(resolvedSearchParams.weeks);
const weeks = buildWeeks(rangeStart, weekCount); const weeks = buildWeeks(rangeStart, weekCount);
const rangeEnd = startOfDay(new Date(weeks[weeks.length - 1].endDate)); const rangeEnd = startOfDay(new Date(weeks[weeks.length - 1].endDate));
@@ -83,43 +84,55 @@ export default async function DienstePage({
const dutyTypeDtos: DutyPlanDutyTypeDto[] = dutyTypes; const dutyTypeDtos: DutyPlanDutyTypeDto[] = dutyTypes;
const familyDtos: DutyPlanFamilyDto[] = families; const familyDtos: DutyPlanFamilyDto[] = families;
const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map((assignment) => ({ const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map(
(assignment) => ({
id: assignment.id, id: assignment.id,
dutyTypeId: assignment.dutyTypeId, dutyTypeId: assignment.dutyTypeId,
familyId: assignment.familyId, familyId: assignment.familyId,
startDate: toDateKey(assignment.startDate), startDate: toDateKey(assignment.startDate),
})); }),
);
return ( return (
<div className="space-y-6 p-6"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div> <div>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<ClipboardList className="h-4 w-4" /> ADMIN · DIENSTPLAN
Admin </p>
</div> <h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
<h1 className="mt-2 text-2xl font-bold tracking-tight"> Dienste{" "}
Dienstplan-Manager <em className="font-normal italic text-[var(--accent-deep)]">
planen
</em>
</h1> </h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground"> <p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Plane Dienste pro Kalenderwoche und Haushalt. Jede Änderung wird Plane Dienste pro Kalenderwoche und Haushalt. Jede Änderung wird
direkt gespeichert. direkt gespeichert.
</p> </p>
</div> </div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{dutyTypes.length} Diensttypen
</span>
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{families.length} Familien
</span>
</div>
</header>
<Card className="w-full lg:max-w-xl"> <Card className="mb-5">
<CardHeader className="pb-3"> <CardContent className="pt-[22px]">
<CardTitle className="flex items-center gap-2 text-base"> <form className="flex flex-wrap items-end gap-3">
<CalendarRange className="h-4 w-4" /> <div className="mr-auto min-w-64">
<div className="flex items-center gap-2 text-base font-medium tracking-[-0.02em] text-[var(--ink)]">
<CalendarRange className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
Zeitraum Zeitraum
</CardTitle> </div>
<CardDescription> <p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Du kannst beliebig weit voraus planen, jeweils in handlichen {weeks[0].label} bis {weeks[weeks.length - 1].label}
Ausschnitten. </p>
</CardDescription> </div>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label htmlFor="start">Startwoche</Label> <Label htmlFor="start">Startwoche</Label>
<Input <Input
@@ -129,7 +142,7 @@ export default async function DienstePage({
defaultValue={toDateInputValue(rangeStart)} defaultValue={toDateInputValue(rangeStart)}
/> />
</div> </div>
<div className="grid gap-1.5"> <div className="grid w-28 gap-1.5">
<Label htmlFor="weeks">Wochen</Label> <Label htmlFor="weeks">Wochen</Label>
<Input <Input
id="weeks" id="weeks"
@@ -141,7 +154,7 @@ export default async function DienstePage({
/> />
</div> </div>
<Button type="submit">Anzeigen</Button> <Button type="submit">Anzeigen</Button>
<Button asChild type="button" variant="outline"> <Button asChild type="button" variant="ghost">
<Link <Link
href={`/dashboard/admin/dienste?start=${toDateInputValue( href={`/dashboard/admin/dienste?start=${toDateInputValue(
rangeStart, rangeStart,
@@ -153,7 +166,6 @@ export default async function DienstePage({
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
</div>
<div className="grid gap-6 xl:grid-cols-[340px_1fr]"> <div className="grid gap-6 xl:grid-cols-[340px_1fr]">
<Card> <Card>
@@ -186,19 +198,19 @@ export default async function DienstePage({
<div className="space-y-2"> <div className="space-y-2">
{dutyTypes.length === 0 ? ( {dutyTypes.length === 0 ? (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground"> <p className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3 text-sm italic text-[var(--ink-faint)]">
Noch keine Diensttypen angelegt. Noch keine Diensttypen angelegt.
</p> </p>
) : ( ) : (
dutyTypes.map((dutyType) => ( dutyTypes.map((dutyType) => (
<div <div
key={dutyType.id} key={dutyType.id}
className="flex items-start justify-between gap-3 rounded-md border p-3" className="flex items-start justify-between gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
> >
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium">{dutyType.name}</p> <p className="font-medium text-[var(--ink)]">{dutyType.name}</p>
{dutyType.description && ( {dutyType.description && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground"> <p className="mt-1 line-clamp-2 text-xs text-[var(--ink-soft)]">
{dutyType.description} {dutyType.description}
</p> </p>
)} )}
@@ -213,9 +225,10 @@ export default async function DienstePage({
type="submit" type="submit"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
aria-label={`${dutyType.name} löschen`} aria-label={`${dutyType.name} löschen`}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button> </Button>
</form> </form>
</div> </div>
@@ -0,0 +1,638 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import {
EventParticipationStatus,
TerminStatus,
TerminType,
} from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import {
CalendarPlus,
Check,
Clock,
HelpCircle,
Pencil,
Users,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import {
createTerminAdmin,
updateTerminAdmin,
} from "../../kalender/actions";
type AdminTerminDto = {
id: string;
title: string;
description: string | null;
type: TerminType;
status: TerminStatus;
startDate: string;
endDate: string;
allDay: boolean;
mitbringselListEnabled: boolean;
requiresRsvp: boolean;
rsvpDeadline: string | null;
participantInfo: string | null;
participations: {
childId: string;
status: EventParticipationStatus;
}[];
};
type ChildDto = {
id: string;
name: string;
familyName: string;
};
export function AdminCalendarManager({
termine,
childRows,
}: {
termine: AdminTerminDto[];
childRows: ChildDto[];
}) {
const rsvpCount = termine.filter((termin) => termin.requiresRsvp).length;
const pendingCount = termine.filter(
(termin) => termin.status === TerminStatus.PENDING,
).length;
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
ADMIN · EVENT-ANMELDUNGEN
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Termine{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
koordinieren
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind
verfolgen.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{termine.length} Termine
</span>
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{rsvpCount} RSVP
</span>
{pendingCount > 0 && (
<span className="inline-flex h-9 items-center rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-3 text-[13px] font-semibold text-[var(--warn)]">
{pendingCount} ausstehend
</span>
)}
<TerminFormDialog mode="create" />
</div>
</header>
{termine.length === 0 ? (
<Card className="border-dashed">
<div className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<CalendarPlus className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h2 className="mt-4 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Noch keine Termine
</h2>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
</p>
</div>
</Card>
) : (
<div className="grid gap-4">
{termine.map((termin) => (
<AdminTerminCard
key={termin.id}
termin={termin}
childRows={childRows}
/>
))}
</div>
)}
</div>
);
}
function AdminTerminCard({
termin,
childRows,
}: {
termin: AdminTerminDto;
childRows: ChildDto[];
}) {
const stats = useMemo(() => getStats(termin, childRows), [termin, childRows]);
return (
<Card className="overflow-hidden">
<CardHeader className="border-b border-[var(--hairline-soft)] bg-[var(--surface-2)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="mb-2 flex flex-wrap gap-2">
<Badge className={getTypeBadgeClass(termin.type)}>
{getTypeLabel(termin.type)}
</Badge>
<Badge variant={getStatusVariant(termin.status)}>
{getStatusLabel(termin.status)}
</Badge>
{termin.requiresRsvp && (
<Badge variant="warning">RSVP aktiv</Badge>
)}
</div>
<CardTitle className="text-lg">{termin.title}</CardTitle>
<CardDescription className="mt-1 flex flex-wrap items-center gap-2 text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(new Date(termin.startDate), "dd.MM.yyyy", {
locale: de,
})}
{!termin.allDay && (
<>
<span>·</span>
<span>
{format(new Date(termin.startDate), "HH:mm", {
locale: de,
})}{" "}
bis{" "}
{format(new Date(termin.endDate), "HH:mm", {
locale: de,
})}
</span>
</>
)}
</CardDescription>
</div>
<TerminFormDialog mode="edit" termin={termin} />
</div>
</CardHeader>
<CardContent className="grid gap-5 pt-5">
{termin.description && (
<p className="whitespace-pre-wrap text-sm text-[var(--ink-soft)]">
{termin.description}
</p>
)}
{termin.requiresRsvp ? (
<div className="grid gap-4">
<div className="flex flex-col gap-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-[var(--ink)] sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
{stats.pending} Ausstehend
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Frist:{" "}
{termin.rsvpDeadline
? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", {
locale: de,
})
: "bis Terminbeginn"}
</p>
</div>
<Users className="h-5 w-5 shrink-0 text-[var(--warn)] [stroke-width:1.7]" />
</div>
{termin.participantInfo && (
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
<span className="font-medium">Hinweis für Teilnehmer: </span>
{termin.participantInfo}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>Kind</TableHead>
<TableHead>Familie</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{childRows.map((child) => {
const status = stats.statusByChildId.get(child.id) ?? null;
return (
<TableRow key={child.id}>
<TableCell className="font-medium">{child.name}</TableCell>
<TableCell>{child.familyName}</TableCell>
<TableCell>
<RsvpStatusBadge status={status} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
) : (
<div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm italic text-[var(--ink-faint)]">
Für diesen Termin ist keine Teilnahme-Abfrage aktiv.
</div>
)}
</CardContent>
</Card>
);
}
function TerminFormDialog({
mode,
termin,
}: {
mode: "create" | "edit";
termin?: AdminTerminDto;
}) {
const [open, setOpen] = useState(false);
const [requiresRsvp, setRequiresRsvp] = useState(
termin?.requiresRsvp ?? false,
);
const [isPending, startTransition] = useTransition();
function handleAction(formData: FormData) {
const startDate = new Date(String(formData.get("startDate")));
const endDate = new Date(String(formData.get("endDate")));
const deadlineRaw = String(formData.get("rsvpDeadline") ?? "");
const hasRsvp = formData.get("requiresRsvp") === "on";
const payload = {
title: String(formData.get("title") ?? ""),
description: String(formData.get("description") ?? ""),
type: formData.get("type") as TerminType,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
allDay: formData.get("allDay") === "on",
mitbringselListEnabled: formData.get("mitbringselListEnabled") === "on",
requiresRsvp: hasRsvp,
rsvpDeadline:
hasRsvp && deadlineRaw ? new Date(deadlineRaw).toISOString() : null,
participantInfo: String(formData.get("participantInfo") ?? ""),
};
startTransition(async () => {
const result =
mode === "edit" && termin
? await updateTerminAdmin(termin.id, payload)
: await createTerminAdmin(payload);
if (result.error) {
toast.error(result.error);
return;
}
toast.success(
mode === "edit" ? "Termin aktualisiert." : "Termin angelegt.",
);
setOpen(false);
});
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen) {
setRequiresRsvp(termin?.requiresRsvp ?? false);
}
}}
>
<DialogTrigger asChild>
<Button variant={mode === "edit" ? "pill" : "default"} size="sm">
{mode === "edit" ? (
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
) : (
<CalendarPlus className="h-4 w-4 [stroke-width:1.7]" />
)}
{mode === "edit" ? "Bearbeiten" : "Termin anlegen"}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<form action={handleAction}>
<DialogHeader>
<DialogTitle>
{mode === "edit" ? "Termin bearbeiten" : "Termin anlegen"}
</DialogTitle>
<DialogDescription>
RSVP kann für Ausflüge und besondere Events aktiviert werden.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor={`${mode}-title`}>Titel</Label>
<Input
id={`${mode}-title`}
name="title"
defaultValue={termin?.title ?? ""}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-description`}>Beschreibung</Label>
<Textarea
id={`${mode}-description`}
name="description"
defaultValue={termin?.description ?? ""}
rows={3}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-type`}>Kategorie</Label>
<select
id={`${mode}-type`}
name="type"
defaultValue={termin?.type ?? TerminType.KITA_FEST}
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
required
>
{Object.values(TerminType).map((type) => (
<option key={type} value={type}>
{getTypeLabel(type)}
</option>
))}
</select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor={`${mode}-startDate`}>Start</Label>
<Input
id={`${mode}-startDate`}
name="startDate"
type="datetime-local"
defaultValue={
termin ? toDatetimeLocalValue(termin.startDate) : ""
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-endDate`}>Ende</Label>
<Input
id={`${mode}-endDate`}
name="endDate"
type="datetime-local"
defaultValue={
termin ? toDatetimeLocalValue(termin.endDate) : ""
}
required
/>
</div>
</div>
<div className="grid gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="allDay"
defaultChecked={termin?.allDay ?? false}
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
/>
Ganztägiger Termin
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="mitbringselListEnabled"
defaultChecked={termin?.mitbringselListEnabled ?? false}
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
/>
Mitbring-Liste aktiv
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="requiresRsvp"
checked={requiresRsvp}
onChange={(event) => setRequiresRsvp(event.target.checked)}
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
/>
Teilnahme-Abfrage (RSVP) aktivieren
</label>
</div>
{requiresRsvp && (
<div className="grid gap-4 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
<div className="grid gap-2">
<Label htmlFor={`${mode}-rsvpDeadline`}>
Rückmeldefrist
</Label>
<Input
id={`${mode}-rsvpDeadline`}
name="rsvpDeadline"
type="datetime-local"
defaultValue={
termin?.rsvpDeadline
? toDatetimeLocalValue(termin.rsvpDeadline)
: ""
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-participantInfo`}>
Hinweise für Teilnehmer
</Label>
<Textarea
id={`${mode}-participantInfo`}
name="participantInfo"
defaultValue={termin?.participantInfo ?? ""}
placeholder="z.B. Zweite Brotdose mitbringen"
rows={3}
/>
</div>
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function RsvpStatusBadge({
status,
}: {
status: EventParticipationStatus | null;
}) {
if (status === EventParticipationStatus.ATTENDING) {
return (
<Badge variant="success" className="gap-1">
<Check className="h-3.5 w-3.5" />
Zugesagt
</Badge>
);
}
if (status === EventParticipationStatus.NOT_ATTENDING) {
return (
<Badge variant="destructive" className="gap-1">
<X className="h-3.5 w-3.5" />
Abgesagt
</Badge>
);
}
return (
<Badge variant="outline" className="gap-1 text-[var(--ink-muted)]">
<HelpCircle className="h-3.5 w-3.5" />
Ausstehend
</Badge>
);
}
function getStats(termin: AdminTerminDto, children: ChildDto[]) {
const statusByChildId = new Map(
termin.participations.map((participation) => [
participation.childId,
participation.status,
]),
);
let attending = 0;
let notAttending = 0;
children.forEach((child) => {
const status = statusByChildId.get(child.id);
if (status === EventParticipationStatus.ATTENDING) attending += 1;
if (status === EventParticipationStatus.NOT_ATTENDING) notAttending += 1;
});
return {
attending,
notAttending,
pending: Math.max(children.length - attending - notAttending, 0),
statusByChildId,
};
}
function toDatetimeLocalValue(value: string) {
const date = new Date(value);
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return localDate.toISOString().slice(0, 16);
}
function getStatusVariant(
status: TerminStatus,
): "default" | "secondary" | "destructive" | "outline" | "success" | "warning" {
switch (status) {
case TerminStatus.CONFIRMED:
return "success";
case TerminStatus.PENDING:
return "warning";
case TerminStatus.REJECTED:
case TerminStatus.CANCELLED:
return "destructive";
default:
return "outline";
}
}
function getStatusLabel(status: TerminStatus) {
switch (status) {
case TerminStatus.CONFIRMED:
return "Bestätigt";
case TerminStatus.PENDING:
return "Ausstehend";
case TerminStatus.REJECTED:
return "Abgelehnt";
case TerminStatus.CANCELLED:
return "Abgesagt";
}
}
function getTypeBadgeClass(type: TerminType) {
switch (type) {
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "border-transparent bg-[var(--warn-soft)] text-[var(--warn)]";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "border-transparent bg-[var(--danger-soft)] text-[var(--danger)]";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "border-transparent bg-[oklch(0.94_0.04_280)] text-[oklch(0.48_0.08_280)]";
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "border-transparent bg-[var(--accent-soft)] text-[var(--accent-deep)]";
default:
return "border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]";
}
}
function getTypeLabel(type: TerminType) {
switch (type) {
case TerminType.KITA_FEST:
return "Kita-Fest";
case TerminType.SCHLIESSTAG:
return "Schließtag";
case TerminType.TEAMTAG:
return "Teamtag";
case TerminType.MITMACH_TAG:
return "Mitmach-Tag";
case TerminType.ELTERNABEND:
return "Elternabend";
case TerminType.MITGLIEDERVERSAMMLUNG:
return "Mitgliederversammlung";
case TerminType.ELTERNCAFE:
return "Elterncafe";
case TerminType.GEBURTSTAG_INTERN:
return "Geburtstag Intern";
case TerminType.GEBURTSTAG_EXTERN:
return "Raumanfrage Extern";
case TerminType.SONSTIGES:
return "Sonstiges";
}
}
+80
View File
@@ -0,0 +1,80 @@
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { AdminCalendarManager } from "./admin-calendar-manager";
export const metadata = { title: "Event-Anmeldungen · Kita-Planer" };
export default async function AdminKalenderPage() {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return <div className="p-8">Kein Mandant zugeordnet.</div>;
}
const [termine, children] = await Promise.all([
prisma.termin.findMany({
where: { kitaId },
select: {
id: true,
title: true,
description: true,
type: true,
status: true,
startDate: true,
endDate: true,
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
rsvpDeadline: true,
participantInfo: true,
participations: {
select: {
childId: true,
status: true,
},
},
},
orderBy: { startDate: "desc" },
}),
prisma.child.findMany({
where: {
kitaId,
active: true,
},
select: {
id: true,
firstName: true,
lastName: true,
family: {
select: {
name: true,
},
},
},
orderBy: [
{ family: { name: "asc" } },
{ lastName: "asc" },
{ firstName: "asc" },
],
}),
]);
return (
<AdminCalendarManager
termine={termine.map((termin) => ({
...termin,
startDate: termin.startDate.toISOString(),
endDate: termin.endDate.toISOString(),
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
}))}
childRows={children.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
familyName: child.family.name,
}))}
/>
);
}
+9 -9
View File
@@ -67,14 +67,14 @@ export function NewsForm() {
placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."} placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."}
required required
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-[var(--ink-muted)]">
Markdown ist aktiv. HTML wird nicht interpretiert. Markdown ist aktiv. HTML wird nicht interpretiert.
</p> </p>
</div> </div>
{content.trim() && ( {content.trim() && (
<div className="rounded-md border bg-muted/30 p-4 text-sm"> <div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm">
<p className="mb-3 text-xs font-semibold uppercase text-muted-foreground"> <p className="mb-3 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Vorschau Vorschau
</p> </p>
<MarkdownContent content={content} /> <MarkdownContent content={content} />
@@ -83,7 +83,7 @@ export function NewsForm() {
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="attachments">Anhänge</Label> <Label htmlFor="attachments">Anhänge</Label>
<div className="rounded-md border border-dashed bg-background p-4"> <div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<Input <Input
id="attachments" id="attachments"
name="attachments" name="attachments"
@@ -91,14 +91,14 @@ export function NewsForm() {
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
multiple multiple
/> />
<p className="mt-2 flex items-center gap-1 text-xs text-muted-foreground"> <p className="mt-2 flex items-center gap-1 text-xs text-[var(--ink-muted)]">
<UploadCloud className="h-3.5 w-3.5" /> <UploadCloud className="h-3.5 w-3.5 [stroke-width:1.7]" />
PDF, JPG und PNG bis 10 MB pro Datei. PDF, JPG und PNG bis 10 MB pro Datei.
</p> </p>
</div> </div>
</div> </div>
<div className="rounded-md border p-4"> <div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
id="sendEmail" id="sendEmail"
@@ -130,9 +130,9 @@ export function NewsForm() {
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{isPending ? ( {isPending ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : ( ) : (
<Send className="h-4 w-4" /> <Send className="h-4 w-4 [stroke-width:1.7]" />
)} )}
Veröffentlichen Veröffentlichen
</Button> </Button>
+38 -19
View File
@@ -1,6 +1,6 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { FileText, Megaphone } from "lucide-react"; import { FileText, Megaphone, Send } from "lucide-react";
import { UserRole } from "@prisma/client"; import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils"; import { requireRole } from "@/lib/auth-utils";
@@ -49,26 +49,37 @@ export default async function AdminNewsPage() {
}); });
return ( return (
<div className="px-8 py-8"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="mb-8"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<Badge className="mb-3 bg-emerald-100 text-emerald-800 hover:bg-emerald-100"> <div>
Offizielle Kita-Kommunikation <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
</Badge> KOMMUNIKATION · SCHWARZES BRETT
<h1 className="flex items-center gap-3 text-2xl font-semibold tracking-tight"> </p>
<Megaphone className="h-6 w-6 text-emerald-700" /> <h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Digitales Schwarzes Brett Meldungen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
veröffentlichen
</em>
</h1> </h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground"> <p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
werden geschützt gespeichert und nur für eingeloggte Nutzer derselben werden geschützt gespeichert und nur für eingeloggte Nutzer
Kita ausgeliefert. derselben Kita ausgeliefert.
</p> </p>
</div> </div>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
<Megaphone className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{announcements.length} zuletzt
</span>
</header>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Neue Ankündigung</CardTitle> <CardTitle className="flex items-center gap-2">
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
Neue Ankündigung
</CardTitle>
<CardDescription> <CardDescription>
Markdown wird sicher gerendert; PDF, JPG und PNG können als Markdown wird sicher gerendert; PDF, JPG und PNG können als
geschützte Anhänge hinzugefügt werden. geschützte Anhänge hinzugefügt werden.
@@ -88,27 +99,35 @@ export default async function AdminNewsPage() {
</CardHeader> </CardHeader>
<CardContent className="grid gap-3"> <CardContent className="grid gap-3">
{announcements.length === 0 ? ( {announcements.length === 0 ? (
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground"> <div className="flex max-h-[280px] min-h-[220px] flex-col items-center justify-center rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-center">
Noch keine Ankündigungen veröffentlicht. <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Megaphone className="h-6 w-6 [stroke-width:1.7]" />
</div>
<p className="text-sm font-medium text-[var(--ink)]">
Noch keine Ankündigungen
</p> </p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Veröffentlichte Meldungen erscheinen hier.
</p>
</div>
) : ( ) : (
announcements.map((announcement) => ( announcements.map((announcement) => (
<div <div
key={announcement.id} key={announcement.id}
className="rounded-md border bg-background p-3" className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
> >
<div className="mb-2 flex items-start justify-between gap-2"> <div className="mb-2 flex items-start justify-between gap-2">
<p className="font-medium leading-snug"> <p className="font-medium leading-snug text-[var(--ink)]">
{announcement.title} {announcement.title}
</p> </p>
{announcement.attachments.length > 0 && ( {announcement.attachments.length > 0 && (
<Badge variant="secondary" className="shrink-0"> <Badge variant="secondary" className="shrink-0">
<FileText className="h-3 w-3" /> <FileText className="h-3 w-3 [stroke-width:1.7]" />
{announcement.attachments.length} {announcement.attachments.length}
</Badge> </Badge>
)} )}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-[var(--ink-muted)]">
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", { {format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
locale: de, locale: de,
})}{" "} })}{" "}
+234 -128
View File
@@ -1,15 +1,20 @@
import { Baby, Contact, Mail, Phone, ShieldCheck, UsersRound } from "lucide-react"; import {
Baby,
Contact,
Mail,
Phone,
Search,
ShieldCheck,
SlidersHorizontal,
UserRound,
} from "lucide-react";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import { Button } from "@/components/ui/button";
Card, import { Card, CardContent } from "@/components/ui/card";
CardContent, import { Input } from "@/components/ui/input";
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export const metadata = { title: "Adressbuch · Kita-Planer" }; export const metadata = { title: "Adressbuch · Kita-Planer" };
@@ -119,140 +124,241 @@ export default async function AdressbuchPage() {
}), }),
})); }));
const visibleParentCount = families.reduce(
(count, family) =>
count + family.parents.filter((parent) => parent.hasDirectoryOptIn).length,
0,
);
const childCount = families.reduce(
(count, family) => count + family.children.length,
0,
);
const groupedFamilies = groupFamiliesByInitial(families);
return ( return (
<div className="flex h-full flex-col gap-6 p-6"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="flex flex-col gap-1"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<h1 className="text-2xl font-bold tracking-tight">Adressbuch</h1> <div>
<p className="max-w-2xl text-sm text-muted-foreground"> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
Haushalte und Kontaktdaten, die explizit fuer das interne KONTAKTE · ADRESSBUCH
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Freigegebene{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
Kontakte
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Haushalte und Kontaktdaten, die explizit für das interne
Kita-Adressbuch freigegeben wurden. Kita-Adressbuch freigegeben wurden.
</p> </p>
</div> </div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
<ShieldCheck className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{visibleParentCount} sichtbar
</span>
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Sichtbarkeit
</Button>
</div>
</header>
{families.length === 0 ? ( {families.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-background p-10 text-center"> <Card className="border-dashed">
<Contact className="mb-4 h-10 w-10 text-muted-foreground" /> <CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<h3 className="text-lg font-semibold">Keine freigegebenen Kontakte</h3> <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<p className="mt-2 max-w-sm text-sm text-muted-foreground"> <Contact className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine freigegebenen Kontakte
</h3>
<p className="mt-2 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Sobald mindestens ein Elternteil eines Haushalts zustimmt, Sobald mindestens ein Elternteil eines Haushalts zustimmt,
erscheint die Familie hier. erscheint die Familie hier.
</p> </p>
</div>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{families.map((family) => (
<Card key={family.id} className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 pb-4">
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<UsersRound className="h-4 w-4 text-primary" />
{family.name}
</CardTitle>
<CardDescription className="mt-1">
{family.parents.filter((user) => user.hasDirectoryOptIn).length}{" "}
von {family.parents.length} Elternteilen sichtbar
</CardDescription>
</div>
<Badge variant="success">Freigegeben</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-5 pt-5">
<section className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Baby className="h-3.5 w-3.5" />
Kinder
</div>
{family.children.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keine Kinder hinterlegt.
</p>
) : (
<div className="flex flex-wrap gap-1.5">
{family.children.map((child) => (
<Badge key={child.id} variant="secondary">
{child.firstName} {child.lastName}
</Badge>
))}
</div>
)}
</section>
<section className="grid gap-3">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<ShieldCheck className="h-3.5 w-3.5" />
Kontakte
</div>
<div className="grid gap-3">
{family.parents.map((user) => {
return (
<div
key={user.id}
className="rounded-md border bg-background p-3"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium">
{user.firstName} {user.lastName}
</p>
{!user.hasDirectoryOptIn && (
<p className="mt-0.5 text-xs text-muted-foreground">
Kontakt nicht freigegeben.
</p>
)}
</div>
{user.hasDirectoryOptIn ? (
<Badge variant="outline">Opt-in</Badge>
) : null}
</div>
{user.hasDirectoryOptIn && (
<div className="mt-3 grid gap-2 text-sm text-muted-foreground">
{user.email && (
<a
href={`mailto:${user.email}`}
className="flex min-w-0 items-center gap-2 hover:text-primary"
>
<Mail className="h-4 w-4 shrink-0" />
<span className="truncate">{user.email}</span>
</a>
)}
{user.phone && (
<a
href={`tel:${user.phone}`}
className="flex items-center gap-2 hover:text-primary"
>
<Phone className="h-4 w-4 shrink-0" />
{user.phone}
</a>
)}
</div>
)}
{user.duties.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5 border-t pt-3">
{user.duties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="bg-primary/5 text-primary"
>
{duty.name}
</Badge>
))}
</div>
)}
</div>
);
})}
</div>
</section>
</CardContent> </CardContent>
</Card> </Card>
) : (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<DirectoryMetric label="Haushalte" value={families.length} />
<DirectoryMetric label="Kontakte" value={visibleParentCount} />
<DirectoryMetric label="Kinder" value={childCount} />
</div>
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input
placeholder="Familien, Namen oder E-Mail suchen"
className="pl-9"
/>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{["Alle Gruppen", "Mit Telefon", "Mit Ämtern", "Nur Opt-in"].map(
(filter, index) => (
<span
key={filter}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium",
index === 0
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)]",
].join(" ")}
>
{filter}
</span>
),
)}
</div>
<Card className="overflow-hidden p-0">
{groupedFamilies.map((group) => (
<section key={group.initial}>
<div className="flex items-center justify-between border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] px-5 py-3">
<h2 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.14em]">
{group.initial}
</h2>
<span className="text-[11px] font-semibold text-[var(--ink-muted)] tabular-nums">
{group.families.length}
</span>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{group.families.map((family) => (
<FamilyDirectoryRow key={family.id} family={family} />
))} ))}
</div> </div>
</section>
))}
</Card>
</div>
)} )}
</div> </div>
); );
} }
function FamilyDirectoryRow({ family }: { family: DirectoryFamilyDto }) {
const visibleParents = family.parents.filter(
(parent) => parent.hasDirectoryOptIn,
);
const duties = family.parents.flatMap((parent) => parent.duties);
return (
<div className="grid grid-cols-[minmax(220px,0.8fr)_minmax(0,1.4fr)_minmax(220px,1fr)] gap-5 px-5 py-4 transition-colors hover:bg-[var(--surface-2)] max-[1050px]:grid-cols-1">
<div className="flex min-w-0 gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-sm font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{family.name}
</h3>
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-[13px] text-[var(--ink-soft)]">
<Baby className="h-3.5 w-3.5 [stroke-width:1.7]" />
{family.children.length === 0
? "Keine Kinder hinterlegt"
: family.children
.map((child) => `${child.firstName} ${child.lastName}`)
.join(", ")}
</p>
</div>
</div>
<div className="grid gap-3">
{family.parents.map((user) => (
<div key={user.id} className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<UserRound className="h-3.5 w-3.5 text-[var(--ink-muted)] [stroke-width:1.7]" />
<p className="text-sm font-medium text-[var(--ink)]">
{user.firstName} {user.lastName}
</p>
{user.hasDirectoryOptIn ? (
<Badge variant="success">Opt-in</Badge>
) : (
<span className="text-xs italic text-[var(--ink-faint)]">
nicht freigegeben
</span>
)}
</div>
{user.hasDirectoryOptIn && (
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 pl-5 text-[13px] text-[var(--ink-soft)]">
{user.email && (
<a
href={`mailto:${user.email}`}
className="inline-flex min-w-0 items-center gap-1.5 hover:text-[var(--accent-deep)]"
>
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{user.email}
</span>
</a>
)}
{user.phone ? (
<a
href={`tel:${user.phone}`}
className="inline-flex items-center gap-1.5 hover:text-[var(--accent-deep)]"
>
<Phone className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
{user.phone}
</a>
) : (
<span className="italic text-[var(--ink-faint)]">
Keine Telefonnummer
</span>
)}
</div>
)}
</div>
))}
</div>
<div className="flex flex-col items-end justify-between gap-3 max-[1050px]:items-start">
<div className="flex flex-wrap justify-end gap-1.5 max-[1050px]:justify-start">
<Badge variant="secondary">
{visibleParents.length} von {family.parents.length} sichtbar
</Badge>
{duties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
>
{duty.name}
</Badge>
))}
</div>
<Button variant="ghost" size="sm">
Kontakt öffnen
</Button>
</div>
</div>
);
}
function DirectoryMetric({ label, value }: { label: string; value: number }) {
return (
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{value}</span>
{label}
</span>
);
}
function groupFamiliesByInitial(families: DirectoryFamilyDto[]) {
const groups = new Map<string, DirectoryFamilyDto[]>();
families.forEach((family) => {
const initial = family.name.trim().slice(0, 1).toLocaleUpperCase("de-DE");
const key = initial || "#";
groups.set(key, [...(groups.get(key) ?? []), family]);
});
return Array.from(groups.entries()).map(([initial, groupFamilies]) => ({
initial,
families: groupFamilies,
}));
}
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Plus, Pencil, Trash2 } from "lucide-react"; import { Plus, Pencil, Search, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -18,6 +18,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Table, Table,
TableBody, TableBody,
@@ -92,11 +93,15 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex justify-end"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input placeholder="Name suchen" className="pl-9" />
</div>
<Dialog open={openCreate} onOpenChange={setOpenCreate}> <Dialog open={openCreate} onOpenChange={setOpenCreate}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="gap-2"> <Button className="gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4 [stroke-width:1.7]" />
ErzieherIn anlegen ErzieherIn anlegen
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@@ -131,39 +136,67 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
</Dialog> </Dialog>
</div> </div>
<div className="rounded-md border bg-background"> <Card>
<CardHeader className="flex-row items-center justify-between">
<div>
<CardTitle>Team</CardTitle>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
{educators.length} Einträge im Personalstamm.
</p>
</div>
<Badge variant="secondary" className="tabular-nums">
{educators.filter((ed) => ed.active).length} aktiv
</Badge>
</CardHeader>
<CardContent className="px-0 pb-0">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Vorname</TableHead> <TableHead className="pl-6">Vorname</TableHead>
<TableHead>Nachname</TableHead> <TableHead>Nachname</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead> <TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{educators.length === 0 ? ( {educators.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground h-24"> <TableCell colSpan={4} className="h-32 text-center">
Keine Einträge gefunden. <div className="flex max-h-[240px] min-h-[200px] flex-col items-center justify-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Plus className="h-6 w-6 [stroke-width:1.7]" />
</div>
<p className="text-sm font-medium text-[var(--ink)]">
Keine Einträge gefunden
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege die erste Person im Team an.
</p>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
educators.map((ed) => ( educators.map((ed) => (
<TableRow key={ed.id}> <TableRow key={ed.id}>
<TableCell className="font-medium">{ed.firstName}</TableCell> <TableCell className="pl-6 font-medium">{ed.firstName}</TableCell>
<TableCell>{ed.lastName}</TableCell> <TableCell>{ed.lastName}</TableCell>
<TableCell> <TableCell>
<Badge variant={ed.active ? "success" : "secondary"}> <Badge variant={ed.active ? "success" : "secondary"}>
{ed.active ? "Aktiv" : "Inaktiv"} {ed.active ? "Aktiv" : "Inaktiv"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="pr-6 text-right">
<Button variant="ghost" size="icon" onClick={() => setEditingEducator(ed)}> <Button variant="ghost" size="icon" onClick={() => setEditingEducator(ed)}>
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4 [stroke-width:1.7]" />
</Button> </Button>
<Button variant="ghost" size="icon" className="text-destructive hover:text-destructive" onClick={() => handleDelete(ed.id)} disabled={isPending}> <Button
<Trash2 className="h-4 w-4" /> variant="ghost"
size="icon"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => handleDelete(ed.id)}
disabled={isPending}
>
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -171,7 +204,8 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </CardContent>
</Card>
{/* Edit Dialog */} {/* Edit Dialog */}
<Dialog open={!!editingEducator} onOpenChange={(open) => !open && setEditingEducator(null)}> <Dialog open={!!editingEducator} onOpenChange={(open) => !open && setEditingEducator(null)}>
+21 -6
View File
@@ -20,21 +20,36 @@ export default async function ErzieherPage() {
}, },
orderBy: [{ lastName: "asc" }, { firstName: "asc" }], orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
}); });
const activeCount = educators.filter((educator) => educator.active).length;
return ( return (
<div className="flex h-full flex-col gap-6 p-6"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="flex items-start justify-between gap-4"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div> <div>
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight"> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<GraduationCap className="h-6 w-6 text-primary" /> VERWALTUNG · TEAM
ErzieherInnen-Verwaltung </p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
ErzieherInnen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
verwalten
</em>
</h1> </h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground"> <p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Stammdaten des Kita-Personals für interne Planung und spätere Stammdaten des Kita-Personals für interne Planung und spätere
Notdienst-Alarmierung. Notdienst-Alarmierung.
</p> </p>
</div> </div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<GraduationCap className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{educators.length} im Team
</span>
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{activeCount} aktiv
</span>
</div> </div>
</header>
<ErzieherList educators={educators} /> <ErzieherList educators={educators} />
</div> </div>
@@ -54,7 +54,7 @@ export function AddFamilyDialog() {
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm"> <Button size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="h-4 w-4" />
Familie hinzufügen Familie hinzufügen
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@@ -82,7 +82,9 @@ export function AddFamilyDialog() {
{/* ── Elternteile ────────────────────────────────────────── */} {/* ── Elternteile ────────────────────────────────────────── */}
<fieldset className="space-y-4"> <fieldset className="space-y-4">
<legend className="text-sm font-semibold">Elternteil 1</legend> <legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Elternteil 1
</legend>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<FormField <FormField
@@ -112,8 +114,13 @@ export function AddFamilyDialog() {
</fieldset> </fieldset>
<fieldset className="space-y-4"> <fieldset className="space-y-4">
<legend className="text-sm font-semibold"> <legend>
Elternteil 2 <span className="font-normal text-muted-foreground">(optional)</span> <span className="text-[11px] uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Elternteil 2
</span>{" "}
<span className="font-normal italic text-[var(--ink-faint)]">
optional
</span>
</legend> </legend>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -149,7 +156,7 @@ export function AddFamilyDialog() {
{/* ── Kinder ─────────────────────────────────────────────── */} {/* ── Kinder ─────────────────────────────────────────────── */}
<fieldset className="space-y-3"> <fieldset className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<legend className="text-sm font-semibold"> <legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Kinder ({childCount}) Kinder ({childCount})
</legend> </legend>
{childCount < 10 && ( {childCount < 10 && (
@@ -168,7 +175,7 @@ export function AddFamilyDialog() {
{Array.from({ length: childCount }).map((_, i) => ( {Array.from({ length: childCount }).map((_, i) => (
<div <div
key={i} key={i}
className="relative rounded-md border p-4" className="relative rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
> >
{childCount > 1 && ( {childCount > 1 && (
<button <button
@@ -178,13 +185,13 @@ export function AddFamilyDialog() {
// Felder nach Index benannt sind und der letzte entfernt wird. // Felder nach Index benannt sind und der letzte entfernt wird.
setChildCount((c) => c - 1); setChildCount((c) => c - 1);
}} }}
className="absolute right-3 top-3 rounded p-0.5 text-muted-foreground hover:text-destructive" className="absolute right-3 top-3 rounded-md p-1 text-[var(--ink-muted)] transition-colors hover:bg-[var(--danger-soft)] hover:text-[var(--danger)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-2)]"
aria-label={`Kind ${i + 1} entfernen`} aria-label={`Kind ${i + 1} entfernen`}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
)} )}
<p className="mb-3 text-xs font-medium text-muted-foreground"> <p className="mb-3 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Kind {i + 1} Kind {i + 1}
</p> </p>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -203,7 +210,7 @@ export function AddFamilyDialog() {
))} ))}
{state.errors?.children?.[0] && ( {state.errors?.children?.[0] && (
<p className="text-xs text-destructive"> <p className="text-xs text-[var(--danger)]">
{state.errors.children[0]} {state.errors.children[0]}
</p> </p>
)} )}
@@ -211,7 +218,7 @@ export function AddFamilyDialog() {
{/* Globaler Fehler */} {/* Globaler Fehler */}
{state.errors?._form?.[0] && ( {state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <p className="rounded-lg border border-[var(--danger)] bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]">
{state.errors._form[0]} {state.errors._form[0]}
</p> </p>
)} )}
@@ -219,7 +226,7 @@ export function AddFamilyDialog() {
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="ghost"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
disabled={pending} disabled={pending}
> >
@@ -268,7 +275,7 @@ function FormField({
required={required} required={required}
aria-invalid={!!error} aria-invalid={!!error}
/> />
{error && <p className="text-xs text-destructive">{error}</p>} {error && <p className="text-xs text-[var(--danger)]">{error}</p>}
</div> </div>
); );
} }
+23 -13
View File
@@ -107,10 +107,10 @@ export function DutyManager({
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1"> <Button variant="ghost" size="sm" className="h-8 gap-1">
<Shield className="h-4 w-4" /> <Shield className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Ämter</span> <span className="hidden sm:inline">Ämter</span>
{userAssignments.length > 0 && ( {userAssignments.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1 py-0 h-4"> <Badge variant="secondary" className="ml-1 h-4 px-1 py-0 tabular-nums">
{userAssignments.length} {userAssignments.length}
</Badge> </Badge>
)} )}
@@ -127,16 +127,20 @@ export function DutyManager({
<div className="flex flex-col gap-6 py-4"> <div className="flex flex-col gap-6 py-4">
{/* Current Assignments */} {/* Current Assignments */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h4 className="text-sm font-semibold">Aktuelle Ämter</h4> <h4 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Aktuelle Ämter
</h4>
{userAssignments.length === 0 ? ( {userAssignments.length === 0 ? (
<p className="text-sm text-muted-foreground italic">Keine Ämter zugewiesen.</p> <p className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-5 text-sm italic text-[var(--ink-faint)]">
Keine Ämter zugewiesen.
</p>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{userAssignments.map((assignment) => ( {userAssignments.map((assignment) => (
<Badge key={assignment.id} variant="default" className="gap-1 pr-1 bg-primary"> <Badge key={assignment.id} variant="default" className="gap-1 pr-1">
{assignment.duty.name} {assignment.duty.name}
<button <button
className="ml-1 rounded-full hover:bg-primary-foreground/20 p-0.5" className="ml-1 rounded-full p-0.5 transition-colors hover:bg-[oklch(0.98_0.005_45_/_0.18)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[oklch(0.98_0.005_45)]"
onClick={() => handleRemove(assignment.id)} onClick={() => handleRemove(assignment.id)}
disabled={isPending} disabled={isPending}
> >
@@ -149,17 +153,21 @@ export function DutyManager({
</div> </div>
{/* Assign new duty */} {/* Assign new duty */}
<div className="flex flex-col gap-2 border-t pt-4"> <div className="flex flex-col gap-2 border-t border-[var(--hairline-soft)] pt-4">
<h4 className="text-sm font-semibold">Vorhandenes Amt zuweisen</h4> <h4 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Vorhandenes Amt zuweisen
</h4>
{unassignedDuties.length === 0 ? ( {unassignedDuties.length === 0 ? (
<p className="text-sm text-muted-foreground italic">Keine weiteren Ämter verfügbar.</p> <p className="text-sm italic text-[var(--ink-faint)]">
Keine weiteren Ämter verfügbar.
</p>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{unassignedDuties.map((duty) => ( {unassignedDuties.map((duty) => (
<Badge <Badge
key={duty.id} key={duty.id}
variant="outline" variant="outline"
className="cursor-pointer hover:bg-muted gap-1 pr-1 group relative" className="group relative cursor-pointer gap-1 pr-1 hover:border-[var(--accent-mid)] hover:bg-[var(--accent-soft)] hover:text-[var(--accent-deep)]"
onClick={() => handleAssign(duty.id)} onClick={() => handleAssign(duty.id)}
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
@@ -168,7 +176,7 @@ export function DutyManager({
{/* Only show delete if no one is assigned to it across the entire DB */} {/* Only show delete if no one is assigned to it across the entire DB */}
{duty.assignments.length === 0 && ( {duty.assignments.length === 0 && (
<button <button
className="ml-1 rounded-full hover:bg-destructive/20 text-destructive p-0.5 opacity-0 group-hover:opacity-100 transition-opacity" className="ml-1 rounded-full p-0.5 text-[var(--danger)] opacity-0 transition-opacity hover:bg-[var(--danger-soft)] group-hover:opacity-100"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteDuty(duty.id); handleDeleteDuty(duty.id);
@@ -186,8 +194,10 @@ export function DutyManager({
</div> </div>
{/* Create new duty */} {/* Create new duty */}
<div className="flex flex-col gap-2 border-t pt-4"> <div className="flex flex-col gap-2 border-t border-[var(--hairline-soft)] pt-4">
<h4 className="text-sm font-semibold">Neues Amt anlegen</h4> <h4 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Neues Amt anlegen
</h4>
<form action={handleCreateDuty} className="flex gap-2"> <form action={handleCreateDuty} className="flex gap-2">
<Input name="name" placeholder="z.B. Wäschedienst" className="h-8" required /> <Input name="name" placeholder="z.B. Wäschedienst" className="h-8" required />
<Button size="sm" className="h-8" disabled={isPending}> <Button size="sm" className="h-8" disabled={isPending}>
@@ -163,7 +163,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
<Dialog onOpenChange={(open) => !open && resetState()}> <Dialog onOpenChange={(open) => !open && resetState()}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1"> <Button variant="ghost" size="sm" className="h-8 gap-1">
<Pencil className="h-4 w-4" /> <Pencil className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Details</span> <span className="hidden sm:inline">Details</span>
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@@ -189,11 +189,13 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
<fieldset className="grid gap-3"> <fieldset className="grid gap-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<legend className="text-sm font-semibold">Elternteile</legend> <legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Elternteile
</legend>
{parents.length < 2 && ( {parents.length < 2 && (
<Button <Button
type="button" type="button"
variant="outline" variant="pill"
size="sm" size="sm"
className="h-8 gap-1" className="h-8 gap-1"
onClick={addParent} onClick={addParent}
@@ -206,9 +208,12 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
</div> </div>
{parents.map((parent, index) => ( {parents.map((parent, index) => (
<div key={parent.id} className="rounded-md border p-3"> <div
key={parent.id}
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
>
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-medium text-muted-foreground"> <p className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Elternteil {index + 1} Elternteil {index + 1}
</p> </p>
{parent.kind === "new" && ( {parent.kind === "new" && (
@@ -216,7 +221,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 gap-1 px-2 text-destructive hover:text-destructive" className="h-7 gap-1 px-2 text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => removeNewParent(parent)} onClick={() => removeNewParent(parent)}
disabled={isPending} disabled={isPending}
> >
@@ -254,10 +259,12 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
<fieldset className="grid gap-3"> <fieldset className="grid gap-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<legend className="text-sm font-semibold">Kinder</legend> <legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Kinder
</legend>
<Button <Button
type="button" type="button"
variant="outline" variant="pill"
size="sm" size="sm"
className="h-8 gap-1" className="h-8 gap-1"
onClick={addChild} onClick={addChild}
@@ -269,21 +276,24 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
</div> </div>
{children.length === 0 ? ( {children.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-5 text-sm italic text-[var(--ink-faint)]">
Keine Kinder verknüpft. Keine Kinder verknüpft.
</p> </p>
) : ( ) : (
children.map((child, index) => ( children.map((child, index) => (
<div key={child.id} className="rounded-md border p-3"> <div
key={child.id}
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
>
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-medium text-muted-foreground"> <p className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Kind {index + 1} Kind {index + 1}
</p> </p>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 gap-1 px-2 text-destructive hover:text-destructive" className="h-7 gap-1 px-2 text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => removeChild(child)} onClick={() => removeChild(child)}
disabled={isPending} disabled={isPending}
> >
+105 -39
View File
@@ -1,8 +1,12 @@
import { UserRole } from "@prisma/client"; import { UserRole } from "@prisma/client";
import { Mail, Search, SlidersHorizontal, Users } from "lucide-react";
import { requireRole } from "@/lib/auth-utils"; import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { import {
Table, Table,
TableBody, TableBody,
@@ -85,36 +89,76 @@ export default async function FamiliesPage() {
0, 0,
); );
const canManageDuties = session.user.role === UserRole.ADMIN; const canManageDuties = session.user.role === UserRole.ADMIN;
const pendingInviteCount = families.reduce(
(acc, family) =>
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
0,
);
return ( return (
<div className="px-8 py-8"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
{/* Header */} <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div className="mb-6 flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight"> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
Familienverwaltung VERWALTUNG · FAMILIEN
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Familien{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
organisieren
</em>
</h1> </h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
{families.length === 0 Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort
? "Noch keine Familien angelegt." pflegen.
: `${families.length} ${families.length === 1 ? "Familie" : "Familien"} · ${childCount} Kinder`}
</p> </p>
</div> </div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Ansicht
</Button>
<AddFamilyDialog /> <AddFamilyDialog />
</div> </div>
</header>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<DirectoryMetric label="Familien" value={families.length} />
<DirectoryMetric label="Kinder" value={childCount} />
<DirectoryMetric label="Offene Einladungen" value={pendingInviteCount} />
</div>
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input placeholder="Familien, Eltern oder Kinder suchen" className="pl-9" />
</div>
</div>
{families.length === 0 ? ( {families.length === 0 ? (
<EmptyState /> <EmptyState />
) : ( ) : (
<div className="rounded-lg border bg-background"> <Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<div>
<CardTitle>Haushalte</CardTitle>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
{families.length} {families.length === 1 ? "Familie" : "Familien"} ·{" "}
{childCount} Kinder · {pendingInviteCount} offene Einladungen
</p>
</div>
<Badge variant="secondary" className="tabular-nums">
{families.length}
</Badge>
</CardHeader>
<CardContent className="px-0 pb-0">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Familie</TableHead> <TableHead className="pl-6">Familie</TableHead>
<TableHead>Elternteile</TableHead> <TableHead>Elternteile</TableHead>
<TableHead>Kinder</TableHead> <TableHead>Kinder</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead> <TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -125,18 +169,34 @@ export default async function FamiliesPage() {
return ( return (
<TableRow key={family.id}> <TableRow key={family.id}>
<TableCell className="font-medium"> <TableCell className="pl-6">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[13px] font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div>
<div className="font-semibold text-[var(--ink)]">
{family.name} {family.name}
</div>
<div className="mt-0.5 text-xs text-[var(--ink-muted)]">
{family.users.length} Elternteil
{family.users.length === 1 ? "" : "e"}
</div>
</div>
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="space-y-1.5"> <div className="space-y-2">
{family.users.map((parent) => ( {family.users.map((parent) => (
<div key={parent.id}> <div key={parent.id} className="min-w-0">
<div className="font-medium"> <div className="font-medium text-[var(--ink)]">
{parent.firstName} {parent.lastName} {parent.firstName} {parent.lastName}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="mt-0.5 flex items-center gap-1.5 text-xs text-[var(--ink-muted)]">
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email} {parent.email}
</span>
</div> </div>
</div> </div>
))} ))}
@@ -144,13 +204,15 @@ export default async function FamiliesPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
{family.children.length === 0 ? ( {family.children.length === 0 ? (
<span className="text-sm text-muted-foreground"></span> <span className="text-sm italic text-[var(--ink-faint)]">
Noch keine Kinder
</span>
) : ( ) : (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1.5">
{family.children.map((child) => ( {family.children.map((child) => (
<span <span
key={child.id} key={child.id}
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium" className="inline-flex items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-xs font-medium text-[var(--ink-soft)]"
> >
{child.firstName} {child.lastName} {child.firstName} {child.lastName}
</span> </span>
@@ -163,7 +225,7 @@ export default async function FamiliesPage() {
{hasPendingInvite ? "Einladung offen" : "Aktiv"} {hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="pr-6 text-right">
<div className="flex flex-wrap justify-end gap-1"> <div className="flex flex-wrap justify-end gap-1">
<EditFamilyDialog <EditFamilyDialog
family={{ family={{
@@ -198,7 +260,8 @@ export default async function FamiliesPage() {
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div> </CardContent>
</Card>
)} )}
</div> </div>
); );
@@ -206,28 +269,31 @@ export default async function FamiliesPage() {
function EmptyState() { function EmptyState() {
return ( return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-background px-6 py-16 text-center"> <Card className="border-dashed">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted"> <div className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<svg <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
className="h-6 w-6 text-muted-foreground" <Users className="h-6 w-6 [stroke-width:1.7]" />
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
</div> </div>
<h3 className="mb-1 font-semibold">Noch keine Familien</h3> <h3 className="mb-1 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
<p className="mb-4 max-w-sm text-sm text-muted-foreground"> Noch keine Familien
</h3>
<p className="mb-5 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Lege die erste Familie an und lade das Elternteil per Link ein, sein Lege die erste Familie an und lade das Elternteil per Link ein, sein
Passwort zu setzen. Passwort zu setzen.
</p> </p>
<AddFamilyDialog /> <AddFamilyDialog />
</div> </div>
</Card>
);
}
function DirectoryMetric({ label, value }: { label: string; value: number }) {
return (
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">
{value}
</span>
{label}
</span>
); );
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import type { ComponentProps, FormEvent } from "react";
import { useMemo, useState, useTransition } from "react";
import { TerminType } from "@prisma/client"; import { TerminType } from "@prisma/client";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -20,18 +21,77 @@ import { Label } from "@/components/ui/label";
import { createTerminAdmin } from "../actions"; import { createTerminAdmin } from "../actions";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
export function AdminTerminModal() { type AdminTerminFormState = {
const [open, setOpen] = useState(false); title: string;
const [isPending, startTransition] = useTransition(); description: string;
type: TerminType;
startDate: string;
endDate: string;
allDay: boolean;
};
const initialFormState: AdminTerminFormState = {
title: "",
description: "",
type: TerminType.KITA_FEST,
startDate: "",
endDate: "",
allDay: false,
};
type AdminTerminModalProps = {
triggerLabel?: string;
triggerVariant?: ComponentProps<typeof Button>["variant"];
triggerClassName?: string;
};
export function AdminTerminModal({
triggerLabel = "Termin direkt anlegen",
triggerVariant,
triggerClassName = "gap-2",
}: AdminTerminModalProps) {
const [open, setOpen] = useState(false);
const [formState, setFormState] = useState<AdminTerminFormState>(initialFormState);
const [isPending, startTransition] = useTransition();
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
const minEndDateTime = formState.startDate || minDateTime;
const updateField = <TKey extends keyof AdminTerminFormState>(
key: TKey,
value: AdminTerminFormState[TKey],
) => {
setFormState((current) => {
const next = { ...current, [key]: value };
if (key === "startDate" && typeof value === "string" && next.endDate && next.endDate < value) {
next.endDate = value;
}
return next;
});
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!formState.startDate || !formState.endDate) {
toast.error("Bitte Start und Ende ausfüllen.");
return;
}
if (formState.startDate < minDateTime) {
toast.error("Der Startzeitpunkt darf nicht in der Vergangenheit liegen.");
return;
}
if (formState.endDate < formState.startDate) {
toast.error("Das Ende darf nicht vor dem Start liegen.");
return;
}
const handleAction = (formData: FormData) => {
const data = { const data = {
title: formData.get("title") as string, title: formState.title,
description: formData.get("description") as string, description: formState.description,
type: formData.get("type") as TerminType, type: formState.type,
startDate: new Date(formData.get("startDate") as string).toISOString(), startDate: new Date(formState.startDate).toISOString(),
endDate: new Date(formData.get("endDate") as string).toISOString(), endDate: new Date(formState.endDate).toISOString(),
allDay: formData.get("allDay") === "on", allDay: formState.allDay,
}; };
startTransition(async () => { startTransition(async () => {
@@ -40,6 +100,7 @@ export function AdminTerminModal() {
toast.error(res.error); toast.error(res.error);
} else { } else {
toast.success("Termin wurde direkt angelegt!"); toast.success("Termin wurde direkt angelegt!");
setFormState(initialFormState);
setOpen(false); setOpen(false);
} }
}); });
@@ -48,13 +109,13 @@ export function AdminTerminModal() {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="gap-2 bg-primary"> <Button variant={triggerVariant} className={triggerClassName}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Termin direkt anlegen {triggerLabel}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<form action={handleAction}> <form onSubmit={handleSubmit}>
<DialogHeader> <DialogHeader>
<DialogTitle>Termin anlegen (Admin)</DialogTitle> <DialogTitle>Termin anlegen (Admin)</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -65,12 +126,25 @@ export function AdminTerminModal() {
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="title">Titel</Label> <Label htmlFor="title">Titel</Label>
<Input id="title" name="title" required placeholder="z.B. Sommerfest" /> <Input
id="title"
name="title"
required
placeholder="z.B. Sommerfest"
value={formState.title}
onChange={(event) => updateField("title", event.target.value)}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="description">Beschreibung (Optional)</Label> <Label htmlFor="description">Beschreibung (Optional)</Label>
<Textarea id="description" name="description" placeholder="Details zum Termin..." /> <Textarea
id="description"
name="description"
placeholder="Details zum Termin..."
value={formState.description}
onChange={(event) => updateField("description", event.target.value)}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@@ -78,7 +152,9 @@ export function AdminTerminModal() {
<select <select
id="type" id="type"
name="type" name="type"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" value={formState.type}
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
onChange={(event) => updateField("type", event.target.value as TerminType)}
required required
> >
<option value={TerminType.KITA_FEST}>Kita-Fest</option> <option value={TerminType.KITA_FEST}>Kita-Fest</option>
@@ -97,16 +173,39 @@ export function AdminTerminModal() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="startDate">Start</Label> <Label htmlFor="startDate">Start</Label>
<Input id="startDate" name="startDate" type="datetime-local" required /> <Input
id="startDate"
name="startDate"
type="datetime-local"
min={minDateTime}
value={formState.startDate}
onChange={(event) => updateField("startDate", event.target.value)}
required
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="endDate">Ende</Label> <Label htmlFor="endDate">Ende</Label>
<Input id="endDate" name="endDate" type="datetime-local" required /> <Input
id="endDate"
name="endDate"
type="datetime-local"
min={minEndDateTime}
value={formState.endDate}
onChange={(event) => updateField("endDate", event.target.value)}
required
/>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<input type="checkbox" id="allDay" name="allDay" className="rounded border-gray-300" /> <input
type="checkbox"
id="allDay"
name="allDay"
checked={formState.allDay}
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
onChange={(event) => updateField("allDay", event.target.checked)}
/>
<Label htmlFor="allDay" className="font-normal cursor-pointer"> <Label htmlFor="allDay" className="font-normal cursor-pointer">
Ganztägiger Termin Ganztägiger Termin
</Label> </Label>
@@ -126,3 +225,10 @@ export function AdminTerminModal() {
</Dialog> </Dialog>
); );
} }
function getLocalDateTimeInputValue(date: Date) {
const localDate = new Date(date);
localDate.setSeconds(0, 0);
const offset = localDate.getTimezoneOffset() * 60_000;
return new Date(localDate.getTime() - offset).toISOString().slice(0, 16);
}
@@ -55,23 +55,25 @@ export function MitbringselList({
}; };
return ( return (
<div className="flex flex-col gap-3 rounded-md bg-muted/50 p-3"> <div className="flex flex-col gap-3 rounded-lg bg-[var(--surface-2)] p-3">
<div className="flex items-center gap-2 font-medium text-sm mb-1"> <div className="mb-1 flex items-center gap-2 text-sm font-medium text-[var(--ink)]">
<Utensils className="h-4 w-4 text-muted-foreground" /> <Utensils className="h-4 w-4 text-[var(--ink-muted)] [stroke-width:1.7]" />
Mitbring-Liste Mitbring-Liste
</div> </div>
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto"> <div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-xs text-muted-foreground italic">Noch keine Einträge vorhanden.</p> <p className="text-xs italic text-[var(--ink-faint)]">
Noch keine Einträge vorhanden.
</p>
) : ( ) : (
items.map((item) => ( items.map((item) => (
<div <div
key={item.id} key={item.id}
className="flex items-center justify-between gap-2 rounded-sm bg-background p-2 text-sm shadow-sm" className="flex items-center justify-between gap-2 rounded-md border border-[var(--hairline-soft)] bg-[var(--surface)] p-2 text-sm text-[var(--ink)]"
> >
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-xs text-primary"> <span className="text-xs font-medium text-[var(--accent-deep)]">
{item.user.firstName} {item.user.lastName} {item.user.firstName} {item.user.lastName}
</span> </span>
<span>{item.content}</span> <span>{item.content}</span>
@@ -80,11 +82,11 @@ export function MitbringselList({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive shrink-0" className="h-6 w-6 shrink-0 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => handleDelete(item.id)} onClick={() => handleDelete(item.id)}
disabled={isPending} disabled={isPending}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button> </Button>
)} )}
</div> </div>
@@ -112,7 +114,7 @@ export function MitbringselList({
onClick={handleAdd} onClick={handleAdd}
disabled={!content.trim() || isPending} disabled={!content.trim() || isPending}
> >
<Plus className="h-4 w-4 mr-1" /> <Plus className="mr-1 h-4 w-4 [stroke-width:1.7]" />
Hinzufügen Hinzufügen
</Button> </Button>
</div> </div>
@@ -3,17 +3,20 @@
import { useTransition } from "react"; import { useTransition } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { Check, X, Clock, CalendarIcon } from "lucide-react"; import { CalendarIcon, Check, Clock, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { approveTermin, rejectTermin } from "../actions"; import { approveTermin, rejectTermin } from "../actions";
export type PendingTerminDto = { export type PendingTerminDto = {
id: string; id: string;
title: string; title: string;
startDate: Date; startDate: Date;
createdAt: Date;
ageInDays: number;
allDay: boolean; allDay: boolean;
createdBy: { firstName: string; lastName: string } | null; createdBy: { firstName: string; lastName: string } | null;
}; };
@@ -21,22 +24,28 @@ export type PendingTerminDto = {
export function PendingAnfragen({ termine }: { termine: PendingTerminDto[] }) { export function PendingAnfragen({ termine }: { termine: PendingTerminDto[] }) {
if (termine.length === 0) { if (termine.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50"> <Card>
<CalendarIcon className="h-10 w-10 text-muted-foreground mb-4" /> <CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center p-8 text-center animate-in fade-in-50">
<h3 className="text-lg font-semibold">Keine ausstehenden Anfragen</h3> <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<p className="text-sm text-muted-foreground"> <CalendarIcon className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="text-lg font-medium text-[var(--ink)]">
Keine ausstehenden Anfragen
</h3>
<p className="mt-2 text-sm text-[var(--ink-soft)]">
Es gibt aktuell keine Terminanfragen, die bestätigt werden müssen. Es gibt aktuell keine Terminanfragen, die bestätigt werden müssen.
</p> </p>
</div> </CardContent>
</Card>
); );
} }
return ( return (
<div className="flex flex-col gap-4"> <Card className="overflow-hidden p-0">
{termine.map((termin) => ( {termine.map((termin) => (
<PendingTerminCard key={termin.id} termin={termin} /> <PendingTerminCard key={termin.id} termin={termin} />
))} ))}
</div> </Card>
); );
} }
@@ -67,40 +76,57 @@ function PendingTerminCard({ termin }: { termin: PendingTerminDto }) {
}; };
return ( return (
<Card> <div className="grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 border-b border-[var(--hairline-soft)] px-5 py-4 last:border-b-0 hover:bg-[var(--surface-2)]">
<CardContent className="flex items-center justify-between p-4 sm:p-6"> <div className="flex h-14 w-14 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)]">
<div className="flex flex-col gap-1"> <span className="text-[22px] font-medium leading-none tracking-[-0.02em] text-[var(--ink)] tabular-nums">
<div className="font-semibold">{termin.title}</div> {format(termin.startDate, "d", { locale: de })}
<div className="text-sm text-muted-foreground flex items-center gap-1"> </span>
<Clock className="h-3.5 w-3.5" /> <span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.08em]">
{format(termin.startDate, "PP", { locale: de })} {format(termin.startDate, "EEE", { locale: de })}
{!termin.allDay && `${format(termin.startDate, "p", { locale: de })}`} </span>
</div>
<div className="text-sm font-medium text-primary mt-1">
Angefragt von: {termin.createdBy?.firstName} {termin.createdBy?.lastName}
</div>
</div> </div>
<div className="flex flex-col sm:flex-row gap-2"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{termin.title}
</h3>
{termin.ageInDays > 7 && (
<Badge variant="warning">Älter als 7 Tage</Badge>
)}
</div>
<p className="mt-1 flex items-center gap-1 text-sm text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(termin.startDate, "PP", { locale: de })}
{!termin.allDay &&
` · ${format(termin.startDate, "p", { locale: de })}`}
</p>
<p className="mt-1 text-sm font-medium text-[var(--accent-deep)]">
Angefragt von: {termin.createdBy?.firstName}{" "}
{termin.createdBy?.lastName} · vor {termin.ageInDays} Tagen
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button <Button
variant="outline" variant="ghost"
className="text-destructive hover:bg-destructive hover:text-destructive-foreground border-destructive/20" size="sm"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={handleReject} onClick={handleReject}
disabled={isPending} disabled={isPending}
> >
<X className="h-4 w-4 sm:mr-2" /> <X className="h-4 w-4 [stroke-width:1.7]" />
<span className="hidden sm:inline">Ablehnen</span> Ablehnen
</Button> </Button>
<Button <Button
className="bg-emerald-600 hover:bg-emerald-700 text-white" size="sm"
onClick={handleApprove} onClick={handleApprove}
disabled={isPending} disabled={isPending}
> >
<Check className="h-4 w-4 sm:mr-2" /> <Check className="h-4 w-4 [stroke-width:1.7]" />
<span className="hidden sm:inline">Freigeben</span> Genehmigen
</Button> </Button>
</div> </div>
</CardContent> </div>
</Card>
); );
} }
@@ -3,16 +3,28 @@
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,
Calendar,
Clock,
MapPin,
MoreHorizontal,
WashingMachine,
} from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { MitbringselList } from "./mitbringsel-list"; import { Button } from "@/components/ui/button";
import { toggleMitbringselList } from "../actions"; import { Card, CardContent } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch"; import {
import { Label } from "@/components/ui/label"; Dialog,
import { useTransition } from "react"; DialogContent,
import { toast } from "sonner"; DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { AdminTerminModal } from "./admin-termin-modal";
import { TerminRequestModal } from "./termin-request-modal";
export type TerminListItemDto = { export type TerminListItemDto = {
kind: "termin"; kind: "termin";
@@ -25,6 +37,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;
@@ -47,7 +62,6 @@ export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto;
export function TerminList({ export function TerminList({
termine, termine,
userId,
isAdmin, isAdmin,
}: { }: {
termine: CalendarListItemDto[]; termine: CalendarListItemDto[];
@@ -56,195 +70,243 @@ export function TerminList({
}) { }) {
if (termine.length === 0) { if (termine.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50"> <Card>
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center"> <CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<Calendar className="h-10 w-10 text-muted-foreground mb-4" /> <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<h3 className="mt-4 text-lg font-semibold">Keine Termine</h3> <Calendar className="h-6 w-6 [stroke-width:1.7]" />
<p className="mb-4 mt-2 text-sm text-muted-foreground"> </div>
Es stehen aktuell keine Termine an. <h3 className="mt-5 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine Termine in diesem Zeitraum
</h3>
<p className="mt-2 max-w-md text-sm leading-6 text-[var(--ink-soft)]">
Lege den ersten Termin an, damit Eltern planen können.
</p> </p>
<div className="mt-5">
{isAdmin ? (
<AdminTerminModal triggerLabel="+ Termin anlegen" triggerClassName="" />
) : (
<TerminRequestModal
triggerLabel="+ Termin anlegen"
triggerVariant="default"
triggerClassName=""
/>
)}
</div> </div>
</div> </CardContent>
</Card>
); );
} }
const groups = groupByMonth(termine);
return ( return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="space-y-8">
{termine.map((termin) => ( {groups.map((group) => (
termin.kind === "duty" ? ( <section key={group.key}>
<DutyCard key={`duty-${termin.id}`} duty={termin} /> <div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
{group.label}
</h2>
<span className="rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)] tabular-nums">
{group.items.length} {group.items.length === 1 ? "Termin" : "Termine"}
</span>
</div>
<Card className="overflow-hidden p-0">
<div className="divide-y divide-[var(--hairline-soft)]">
{group.items.map((item) =>
item.kind === "duty" ? (
<DutyRow key={`duty-${item.id}`} duty={item} />
) : ( ) : (
<TerminCard <TerminRow key={`termin-${item.id}`} termin={item} />
key={`termin-${termin.id}`} ),
termin={termin} )}
userId={userId} </div>
isAdmin={isAdmin} </Card>
/> </section>
)
))} ))}
</div> </div>
); );
} }
function DutyCard({ duty }: { duty: DutyCalendarItemDto }) { function DutyRow({ duty }: { duty: DutyCalendarItemDto }) {
const color = "var(--good)";
return ( return (
<Card className="flex flex-col overflow-hidden border-emerald-200 bg-emerald-50/60"> <div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<CardHeader className="pb-3"> <div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<div className="mb-2 flex items-start justify-between gap-2"> <DateTile date={duty.startDate} />
<Badge className="border-transparent bg-emerald-600 text-white hover:bg-emerald-700"> <div className="min-w-0">
Elterndienst <h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
</Badge>
</div>
<CardTitle className="line-clamp-2 leading-tight">
{duty.title} {duty.title}
</CardTitle> </h3>
<CardDescription className="mt-1 flex items-center gap-1 text-sm"> <p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5" /> <Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(duty.startDate, "dd.MM.", { locale: de })} -{" "} {format(duty.startDate, "dd.MM.", { locale: de })} -{" "}
{format(duty.endDate, "dd.MM.yyyy", { locale: de })} {format(duty.endDate, "dd.MM.yyyy", { locale: de })}
</CardDescription> <WashingMachine className="ml-1 h-3.5 w-3.5 [stroke-width:1.7]" />
</CardHeader> {duty.familyName}
<CardContent className="flex-1 pb-4">
<div className="flex items-start gap-2 text-sm text-emerald-900">
<WashingMachine className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Eingeteilt: <span className="font-medium">{duty.familyName}</span>
</p> </p>
</div> </div>
</CardContent> <div className="flex items-center gap-2">
</Card> <CategoryPill color={color}>Elterndienst</CategoryPill>
<Button variant="ghost" size="icon" aria-label="Mehr Optionen">
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</div>
</div>
); );
} }
function TerminCard({ function TerminRow({ termin }: { termin: TerminListItemDto }) {
termin, const color = getTypeColor(termin.type);
userId,
isAdmin,
}: {
termin: TerminListItemDto;
userId: string;
isAdmin: boolean;
}) {
const [isPending, startTransition] = useTransition();
const handleToggleMitbringsel = (enabled: boolean) => {
startTransition(async () => {
const result = await toggleMitbringselList(termin.id, enabled);
if (result.error) {
toast.error(result.error);
} else {
toast.success(
enabled
? "Mitbring-Liste aktiviert"
: "Mitbring-Liste deaktiviert"
);
}
});
};
return ( return (
<Card className="flex flex-col overflow-hidden relative"> <Dialog>
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<DateTile date={termin.startDate} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{termin.title}
</h3>
{termin.status === TerminStatus.PENDING && ( {termin.status === TerminStatus.PENDING && (
<div className="absolute top-0 right-0 bg-yellow-500 text-white text-xs font-bold px-2 py-1 rounded-bl-lg z-10"> <Badge variant="warning">Ausstehend</Badge>
Ausstehend
</div>
)} )}
<CardHeader className="pb-3">
<div className="flex justify-between items-start mb-2 gap-2">
<Badge variant={getBadgeVariant(termin.type)} className={getBadgeColor(termin.type)}>
{getTypeLabel(termin.type)}
</Badge>
</div> </div>
<CardTitle className="line-clamp-2 leading-tight">{termin.title}</CardTitle> <p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<CardDescription className="flex items-center gap-1 mt-1 text-sm"> <Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
<Clock className="h-3.5 w-3.5" /> {formatTerminTime(termin)}
<MapPin className="ml-1 h-3.5 w-3.5 [stroke-width:1.7]" />
Kita
</p>
</div>
<div className="flex items-center gap-2">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
Details
</Button>
</DialogTrigger>
<Button variant="ghost" size="icon" aria-label="Mehr Optionen">
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</div>
</div>
<DialogContent>
<DialogHeader>
<DialogTitle>{termin.title}</DialogTitle>
<DialogDescription>
{format(termin.startDate, "PP", { locale: de })} {format(termin.startDate, "PP", { locale: de })}
{!termin.allDay && ( {!termin.allDay &&
<> ` · ${format(termin.startDate, "p", { locale: de })} - ${format(
{" • "} termin.endDate,
{format(termin.startDate, "p", { locale: de })} -{" "} "p",
{format(termin.endDate, "p", { locale: de })} { locale: de },
</> )}`}
)} </DialogDescription>
</CardDescription> </DialogHeader>
</CardHeader>
<CardContent className="flex-1 pb-4"> <div className="grid gap-4">
{termin.description && ( <CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
<div className="flex items-start gap-2 text-sm text-muted-foreground mt-2">
<AlignLeft className="h-4 w-4 shrink-0 mt-0.5" /> {termin.description ? (
<p className="line-clamp-3 whitespace-pre-wrap">{termin.description}</p> <p className="whitespace-pre-wrap text-sm leading-6 text-[var(--ink-soft)]">
</div> {termin.description}
</p>
) : (
<p className="text-sm italic text-[var(--ink-faint)]">
Keine Beschreibung hinterlegt.
</p>
)} )}
{/* Admin Toggle for Mitbringsel-List */} {termin.participantInfo && termin.showParticipantInfo && (
{isAdmin && termin.status === TerminStatus.CONFIRMED && ( <div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
<div className="flex items-center space-x-2 mt-6 pt-4 border-t"> <div className="mb-1 flex items-center gap-2 font-medium">
<Switch <AlertTriangle className="h-4 w-4 text-[var(--warn)] [stroke-width:1.7]" />
id={`mitbringsel-${termin.id}`} Wichtiger Hinweis für Teilnehmer
checked={termin.mitbringselListEnabled} </div>
onCheckedChange={handleToggleMitbringsel} <p className="whitespace-pre-wrap">{termin.participantInfo}</p>
disabled={isPending}
/>
<Label htmlFor={`mitbringsel-${termin.id}`} className="text-xs">
Mitbring-Liste aktiv
</Label>
</div> </div>
)} )}
{/* Mitbringsel-List UI */}
{termin.mitbringselListEnabled && termin.status === TerminStatus.CONFIRMED && (
<div className="mt-4">
<MitbringselList
terminId={termin.id}
items={termin.mitbringselItems || []}
currentUserId={userId}
isAdmin={isAdmin}
/>
</div> </div>
)} </DialogContent>
</CardContent> </Dialog>
</Card>
); );
} }
function getBadgeVariant(type: TerminType): "default" | "secondary" | "destructive" | "outline" { function DateTile({ date }: { date: Date }) {
switch (type) { return (
case TerminType.KITA_FEST: <div className="flex h-14 w-14 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)]">
case TerminType.MITMACH_TAG: <span className="text-[22px] font-medium leading-none tracking-[-0.02em] text-[var(--ink)] tabular-nums">
return "default"; {format(date, "d", { locale: de })}
case TerminType.SCHLIESSTAG: </span>
case TerminType.TEAMTAG: <span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.08em]">
return "destructive"; {format(date, "EEE", { locale: de })}
case TerminType.GEBURTSTAG_INTERN: </span>
case TerminType.GEBURTSTAG_EXTERN: </div>
case TerminType.ELTERNABEND: );
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "secondary";
default:
return "outline";
}
} }
function getBadgeColor(type: TerminType): string { function CategoryPill({
color,
children,
}: {
color: string;
children: React.ReactNode;
}) {
return (
<span
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)]"
style={{ color }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: color }} />
{children}
</span>
);
}
function groupByMonth(items: CalendarListItemDto[]) {
const groups = new Map<string, { key: string; label: string; items: CalendarListItemDto[] }>();
items.forEach((item) => {
const key = format(item.startDate, "yyyy-MM");
const label = format(item.startDate, "MMMM yyyy", { locale: de });
const group = groups.get(key) ?? { key, label, items: [] };
group.items.push(item);
groups.set(key, group);
});
return Array.from(groups.values());
}
function formatTerminTime(termin: TerminListItemDto) {
if (termin.allDay) return "ganztägig";
return `${format(termin.startDate, "HH:mm", { locale: de })} - ${format(
termin.endDate,
"HH:mm",
{ locale: de },
)}`;
}
function getTypeColor(type: TerminType): string {
switch (type) { switch (type) {
case TerminType.KITA_FEST: case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG: case TerminType.MITMACH_TAG:
return "bg-amber-500 hover:bg-amber-600 text-white border-transparent"; return "var(--good)";
case TerminType.SCHLIESSTAG: case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG: case TerminType.TEAMTAG:
return "bg-rose-500 hover:bg-rose-600 text-white border-transparent"; return "var(--danger)";
case TerminType.GEBURTSTAG_INTERN: case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN: case TerminType.GEBURTSTAG_EXTERN:
return "bg-blue-500 hover:bg-blue-600 text-white border-transparent"; return "oklch(0.50 0.10 320)";
case TerminType.ELTERNABEND: case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG: case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE: case TerminType.ELTERNCAFE:
return "bg-purple-500 hover:bg-purple-600 text-white border-transparent"; return "oklch(0.50 0.10 280)";
default: default:
return "bg-slate-200 text-slate-800 border-slate-300 dark:bg-slate-800 dark:text-slate-200 dark:border-slate-700"; return "var(--ink-muted)";
} }
} }
@@ -257,7 +319,7 @@ function getTypeLabel(type: TerminType): string {
case TerminType.SCHLIESSTAG: case TerminType.SCHLIESSTAG:
return "Schließtag"; return "Schließtag";
case TerminType.TEAMTAG: case TerminType.TEAMTAG:
return "Teamtag (Kita geschlossen)"; return "Teamtag";
case TerminType.ELTERNABEND: case TerminType.ELTERNABEND:
return "Elternabend"; return "Elternabend";
case TerminType.MITGLIEDERVERSAMMLUNG: case TerminType.MITGLIEDERVERSAMMLUNG:
@@ -265,9 +327,9 @@ function getTypeLabel(type: TerminType): string {
case TerminType.ELTERNCAFE: case TerminType.ELTERNCAFE:
return "Elterncafe"; return "Elterncafe";
case TerminType.GEBURTSTAG_INTERN: case TerminType.GEBURTSTAG_INTERN:
return "Geburtstag (Intern)"; return "Geburtstag";
case TerminType.GEBURTSTAG_EXTERN: case TerminType.GEBURTSTAG_EXTERN:
return "Raumanfrage (Extern)"; return "Raumanfrage";
default: default:
return "Sonstiges"; return "Sonstiges";
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import type { ComponentProps, FormEvent } from "react";
import { useMemo, useState, useTransition } from "react";
import { TerminType } from "@prisma/client"; import { TerminType } from "@prisma/client";
import { CalendarPlus } from "lucide-react"; import { CalendarPlus } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -19,17 +20,74 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { createTerminRequest } from "../actions"; import { createTerminRequest } from "../actions";
export function TerminRequestModal() { type TerminRequestFormState = {
const [open, setOpen] = useState(false); title: string;
const [isPending, startTransition] = useTransition(); type: TerminType;
startDate: string;
endDate: string;
allDay: boolean;
};
const initialFormState: TerminRequestFormState = {
title: "",
type: TerminType.KITA_FEST,
startDate: "",
endDate: "",
allDay: false,
};
type TerminRequestModalProps = {
triggerLabel?: string;
triggerVariant?: ComponentProps<typeof Button>["variant"];
triggerClassName?: string;
};
export function TerminRequestModal({
triggerLabel = "Termin anfragen",
triggerVariant = "ghost",
triggerClassName = "gap-2",
}: TerminRequestModalProps) {
const [open, setOpen] = useState(false);
const [formState, setFormState] = useState<TerminRequestFormState>(initialFormState);
const [isPending, startTransition] = useTransition();
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
const minEndDateTime = formState.startDate || minDateTime;
const updateField = <TKey extends keyof TerminRequestFormState>(
key: TKey,
value: TerminRequestFormState[TKey],
) => {
setFormState((current) => {
const next = { ...current, [key]: value };
if (key === "startDate" && typeof value === "string" && next.endDate && next.endDate < value) {
next.endDate = value;
}
return next;
});
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!formState.startDate || !formState.endDate) {
toast.error("Bitte Start und Ende ausfüllen.");
return;
}
if (formState.startDate < minDateTime) {
toast.error("Der Startzeitpunkt darf nicht in der Vergangenheit liegen.");
return;
}
if (formState.endDate < formState.startDate) {
toast.error("Das Ende darf nicht vor dem Start liegen.");
return;
}
const handleAction = (formData: FormData) => {
const data = { const data = {
title: formData.get("title") as string, title: formState.title,
type: formData.get("type") as TerminType, type: formState.type,
startDate: new Date(formData.get("startDate") as string).toISOString(), startDate: new Date(formState.startDate).toISOString(),
endDate: new Date(formData.get("endDate") as string).toISOString(), endDate: new Date(formState.endDate).toISOString(),
allDay: formData.get("allDay") === "on", allDay: formState.allDay,
}; };
startTransition(async () => { startTransition(async () => {
@@ -38,6 +96,7 @@ export function TerminRequestModal() {
toast.error(res.error); toast.error(res.error);
} else { } else {
toast.success("Terminanfrage wurde erfolgreich gestellt!"); toast.success("Terminanfrage wurde erfolgreich gestellt!");
setFormState(initialFormState);
setOpen(false); setOpen(false);
} }
}); });
@@ -46,13 +105,13 @@ export function TerminRequestModal() {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="gap-2"> <Button variant={triggerVariant} className={triggerClassName}>
<CalendarPlus className="h-4 w-4" /> <CalendarPlus className="h-4 w-4 [stroke-width:1.7]" />
Termin anfragen {triggerLabel}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<form action={handleAction}> <form onSubmit={handleSubmit}>
<DialogHeader> <DialogHeader>
<DialogTitle>Termin anfragen</DialogTitle> <DialogTitle>Termin anfragen</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -64,7 +123,14 @@ export function TerminRequestModal() {
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="title">Titel</Label> <Label htmlFor="title">Titel</Label>
<Input id="title" name="title" required placeholder="z.B. Geburtstag von Mia" /> <Input
id="title"
name="title"
required
placeholder="z.B. Geburtstag von Mia"
value={formState.title}
onChange={(event) => updateField("title", event.target.value)}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@@ -72,7 +138,9 @@ export function TerminRequestModal() {
<select <select
id="type" id="type"
name="type" name="type"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" value={formState.type}
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] disabled:cursor-not-allowed disabled:opacity-50"
onChange={(event) => updateField("type", event.target.value as TerminType)}
required required
> >
<option value={TerminType.KITA_FEST}>Kita-Fest</option> <option value={TerminType.KITA_FEST}>Kita-Fest</option>
@@ -91,16 +159,39 @@ export function TerminRequestModal() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="startDate">Start</Label> <Label htmlFor="startDate">Start</Label>
<Input id="startDate" name="startDate" type="datetime-local" required /> <Input
id="startDate"
name="startDate"
type="datetime-local"
min={minDateTime}
value={formState.startDate}
onChange={(event) => updateField("startDate", event.target.value)}
required
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="endDate">Ende</Label> <Label htmlFor="endDate">Ende</Label>
<Input id="endDate" name="endDate" type="datetime-local" required /> <Input
id="endDate"
name="endDate"
type="datetime-local"
min={minEndDateTime}
value={formState.endDate}
onChange={(event) => updateField("endDate", event.target.value)}
required
/>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<input type="checkbox" id="allDay" name="allDay" className="rounded border-gray-300" /> <input
type="checkbox"
id="allDay"
name="allDay"
checked={formState.allDay}
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
onChange={(event) => updateField("allDay", event.target.checked)}
/>
<Label htmlFor="allDay" className="font-normal cursor-pointer"> <Label htmlFor="allDay" className="font-normal cursor-pointer">
Ganztägiger Termin Ganztägiger Termin
</Label> </Label>
@@ -120,3 +211,10 @@ export function TerminRequestModal() {
</Dialog> </Dialog>
); );
} }
function getLocalDateTimeInputValue(date: Date) {
const localDate = new Date(date);
localDate.setSeconds(0, 0);
const offset = localDate.getTimezoneOffset() * 60_000;
return new Date(localDate.getTime() - offset).toISOString().slice(0, 16);
}
+100 -14
View File
@@ -12,14 +12,54 @@ import { prisma } from "@/lib/prisma";
// ===================================================================== // =====================================================================
const terminSchema = z.object({ const terminSchema = z.object({
title: z.string().min(2, "Titel muss mindestens 2 Zeichen lang sein"), title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein"),
description: z.string().optional(), description: z.string().trim().optional(),
type: z.nativeEnum(TerminType), type: z.nativeEnum(TerminType),
startDate: z.string().datetime(), startDate: z.string().datetime(),
endDate: z.string().datetime(), endDate: z.string().datetime(),
allDay: z.boolean().default(false), allDay: z.boolean().default(false),
mitbringselListEnabled: z.boolean().default(false),
requiresRsvp: z.boolean().default(false),
rsvpDeadline: z.string().datetime().nullable().optional(),
participantInfo: z.string().trim().max(1000).optional(),
}); });
function buildTerminData(data: z.infer<typeof terminSchema>) {
const startDate = new Date(data.startDate);
const endDate = new Date(data.endDate);
const now = new Date();
now.setSeconds(0, 0);
if (startDate < now) {
return { error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen." };
}
if (endDate < startDate) {
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
}
return {
data: {
title: data.title,
description: data.description || null,
type: data.type,
startDate,
endDate,
allDay: data.allDay,
mitbringselListEnabled: data.mitbringselListEnabled,
requiresRsvp: data.requiresRsvp,
rsvpDeadline:
data.requiresRsvp && data.rsvpDeadline
? new Date(data.rsvpDeadline)
: null,
participantInfo:
data.requiresRsvp && data.participantInfo
? data.participantInfo
: null,
},
};
}
export async function createTerminRequest(rawPayload: unknown) { export async function createTerminRequest(rawPayload: unknown) {
const session = await requireKitaSession(); const session = await requireKitaSession();
@@ -29,18 +69,23 @@ export async function createTerminRequest(rawPayload: unknown) {
} }
const data = parsed.data; const data = parsed.data;
const terminData = buildTerminData(data);
if ("error" in terminData) {
return { error: terminData.error };
}
try { try {
await prisma.termin.create({ await prisma.termin.create({
data: { data: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
createdById: session.user.id, createdById: session.user.id,
title: data.title, title: terminData.data.title,
description: data.description, description: terminData.data.description,
type: data.type, type: terminData.data.type,
startDate: new Date(data.startDate), startDate: terminData.data.startDate,
endDate: new Date(data.endDate), endDate: terminData.data.endDate,
allDay: data.allDay, allDay: terminData.data.allDay,
status: TerminStatus.PENDING, status: TerminStatus.PENDING,
}, },
}); });
@@ -66,18 +111,18 @@ export async function createTerminAdmin(rawPayload: unknown) {
} }
const data = parsed.data; const data = parsed.data;
const terminData = buildTerminData(data);
if ("error" in terminData) {
return { error: terminData.error };
}
try { try {
await prisma.termin.create({ await prisma.termin.create({
data: { data: {
kitaId, kitaId,
createdById: session.user.id, createdById: session.user.id,
title: data.title, ...terminData.data,
description: data.description,
type: data.type,
startDate: new Date(data.startDate),
endDate: new Date(data.endDate),
allDay: data.allDay,
status: TerminStatus.CONFIRMED, status: TerminStatus.CONFIRMED,
approvedById: session.user.id, approvedById: session.user.id,
approvedAt: new Date(), approvedAt: new Date(),
@@ -85,6 +130,7 @@ export async function createTerminAdmin(rawPayload: unknown) {
}); });
revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Erstellen des Termins:", error); console.error("Fehler beim Erstellen des Termins:", error);
@@ -92,6 +138,43 @@ export async function createTerminAdmin(rawPayload: unknown) {
} }
} }
export async function updateTerminAdmin(terminId: string, rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const terminData = buildTerminData(parsed.data);
if ("error" in terminData) {
return { error: terminData.error };
}
try {
await prisma.termin.update({
where: {
id: terminId,
kitaId,
},
data: terminData.data,
});
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren des Termins:", error);
return { error: "Termin konnte nicht aktualisiert werden." };
}
}
export async function approveTermin(terminId: string) { export async function approveTermin(terminId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId; const kitaId = session.user.kitaId;
@@ -113,6 +196,7 @@ export async function approveTermin(terminId: string) {
}); });
revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Bestätigen des Termins:", error); console.error("Fehler beim Bestätigen des Termins:", error);
@@ -142,6 +226,7 @@ export async function rejectTermin(terminId: string, reason?: string) {
}); });
revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Ablehnen des Termins:", error); console.error("Fehler beim Ablehnen des Termins:", error);
@@ -168,6 +253,7 @@ export async function toggleMitbringselList(terminId: string, enabled: boolean)
}); });
revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Umschalten der Mitbring-Liste:", error); console.error("Fehler beim Umschalten der Mitbring-Liste:", error);
+272 -44
View File
@@ -1,23 +1,36 @@
import { DutyAssignmentStatus, TerminStatus, UserRole } from "@prisma/client"; import {
DutyAssignmentStatus,
EventParticipationStatus,
TerminStatus,
TerminType,
UserRole,
} from "@prisma/client";
import { HelpCircle, ListFilter, Search } from "lucide-react";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Input } from "@/components/ui/input";
import { AdminTerminModal } from "./_components/admin-termin-modal"; import { AdminTerminModal } from "./_components/admin-termin-modal";
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen"; import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
import { TerminList, type CalendarListItemDto } from "./_components/termin-list"; import { TerminList, type CalendarListItemDto } from "./_components/termin-list";
import { TerminRequestModal } from "./_components/termin-request-modal"; import { TerminRequestModal } from "./_components/termin-request-modal";
export const metadata = { title: "Terminkalender · Kita-Planer" };
export default async function KalenderPage({ export default async function KalenderPage({
searchParams, searchParams,
}: { }: {
searchParams: { tab?: string }; searchParams: Promise<{ tab?: string }>;
}) { }) {
const session = await requireKitaSession(); const session = await requireKitaSession();
const isAdmin = const isAdmin =
session.user.role === UserRole.ADMIN || session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.KOORDINATOR; session.user.role === UserRole.KOORDINATOR;
const familyIdForParticipation = session.user.familyId ?? "__no_family__";
const currentTab = searchParams.tab || "übersicht"; const resolvedSearchParams = await searchParams;
const currentTab = resolvedSearchParams.tab || "übersicht";
const now = new Date();
const confirmedTermineRows = await prisma.termin.findMany({ const confirmedTermineRows = await prisma.termin.findMany({
where: { where: {
@@ -34,6 +47,8 @@ export default async function KalenderPage({
endDate: true, endDate: true,
allDay: true, allDay: true,
mitbringselListEnabled: true, mitbringselListEnabled: true,
requiresRsvp: true,
participantInfo: true,
mitbringselItems: { mitbringselItems: {
select: { select: {
id: true, id: true,
@@ -47,6 +62,17 @@ export default async function KalenderPage({
}, },
}, },
}, },
participations: {
where: {
child: {
familyId: familyIdForParticipation,
},
},
select: {
childId: true,
status: true,
},
},
}, },
orderBy: { startDate: "asc" }, orderBy: { startDate: "asc" },
}); });
@@ -67,6 +93,8 @@ export default async function KalenderPage({
endDate: true, endDate: true,
allDay: true, allDay: true,
mitbringselListEnabled: true, mitbringselListEnabled: true,
requiresRsvp: true,
participantInfo: true,
mitbringselItems: { mitbringselItems: {
select: { select: {
id: true, id: true,
@@ -80,6 +108,17 @@ export default async function KalenderPage({
}, },
}, },
}, },
participations: {
where: {
child: {
familyId: familyIdForParticipation,
},
},
select: {
childId: true,
status: true,
},
},
}, },
orderBy: { startDate: "asc" }, orderBy: { startDate: "asc" },
}); });
@@ -88,7 +127,7 @@ export default async function KalenderPage({
where: { where: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
status: DutyAssignmentStatus.PLANNED, status: DutyAssignmentStatus.PLANNED,
endDate: { gte: new Date() }, endDate: { gte: now },
}, },
select: { select: {
id: true, id: true,
@@ -113,10 +152,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,
@@ -131,7 +182,7 @@ export default async function KalenderPage({
let allPendingTermine: PendingTerminDto[] = []; let allPendingTermine: PendingTerminDto[] = [];
if (isAdmin) { if (isAdmin) {
allPendingTermine = await prisma.termin.findMany({ const pendingRows = await prisma.termin.findMany({
where: { where: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
status: TerminStatus.PENDING, status: TerminStatus.PENDING,
@@ -140,6 +191,8 @@ export default async function KalenderPage({
id: true, id: true,
title: true, title: true,
startDate: true, startDate: true,
createdAt: true,
type: true,
allDay: true, allDay: true,
createdBy: { createdBy: {
select: { select: {
@@ -150,67 +203,74 @@ export default async function KalenderPage({
}, },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
allPendingTermine = pendingRows.map((termin) => ({
...termin,
ageInDays: Math.floor(
(now.getTime() - termin.createdAt.getTime()) / (1000 * 60 * 60 * 24),
),
}));
} }
return ( return (
<div className="flex h-full flex-col gap-6 p-6"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="flex items-center justify-between"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight">Terminkalender</h1> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<p className="text-muted-foreground"> TERMINKALENDER
Alle anstehenden Termine, Feste und Schließtage im Überblick. </p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Was steht{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
an
</em>
?
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Alle anstehenden Termine, Feste, Dienste und Schließtage im
Überblick.
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex shrink-0 flex-wrap items-center gap-2">
<TerminRequestModal /> <TerminRequestModal />
<span className="group relative inline-flex h-9 w-9 items-center justify-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-muted)]">
<HelpCircle className="h-4 w-4 [stroke-width:1.7]" />
<span className="pointer-events-none absolute right-0 top-11 z-30 hidden w-72 rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-left text-xs leading-5 text-white shadow-lg group-hover:block">
Anfragen müssen vom Vorstand bestätigt werden, direkte Anlage
erscheint sofort im Kalender.
</span>
</span>
{isAdmin && <AdminTerminModal />} {isAdmin && <AdminTerminModal />}
</div> </div>
</div> </header>
{isAdmin ? ( {isAdmin ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-5">
<div className="flex gap-4 border-b"> <TabBar
<a currentTab={currentTab}
href="/dashboard/kalender?tab=übersicht" overviewCount={allUserTermine.length}
className={`pb-2 text-sm font-medium transition-colors hover:text-primary ${ pendingCount={allPendingTermine.length}
currentTab === "übersicht" />
? "border-b-2 border-primary text-primary"
: "text-muted-foreground"
}`}
>
Übersicht
</a>
<a
href="/dashboard/kalender?tab=anfragen"
className={`flex items-center gap-2 pb-2 text-sm font-medium transition-colors hover:text-primary ${
currentTab === "anfragen"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground"
}`}
>
Ausstehende Anfragen
{allPendingTermine.length > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
{allPendingTermine.length}
</span>
)}
</a>
</div>
<div className="pt-2"> <div>
{currentTab === "übersicht" ? ( {currentTab === "übersicht" ? (
<TerminList <CalendarOverview
termine={allUserTermine} termine={allUserTermine}
userId={session.user.id} userId={session.user.id}
isAdmin={isAdmin} isAdmin={isAdmin}
/> />
) : ( ) : currentTab === "anfragen" ? (
<PendingAnfragen termine={allPendingTermine} /> <PendingAnfragen termine={allPendingTermine} />
) : (
<CalendarOverview
termine={[]}
userId={session.user.id}
isAdmin={isAdmin}
/>
)} )}
</div> </div>
</div> </div>
) : ( ) : (
<TerminList <CalendarOverview
termine={allUserTermine} termine={allUserTermine}
userId={session.user.id} userId={session.user.id}
isAdmin={isAdmin} isAdmin={isAdmin}
@@ -219,3 +279,171 @@ export default async function KalenderPage({
</div> </div>
); );
} }
function CalendarOverview({
termine,
userId,
isAdmin,
}: {
termine: CalendarListItemDto[];
userId: string;
isAdmin: boolean;
}) {
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
{["Liste", "Monat", "Agenda"].map((label, index) => (
<button
key={label}
type="button"
className={[
"h-9 px-4 text-[13px] font-semibold transition",
index > 0 ? "border-l border-[var(--hairline-soft)]" : "",
index === 0
? "bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
].join(" ")}
>
{label}
</button>
))}
</div>
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
<CategoryChips />
<div className="relative min-w-56">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input placeholder="Termine suchen" className="h-9 pl-9" />
</div>
</div>
</div>
<TerminList termine={termine} userId={userId} isAdmin={isAdmin} />
</div>
);
}
function TabBar({
currentTab,
overviewCount,
pendingCount,
}: {
currentTab: string;
overviewCount: number;
pendingCount: number;
}) {
return (
<div className="flex border-b border-[var(--hairline)]">
<TabLink
href="/dashboard/kalender?tab=übersicht"
active={currentTab === "übersicht"}
count={overviewCount}
>
Übersicht
</TabLink>
<TabLink
href="/dashboard/kalender?tab=anfragen"
active={currentTab === "anfragen"}
count={pendingCount}
attention={pendingCount > 0}
>
Ausstehende Anfragen
</TabLink>
<TabLink
href="/dashboard/kalender?tab=vergangen"
active={currentTab === "vergangen"}
>
Vergangene
</TabLink>
</div>
);
}
function TabLink({
href,
active,
count,
attention,
children,
}: {
href: string;
active: boolean;
count?: number;
attention?: boolean;
children: React.ReactNode;
}) {
return (
<a
href={href}
className={[
"-mb-px inline-flex h-11 items-center gap-2 border-b-2 px-1.5 text-sm transition first:pl-0",
active
? "border-[var(--accent)] font-semibold text-[var(--ink)]"
: "border-transparent font-medium text-[var(--ink-soft)] hover:text-[var(--ink)]",
].join(" ")}
>
{children}
{typeof count === "number" && (
<span
className={[
"rounded-full px-2 py-0.5 text-[11px] font-semibold tabular-nums",
attention
? "bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)]"
: "border border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]",
].join(" ")}
>
{count}
</span>
)}
</a>
);
}
function CategoryChips() {
const categories = [
["Schließtag", TerminType.SCHLIESSTAG],
["Teamtag", TerminType.TEAMTAG],
["Elternabend", TerminType.ELTERNABEND],
["Fest", TerminType.KITA_FEST],
["Geburtstag", TerminType.GEBURTSTAG_INTERN],
["Sonstiges", TerminType.SONSTIGES],
] as const;
return (
<div className="flex flex-wrap gap-1.5">
<span className="inline-flex h-9 items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-medium text-[var(--ink-soft)]">
<ListFilter className="h-3.5 w-3.5 [stroke-width:1.7]" />
Filter
</span>
{categories.map(([label, type]) => (
<span
key={label}
className="inline-flex h-9 items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-medium text-[var(--ink-soft)]"
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: getCategoryColor(type) }}
/>
{label}
</span>
))}
</div>
);
}
function getCategoryColor(type: TerminType) {
switch (type) {
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "var(--danger)";
case TerminType.ELTERNABEND:
return "oklch(0.50 0.10 280)";
case TerminType.KITA_FEST:
return "var(--good)";
case TerminType.GEBURTSTAG_INTERN:
return "oklch(0.50 0.10 320)";
default:
return "var(--ink-muted)";
}
}
+31 -18
View File
@@ -1,11 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Baby, LogOut } from "lucide-react"; import { LogOut } from "lucide-react";
import { signOut } from "@/auth"; import { signOut } from "@/auth";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { SidebarNav } from "@/components/dashboard/sidebar-nav"; import { SidebarNav } from "@/components/dashboard/sidebar-nav";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -34,40 +33,45 @@ export default async function DashboardLayout({
where: { id: session.user.kitaId }, where: { id: session.user.kitaId },
select: { name: true }, select: { name: true },
}); });
const displayName = session.user.name ?? session.user.email ?? "Kita-Planer";
return ( return (
<div className="flex min-h-screen bg-muted/30"> <div className="grid min-h-screen grid-cols-[256px_minmax(0,1fr)] bg-[var(--bg)] max-[760px]:grid-cols-1">
{/* ── Sidebar ───────────────────────────────────────────────────── */} {/* ── Sidebar ───────────────────────────────────────────────────── */}
<aside className="flex w-60 shrink-0 flex-col border-r bg-background"> <aside className="sticky top-0 flex h-screen shrink-0 flex-col border-r border-[var(--hairline)] bg-[var(--surface)] max-[760px]:relative max-[760px]:h-auto">
{/* Logo / Kita-Name */} {/* Logo / Kita-Name */}
<div className="flex h-16 items-center gap-2.5 border-b px-5"> <div className="flex h-[82px] items-center gap-3 border-b border-[var(--hairline-soft)] px-5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-deep)] text-[13px] font-bold text-white">
<Baby className="h-4 w-4" /> {kita.name.slice(0, 1).toUpperCase()}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-semibold leading-none"> <p className="truncate text-sm font-semibold leading-none text-[var(--ink)]">
{kita.name} {kita.name}
</p> </p>
<p className="mt-0.5 text-xs text-muted-foreground">Kita-Planer</p> <p className="mt-1 text-xs text-[var(--ink-soft)]">Kita-Planer</p>
</div> </div>
</div> </div>
{/* Navigation */} {/* Navigation */}
<div className="flex-1 overflow-y-auto py-4"> <div className="flex-1 overflow-y-auto py-6">
<SidebarNav role={session.user.role} /> <SidebarNav role={session.user.role} />
</div> </div>
{/* Footer: User-Info + Abmelden */} {/* Footer: User-Info + Abmelden */}
<div className="border-t p-3"> <div className="border-t border-[var(--hairline-soft)] p-4">
<Separator className="mb-3" /> <div className="mb-3 flex items-center gap-2.5 px-1">
<div className="mb-2 px-3"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
<p className="truncate text-xs font-medium"> {getInitials(displayName)}
{session.user.name ?? session.user.email} </div>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--ink)]">
{displayName}
</p> </p>
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-[11px] font-semibold uppercase text-[var(--ink-muted)]">
{session.user.role} {session.user.role}
</p> </p>
</div> </div>
</div>
<form <form
action={async () => { action={async () => {
"use server"; "use server";
@@ -78,7 +82,7 @@ export default async function DashboardLayout({
type="submit" type="submit"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="w-full justify-start gap-2 text-muted-foreground" className="w-full justify-start"
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
Abmelden Abmelden
@@ -88,9 +92,18 @@ export default async function DashboardLayout({
</aside> </aside>
{/* ── Main Content ──────────────────────────────────────────────── */} {/* ── Main Content ──────────────────────────────────────────────── */}
<main className="flex-1 overflow-y-auto"> <main className="min-w-0 overflow-y-auto">
{children} {children}
</main> </main>
</div> </div>
); );
} }
function getInitials(value: string) {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return value.slice(0, 2).toUpperCase();
}
+12 -12
View File
@@ -58,12 +58,12 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
return ( return (
<> <>
<Card className="mb-8 border-emerald-200 bg-emerald-50/60"> <Card className="mb-8 border-[var(--good)] bg-[var(--good-soft)]">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<CardTitle className="flex items-center gap-2 text-lg"> <CardTitle className="flex items-center gap-2 text-lg">
<Megaphone className="h-5 w-5 text-emerald-700" /> <Megaphone className="h-5 w-5 text-[var(--good)] [stroke-width:1.7]" />
Schwarzes Brett Schwarzes Brett
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
@@ -81,26 +81,26 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
key={item.id} key={item.id}
type="button" type="button"
onClick={() => openAnnouncement(item)} onClick={() => openAnnouncement(item)}
className="rounded-md border bg-background p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-300 hover:shadow-md" className="rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-4 text-left transition hover:border-[var(--accent-mid)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
> >
<div className="mb-3 flex items-start justify-between gap-2"> <div className="mb-3 flex items-start justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-[var(--ink-muted)]">
<Bell className="h-3.5 w-3.5" /> <Bell className="h-3.5 w-3.5 [stroke-width:1.7]" />
{item.createdAt} {item.createdAt}
</div> </div>
{isUnread && ( {isUnread && (
<Badge className="bg-emerald-600 text-white">NEU</Badge> <Badge variant="success">NEU</Badge>
)} )}
</div> </div>
<h3 className="line-clamp-2 font-semibold leading-snug"> <h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
{item.title} {item.title}
</h3> </h3>
<p className="mt-2 line-clamp-3 text-sm leading-6 text-muted-foreground"> <p className="mt-2 line-clamp-3 text-sm leading-6 text-[var(--ink-soft)]">
{item.content} {item.content}
</p> </p>
{item.attachments.length > 0 && ( {item.attachments.length > 0 && (
<p className="mt-3 flex items-center gap-1 text-xs font-medium text-emerald-700"> <p className="mt-3 flex items-center gap-1 text-xs font-medium text-[var(--accent-deep)]">
<FileText className="h-3.5 w-3.5" /> <FileText className="h-3.5 w-3.5 [stroke-width:1.7]" />
{item.attachments.length} Anhang {item.attachments.length} Anhang
{item.attachments.length === 1 ? "" : "e"} {item.attachments.length === 1 ? "" : "e"}
</p> </p>
@@ -122,7 +122,7 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="rounded-md border bg-muted/30 p-4 text-sm"> <div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm">
<MarkdownContent content={selected.content} /> <MarkdownContent content={selected.content} />
</div> </div>
@@ -138,7 +138,7 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
className="justify-start" className="justify-start"
> >
<a href={attachment.fileUrl} target="_blank" rel="noreferrer"> <a href={attachment.fileUrl} target="_blank" rel="noreferrer">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4 [stroke-width:1.7]" />
{attachment.fileName} {attachment.fileName}
</a> </a>
</Button> </Button>
+27 -8
View File
@@ -15,11 +15,23 @@ import { getTargetMonthData } from "@/lib/date-utils";
// Die Termine werden Round-Robin auf die Kinder aufgeteilt. // Die Termine werden Round-Robin auf die Kinder aufgeteilt.
// ===================================================================== // =====================================================================
const dateKeyPattern = /^\d{4}-\d{2}-\d{2}$/;
const payloadSchema = z.object({ const payloadSchema = z.object({
childrenIds: z.array(z.string()).min(1), childrenIds: z.array(z.string()).min(1),
dates: z.array(z.string().datetime()), // ISO strings dates: z.array(
z.string().regex(dateKeyPattern).refine((value) => {
const date = dateKeyToUtcDate(value);
return value === date.toISOString().slice(0, 10);
}),
),
}); });
function dateKeyToUtcDate(dateKey: string) {
const [year, month, day] = dateKey.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, day));
}
export async function saveNotdienstAvailabilities(payloadRaw: { export async function saveNotdienstAvailabilities(payloadRaw: {
childrenIds: string[]; childrenIds: string[];
dates: string[]; dates: string[];
@@ -35,7 +47,8 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
return { error: "Ungültige Eingabedaten." }; return { error: "Ungültige Eingabedaten." };
} }
const { childrenIds, dates } = parsed.data; const { childrenIds } = parsed.data;
const dates = Array.from(new Set(parsed.data.dates)).sort();
// ── 1. Sperrfrist prüfen ─────────────────────────────────────────── // ── 1. Sperrfrist prüfen ───────────────────────────────────────────
const { targetYear, targetMonth, isLocked } = getTargetMonthData(); const { targetYear, targetMonth, isLocked } = getTargetMonthData();
@@ -79,9 +92,15 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
} }
// Termine in Date-Objekte wandeln und sortieren // Termine in Date-Objekte wandeln und sortieren
const parsedDates = dates const parsedDates = dates.map(dateKeyToUtcDate);
.map((d) => new Date(d)) const hasOutsideTargetMonth = parsedDates.some(
.sort((a, b) => a.getTime() - b.getTime()); (date) =>
date.getUTCFullYear() !== targetYear ||
date.getUTCMonth() !== targetMonth - 1,
);
if (hasOutsideTargetMonth) {
return { error: "Bitte wähle Termine im Zielmonat aus." };
}
// Round-Robin Zuweisung // Round-Robin Zuweisung
const inserts: { const inserts: {
@@ -110,8 +129,8 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
kitaId, kitaId,
childId: { in: childrenIds }, childId: { in: childrenIds },
date: { date: {
gte: new Date(targetYear, targetMonth - 1, 1), gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
lt: new Date(targetYear, targetMonth, 1), lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
}, },
}, },
}); });
@@ -126,7 +145,7 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
}); });
revalidatePath("/dashboard/notdienst"); revalidatePath("/dashboard/notdienst");
return { success: true }; return { success: true, dates };
} catch (error) { } catch (error) {
console.error("Fehler beim Speichern der Notdienste:", error); console.error("Fehler beim Speichern der Notdienste:", error);
return { error: "Ein Fehler ist aufgetreten beim Speichern der Termine." }; return { error: "Ein Fehler ist aufgetreten beim Speichern der Termine." };
@@ -1,58 +1,29 @@
"use client"; "use client";
import { useState } from "react"; import {
import { ArrowRight, CalendarHeart } from "lucide-react"; NotdienstForm,
type NotdienstCalendarItem,
import { Button } from "@/components/ui/button"; } from "./notdienst-form";
import { NotdienstForm } from "./notdienst-form";
type NotdienstEntryProps = { type NotdienstEntryProps = {
hasAnyAvailability: boolean;
targetYear: number; targetYear: number;
targetMonth: number; targetMonth: number;
isLocked: boolean; isLocked: boolean;
requiredDaysTotal: number; requiredDaysTotal: number;
initialSelectedDates: string[]; initialSelectedDates: string[];
childrenIds: string[]; childrenIds: string[];
calendarItems: NotdienstCalendarItem[];
}; };
export function NotdienstEntry({ export function NotdienstEntry({
hasAnyAvailability,
targetYear, targetYear,
targetMonth, targetMonth,
isLocked, isLocked,
requiredDaysTotal, requiredDaysTotal,
initialSelectedDates, initialSelectedDates,
childrenIds, childrenIds,
calendarItems,
}: NotdienstEntryProps) { }: NotdienstEntryProps) {
const [showForm, setShowForm] = useState(hasAnyAvailability);
if (!showForm) {
return (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-gradient-to-b from-emerald-50/70 to-background p-8 text-center">
<div className="mx-auto max-w-lg">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 shadow-sm">
<CalendarHeart className="h-10 w-10" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
Willkommen beim Notdienst!
</h2>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Es sieht so aus, als hättest du noch keine Termine eingetragen.
</p>
<Button
type="button"
className="mt-6"
onClick={() => setShowForm(true)}
>
Jetzt erste Pflicht-Termine eintragen
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
return ( return (
<NotdienstForm <NotdienstForm
targetYear={targetYear} targetYear={targetYear}
@@ -61,6 +32,7 @@ export function NotdienstEntry({
requiredDaysTotal={requiredDaysTotal} requiredDaysTotal={requiredDaysTotal}
initialSelectedDates={initialSelectedDates} initialSelectedDates={initialSelectedDates}
childrenIds={childrenIds} childrenIds={childrenIds}
calendarItems={calendarItems}
/> />
); );
} }
+557 -102
View File
@@ -1,30 +1,92 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { Info, Loader2, CheckCircle2 } from "lucide-react"; import { useRouter } from "next/navigation";
import { format } from "date-fns"; import {
Check,
ChevronLeft,
ChevronRight,
Clock3,
Info,
Loader2,
LockKeyhole,
X,
} from "lucide-react";
import {
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameMonth,
startOfDay,
startOfMonth,
startOfWeek,
} from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getWorkingDaysOfMonth } from "@/lib/date-utils"; import { formatDateKey, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { Button } from "@/components/ui/button";
import { saveNotdienstAvailabilities } from "./actions"; import { saveNotdienstAvailabilities } from "./actions";
// ===================================================================== export const notdienstEventCategories = {
// NotdienstForm · Client Component closure: {
// --------------------------------------------------------------------- label: "Schließtag",
// Erlaubt das Auswählen von N Tagen aus der Liste der Werktage des dot: "oklch(0.62 0.10 35)",
// Zielmonats. Speichert als Array von Datum-Strings. bg: "oklch(0.96 0.04 35)",
// ===================================================================== border: "oklch(0.88 0.04 35)",
text: "oklch(0.38 0.08 35)",
},
team: {
label: "Teamtag",
dot: "oklch(0.62 0.10 70)",
bg: "oklch(0.96 0.04 70)",
border: "oklch(0.88 0.04 70)",
text: "oklch(0.38 0.08 70)",
},
parents: {
label: "Elternabend",
dot: "oklch(0.62 0.10 280)",
bg: "oklch(0.96 0.04 280)",
border: "oklch(0.88 0.04 280)",
text: "oklch(0.38 0.08 280)",
},
social: {
label: "Fest",
dot: "oklch(0.62 0.10 155)",
bg: "oklch(0.96 0.04 155)",
border: "oklch(0.88 0.04 155)",
text: "oklch(0.38 0.08 155)",
},
birthday: {
label: "Geburtstag",
dot: "oklch(0.62 0.10 320)",
bg: "oklch(0.96 0.04 320)",
border: "oklch(0.88 0.04 320)",
text: "oklch(0.38 0.08 320)",
},
} as const;
type NotdienstEventCategory = keyof typeof notdienstEventCategories;
export type NotdienstCalendarItem = {
id: string;
title: string;
category: NotdienstEventCategory;
startDate: Date;
endDate: Date;
allDay: boolean;
note: string | null;
};
interface NotdienstFormProps { interface NotdienstFormProps {
targetYear: number; targetYear: number;
targetMonth: number; targetMonth: number;
isLocked: boolean; isLocked: boolean;
requiredDaysTotal: number; requiredDaysTotal: number;
initialSelectedDates: string[]; // ISO Strings initialSelectedDates: string[];
childrenIds: string[]; childrenIds: string[];
calendarItems: NotdienstCalendarItem[];
} }
export function NotdienstForm({ export function NotdienstForm({
@@ -34,33 +96,44 @@ export function NotdienstForm({
requiredDaysTotal, requiredDaysTotal,
initialSelectedDates, initialSelectedDates,
childrenIds, childrenIds,
calendarItems,
}: NotdienstFormProps) { }: NotdienstFormProps) {
// Lokaler State für die Auswahl. Ein Datum liegt immer als ISO-String vor (z.B. "2026-06-01T00:00:00.000Z") const router = useRouter();
// Lokaler State für die Auswahl. Ein Datum liegt als stabiler yyyy-MM-dd-Schlüssel vor.
const [selectedDates, setSelectedDates] = useState<Set<string>>( const [selectedDates, setSelectedDates] = useState<Set<string>>(
new Set(initialSelectedDates), new Set(initialSelectedDates),
); );
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const closureDateKeys = useMemo(
() => getClosureDateKeys(calendarItems),
[calendarItems],
);
const selectableDateKeys = useMemo(() => {
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth); const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
return new Set(
workingDays
.map(formatDateKey)
.filter((dateKey) => !closureDateKeys.has(dateKey)),
);
}, [closureDateKeys, targetMonth, targetYear]);
const monthStart = startOfMonth(new Date(targetYear, targetMonth - 1));
const selectedCount = selectedDates.size; const selectedCount = selectedDates.size;
const isFulfilled = selectedCount >= requiredDaysTotal; const hasMinimumRequirement = requiredDaysTotal > 0;
const isFulfilled =
!hasMinimumRequirement || selectedCount >= requiredDaysTotal;
const selectedLabel =
selectedCount === 1 ? "1 Tag markiert" : `${selectedCount} Tage markiert`;
function toggleDate(dateStr: string) { function handleChange(nextDates: Set<string>) {
if (isLocked || isPending) return; if (isLocked || isPending) return;
setSelectedDates(nextDates);
const newSet = new Set(selectedDates);
if (newSet.has(dateStr)) {
newSet.delete(dateStr);
} else {
newSet.add(dateStr);
}
setSelectedDates(newSet);
} }
function handleSave() { function handleSave() {
if (isLocked) return; if (isLocked) return;
if (!isFulfilled) { if (hasMinimumRequirement && !isFulfilled) {
toast.error( toast.error(
`Bitte wähle noch ${requiredDaysTotal - selectedCount} weitere Termine aus.`, `Bitte wähle noch ${requiredDaysTotal - selectedCount} weitere Termine aus.`,
); );
@@ -78,107 +151,489 @@ export function NotdienstForm({
if (result?.error) { if (result?.error) {
toast.error(result.error); toast.error(result.error);
} else { } else {
setSelectedDates(new Set(result.dates));
toast.success("Verfügbarkeiten erfolgreich gespeichert."); toast.success("Verfügbarkeiten erfolgreich gespeichert.");
router.refresh();
} }
}); });
} }
return ( return (
<div className="space-y-6">
{/* Fortschritt / Sperr-Hinweis */}
<div
className={cn(
"flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between",
isLocked ? "bg-destructive/5" : "bg-card",
)}
>
<div className="flex items-start gap-3">
{isLocked ? (
<Info className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
) : isFulfilled ? (
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
) : (
<Info className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div> <div>
<h3 className="font-medium"> {isLocked ? (
{isLocked <div className="mb-4 flex items-start gap-3 rounded-[10px] border border-[oklch(0.88_0.04_35)] bg-[oklch(0.96_0.018_35)] p-4 text-sm text-[oklch(0.38_0.08_35)]">
? "Planung abgeschlossen" <Info className="mt-0.5 h-4 w-4 shrink-0" />
: "Deine ausgewählten Termine"} <p>
</h3> Die Frist für diesen Monat ist abgelaufen. Änderungen sind nicht
<p className="text-sm text-muted-foreground"> mehr möglich.
{isLocked
? "Die Frist für diesen Monat ist abgelaufen. Änderungen sind nicht mehr möglich."
: `Du hast ${selectedCount} von ${requiredDaysTotal} benötigten Tagen markiert.`}
</p> </p>
</div> </div>
</div>
<Button
onClick={handleSave}
disabled={isLocked || isPending || !isFulfilled}
className="shrink-0"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null} ) : null}
{isLocked ? "Gesperrt" : "Speichern"}
</Button>
</div>
{/* Datumsliste */} <div className="overflow-x-auto">
<div className="rounded-lg border bg-card"> <NotdienstCalendar
<div className="p-4 border-b bg-muted/30"> month={monthStart}
<p className="text-sm font-medium">Werktage im {format(new Date(targetYear, targetMonth - 1, 1), "MMMM", { locale: de })}</p> events={calendarItems}
selected={selectedDates}
requiredDays={requiredDaysTotal}
disabled={isLocked || isPending}
selectableDateKeys={selectableDateKeys}
onChange={handleChange}
onSave={handleSave}
isSaving={isPending}
/>
</div> </div>
<div className="divide-y"> <p className="sr-only" aria-live="polite">
{workingDays.map((day) => { {hasMinimumRequirement
const dateStr = day.toISOString(); ? `Du hast ${selectedCount} von ${requiredDaysTotal} benötigten Tagen markiert.`
const isSelected = selectedDates.has(dateStr); : `${selectedLabel}. Für diese Kita ist aktuell keine Mindestanzahl hinterlegt.`}
const isToday = day.getTime() === new Date().setHours(0, 0, 0, 0); </p>
</div>
);
}
type NotdienstCalendarProps = {
month: Date;
events: NotdienstCalendarItem[];
selected: Set<string>;
requiredDays: number;
disabled?: boolean;
selectableDateKeys: Set<string>;
isSaving?: boolean;
onChange: (selected: Set<string>) => void;
onSave: () => void;
};
export function NotdienstCalendar({
month,
events,
selected,
requiredDays,
disabled = false,
selectableDateKeys,
isSaving = false,
onChange,
onSave,
}: NotdienstCalendarProps) {
const [visibleMonth, setVisibleMonth] = useState(month);
const [activeEvent, setActiveEvent] = useState<NotdienstCalendarItem | null>(
null,
);
const visibleMonthStart = startOfMonth(visibleMonth);
const visibleMonthEnd = endOfMonth(visibleMonthStart);
const calendarStart = startOfWeek(visibleMonthStart, { weekStartsOn: 1 });
const calendarEnd = endOfWeek(visibleMonthEnd, { weekStartsOn: 1 });
const calendarDays = eachDayOfInterval({
start: calendarStart,
end: calendarEnd,
}).map((day) => startOfDay(day));
const selectedCount = selected.size;
const extraCount = Math.max(0, selectedCount - requiredDays);
const statusText =
requiredDays > 0 && selectedCount >= requiredDays
? `Fertig — alle ${requiredDays} Tage markiert`
: `${selectedCount} von ${requiredDays} Tagen`;
function toggleDate(dateKey: string) {
if (disabled || !selectableDateKeys.has(dateKey)) return;
const nextSelected = new Set(selected);
if (nextSelected.has(dateKey)) {
nextSelected.delete(dateKey);
} else {
nextSelected.add(dateKey);
}
onChange(nextSelected);
}
return ( return (
<button <section
key={dateStr} className="min-w-[1040px] bg-[var(--notdienst-bg)] px-11 py-10 text-[var(--notdienst-ink)] shadow-sm [--notdienst-accent-deep:oklch(0.42_0.10_38)] [--notdienst-accent-mid:oklch(0.82_0.07_38)] [--notdienst-accent-soft:oklch(0.93_0.04_38)] [--notdienst-accent:oklch(0.58_0.11_38)] [--notdienst-bg:oklch(0.965_0.012_75)] [--notdienst-hairline-soft:oklch(0.93_0.01_75)] [--notdienst-hairline:oklch(0.90_0.012_75)] [--notdienst-ink-faint:oklch(0.76_0.01_45)] [--notdienst-ink-muted:oklch(0.60_0.012_45)] [--notdienst-ink-soft:oklch(0.45_0.015_45)] [--notdienst-ink:oklch(0.26_0.015_45)] [--notdienst-out-month:oklch(0.82_0.012_75)] [--notdienst-surface-week:oklch(0.945_0.014_75)] [--notdienst-surface:oklch(0.985_0.008_75)]"
type="button" style={{
disabled={isLocked || isPending} backgroundImage:
onClick={() => toggleDate(dateStr)} "radial-gradient(oklch(0.85 0.02 75 / 0.10) 1px, transparent 1px)",
className={cn( backgroundSize: "16px 16px",
"flex w-full items-center justify-between px-4 py-3 text-left transition-colors", }}
!isLocked && !isPending && "hover:bg-muted/50",
isSelected && "bg-primary/5 hover:bg-primary/10",
(isLocked || isPending) && "cursor-not-allowed opacity-70",
)}
> >
<div className="flex items-center gap-3"> <header className="flex items-start justify-between gap-8">
{/* Custom Checkbox Indicator */}
<div
className={cn(
"flex h-5 w-5 items-center justify-center rounded border",
isSelected
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-background",
)}
>
{isSelected && <CheckCircle2 className="h-3.5 w-3.5" />}
</div>
<div> <div>
<span className={cn("font-medium", isSelected && "text-primary")}> <p className="text-[11px] font-semibold uppercase leading-none text-[var(--notdienst-accent)] [letter-spacing:0.18em]">
{format(day, "EEEE, dd. MMMM", { locale: de })} NOTDIENST · {format(visibleMonthStart, "MMMM yyyy", { locale: de })}
</span> </p>
{isToday && ( <h1 className="mt-2 text-[36px] font-normal leading-tight text-[var(--notdienst-ink)] [letter-spacing:-0.025em]">
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground"> Wann magst du{" "}
Heute <em className="font-normal italic text-[var(--notdienst-accent-deep)]">
</span> helfen
</em>
?
</h1>
</div>
<div className="mt-3 flex items-center gap-4 rounded-full border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] px-5 py-3 shadow-[0_12px_30px_oklch(0.36_0.02_45_/_0.06)]">
<div className="flex items-center gap-1.5">
{Array.from({ length: Math.max(requiredDays, 1) }).map(
(_, index) => {
const isMarked = index < selectedCount;
return (
<span
key={index}
className={cn(
"h-2.5 w-2.5 rounded-full border border-[var(--notdienst-accent)]",
isMarked && "bg-[var(--notdienst-accent)]",
)} )}
/>
);
},
)}
{extraCount > 0 ? (
<span className="ml-1 text-[11px] font-semibold text-[var(--notdienst-accent-deep)]">
+{extraCount}
</span>
) : null}
</div> </div>
</div> <span className="text-[13px] font-semibold text-[var(--notdienst-ink-soft)]">
{statusText}
</span>
<button
type="button"
onClick={onSave}
disabled={disabled || isSaving || selectedCount < requiredDays}
className="inline-flex h-8 items-center gap-2 rounded-full bg-[var(--notdienst-accent-deep)] px-5 text-[13px] font-bold text-white transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-55"
>
{isSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
Speichern
</button> </button>
</div>
</header>
<div className="mt-9 flex items-center justify-between gap-8">
<div className="flex items-center gap-3">
<h2 className="text-[22px] font-medium text-[var(--notdienst-ink)] [font-variant-numeric:tabular-nums]">
{format(visibleMonthStart, "MMMM yyyy", { locale: de })}
</h2>
<div className="flex items-center gap-1.5">
<button
type="button"
aria-label="Vorheriger Monat"
onClick={() =>
setVisibleMonth(
new Date(
visibleMonthStart.getFullYear(),
visibleMonthStart.getMonth() - 1,
1,
),
)
}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] text-[var(--notdienst-ink-soft)] transition hover:border-[var(--notdienst-accent-mid)] hover:text-[var(--notdienst-accent-deep)]"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
type="button"
aria-label="Nächster Monat"
onClick={() =>
setVisibleMonth(
new Date(
visibleMonthStart.getFullYear(),
visibleMonthStart.getMonth() + 1,
1,
),
)
}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] text-[var(--notdienst-ink-soft)] transition hover:border-[var(--notdienst-accent-mid)] hover:text-[var(--notdienst-accent-deep)]"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
<CalendarLegend />
</div>
<div className="mt-8 grid grid-cols-7 gap-1.5">
{[
"Montag",
"Dienstag",
"Mittwoch",
"Donnerstag",
"Freitag",
"Samstag",
"Sonntag",
].map((weekday) => (
<div
key={weekday}
className="px-2 text-[11px] font-semibold uppercase text-[var(--notdienst-ink-muted)] [letter-spacing:0.08em]"
>
{weekday}
</div>
))}
</div>
<div className="mt-2 grid grid-cols-7 gap-1.5">
{calendarDays.map((day) => {
const dateKey = formatDateKey(day);
const isInTargetMonth = isSameMonth(day, visibleMonthStart);
const isSelected = selected.has(dateKey);
const dayEvents = getCalendarItemsForDay(events, day);
const hasClosure = dayEvents.some(
(event) => event.category === "closure",
);
const isSelectable =
isInTargetMonth && selectableDateKeys.has(dateKey) && !hasClosure;
const isWeekend = day.getDay() === 0 || day.getDay() === 6;
return (
<CalendarTile
key={dateKey}
day={day}
events={dayEvents}
isInTargetMonth={isInTargetMonth}
isSelected={isSelected}
isSelectable={isSelectable}
isWeekend={isWeekend}
hasClosure={hasClosure}
disabled={disabled}
onToggle={() => toggleDate(dateKey)}
onOpenEvent={setActiveEvent}
/>
); );
})} })}
</div> </div>
{activeEvent ? (
<EventDialog
event={activeEvent}
onClose={() => setActiveEvent(null)}
/>
) : null}
</section>
);
}
function CalendarTile({
day,
events,
isInTargetMonth,
isSelected,
isSelectable,
isWeekend,
hasClosure,
disabled,
onToggle,
onOpenEvent,
}: {
day: Date;
events: NotdienstCalendarItem[];
isInTargetMonth: boolean;
isSelected: boolean;
isSelectable: boolean;
isWeekend: boolean;
hasClosure: boolean;
disabled: boolean;
onToggle: () => void;
onOpenEvent: (event: NotdienstCalendarItem) => void;
}) {
return (
<div
className={cn(
"relative min-h-24 rounded-[10px] border p-[10px_11px] text-left transition",
"border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)]",
isSelectable &&
!disabled &&
"cursor-pointer hover:border-[var(--notdienst-accent-mid)] hover:shadow-[0_10px_28px_oklch(0.36_0.02_45_/_0.08)]",
isSelected &&
"border-[var(--notdienst-accent-mid)] bg-[var(--notdienst-accent-soft)]",
hasClosure &&
"cursor-default border-[oklch(0.88_0.04_35)] bg-[oklch(0.96_0.018_35)]",
(isWeekend || !isInTargetMonth) &&
"cursor-default border-[var(--notdienst-hairline-soft)] bg-[var(--notdienst-surface-week)]",
)}
onClick={isSelectable && !disabled ? onToggle : undefined}
>
<div className="flex items-start justify-between gap-2">
<span
className={cn(
"font-medium leading-none [font-variant-numeric:tabular-nums] [letter-spacing:-0.02em]",
isWeekend || !isInTargetMonth
? "text-[13px] text-[var(--notdienst-out-month)]"
: "text-[17px] text-[var(--notdienst-ink)]",
isSelected && "text-[var(--notdienst-accent-deep)]",
hasClosure && "text-[oklch(0.38_0.08_35)]",
)}
>
{format(day, "d")}
</span>
{isSelected ? (
<Check className="h-2.5 w-2.5 text-[var(--notdienst-accent-deep)] [stroke-width:2.5]" />
) : null}
{hasClosure ? (
<LockKeyhole className="h-3 w-3 text-[oklch(0.38_0.08_35)]" />
) : null}
</div>
<div className="absolute inset-x-[11px] bottom-[10px] space-y-1.5">
{events.slice(0, 2).map((event) => (
<button
key={`${formatDateKey(day)}-${event.id}`}
type="button"
className="group/event relative flex w-full items-center gap-1.5 overflow-visible text-left"
onClick={(clickEvent) => {
clickEvent.stopPropagation();
onOpenEvent(event);
}}
>
<span
className="h-4 w-[3px] shrink-0 rounded-sm"
style={{
backgroundColor: notdienstEventCategories[event.category].dot,
}}
/>
<span className="truncate text-[11px] font-medium text-[var(--notdienst-ink-soft)]">
{event.title}
</span>
<span className="pointer-events-none absolute bottom-full left-0 z-20 mb-2 hidden min-w-44 rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-left text-white shadow-xl group-hover/event:block">
<span className="block text-[10px] font-semibold uppercase text-white/65 [letter-spacing:0.08em]">
{notdienstEventCategories[event.category].label}
</span>
<span className="mt-0.5 block text-[12px] font-semibold">
{event.title}
</span>
<span className="mt-1 block text-[11px] text-white/75">
{formatEventTime(event)}
</span>
</span>
</button>
))}
{events.length > 2 ? (
<p className="text-[11px] font-medium text-[var(--notdienst-ink-muted)]">
+{events.length - 2} weitere
</p>
) : null}
</div> </div>
</div> </div>
); );
} }
function CalendarLegend() {
return (
<div className="flex flex-wrap items-center justify-end gap-4 text-[11px] font-medium text-[var(--notdienst-ink-soft)]">
<span className="inline-flex items-center gap-1.5">
<span className="h-[9px] w-[9px] rounded-[2px] bg-[var(--notdienst-accent)]" />
Dein Notdienst
</span>
{Object.entries(notdienstEventCategories).map(([key, category]) => (
<span key={key} className="inline-flex items-center gap-1.5">
<span
className="h-[9px] w-[9px] rounded-[2px]"
style={{ backgroundColor: category.dot }}
/>
{category.label}
</span>
))}
</div>
);
}
function EventDialog({
event,
onClose,
}: {
event: NotdienstCalendarItem;
onClose: () => void;
}) {
const category = notdienstEventCategories[event.category];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[oklch(0.25_0.015_45_/_0.20)] p-6 backdrop-blur-[2px]"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-xl border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] p-6 text-[var(--notdienst-ink)] shadow-2xl"
onClick={(eventClick) => eventClick.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<span
className="inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold"
style={{
backgroundColor: category.bg,
borderColor: category.border,
color: category.text,
}}
>
{category.label}
</span>
<button
type="button"
aria-label="Detail schließen"
onClick={onClose}
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-[var(--notdienst-ink-muted)] transition hover:bg-[var(--notdienst-surface-week)] hover:text-[var(--notdienst-ink)]"
>
<X className="h-4 w-4" />
</button>
</div>
<h3 className="mt-4 text-2xl font-medium leading-tight [letter-spacing:-0.02em]">
{event.title}
</h3>
<p className="mt-3 flex items-center gap-2 text-sm font-medium text-[var(--notdienst-ink-soft)]">
<Clock3 className="h-4 w-4" />
{formatEventDate(event)}
</p>
{event.note ? (
<p className="mt-5 text-sm leading-6 text-[var(--notdienst-ink-soft)]">
{event.note}
</p>
) : null}
</div>
</div>
);
}
function getCalendarItemsForDay(
items: NotdienstCalendarItem[],
day: Date,
) {
const dayStart = startOfDay(day).getTime();
return items.filter((item) => {
const itemStart = startOfDay(item.startDate).getTime();
const itemEnd = startOfDay(item.endDate).getTime();
return itemStart <= dayStart && itemEnd >= dayStart;
});
}
function getClosureDateKeys(items: NotdienstCalendarItem[]) {
const closureKeys = new Set<string>();
for (const item of items) {
if (item.category !== "closure") continue;
const days = eachDayOfInterval({
start: startOfDay(item.startDate),
end: startOfDay(item.endDate),
});
for (const day of days) {
closureKeys.add(formatDateKey(day));
}
}
return closureKeys;
}
function formatEventDate(event: NotdienstCalendarItem) {
const date = format(event.startDate, "EEEE, d. MMMM yyyy", { locale: de });
return event.allDay
? `${date} · Ganztägig`
: `${date} · ${formatEventTime(event)}`;
}
function formatEventTime(event: NotdienstCalendarItem) {
if (event.allDay) return "Ganztägig";
return `${format(event.startDate, "HH:mm")} - ${format(
event.endDate,
"HH:mm",
)}`;
}
+66 -44
View File
@@ -1,15 +1,21 @@
import Link from "next/link"; import Link from "next/link";
import { UserRole } from "@prisma/client"; import { TerminStatus, TerminType, UserRole } from "@prisma/client";
import { Info, Calendar, ArrowRight, ShieldAlert } from "lucide-react"; import { ArrowRight, ShieldAlert } from "lucide-react";
import { Manrope } from "next/font/google";
import { requireRole } from "@/lib/auth-utils"; import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getTargetMonthData } from "@/lib/date-utils"; import { formatDateKey, getTargetMonthData } from "@/lib/date-utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { NotdienstEntry } from "./notdienst-entry"; import { NotdienstEntry } from "./notdienst-entry";
export const metadata = { title: "Notdienst · Kita-Planer" }; export const metadata = { title: "Notdienst · Kita-Planer" };
const manrope = Manrope({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export default async function NotdienstPage() { export default async function NotdienstPage() {
// ELTERN können eintragen; Admins/Koordinatoren haben hier eigentlich // ELTERN können eintragen; Admins/Koordinatoren haben hier eigentlich
// andere Ansichten (Plan-Generierung), aber wir lassen sie zur Demonstration // andere Ansichten (Plan-Generierung), aber wir lassen sie zur Demonstration
@@ -44,13 +50,15 @@ export default async function NotdienstPage() {
: []; : [];
const targetData = getTargetMonthData(); const targetData = getTargetMonthData();
const { targetYear, targetMonth, monthName, isLocked } = targetData; const { targetYear, targetMonth, isLocked } = targetData;
const targetMonthStart = new Date(Date.UTC(targetYear, targetMonth - 1, 1));
const targetMonthEnd = new Date(Date.UTC(targetYear, targetMonth, 1));
const requiredDaysTotal = children.length * kita.notdienstMinPerChildPerMonth; const requiredDaysTotal = children.length * kita.notdienstMinPerChildPerMonth;
const childIds = children.map((child) => child.id); const childIds = children.map((child) => child.id);
// Bisherige Einträge für den Zielmonat laden, haushaltsweit über die Kinder. // Bisherige Einträge für den Zielmonat laden, haushaltsweit über die Kinder.
const [availabilities, availabilityCountForCurrentOrNextMonth] = const [availabilities, termine] =
children.length > 0 children.length > 0
? await Promise.all([ ? await Promise.all([
prisma.notdienstAvailability.findMany({ prisma.notdienstAvailability.findMany({
@@ -58,80 +66,94 @@ export default async function NotdienstPage() {
kitaId, kitaId,
childId: { in: childIds }, childId: { in: childIds },
date: { date: {
gte: new Date(targetYear, targetMonth - 1, 1), gte: targetMonthStart,
lt: new Date(targetYear, targetMonth, 1), lt: targetMonthEnd,
}, },
}, },
select: { date: true }, select: { date: true },
}), }),
prisma.notdienstAvailability.count({ prisma.termin.findMany({
where: { where: {
kitaId, kitaId,
childId: { in: childIds }, status: TerminStatus.CONFIRMED,
date: { startDate: { lt: targetMonthEnd },
gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1), endDate: { gte: targetMonthStart },
lt: new Date(new Date().getFullYear(), new Date().getMonth() + 2, 1),
}, },
select: {
id: true,
title: true,
description: true,
type: true,
startDate: true,
endDate: true,
allDay: true,
}, },
orderBy: { startDate: "asc" },
}), }),
]) ])
: [[], 0]; : [[], []];
const selectedDates = availabilities.map((a) => a.date.toISOString()); const selectedDates = availabilities.map((a) => formatDateKey(a.date));
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-8"> <div className={`${manrope.className} mx-auto max-w-7xl px-4 py-8 sm:px-8`}>
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Calendar className="h-6 w-6 text-primary" />
Notdienst planen
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Gib hier an, an welchen Tagen du im Notdienst unterstützen kannst.
</p>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<span className="rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
Zielmonat: {monthName}
</span>
{isLocked && (
<span className="text-xs font-medium text-destructive flex items-center gap-1">
<Info className="h-3 w-3" />
Eingabefrist abgelaufen
</span>
)}
</div>
</div>
{children.length === 0 ? ( {children.length === 0 ? (
<NotdienstLockout /> <NotdienstLockout />
) : ( ) : (
<NotdienstEntry <NotdienstEntry
hasAnyAvailability={availabilityCountForCurrentOrNextMonth > 0}
targetYear={targetYear} targetYear={targetYear}
targetMonth={targetMonth} targetMonth={targetMonth}
isLocked={isLocked} isLocked={isLocked}
requiredDaysTotal={requiredDaysTotal} requiredDaysTotal={requiredDaysTotal}
initialSelectedDates={selectedDates} initialSelectedDates={selectedDates}
childrenIds={childIds} childrenIds={childIds}
calendarItems={termine.map((termin) => ({
id: termin.id,
title: termin.title,
category: getTerminCategory(termin.type),
startDate: termin.startDate,
endDate: termin.endDate,
allDay: termin.allDay,
note: termin.description,
}))}
/> />
)} )}
</div> </div>
); );
} }
function getTerminCategory(type: TerminType) {
switch (type) {
case TerminType.SCHLIESSTAG:
return "closure" as const;
case TerminType.TEAMTAG:
return "team" as const;
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "social" as const;
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "parents" as const;
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "birthday" as const;
default:
return "social" as const;
}
}
function NotdienstLockout() { function NotdienstLockout() {
return ( return (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-muted/20 p-8 text-center"> <div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-8 text-center">
<div className="mx-auto max-w-lg"> <div className="mx-auto max-w-lg">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-amber-100 text-amber-700 shadow-sm"> <div className="mx-auto flex h-20 w-20 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<ShieldAlert className="h-10 w-10" /> <ShieldAlert className="h-10 w-10 [stroke-width:1.7]" />
</div> </div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight"> <h2 className="mt-6 text-2xl font-medium tracking-[-0.02em] text-[var(--ink)]">
Erst Kinder hinterlegen Erst Kinder hinterlegen
</h2> </h2>
<p className="mt-3 text-sm leading-6 text-muted-foreground"> <p className="mt-3 text-sm leading-6 text-[var(--ink-soft)]">
Bitte lege zuerst deine Kinder im Profil an, bevor du Notdienste Bitte lege zuerst deine Kinder im Profil an, bevor du Notdienste
übernimmst. übernimmst.
</p> </p>
+125 -18
View File
@@ -1,9 +1,10 @@
import { UserRole } from "@prisma/client"; import { TerminType, UserRole } from "@prisma/client";
import { Settings2 } from "lucide-react"; import { ChevronLeft, ChevronRight, SlidersHorizontal } from "lucide-react";
import { requireRole } from "@/lib/auth-utils"; import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils"; import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { Button } from "@/components/ui/button";
import { PlanView } from "./plan-view"; import { PlanView } from "./plan-view";
export const metadata = { title: "Plan-Generierung · Kita-Planer" }; export const metadata = { title: "Plan-Generierung · Kita-Planer" };
@@ -14,8 +15,12 @@ export default async function PlanungsZentralePage() {
const { targetYear, targetMonth, monthName } = getTargetMonthData(); const { targetYear, targetMonth, monthName } = getTargetMonthData();
// Aktuellen Plan suchen const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
const plan = await prisma.notdienstPlan.findUnique({ const monthStart = new Date(targetYear, targetMonth - 1, 1);
const monthEnd = new Date(targetYear, targetMonth, 1);
const [plan, families, availabilities, closureCount] = await Promise.all([
prisma.notdienstPlan.findUnique({
where: { where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth }, kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
}, },
@@ -30,10 +35,51 @@ export default async function PlanungsZentralePage() {
}, },
}, },
}, },
}); }),
prisma.family.findMany({
// Alle Werktage where: {
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth); kitaId,
children: { some: { active: true } },
},
select: {
id: true,
name: true,
children: {
where: { active: true },
select: { id: true },
},
},
orderBy: { name: "asc" },
}),
prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: monthStart,
lt: monthEnd,
},
},
select: {
date: true,
child: {
select: {
familyId: true,
},
},
},
orderBy: { date: "asc" },
}),
prisma.termin.count({
where: {
kitaId,
type: TerminType.SCHLIESSTAG,
startDate: {
gte: monthStart,
lt: monthEnd,
},
},
}),
]);
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown. // Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle", // Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
@@ -71,6 +117,40 @@ export default async function PlanungsZentralePage() {
}; };
}); });
const availabilityDatesByFamily = new Map<string, Set<string>>();
availabilities.forEach((availability) => {
const familyId = availability.child.familyId;
const dates = availabilityDatesByFamily.get(familyId) ?? new Set<string>();
dates.add(availability.date.toISOString());
availabilityDatesByFamily.set(familyId, dates);
});
const familyStatuses = families.map((family) => {
const dates = Array.from(availabilityDatesByFamily.get(family.id) ?? []);
return {
id: family.id,
name: family.name,
selectedDays: dates.map((date) => ({
iso: date,
label: new Intl.DateTimeFormat("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
}).format(new Date(date)),
})),
childCount: family.children.length,
};
});
const respondedFamilies = familyStatuses.filter(
(family) => family.selectedDays.length > 0,
).length;
const openFamilies = Math.max(familyStatuses.length - respondedFamilies, 0);
const totalAvailabilitySlots = availabilities.length;
const requiredDays = workingDays.length;
const coverageFactor =
requiredDays > 0 ? totalAvailabilitySlots / requiredDays : 0;
// Wir mappen die Assignments auf die Werktage // Wir mappen die Assignments auf die Werktage
const daysWithAssignments = workingDays.map((day) => { const daysWithAssignments = workingDays.map((day) => {
const assignment = plan?.assignments.find( const assignment = plan?.assignments.find(
@@ -84,29 +164,56 @@ export default async function PlanungsZentralePage() {
}); });
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-8"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2"> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<Settings2 className="h-6 w-6 text-primary" /> NOTDIENST · PLANUNG
Notdienst-Planung </p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Notdienst{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
zuweisen
</em>
</h1> </h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Koordiniere und generiere den Notdienst-Plan. Koordiniere und generiere den Notdienst-Plan.
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary"> <div className="flex shrink-0 items-center gap-2">
Zielmonat: {monthName} <div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
</span> <Button variant="ghost" size="icon" className="h-9 w-9 rounded-none border-r border-[var(--hairline-soft)]">
<ChevronLeft className="h-4 w-4 [stroke-width:1.7]" />
</Button>
<div className="flex h-9 min-w-32 items-center justify-center px-4 text-[13px] font-semibold text-[var(--ink)]">
{monthName} {targetYear}
</div> </div>
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-none border-l border-[var(--hairline-soft)]">
<ChevronRight className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</div> </div>
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Einstellungen
</Button>
</div>
</header>
<PlanView <PlanView
planId={plan?.id} planId={plan?.id}
status={plan?.status} status={plan?.status}
days={daysWithAssignments} days={daysWithAssignments}
allChildren={allChildren} allChildren={allChildren}
familyStatuses={familyStatuses}
requiredDays={requiredDays}
respondedFamilies={respondedFamilies}
openFamilies={openFamilies}
totalFamilies={familyStatuses.length}
totalAvailabilitySlots={totalAvailabilitySlots}
coverageFactor={coverageFactor}
closureCount={closureCount}
monthName={monthName}
/> />
</div> </div>
); );
+422 -64
View File
@@ -1,14 +1,23 @@
"use client"; "use client";
import { useTransition } from "react"; import { useState, useTransition } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { NotdienstPlanStatus } from "@prisma/client"; import { NotdienstPlanStatus } from "@prisma/client";
import { Loader2, Wand2, CheckSquare } from "lucide-react"; import {
ArrowRight,
CheckSquare,
Download,
Loader2,
RotateCcw,
Send,
Wand2,
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { import {
generatePlanAction, generatePlanAction,
publishPlanAction, publishPlanAction,
@@ -19,7 +28,7 @@ interface PlanViewProps {
planId?: string; planId?: string;
status?: NotdienstPlanStatus; status?: NotdienstPlanStatus;
days: { days: {
date: string; // ISO date: string;
assignmentId: string | null; assignmentId: string | null;
childId: string | null; childId: string | null;
}[]; }[];
@@ -28,10 +37,45 @@ interface PlanViewProps {
name: string; name: string;
parentName: string; parentName: string;
}[]; }[];
familyStatuses: {
id: string;
name: string;
childCount: number;
selectedDays: {
iso: string;
label: string;
}[];
}[];
requiredDays: number;
respondedFamilies: number;
openFamilies: number;
totalFamilies: number;
totalAvailabilitySlots: number;
coverageFactor: number;
closureCount: number;
monthName: string;
} }
export function PlanView({ planId, status, days, allChildren }: PlanViewProps) { export function PlanView({
planId,
status,
days,
allChildren,
familyStatuses,
requiredDays,
respondedFamilies,
openFamilies,
totalFamilies,
totalAvailabilitySlots,
coverageFactor,
closureCount,
monthName,
}: PlanViewProps) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [activeTab, setActiveTab] = useState<"preview" | "manual">("preview");
const responsePercent =
totalFamilies > 0 ? Math.round((respondedFamilies / totalFamilies) * 100) : 0;
const handleGenerate = () => { const handleGenerate = () => {
startTransition(async () => { startTransition(async () => {
@@ -70,117 +114,431 @@ export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
}); });
}; };
if (!planId && respondedFamilies === 0) {
return (
<Card className="border-dashed">
<div className="flex min-h-[280px] flex-col items-center justify-center p-8 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Wand2 className="h-7 w-7 [stroke-width:1.7]" />
</div>
<h3 className="mb-1 text-xl font-medium tracking-[-0.02em] text-[var(--ink)]">
Noch keine Verfügbarkeiten
</h3>
<p className="mb-5 max-w-md text-sm leading-6 text-[var(--ink-soft)]">
Für {monthName} hat noch keine Familie geantwortet. Verschicke
zuerst Erinnerungen, damit die automatische Planung belastbar ist.
</p>
<div className="flex flex-wrap justify-center gap-2">
<Button disabled={isPending} onClick={handleGenerate}>
Trotzdem generieren
</Button>
<Button variant="ghost">
<Send className="h-4 w-4 [stroke-width:1.7]" />
Erinnerungen verschicken
</Button>
</div>
</div>
</Card>
);
}
if (!planId) { if (!planId) {
return ( return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-card p-12 text-center"> <div className="space-y-5">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary"> <div className="grid grid-cols-[minmax(0,2fr)_minmax(300px,1fr)] gap-5 max-[1050px]:grid-cols-1">
<Wand2 className="h-6 w-6" /> <Card className="p-0">
</div> <div className="border-b border-[var(--hairline-soft)] px-6 py-5">
<h3 className="mb-1 text-lg font-semibold">Noch kein Plan vorhanden</h3> <div className="flex items-start justify-between gap-4">
<p className="mb-6 max-w-sm text-sm text-muted-foreground"> <div>
Generiere den Plan basierend auf den Verfügbarkeiten der Eltern <h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
automatisch. Verfügbarkeiten Eltern
</h2>
<p className="mt-1 text-[13.5px] leading-5 text-[var(--ink-soft)]">
{respondedFamilies} von {totalFamilies} Familien haben
geantwortet · noch {openFamilies} offen
</p> </p>
<Button onClick={handleGenerate} disabled={isPending}> </div>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} <span className="text-sm font-semibold tabular-nums text-[var(--ink-soft)]">
Plan automatisch generieren {responsePercent}%
</span>
</div>
<div className="mt-4 h-1 overflow-hidden rounded-full bg-[var(--accent-soft)]">
<div
className="h-full rounded-full bg-[var(--accent)]"
style={{ width: `${responsePercent}%` }}
/>
</div>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{familyStatuses.map((family) => (
<div
key={family.id}
className="group relative flex items-center gap-3 px-6 py-3.5 transition-colors hover:bg-[var(--surface-2)]"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[11px] font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-[var(--ink)]">
{family.name}
</p>
<p className="text-xs text-[var(--ink-muted)]">
{family.childCount} Kind
{family.childCount === 1 ? "" : "er"}
</p>
</div>
<FamilyStatusPill selectedDays={family.selectedDays.length} />
<div className="pointer-events-none absolute right-6 top-10 z-20 hidden max-w-xs rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-xs leading-5 text-white shadow-lg group-hover:block">
{family.selectedDays.length > 0
? family.selectedDays.map((day) => day.label).join(" · ")
: "Noch keine Tage markiert"}
</div>
</div>
))}
</div>
<div className="border-t border-[var(--hairline-soft)] px-6 py-4">
<Button variant="ghost" disabled={openFamilies === 0}>
<Send className="h-4 w-4 [stroke-width:1.7]" />
Erinnerung an offene senden ({openFamilies})
</Button> </Button>
</div> </div>
</Card>
<Card className="p-6">
<div>
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Bedarfs-Übersicht
</h2>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Planungsgrundlage für {monthName}.
</p>
</div>
<div className="mt-5 grid gap-4">
<PlanMetric
label={`Tage benötigt in ${monthName}`}
value={requiredDays}
/>
<PlanMetric
label="Verfügbarkeiten gesamt"
value={totalAvailabilitySlots}
/>
<PlanMetric
label="Überdeckung"
value={`${coverageFactor.toFixed(1)}×`}
badge="good"
/>
<PlanMetric label="Schließtage" value={closureCount} />
</div>
<div className="mt-5 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-3 text-sm leading-6 text-[var(--ink-soft)]">
{coverageFactor >= 1
? "Du hast genug Verfügbarkeiten für einen Plan."
: "Die Verfügbarkeiten reichen noch nicht für einen belastbaren Plan."}
</div>
</Card>
</div>
<Card variant="featured" className="sticky bottom-6 z-10 p-5">
<div className="flex items-center justify-between gap-5 max-[760px]:flex-col max-[760px]:items-start">
<div className="flex gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Wand2 className="h-5 w-5 [stroke-width:1.7]" />
</div>
<div>
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Plan automatisch generieren
</h3>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Verteilt {requiredDays} Notdiensttage fair auf{" "}
{respondedFamilies} Familien.
</p>
<details className="mt-3 text-xs text-[var(--ink-soft)]">
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
Faire Verteilung berücksichtigt
</summary>
<div className="mt-2 flex flex-wrap gap-2">
<span> Quote pro Familie</span>
<span> Bevorzugte Tage</span>
<span> Geschwister-Familien zusammen</span>
</div>
</details>
</div>
</div>
<Button onClick={handleGenerate} disabled={isPending}>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
)}
Generieren
<ArrowRight className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</div>
</Card>
</div>
); );
} }
const isDraft = status === "DRAFT"; const isDraft = status === "DRAFT";
return ( return (
<div className="space-y-6"> <div className="space-y-5">
<div className="flex items-center justify-between rounded-lg border bg-card p-4"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<TabButton
active={activeTab === "preview"}
onClick={() => setActiveTab("preview")}
>
Plan-Vorschau
</TabButton>
<TabButton
active={activeTab === "manual"}
onClick={() => setActiveTab("manual")}
withDivider
>
Manuell zuweisen
</TabButton>
</div>
<div className="flex flex-wrap gap-2">
{isDraft && (
<Button onClick={handlePublish} disabled={isPending}>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
)}
<CheckSquare className="h-4 w-4 [stroke-width:1.7]" />
Plan veröffentlichen
</Button>
)}
<Button variant="ghost">
<Download className="h-4 w-4 [stroke-width:1.7]" />
Als PDF exportieren
</Button>
<Button
variant="ghost"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
>
<RotateCcw className="h-4 w-4 [stroke-width:1.7]" />
Plan zurücksetzen
</Button>
</div>
</div>
<Card className="flex items-center justify-between p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Badge variant={isDraft ? "warning" : "success"}> <Badge variant={isDraft ? "warning" : "success"}>
{isDraft ? "Entwurf" : "Veröffentlicht"} {isDraft ? "Entwurf" : "Veröffentlicht"}
</Badge> </Badge>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-[var(--ink-soft)]">
{isDraft {isDraft
? "Du kannst die Liste jetzt manuell anpassen." ? "Du kannst die Liste jetzt manuell anpassen."
: "Dieser Plan ist verbindlich."} : "Dieser Plan ist verbindlich."}
</span> </span>
</div> </div>
</Card>
{isDraft && ( {activeTab === "preview" ? (
<Button onClick={handlePublish} disabled={isPending}> <PlanPreviewTable days={days} allChildren={allChildren} />
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} ) : (
<CheckSquare className="mr-2 h-4 w-4" /> <ManualAssignmentTable
Plan veröffentlichen days={days}
</Button> allChildren={allChildren}
isDraft={isDraft}
isPending={isPending}
onAssignmentChange={handleAssignmentChange}
/>
)} )}
</div> </div>
);
}
<div className="rounded-lg border bg-card"> function FamilyStatusPill({ selectedDays }: { selectedDays: number }) {
<div className="grid grid-cols-[1fr_2fr] border-b bg-muted/30 p-4 text-sm font-medium"> if (selectedDays === 0) {
<div>Datum</div> return (
<div>Eingeteilte Familie</div> <span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
Noch nicht
</span>
);
}
return (
<span className="rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]">
{selectedDays} Tage
</span>
);
}
function PlanMetric({
label,
value,
badge,
}: {
label: string;
value: number | string;
badge?: "good";
}) {
return (
<div className="flex items-center justify-between gap-4 border-b border-[var(--hairline-soft)] pb-3 last:border-b-0 last:pb-0">
<span className="text-[13.5px] text-[var(--ink-soft)]">{label}</span>
{badge ? (
<span className="rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-sm font-semibold text-[var(--good)] tabular-nums">
{value}
</span>
) : (
<span className="text-2xl font-medium tracking-[-0.025em] text-[var(--ink)] tabular-nums">
{value}
</span>
)}
</div> </div>
<div className="divide-y"> );
}
function TabButton({
active,
withDivider,
onClick,
children,
}: {
active: boolean;
withDivider?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={[
"h-9 px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]",
withDivider ? "border-l border-[var(--hairline-soft)]" : "",
active
? "bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
{children}
</button>
);
}
function PlanPreviewTable({
days,
allChildren,
}: Pick<PlanViewProps, "days" | "allChildren">) {
return (
<Card className="overflow-hidden p-0">
<div className="grid grid-cols-[1.1fr_1fr_2fr_1.6fr_1fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
<div>Datum</div>
<div>Wochentag</div>
<div>Zugewiesen</div>
<div>Backup</div>
<div>Status</div>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{days.map((day) => { {days.map((day) => {
const dateObj = new Date(day.date); const dateObj = new Date(day.date);
const isUnbesetzt = !day.childId; const assignedChild = allChildren.find(
(child) => child.id === day.childId,
);
return ( return (
<div <div
key={day.date} key={day.date}
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-muted/10" className="grid cursor-pointer grid-cols-[1.1fr_1fr_2fr_1.6fr_1fr] items-center p-4 text-sm transition-colors hover:bg-[var(--surface-2)]"
> >
<div className="font-medium"> <div className="font-medium tabular-nums text-[var(--ink)]">
{format(dateObj, "dd.MM.", { locale: de })}
</div>
<div className="text-[var(--ink-soft)]">
{format(dateObj, "EEEE", { locale: de })}
</div>
<div className="text-[var(--ink)]">
{assignedChild ? assignedChild.parentName : "Unbesetzt"}
{assignedChild && (
<span className="ml-1 text-[var(--ink-muted)]">
({assignedChild.name})
</span>
)}
</div>
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
<div>
<Badge variant={assignedChild ? "success" : "destructive"}>
{assignedChild ? "Besetzt" : "Offen"}
</Badge>
</div>
</div>
);
})}
</div>
</Card>
);
}
function ManualAssignmentTable({
days,
allChildren,
isDraft,
isPending,
onAssignmentChange,
}: Pick<PlanViewProps, "days" | "allChildren"> & {
isDraft: boolean;
isPending: boolean;
onAssignmentChange: (date: string, newChildId: string) => void;
}) {
return (
<Card className="overflow-hidden p-0">
<div className="grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
<div>Datum</div>
<div>Eingeteilte Familie</div>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{days.map((day) => {
const dateObj = new Date(day.date);
const assignedChild = allChildren.find(
(child) => child.id === day.childId,
);
return (
<div
key={day.date}
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
>
<div className="font-medium text-[var(--ink)]">
{format(dateObj, "EE, dd.MM.", { locale: de })} {format(dateObj, "EE, dd.MM.", { locale: de })}
</div> </div>
<div> <div>
{isDraft ? ( {isDraft ? (
<select <select
className="w-full max-w-sm rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
value={day.childId || "empty"} value={day.childId || "empty"}
disabled={isPending} disabled={isPending}
onChange={(e) => onChange={(event) =>
handleAssignmentChange(day.date, e.target.value) onAssignmentChange(day.date, event.target.value)
} }
> >
<option value="empty" className="text-destructive"> <option value="empty">-- Unbesetzt --</option>
-- Unbesetzt -- {allChildren.map((child) => (
</option> <option key={child.id} value={child.id}>
{allChildren.map((c) => ( {child.parentName} ({child.name})
<option key={c.id} value={c.id}>
{c.parentName} ({c.name})
</option> </option>
))} ))}
</select> </select>
) : assignedChild ? (
<span className="text-sm text-[var(--ink)]">
{assignedChild.parentName}{" "}
<span className="text-[var(--ink-muted)]">
({assignedChild.name})
</span>
</span>
) : ( ) : (
<div> <span className="text-sm font-medium text-[var(--danger)]">
{isUnbesetzt ? (
<span className="text-sm font-medium text-destructive">
Unbesetzt! Unbesetzt!
</span> </span>
) : (
<span className="text-sm">
{
allChildren.find((c) => c.id === day.childId)
?.parentName
}{" "}
<span className="text-muted-foreground">
(
{
allChildren.find((c) => c.id === day.childId)
?.name
}
)
</span>
</span>
)}
</div>
)} )}
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
</div> </Card>
</div>
); );
} }
+167 -279
View File
@@ -1,81 +1,83 @@
import Link from "next/link"; import Link from "next/link";
import type { ComponentType } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle, ClipboardList } from "lucide-react"; import {
import { DutyAssignmentStatus, UserRole, NotdienstPlanStatus } from "@prisma/client"; CalendarCheck2,
CalendarClock,
ChevronRight,
Clock3,
ListTodo,
Users,
type LucideProps,
} from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { getTargetMonthData } from "@/lib/date-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { import { Stat } from "@/components/ui/stat";
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { OnboardingDialog } from "@/components/OnboardingDialog"; import { OnboardingDialog } from "@/components/OnboardingDialog";
import { AlertButton } from "./alert-button"; import { AbsenceCard, UpcomingAbsencesCard } from "./absence-card";
import { AbsenceCard } from "./absence-card";
import { NewsTicker } from "./news-ticker";
export const metadata = { title: "Übersicht · Kita-Planer" }; export const metadata = { title: "Übersicht · Kita-Planer" };
export default async function DashboardPage() { export default async function DashboardPage() {
const session = await requireKitaSession(); const session = await requireKitaSession();
const now = new Date();
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const targetData = getTargetMonthData();
const targetMonthStart = new Date(
Date.UTC(targetData.targetYear, targetData.targetMonth - 1, 1),
);
const targetMonthEnd = new Date(
Date.UTC(targetData.targetYear, targetData.targetMonth, 1),
);
const kita = await prisma.kita.findUniqueOrThrow({ const kita = await prisma.kita.findUniqueOrThrow({
where: { id: session.user.kitaId }, where: { id: session.user.kitaId },
select: { select: {
name: true, name: true,
notdienstModuleEnabled: true, notdienstModuleEnabled: true,
notdienstMinPerChildPerMonth: true,
terminModuleEnabled: true, terminModuleEnabled: true,
adressbuchModuleEnabled: true, adressbuchModuleEnabled: true,
}, },
}); });
const today = new Date();
today.setHours(0, 0, 0, 0);
// Schnelle Statistiken und persönliche Dienste für die Übersicht.
const [ const [
familyCount, familyCount,
childCount, childCount,
upcomingDuties, todayAbsenceCount,
notdienstAvailabilityCount,
onboardingUser, onboardingUser,
absenceChildren, absenceChildren,
upcomingAbsences, upcomingAbsences,
latestAnnouncements,
] = await Promise.all([ ] = await Promise.all([
prisma.family.count({ prisma.family.count({
where: { kitaId: session.user.kitaId }, where: { kitaId: session.user.kitaId },
}), }),
prisma.child.count({ prisma.child.count({
where: { kitaId: session.user.kitaId }, where: { kitaId: session.user.kitaId, active: true },
}), }),
session.user.familyId prisma.absence.count({
? prisma.dutyAssignment.findMany({
where: { where: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
familyId: session.user.familyId, startDate: { lte: today },
status: DutyAssignmentStatus.PLANNED,
endDate: { gte: today }, endDate: { gte: today },
}, },
select: { }),
id: true, prisma.notdienstAvailability.count({
startDate: true, where: {
endDate: true, kitaId: session.user.kitaId,
dutyType: { date: {
select: { gte: targetMonthStart,
name: true, lt: targetMonthEnd,
}, },
}, },
}, }),
orderBy: { startDate: "asc" },
take: 5,
})
: Promise.resolve([]),
prisma.user.findFirstOrThrow({ prisma.user.findFirstOrThrow({
where: { where: {
id: session.user.id, id: session.user.id,
@@ -140,45 +142,25 @@ export default async function DashboardPage() {
take: 6, take: 6,
}) })
: Promise.resolve([]), : Promise.resolve([]),
prisma.announcement.findMany({
where: { kitaId: session.user.kitaId },
select: {
id: true,
title: true,
content: true,
createdAt: true,
author: {
select: {
firstName: true,
lastName: true,
},
},
attachments: {
select: {
id: true,
fileName: true,
fileUrl: true,
fileType: true,
},
},
reads: {
where: { userId: session.user.id },
select: { id: true },
take: 1,
},
},
orderBy: { createdAt: "desc" },
take: 3,
}),
]); ]);
const isAdmin =
session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.SUPERADMIN;
const canReportAbsences = session.user.role !== UserRole.ERZIEHER; const canReportAbsences = session.user.role !== UserRole.ERZIEHER;
const requiredNotdienstDays = Math.max(
childCount * kita.notdienstMinPerChildPerMonth,
1,
);
const openNotdienstDays = Math.max(
requiredNotdienstDays - notdienstAvailabilityCount,
0,
);
const todayPresentCount = Math.max(childCount - todayAbsenceCount, 0);
const firstName =
session.user.name?.split(" ")[0] ??
session.user.email?.split("@")[0] ??
"zusammen";
return ( return (
<div className="px-8 py-8"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<OnboardingDialog <OnboardingDialog
user={{ user={{
phone: onboardingUser.phone, phone: onboardingUser.phone,
@@ -195,42 +177,33 @@ export default async function DashboardPage() {
} }
/> />
<div className="mb-8"> <header className="mb-9 flex items-start justify-between gap-8">
<h1 className="text-2xl font-semibold tracking-tight"> <div>
Willkommen, {session.user.name?.split(" ")[0] ?? "zusammen"}! <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
ÜBERSICHT · {kita.name}
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Willkommen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
zurück
</em>
, {firstName}.
</h1> </h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Hier ist die Übersicht für <strong>{kita.name}</strong>. Heute, {format(now, "EEEE, d. MMMM yyyy", { locale: de })}. Hier
siehst du auf einen Blick, was bei {kita.name} ansteht und kannst
dein Kind schnell für Abwesenheiten melden.
</p> </p>
</div> </div>
<div className="mt-9 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 py-2 text-[13px] font-semibold text-[var(--ink-soft)]">
<NewsTicker <Clock3 className="h-3.5 w-3.5 [stroke-width:1.7]" />
items={latestAnnouncements.map((announcement) => ({ {format(now, "EEE, d. MMM yyyy", { locale: de })}
id: announcement.id,
title: announcement.title,
content: announcement.content,
createdAt: format(announcement.createdAt, "dd.MM.yyyy", {
locale: de,
}),
authorName: `${announcement.author.firstName} ${announcement.author.lastName}`,
isUnread: announcement.reads.length === 0,
attachments: announcement.attachments.map((attachment) => ({
id: attachment.id,
fileName: attachment.fileName,
fileUrl: attachment.fileUrl,
fileType: attachment.fileType,
})),
}))}
/>
{/* Heutiger Notdienst (Nur für Admins/Koordinatoren) */}
{isAdmin && (
<div className="mb-8">
<TodaysNotdienstCard kitaId={session.user.kitaId!} />
</div> </div>
)} </header>
{canReportAbsences && ( <div className="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
<div className="min-w-0">
{canReportAbsences ? (
<AbsenceCard <AbsenceCard
today={format(today, "yyyy-MM-dd")} today={format(today, "yyyy-MM-dd")}
childOptions={absenceChildren.map((child) => ({ childOptions={absenceChildren.map((child) => ({
@@ -246,196 +219,111 @@ export default async function DashboardPage() {
endDate: format(absence.endDate, "yyyy-MM-dd"), endDate: format(absence.endDate, "yyyy-MM-dd"),
}))} }))}
/> />
)} ) : null}
</div>
{upcomingDuties.length > 0 && ( <aside>
<Card className="mb-8 border-emerald-200 bg-emerald-50/50"> <UpcomingAbsencesCard
<CardHeader className="pb-3"> absences={upcomingAbsences.map((absence) => ({
<CardTitle className="flex items-center gap-2 text-lg"> id: absence.id,
<ClipboardList className="h-5 w-5 text-emerald-700" /> childName: `${absence.child.firstName} ${absence.child.lastName}`,
Deine anstehenden Dienste reason: absence.reason,
</CardTitle> note: absence.note,
<CardDescription> startDate: format(absence.startDate, "yyyy-MM-dd"),
Die nächsten Einteilungen für deinen Haushalt. endDate: format(absence.endDate, "yyyy-MM-dd"),
</CardDescription> }))}
</CardHeader> />
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3"> </aside>
{upcomingDuties.map((duty) => ( </div>
<div key={duty.id} className="rounded-md border bg-background p-3">
<p className="font-medium">{duty.dutyType.name}</p> <section className="mt-5 grid grid-cols-4 gap-3.5 max-[1100px]:grid-cols-2 max-[640px]:grid-cols-1">
<p className="mt-1 text-sm text-muted-foreground"> <Stat label="Familien" value={familyCount} hint="registrierte Familien" />
{format(duty.startDate, "dd.MM.", { locale: de })} bis{" "} <Stat label="Kinder" value={childCount} hint="in der Einrichtung" />
{format(duty.endDate, "dd.MM.yyyy", { locale: de })} <Stat
label={`Notdienst ${targetData.monthName}`}
value={notdienstAvailabilityCount}
suffix={`/ ${requiredNotdienstDays}`}
hint={`Tage abgedeckt · ${openNotdienstDays} offen`}
/>
<Stat
label="Heute anwesend"
value={todayPresentCount}
hint="Kinder in der Kita"
/>
</section>
<section className="mt-7">
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Schnellstart
</h2>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Häufige Aktionen für Vorstand und Koordination.
</p> </p>
</div> <div className="mt-4 grid grid-cols-3 gap-3 max-[1100px]:grid-cols-1">
))} <QuickCard
</CardContent> href="/dashboard/families"
</Card> icon={Users}
)} title="Familien verwalten"
sub="Familien anlegen, Kinder zuordnen, Kontakte pflegen."
{/* Statistik-Kacheln */}
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
label="Familien"
value={familyCount}
description="registrierte Familien"
icon={<Users className="h-5 w-5 text-muted-foreground" />}
/> />
<StatCard {kita.terminModuleEnabled ? (
label="Kinder" <QuickCard
value={childCount} href="/dashboard/kalender"
description="in der Einrichtung" icon={CalendarClock}
icon={<ShieldCheck className="h-5 w-5 text-muted-foreground" />} title="Terminkalender"
sub="Schließtage, Elternabende und Feste planen."
/> />
) : null}
{kita.notdienstModuleEnabled ? (
<QuickCard
href="/dashboard/notdienst/plan"
icon={ListTodo}
title="Notdienst-Planung"
sub="Verfügbarkeiten überblicken und Dienste zuweisen."
/>
) : null}
{kita.adressbuchModuleEnabled ? (
<QuickCard
href="/dashboard/adressbuch"
icon={CalendarCheck2}
title="Adressbuch"
sub="Kontakte der Kita-Gemeinschaft schnell finden."
/>
) : null}
</div> </div>
</section>
{/* Schnell-Aktionen für Admins */}
{isAdmin && (
<Card>
<CardHeader>
<CardTitle className="text-base">Schnellstart</CardTitle>
<CardDescription>
Die nächsten Schritte, um deine Kita einzurichten.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3">
<Button asChild size="sm">
<Link href="/dashboard/families">
<Users className="mr-2 h-4 w-4" />
Familien verwalten
</Link>
</Button>
{kita.terminModuleEnabled && (
<Button asChild size="sm" variant="outline">
<Link href="/dashboard/termine">
<CalendarCheck2 className="mr-2 h-4 w-4" />
Termine (bald verfügbar)
</Link>
</Button>
)}
</CardContent>
</Card>
)}
</div> </div>
); );
} }
function StatCard({ function QuickCard({
label, href,
value, icon: Icon,
description, title,
icon, sub,
}: { }: {
label: string; href: string;
value: number; icon: ComponentType<LucideProps>;
description: string; title: string;
icon: React.ReactNode; sub: string;
}) { }) {
return ( return (
<Card> <Link
<CardHeader className="flex flex-row items-center justify-between pb-2"> href={href}
<CardTitle className="text-sm font-medium text-muted-foreground"> className="group flex min-h-[86px] items-center gap-4 rounded-[14px] border border-[var(--hairline)] bg-[var(--surface)] p-4 transition hover:border-[var(--accent-mid)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
{label}
</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{value}</div>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
const today = new Date();
today.setHours(0, 0, 0, 0);
// Wir suchen das Assignment für heute, aber nur wenn der Plan PUBLISHED ist
const assignment = await prisma.notdienstAssignment.findFirst({
where: {
kitaId,
date: today,
plan: { status: NotdienstPlanStatus.PUBLISHED },
},
select: {
id: true,
child: {
select: {
firstName: true,
lastName: true,
family: {
select: {
name: true,
users: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
},
alerts: {
select: {
status: true,
},
},
},
});
if (!assignment) return null;
const parentNames = assignment.child.family.users
.map((parent) => `${parent.firstName} ${parent.lastName}`)
.join(", ");
const existingAlert = assignment.alerts[0]; // Kann PENDING, CONFIRMED oder CANCELLED sein
return (
<Card className="border-primary/20 bg-primary/5">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-primary" />
Heutiger Notdienst
</CardTitle>
<CardDescription>
Laut dem veröffentlichten Plan ist heute folgende Familie eingeteilt:
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium text-base">
{parentNames || assignment.child.family.name}
</p>
<p className="text-sm text-muted-foreground">
Kind: {assignment.child.firstName} {assignment.child.lastName}
</p>
</div>
<div className="flex items-center gap-3">
{existingAlert ? (
<Badge
variant={
existingAlert.status === "CONFIRMED"
? "success"
: existingAlert.status === "PENDING"
? "warning"
: "destructive"
}
className="px-3 py-1 text-sm"
> >
{existingAlert.status === "CONFIRMED" <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
? "Einsatz bestätigt" <Icon className="h-4.5 w-4.5 [stroke-width:1.7]" />
: existingAlert.status === "PENDING"
? "Wartet auf Bestätigung"
: "Abgebrochen"}
</Badge>
) : (
<AlertButton assignmentId={assignment.id} />
)}
</div> </div>
</CardContent> <div className="min-w-0 flex-1">
</Card> <p className="truncate text-sm font-semibold text-[var(--ink)]">
{title}
</p>
<p className="mt-1 line-clamp-2 text-[13px] leading-5 text-[var(--ink-soft)]">
{sub}
</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--ink-faint)] transition group-hover:translate-x-1 group-hover:text-[var(--accent-deep)]" />
</Link>
); );
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useTransition } from "react"; import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Save } from "lucide-react"; import { Loader2, Save } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -19,6 +20,7 @@ type ContactFormProps = {
}; };
export function ContactForm({ contact }: ContactFormProps) { export function ContactForm({ contact }: ContactFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
function handleSubmit(formData: FormData) { function handleSubmit(formData: FormData) {
@@ -36,13 +38,19 @@ export function ContactForm({ contact }: ContactFormProps) {
} }
toast.success("Kontaktdaten gespeichert."); toast.success("Kontaktdaten gespeichert.");
router.refresh();
}); });
} }
return ( return (
<form action={handleSubmit} className="grid gap-4"> <form action={handleSubmit} className="grid gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="phone">Telefonnummer</Label> <Label
htmlFor="phone"
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Telefonnummer
</Label>
<Input <Input
id="phone" id="phone"
name="phone" name="phone"
@@ -53,7 +61,12 @@ export function ContactForm({ contact }: ContactFormProps) {
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="street">Straße und Hausnummer</Label> <Label
htmlFor="street"
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Straße und Hausnummer
</Label>
<Input <Input
id="street" id="street"
name="street" name="street"
@@ -62,9 +75,14 @@ export function ContactForm({ contact }: ContactFormProps) {
/> />
</div> </div>
<div className="grid gap-3 sm:grid-cols-[120px_1fr]"> <div className="grid gap-3 sm:grid-cols-[100px_1fr]">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="postalCode">PLZ</Label> <Label
htmlFor="postalCode"
className="text-xs font-semibold text-[var(--ink-soft)]"
>
PLZ
</Label>
<Input <Input
id="postalCode" id="postalCode"
name="postalCode" name="postalCode"
@@ -73,7 +91,12 @@ export function ContactForm({ contact }: ContactFormProps) {
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="city">Ort</Label> <Label
htmlFor="city"
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Ort
</Label>
<Input <Input
id="city" id="city"
name="city" name="city"
@@ -83,7 +106,7 @@ export function ContactForm({ contact }: ContactFormProps) {
</div> </div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end pt-1">
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{isPending ? ( {isPending ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -23,7 +23,7 @@ export function DeleteAccountDialog() {
const [confirmText, setConfirmText] = useState(""); const [confirmText, setConfirmText] = useState("");
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const isConfirmed = confirmText === "LÖSCHEN"; const isConfirmed = confirmText === "DELETE";
const handleDelete = () => { const handleDelete = () => {
if (!isConfirmed) return; if (!isConfirmed) return;
@@ -44,18 +44,19 @@ export function DeleteAccountDialog() {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="destructive" className="w-full sm:w-auto"> <Button variant="destructive" className="w-full sm:w-auto">
<AlertTriangle className="h-4 w-4 mr-2" /> <AlertTriangle className="h-4 w-4" />
Account & Daten endgültig löschen Account & Daten endgültig löschen
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="border-destructive/30"> <DialogContent className="border-[var(--danger)]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-destructive flex items-center gap-2"> <DialogTitle className="flex items-center gap-2 text-[var(--danger)]">
<AlertTriangle className="h-5 w-5" /> <AlertTriangle className="h-5 w-5" />
Gefahrenzone: Account löschen Gefahrenzone: Account löschen
</DialogTitle> </DialogTitle>
<DialogDescription className="text-base pt-2"> <DialogDescription className="pt-2 text-base">
Diese Aktion kann <strong className="text-foreground">nicht</strong> rückgängig gemacht werden. Diese Aktion kann <strong className="text-[var(--ink)]">nicht</strong>{" "}
rückgängig gemacht werden.
Wenn du das letzte Elternteil im Haushalt bist, wird die gesamte Wenn du das letzte Elternteil im Haushalt bist, wird die gesamte
Familie inklusive Kinder gelöscht. Gibt es ein weiteres Elternteil, Familie inklusive Kinder gelöscht. Gibt es ein weiteres Elternteil,
bleibt der Haushalt für diese Person erhalten und nur dein Account bleibt der Haushalt für diese Person erhalten und nur dein Account
@@ -63,16 +64,17 @@ export function DeleteAccountDialog() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="bg-destructive/10 text-destructive-foreground p-3 text-sm rounded-md my-2 border border-destructive/20"> <div className="my-2 rounded-lg border border-[var(--danger)] bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]">
Bitte tippe das Wort <strong>LÖSCHEN</strong> in das Feld unten ein, um den Vorgang zu bestätigen. Bitte tippe <strong>DELETE</strong> in das Feld unten ein, um den
Vorgang zu bestätigen.
</div> </div>
<div className="grid gap-2 py-4"> <div className="grid gap-2 py-4">
<Input <Input
value={confirmText} value={confirmText}
onChange={(e) => setConfirmText(e.target.value)} onChange={(e) => setConfirmText(e.target.value)}
placeholder="LÖSCHEN" placeholder="DELETE"
className="border-destructive/50 focus-visible:ring-destructive" className="border-[var(--danger)] focus-visible:ring-[var(--focus-ring)]"
/> />
</div> </div>
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { type FormEvent, useState, useTransition } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react"; import { Pencil, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -16,6 +16,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { getChildBirthdateRange } from "@/lib/child-birthdate";
import { createMyChild, deleteMyChild, updateMyChild } from "../actions"; import { createMyChild, deleteMyChild, updateMyChild } from "../actions";
export type MyChild = { export type MyChild = {
@@ -25,6 +26,18 @@ export type MyChild = {
dateOfBirth: string | null; dateOfBirth: string | null;
}; };
type ChildPayload = {
firstName: string;
lastName: string;
dateOfBirth: string;
};
const emptyChildPayload: ChildPayload = {
firstName: "",
lastName: "",
dateOfBirth: "",
};
export function MyChildrenManager({ export function MyChildrenManager({
items, items,
canManage, canManage,
@@ -33,38 +46,40 @@ export function MyChildrenManager({
canManage: boolean; canManage: boolean;
}) { }) {
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createPayload, setCreatePayload] =
useState<ChildPayload>(emptyChildPayload);
const [editingChild, setEditingChild] = useState<MyChild | null>(null); const [editingChild, setEditingChild] = useState<MyChild | null>(null);
const [editPayload, setEditPayload] =
useState<ChildPayload>(emptyChildPayload);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
function payloadFromForm(formData: FormData) { function openEditor(child: MyChild) {
return { setEditingChild(child);
firstName: String(formData.get("firstName") ?? ""), setEditPayload(payloadFromChild(child));
lastName: String(formData.get("lastName") ?? ""),
dateOfBirth: String(formData.get("dateOfBirth") ?? ""),
};
} }
function handleCreate(formData: FormData) { function handleCreate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
startTransition(async () => { startTransition(async () => {
const result = await createMyChild(payloadFromForm(formData)); const result = await createMyChild(createPayload);
if (result.error) { if (result.error) {
toast.error(result.error); toast.error(result.error);
return; return;
} }
toast.success("Kind angelegt."); toast.success("Kind angelegt.");
setCreatePayload(emptyChildPayload);
setCreateOpen(false); setCreateOpen(false);
}); });
} }
function handleUpdate(formData: FormData) { function handleUpdate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!editingChild) return; if (!editingChild) return;
startTransition(async () => { startTransition(async () => {
const result = await updateMyChild( const result = await updateMyChild(editingChild.id, editPayload);
editingChild.id,
payloadFromForm(formData),
);
if (result.error) { if (result.error) {
toast.error(result.error); toast.error(result.error);
return; return;
@@ -95,31 +110,45 @@ export function MyChildrenManager({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Kinder verknüpft.</p> <p className="text-sm italic text-[var(--ink-faint)]">
Keine Kinder verknüpft.
</p>
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{items.map((child) => ( {items.map((child) => (
<div <div
key={child.id} key={child.id}
className="flex items-center justify-between gap-3 rounded-md bg-muted/50 p-2" className="flex items-center justify-between gap-3 rounded-lg bg-[var(--surface-2)] p-[10px_12px]"
> >
<div> <div className="flex min-w-0 items-center gap-3">
<p className="font-medium"> <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
{getInitials(child)}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--ink)]">
{child.firstName} {child.lastName} {child.firstName} {child.lastName}
</p> </p>
{child.dateOfBirth && ( {child.dateOfBirth ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-[var(--ink-soft)]">
Geboren am {new Date(child.dateOfBirth).toLocaleDateString("de-DE")} Geboren am{" "}
{new Date(child.dateOfBirth).toLocaleDateString("de-DE")}
</p>
) : (
<p className="text-xs italic text-[var(--ink-faint)]">
Geburtsdatum fehlt
</p> </p>
)} )}
</div> </div>
</div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setEditingChild(child)} className="h-7 w-7"
onClick={() => openEditor(child)}
disabled={isPending || !canManage} disabled={isPending || !canManage}
aria-label="Kind bearbeiten"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
@@ -127,9 +156,10 @@ export function MyChildrenManager({
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-destructive hover:text-destructive" className="h-7 w-7 text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => handleDelete(child)} onClick={() => handleDelete(child)}
disabled={isPending || !canManage} disabled={isPending || !canManage}
aria-label="Kind löschen"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@@ -140,22 +170,32 @@ export function MyChildrenManager({
)} )}
{canManage ? ( {canManage ? (
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) setCreatePayload(emptyChildPayload);
}}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button type="button" variant="outline" size="sm" className="gap-2"> <Button type="button" variant="pill" size="sm" className="w-full">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Kind hinzufügen Kind hinzufügen
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<form action={handleCreate}> <form onSubmit={handleCreate}>
<DialogHeader> <DialogHeader>
<DialogTitle>Kind hinzufügen</DialogTitle> <DialogTitle>Kind hinzufügen</DialogTitle>
<DialogDescription> <DialogDescription>
Dieses Kind wird mit deinem Haushalt verknüpft. Dieses Kind wird mit deinem Haushalt verknüpft.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ChildFields /> <ChildFields
idPrefix="create-child"
value={createPayload}
onChange={setCreatePayload}
/>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
@@ -173,7 +213,7 @@ export function MyChildrenManager({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) : ( ) : (
<p className="text-xs text-muted-foreground"> <p className="text-xs italic text-[var(--ink-faint)]">
Dein Account ist noch keinem Haushalt zugeordnet. Dein Account ist noch keinem Haushalt zugeordnet.
</p> </p>
)} )}
@@ -183,11 +223,17 @@ export function MyChildrenManager({
onOpenChange={(open) => !open && setEditingChild(null)} onOpenChange={(open) => !open && setEditingChild(null)}
> >
<DialogContent> <DialogContent>
<form action={handleUpdate}> <form onSubmit={handleUpdate}>
<DialogHeader> <DialogHeader>
<DialogTitle>Kind bearbeiten</DialogTitle> <DialogTitle>Kind bearbeiten</DialogTitle>
</DialogHeader> </DialogHeader>
{editingChild && <ChildFields child={editingChild} />} {editingChild && (
<ChildFields
idPrefix="edit-child"
value={editPayload}
onChange={setEditPayload}
/>
)}
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
@@ -208,36 +254,84 @@ export function MyChildrenManager({
); );
} }
function ChildFields({ child }: { child?: MyChild }) { function ChildFields({
idPrefix,
value,
onChange,
}: {
idPrefix: string;
value: ChildPayload;
onChange: (value: ChildPayload) => void;
}) {
const birthdateRange = getChildBirthdateRange();
return ( return (
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="child-firstName">Vorname</Label> <Label
htmlFor={`${idPrefix}-firstName`}
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Vorname
</Label>
<Input <Input
id="child-firstName" id={`${idPrefix}-firstName`}
name="firstName" name="firstName"
defaultValue={child?.firstName ?? ""} value={value.firstName}
onChange={(event) =>
onChange({ ...value, firstName: event.target.value })
}
required required
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="child-lastName">Nachname</Label> <Label
htmlFor={`${idPrefix}-lastName`}
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Nachname
</Label>
<Input <Input
id="child-lastName" id={`${idPrefix}-lastName`}
name="lastName" name="lastName"
defaultValue={child?.lastName ?? ""} value={value.lastName}
onChange={(event) =>
onChange({ ...value, lastName: event.target.value })
}
required required
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="child-dateOfBirth">Geburtsdatum</Label> <Label
htmlFor={`${idPrefix}-dateOfBirth`}
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Geburtsdatum
</Label>
<Input <Input
id="child-dateOfBirth" id={`${idPrefix}-dateOfBirth`}
name="dateOfBirth" name="dateOfBirth"
type="date" type="date"
defaultValue={child?.dateOfBirth ?? ""} min={birthdateRange.min}
max={birthdateRange.max}
value={value.dateOfBirth}
onChange={(event) =>
onChange({ ...value, dateOfBirth: event.target.value })
}
/> />
</div> </div>
</div> </div>
); );
} }
function payloadFromChild(child: MyChild): ChildPayload {
return {
firstName: child.firstName,
lastName: child.lastName,
dateOfBirth: child.dateOfBirth ?? "",
};
}
function getInitials(child: MyChild) {
return `${child.firstName[0] ?? ""}${child.lastName[0] ?? ""}`.toUpperCase();
}
+95 -12
View File
@@ -2,32 +2,94 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { NotdienstPlanStatus } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import {
CHILD_MAX_AGE_YEARS,
getChildBirthdateRange,
parseDateInput,
} from "@/lib/child-birthdate";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
const childSchema = z.object({ // Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
// Klammern oder Schrägstriche. Mindestens 6 Ziffern damit es plausibel ist.
// Schließt damit "abc-123-not-a-phone" aus, lässt aber internationale Formate zu.
const phonePattern = /^[+]?[0-9][\d\s\-()/.]{4,}$/;
// PLZ: Ziffern/Buchstaben/Leerzeichen/Bindestriche, 310 Zeichen.
// Kein striktes DE-Format (5 Ziffern), damit auch AT/CH/UK denkbar sind.
const postalCodePattern = /^[0-9A-Za-z][0-9A-Za-z\s-]{2,9}$/;
const childSchema = z
.object({
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(), firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(), lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
dateOfBirth: z.string().optional(), dateOfBirth: z.string().optional(),
}); })
.superRefine((data, ctx) => {
if (!data.dateOfBirth) return;
const parsed = parseDateInput(data.dateOfBirth);
if (!parsed) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: "Bitte ein gültiges Datum angeben.",
});
return;
}
const { min, max } = getChildBirthdateRange();
if (data.dateOfBirth > max) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: "Geburtsdatum darf nicht in der Zukunft liegen.",
});
}
if (data.dateOfBirth < min) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: `Kinder dürfen maximal ${CHILD_MAX_AGE_YEARS} Jahre alt sein.`,
});
}
});
const familySchema = z.object({ const familySchema = z.object({
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(), familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
}); });
const contactSchema = z.object({ const contactSchema = z.object({
phone: z.string().trim().max(50).optional(), phone: z
.string()
.trim()
.max(50)
.optional()
.refine(
(v) => !v || phonePattern.test(v),
"Bitte eine gültige Telefonnummer angeben.",
),
street: z.string().trim().max(120).optional(), street: z.string().trim().max(120).optional(),
postalCode: z.string().trim().max(20).optional(), postalCode: z
.string()
.trim()
.max(20)
.optional()
.refine(
(v) => !v || postalCodePattern.test(v),
"Bitte eine gültige Postleitzahl angeben.",
),
city: z.string().trim().max(100).optional(), city: z.string().trim().max(100).optional(),
}); });
function parseDateInput(value?: string) { // Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
if (!value) return null; // damit der User nicht nur "Ungültige Eingabedaten" sieht.
function firstZodMessage(
const date = new Date(`${value}T00:00:00`); error: z.ZodError,
return Number.isNaN(date.getTime()) ? null : date; fallback: string,
): string {
const issue = error.issues[0];
return issue?.message || fallback;
} }
async function requireOwnFamilyChild( async function requireOwnFamilyChild(
@@ -51,7 +113,7 @@ export async function createMyChild(rawPayload: unknown) {
} }
if (!parsed.success) { if (!parsed.success) {
return { error: "Ungültige Eingabedaten." }; return { error: firstZodMessage(parsed.error, "Ungültige Eingabedaten.") };
} }
try { try {
@@ -117,7 +179,7 @@ export async function updateMyContact(rawPayload: unknown) {
const parsed = contactSchema.safeParse(rawPayload); const parsed = contactSchema.safeParse(rawPayload);
if (!parsed.success) { if (!parsed.success) {
return { error: "Ungültige Kontaktdaten." }; return { error: firstZodMessage(parsed.error, "Ungültige Kontaktdaten.") };
} }
try { try {
@@ -146,7 +208,7 @@ export async function updateMyChild(childId: string, rawPayload: unknown) {
const parsed = childSchema.safeParse(rawPayload); const parsed = childSchema.safeParse(rawPayload);
if (!parsed.success) { if (!parsed.success) {
return { error: "Ungültige Eingabedaten." }; return { error: firstZodMessage(parsed.error, "Ungültige Eingabedaten.") };
} }
try { try {
@@ -224,6 +286,27 @@ export async function deleteMyAccount() {
return { error: "Account wurde nicht gefunden." }; return { error: "Account wurde nicht gefunden." };
} }
const today = new Date();
today.setHours(0, 0, 0, 0);
const activeNotdienstAssignments = user.familyId
? await prisma.notdienstAssignment.count({
where: {
kitaId: session.user.kitaId,
date: { gte: today },
plan: { status: NotdienstPlanStatus.PUBLISHED },
child: { familyId: user.familyId },
},
})
: 0;
if (activeNotdienstAssignments > 0) {
return {
error:
"Dein Account kann nicht gelöscht werden, solange aktive Notdienst-Verpflichtungen bestehen.",
};
}
if (!user.familyId) { if (!user.familyId) {
await prisma.user.delete({ await prisma.user.delete({
where: { id: user.id }, where: { id: user.id },
+156 -100
View File
@@ -1,19 +1,28 @@
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { import {
AtSign,
Baby, Baby,
CalendarHeart, CalendarHeart,
Home, Home,
Mail, Mail,
Phone, Phone,
Shield, Shield,
Trash2,
UserCircle, UserCircle,
} from "lucide-react"; } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { requireKitaSession } from "@/lib/auth-utils";
import { Badge } from "@/components/ui/badge"; import { prisma } from "@/lib/prisma";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DefItem, DefList, EmptyDefValue } from "@/components/ui/def-list";
import { RoleBadge } from "@/components/ui/role-badge";
import { DeleteAccountDialog } from "./_components/delete-account-dialog"; import { DeleteAccountDialog } from "./_components/delete-account-dialog";
import { MyChildrenManager } from "./_components/my-children-manager"; import { MyChildrenManager } from "./_components/my-children-manager";
import { CreateFamilyDialog } from "./_components/create-family-dialog"; import { CreateFamilyDialog } from "./_components/create-family-dialog";
@@ -24,7 +33,12 @@ export const metadata = { title: "Mein Profil · Kita-Planer" };
export default async function ProfilPage() { export default async function ProfilPage() {
const session = await requireKitaSession(); const session = await requireKitaSession();
const user = await prisma.user.findUniqueOrThrow({ const [kita, user] = await Promise.all([
prisma.kita.findUniqueOrThrow({
where: { id: session.user.kitaId },
select: { name: true },
}),
prisma.user.findUniqueOrThrow({
where: { id: session.user.id }, where: { id: session.user.id },
select: { select: {
id: true, id: true,
@@ -63,94 +77,94 @@ export default async function ProfilPage() {
}, },
}, },
}, },
}); }),
]);
const fullName = `${user.firstName} ${user.lastName}`.trim();
return ( return (
<div className="flex h-full flex-col gap-6 p-6 max-w-4xl mx-auto w-full"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div> <header className="mb-9">
<h1 className="text-2xl font-bold tracking-tight">Mein Profil</h1> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<p className="text-muted-foreground"> PROFIL · {kita.name}
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Mein{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
Profil
</em>
</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen. Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen.
</p> </p>
</div> </header>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid items-start gap-5 lg:grid-cols-2">
{/* Persönliche Daten */} <Card className="self-stretch">
<Card> <CardHeader className="pb-5">
<CardHeader> <div className="flex items-start gap-3">
<CardTitle className="flex items-center gap-2"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<UserCircle className="h-5 w-5" /> <UserCircle className="h-4 w-4 [stroke-width:1.7]" />
Persönliche Daten </div>
</CardTitle> <div>
<CardTitle>Persönliche Daten</CardTitle>
<CardDescription className="mt-2">
Read-only Stammdaten deines Accounts.
</CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent>
<div> <DefList>
<div className="text-sm text-muted-foreground">Name</div> <DefItem icon={UserCircle} label="Name" value={fullName} />
<div className="font-medium text-lg"> <DefItem icon={Mail} label="E-Mail" value={user.email} />
{user.firstName} {user.lastName} <DefItem
</div> icon={Phone}
</div> label="Telefon"
<div> value={user.phone ? user.phone : <EmptyDefValue />}
<div className="text-sm text-muted-foreground flex items-center gap-1"> />
<Mail className="h-3.5 w-3.5" /> E-Mail <DefItem icon={Shield} label="Rolle" value={<RoleBadge role={user.role} />} />
</div> <DefItem
<div className="font-medium">{user.email}</div> icon={Home}
</div> label="Haushalt"
<div> value={
<div className="text-sm text-muted-foreground flex items-center gap-1"> user.family ? (
<Phone className="h-3.5 w-3.5" /> Telefon user.family.name
</div>
<div className="font-medium">
{user.phone ?? "Noch nicht hinterlegt"}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Rolle im Verein</div>
<Badge variant={user.role === "ELTERN" ? "secondary" : "default"} className="mt-1">
{user.role}
</Badge>
</div>
<div>
<div className="text-sm text-muted-foreground flex items-center gap-1">
<Home className="h-3.5 w-3.5" /> Haushalt
</div>
{user.family ? (
<div className="font-medium">{user.family.name}</div>
) : ( ) : (
<div className="mt-2 space-y-3 rounded-md border border-dashed p-3"> <div className="grid gap-3 rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm font-normal leading-5 text-[var(--ink-soft)]">
Dein Account ist noch keinem Haushalt zugeordnet. Lege hier Dein Account ist noch keinem Haushalt zugeordnet.
deinen eigenen Haushalt an, wenn du selbst Elternteil in der
Kita bist.
</p> </p>
<CreateFamilyDialog <CreateFamilyDialog
defaultFamilyName={`Familie ${user.lastName}`} defaultFamilyName={`Familie ${user.lastName}`}
/> />
</div> </div>
)} )
</div> }
<div className="pt-2 border-t mt-2"> />
<div className="text-sm text-muted-foreground flex items-center gap-1"> <DefItem
<CalendarHeart className="h-3.5 w-3.5" /> Mitglied seit icon={CalendarHeart}
</div> label="Mitglied seit"
<div className="text-sm"> value={format(user.createdAt, "PPP", { locale: de })}
{format(user.createdAt, "PPP", { locale: de })} />
</div> </DefList>
</div>
</CardContent> </CardContent>
</Card> </Card>
<div className="flex flex-col gap-6"> <div className="grid gap-4">
<Card> <Card>
<CardHeader> <CardHeader className="pb-5">
<CardTitle className="flex items-center gap-2"> <div className="flex items-start gap-3">
<Phone className="h-5 w-5" /> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
Kontaktdaten <AtSign className="h-4 w-4 [stroke-width:1.7]" />
</CardTitle> </div>
<CardDescription> <div>
Diese Angaben helfen bei Notdienst, Adressbuch und Abstimmung in <CardTitle>Kontaktdaten</CardTitle>
der Kita. <CardDescription className="mt-2">
Für Notdienst, Adressbuch und Abstimmung in der Kita.
</CardDescription> </CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ContactForm <ContactForm
@@ -164,13 +178,19 @@ export default async function ProfilPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Kinder */}
<Card> <Card>
<CardHeader> <CardHeader className="pb-5">
<CardTitle className="flex items-center gap-2"> <div className="flex items-start gap-3">
<Baby className="h-5 w-5" /> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
Meine Kinder <Baby className="h-4 w-4 [stroke-width:1.7]" />
</CardTitle> </div>
<div>
<CardTitle>Meine Kinder</CardTitle>
<CardDescription className="mt-2">
Kinder deines Haushalts verwalten.
</CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<MyChildrenManager <MyChildrenManager
@@ -187,23 +207,53 @@ export default async function ProfilPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Ämter */}
<Card> <Card>
<CardHeader> <CardHeader className="pb-5">
<CardTitle className="flex items-center gap-2"> <div className="flex items-start gap-3">
<Shield className="h-5 w-5" /> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
Meine Ämter & Dienste <Shield className="h-4 w-4 [stroke-width:1.7]" />
</CardTitle> </div>
<div>
<CardTitle>Meine Ämter & Dienste</CardTitle>
<CardDescription className="mt-2">
Feste Aufgaben und laufende Zuständigkeiten.
</CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{user.dutyAssignments.length === 0 ? ( {user.dutyAssignments.length === 0 ? (
<p className="text-sm text-muted-foreground">Du hast aktuell keine festen Ämter übernommen.</p> <div className="flex min-h-32 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Shield className="h-4 w-4 [stroke-width:1.7]" />
</div>
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
Keine festen Ämter übernommen
</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">
Dienste und Rollen erscheinen hier, sobald sie zugewiesen
sind.
</p>
</div>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="grid gap-2">
{user.dutyAssignments.map((assignment) => ( {user.dutyAssignments.map((assignment) => (
<Badge key={assignment.id} variant="default" className="text-sm py-1 px-3"> <div
key={assignment.id}
className="flex items-center justify-between gap-3 rounded-lg bg-[var(--surface-2)] p-[10px_12px]"
>
<div>
<p className="text-sm font-semibold text-[var(--ink)]">
{assignment.duty.name} {assignment.duty.name}
</Badge> </p>
<p className="text-xs text-[var(--ink-soft)]">
laufender Dienst
</p>
</div>
<span className="rounded-full border border-[var(--accent-mid)] bg-[var(--accent-soft)] px-2.5 py-1 text-[10.5px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.1em]">
Aktiv
</span>
</div>
))} ))}
</div> </div>
)} )}
@@ -212,18 +262,24 @@ export default async function ProfilPage() {
</div> </div>
</div> </div>
<Card className="border-destructive/20 mt-8"> <Card className="mt-5 bg-[var(--surface-2)]">
<CardHeader> <CardContent className="flex items-center justify-between gap-6 pt-[22px] max-[760px]:flex-col max-[760px]:items-start">
<CardDescription> <div className="max-w-2xl">
<div className="flex items-center gap-2 text-[var(--danger)]">
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
<h2 className="text-lg font-medium tracking-[-0.02em]">
Danger Zone
</h2>
</div>
<p className="mt-2 text-[13.5px] leading-5 text-[var(--ink-soft)]">
Hier kannst du dein Profil und je nach Haushaltssituation auch die Hier kannst du dein Profil und je nach Haushaltssituation auch die
zugehörigen Familiendaten DSGVO-konform löschen. zugehörigen Familiendaten DSGVO-konform löschen. Aktive
</CardDescription> Notdienst-Verpflichtungen blockieren die Löschung.
</CardHeader> </p>
<CardContent> </div>
<DeleteAccountDialog /> <DeleteAccountDialog />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }
+207
View File
@@ -0,0 +1,207 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { EventParticipationStatus } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { AlertTriangle, Check, X } from "lucide-react";
import { toast } from "sonner";
import { submitEventRsvp } from "@/actions/rsvp";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export type DashboardRsvpEventDto = {
id: string;
title: string;
startDate: string;
rsvpDeadline: string | null;
children: {
id: string;
name: string;
status: EventParticipationStatus | null;
}[];
};
export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] }) {
if (events.length === 0) return null;
return (
<Card className="mb-8 border-[var(--warn)] bg-[var(--warn-soft)]">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-[var(--ink)]">
<AlertTriangle className="h-5 w-5 text-[var(--warn)] [stroke-width:1.7]" />
Aktion erforderlich
</CardTitle>
<CardDescription className="text-[var(--ink-soft)]">
Für diese Termine fehlt noch mindestens eine Rückmeldung deiner
Familie.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
{events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="font-medium text-[var(--ink)]">{event.title}</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Rückmeldung bis{" "}
{format(
new Date(event.rsvpDeadline ?? event.startDate),
"dd.MM.yyyy HH:mm",
{ locale: de },
)}
</p>
</div>
<RsvpDialog event={event} />
</div>
))}
</CardContent>
</Card>
);
}
function RsvpDialog({ event }: { event: DashboardRsvpEventDto }) {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const initialSelection = useMemo(
() =>
Object.fromEntries(
event.children.map((child) => [child.id, child.status ?? ""]),
) as Record<string, EventParticipationStatus | "">,
[event.children],
);
const [selection, setSelection] = useState(initialSelection);
function submit() {
const missingChild = event.children.find((child) => !selection[child.id]);
if (missingChild) {
toast.error(`Bitte Rückmeldung für ${missingChild.name} auswählen.`);
return;
}
startTransition(async () => {
for (const child of event.children) {
const status = selection[child.id];
if (!status) continue;
const result = await submitEventRsvp({
eventId: event.id,
childId: child.id,
status,
});
if (result.error) {
toast.error(result.error);
return;
}
}
toast.success("Rückmeldung gespeichert.");
setOpen(false);
});
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen) setSelection(initialSelection);
}}
>
<DialogTrigger asChild>
<Button size="sm">Jetzt abstimmen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Rückmeldung für {event.title}</DialogTitle>
<DialogDescription>
Wähle pro Kind aus, ob es am Termin teilnimmt.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
{event.children.map((child) => (
<div
key={child.id}
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<p className="font-medium text-[var(--ink)]">{child.name}</p>
{child.status && (
<Badge variant="outline">bereits gemeldet</Badge>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]">
<input
type="radio"
name={`rsvp-${child.id}`}
checked={
selection[child.id] ===
EventParticipationStatus.ATTENDING
}
onChange={() =>
setSelection((current) => ({
...current,
[child.id]: EventParticipationStatus.ATTENDING,
}))
}
/>
<Check className="h-4 w-4 text-[var(--good)] [stroke-width:1.7]" />
Nimmt teil
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]">
<input
type="radio"
name={`rsvp-${child.id}`}
checked={
selection[child.id] ===
EventParticipationStatus.NOT_ATTENDING
}
onChange={() =>
setSelection((current) => ({
...current,
[child.id]: EventParticipationStatus.NOT_ATTENDING,
}))
}
/>
<X className="h-4 w-4 text-[var(--danger)] [stroke-width:1.7]" />
Nimmt nicht teil
</label>
</div>
</div>
))}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Abbrechen
</Button>
<Button type="button" disabled={isPending} onClick={submit}>
{isPending ? "Speichern..." : "Rückmeldung speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+327
View File
@@ -0,0 +1,327 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
ArrowLeft,
ArrowRight,
CheckCircle2,
HelpCircle,
ShieldCheck,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
getMarketingFeature,
marketingFeatures,
} from "@/lib/marketing/features";
type FeaturePageProps = {
params: Promise<{
slug: string;
}>;
};
export const dynamicParams = false;
export function generateStaticParams() {
return marketingFeatures.map((feature) => ({
slug: feature.slug,
}));
}
export async function generateMetadata({
params,
}: FeaturePageProps): Promise<Metadata> {
const { slug } = await params;
const feature = getMarketingFeature(slug);
if (!feature) {
return {
title: "Feature nicht gefunden",
};
}
return {
title: feature.seoTitle,
description: feature.seoDescription,
openGraph: {
title: feature.seoTitle,
description: feature.seoDescription,
type: "website",
locale: "de_DE",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Ein Kind spielt mit bunten Holzklötzen.",
},
],
},
twitter: {
card: "summary_large_image",
title: feature.seoTitle,
description: feature.seoDescription,
images: ["/og-image.png"],
},
};
}
export default async function FeaturePage({ params }: FeaturePageProps) {
const { slug } = await params;
const feature = getMarketingFeature(slug);
if (!feature) {
notFound();
}
const Icon = feature.icon;
return (
<div className="min-h-screen bg-[#f8faf8] text-slate-950">
<header className="border-b border-slate-200 bg-white/95">
<div className="mx-auto flex h-20 w-full max-w-7xl items-center justify-between px-5 sm:px-6">
<Link href="/" className="flex items-center gap-3 text-slate-950">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-950 text-white">
<ShieldCheck className="h-5 w-5" />
</span>
<span className="text-base font-semibold tracking-tight">
Kita-Planer
</span>
</Link>
<nav className="flex items-center gap-2">
<Button asChild variant="ghost">
<Link href="/login">Login</Link>
</Button>
<Button asChild>
<Link href="/#kontakt">Demo anfragen</Link>
</Button>
</nav>
</div>
</header>
<main>
<section className="bg-slate-950 px-5 py-20 text-white sm:px-6 lg:py-24">
<div className="mx-auto grid w-full max-w-7xl gap-12 lg:grid-cols-[0.95fr_1.05fr] lg:items-center">
<div>
<Button
asChild
variant="ghost"
className="-ml-3 mb-8 text-slate-300 hover:bg-white/10 hover:text-white"
>
<Link href="/">
<ArrowLeft className="h-4 w-4" />
Zur Startseite
</Link>
</Button>
<Badge className="mb-6 border-white/15 bg-white/10 text-white hover:bg-white/10">
{feature.eyebrow}
</Badge>
<h1 className="max-w-4xl text-balance text-4xl font-semibold leading-tight sm:text-5xl lg:text-6xl">
{feature.heroTitle}
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-300">
{feature.heroDescription}
</p>
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
<Button
asChild
size="lg"
className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90"
>
<Link href="/#kontakt">
Demo anfragen
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<Button
asChild
size="lg"
variant="outline"
className="h-12 border-white/35 bg-white/10 px-6 text-white hover:bg-white/20 hover:text-white"
>
<Link href="/register">Kita registrieren</Link>
</Button>
</div>
</div>
<div className="rounded-lg border border-white/10 bg-white/[0.06] p-6 shadow-2xl shadow-slate-950/30">
<div
className={`mb-6 flex h-14 w-14 items-center justify-center rounded-lg ring-1 ${feature.accent}`}
>
<Icon className="h-7 w-7" />
</div>
<p className="text-sm font-semibold uppercase tracking-wide text-emerald-200">
In einem Satz
</p>
<p className="mt-4 text-2xl font-semibold leading-snug">
{feature.description}
</p>
<div className="mt-8 grid gap-3 text-sm text-slate-300">
{feature.benefits.slice(0, 3).map((benefit) => (
<div key={benefit.title} className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-300" />
<span>{benefit.title}</span>
</div>
))}
</div>
</div>
</div>
</section>
<section className="mx-auto grid w-full max-w-7xl gap-6 px-5 py-20 sm:px-6 lg:grid-cols-2">
<Card className="border-slate-200 bg-white">
<CardHeader>
<CardTitle className="text-2xl tracking-tight">
{feature.problemTitle}
</CardTitle>
<CardDescription className="text-base leading-7 text-slate-600">
{feature.problemText}
</CardDescription>
</CardHeader>
</Card>
<Card className="border-slate-200 bg-white">
<CardHeader>
<CardTitle className="text-2xl tracking-tight">
{feature.solutionTitle}
</CardTitle>
<CardDescription className="text-base leading-7 text-slate-600">
{feature.solutionText}
</CardDescription>
</CardHeader>
</Card>
</section>
<section className="px-5 pb-20 sm:px-6">
<div className="mx-auto w-full max-w-7xl">
<div className="mb-10 max-w-3xl">
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
Was das konkret bringt
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Weniger Abstimmungslast, mehr Klarheit im Kita-Alltag.
</h2>
</div>
<div className="grid gap-5 md:grid-cols-2">
{feature.benefits.map((benefit) => (
<Card key={benefit.title} className="border-slate-200 bg-white">
<CardHeader>
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<CheckCircle2 className="h-5 w-5" />
</div>
<CardTitle className="text-xl tracking-tight">
{benefit.title}
</CardTitle>
<CardDescription className="text-sm leading-6 text-slate-600">
{benefit.text}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
</section>
<section className="bg-slate-950 px-5 py-18 text-white sm:px-6">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<div className="max-w-3xl">
<Badge className="mb-5 border-emerald-300/20 bg-emerald-300/10 text-emerald-100 hover:bg-emerald-300/10">
Vertrauen & Datenschutz
</Badge>
<h2 className="text-3xl font-semibold tracking-tight">
{feature.trustTitle}
</h2>
<p className="mt-4 text-base leading-7 text-slate-300">
{feature.trustText}
</p>
</div>
<Button
asChild
size="lg"
className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90 lg:shrink-0"
>
<Link href="/datenschutz">Datenschutz ansehen</Link>
</Button>
</div>
</section>
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6">
<div className="mb-10 max-w-3xl">
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
Fragen aus dem Vorstand
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Kurz beantwortet.
</h2>
</div>
<div className="grid gap-4 lg:grid-cols-3">
{feature.faqs.map((faq) => (
<Card key={faq.question} className="border-slate-200 bg-white">
<CardHeader>
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-slate-100 text-slate-700">
<HelpCircle className="h-5 w-5" />
</div>
<CardTitle className="text-lg leading-snug">
{faq.question}
</CardTitle>
<CardDescription className="text-sm leading-6 text-slate-600">
{faq.answer}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</section>
<section className="px-5 pb-20 sm:px-6">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 rounded-lg border border-slate-200 bg-white p-8 shadow-sm sm:p-10 md:flex-row md:items-center md:justify-between">
<div className="max-w-2xl">
<h2 className="text-2xl font-semibold tracking-tight">
Wollt ihr {feature.shortTitle} im echten Kita-Alltag sehen?
</h2>
<p className="mt-3 text-sm leading-6 text-slate-600">
Schreibt uns kurz, wie ihr heute organisiert seid. Wir zeigen
euch, wie Kita-Planer zu eurem Elternverein passen kann.
</p>
</div>
<Button asChild size="lg">
<Link href="/#kontakt">
Demo anfragen
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div>
</section>
</main>
<footer className="border-t border-slate-200 bg-white">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-5 py-8 text-sm text-slate-500 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<span>© {new Date().getFullYear()} Kita-Planer</span>
<nav className="flex gap-5">
<Link href="/impressum" className="hover:text-slate-950">
Impressum
</Link>
<Link href="/datenschutz" className="hover:text-slate-950">
Datenschutz
</Link>
<Link href="/#kontakt" className="hover:text-slate-950">
Kontakt
</Link>
</nav>
</div>
</footer>
</div>
);
}
+49 -66
View File
@@ -1,86 +1,69 @@
@import "tailwindcss"; @import "tailwindcss";
@import "./styles/tokens.css";
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--bg);
--color-foreground: var(--foreground); --color-foreground: var(--ink);
--color-card: var(--card); --color-card: var(--surface);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--ink);
--color-popover: var(--popover); --color-popover: var(--surface);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--ink);
--color-primary: var(--primary); --color-primary: var(--accent-deep);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: oklch(0.98 0.005 45);
--color-secondary: var(--secondary); --color-secondary: var(--surface-2);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--ink);
--color-muted: var(--muted); --color-muted: var(--surface-week);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--ink-muted);
--color-accent: var(--accent); --color-accent: var(--accent-soft);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-deep);
--color-destructive: var(--destructive); --color-destructive: var(--danger);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: oklch(0.98 0.005 45);
--color-border: var(--border); --color-border: var(--hairline);
--color-input: var(--input); --color-input: var(--hairline);
--color-ring: var(--ring); --color-ring: var(--accent);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-manrope);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-manrope);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
} }
:root { :root {
--background: oklch(1 0 0); --background: var(--bg);
--foreground: oklch(0.145 0 0); --foreground: var(--ink);
--card: oklch(1 0 0); --card: var(--surface);
--card-foreground: oklch(0.145 0 0); --card-foreground: var(--ink);
--popover: oklch(1 0 0); --popover: var(--surface);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: var(--ink);
--primary: oklch(0.205 0 0); --primary: var(--accent-deep);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.98 0.005 45);
--secondary: oklch(0.97 0 0); --secondary: var(--surface-2);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: var(--ink);
--muted: oklch(0.97 0 0); --muted: var(--surface-week);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: var(--ink-muted);
--accent: oklch(0.97 0 0); --accent-foreground: var(--accent-deep);
--accent-foreground: oklch(0.205 0 0); --destructive: var(--danger);
--destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.98 0.005 45);
--destructive-foreground: oklch(0.985 0 0); --border: var(--hairline);
--border: oklch(0.922 0 0); --input: var(--hairline);
--input: oklch(0.922 0 0); --ring: var(--accent);
--ring: oklch(0.708 0 0);
--radius: 0.625rem; --radius: 0.625rem;
} }
@media (prefers-color-scheme: dark) {
:root {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 0.1);
--input: oklch(1 0 0 / 0.15);
--ring: oklch(0.556 0 0);
}
}
@layer base { @layer base {
* { * {
border-color: var(--border); border-color: var(--border);
} }
body { body {
background-color: var(--background); background-color: var(--bg);
color: var(--foreground); background-image: var(--paper-pattern);
background-size: 16px 16px;
color: var(--ink);
font-family: var(--font-sans), system-ui, sans-serif; font-family: var(--font-sans), system-ui, sans-serif;
} }
::selection {
background: var(--accent-soft);
color: var(--accent-deep);
}
} }
+37 -5
View File
@@ -34,6 +34,16 @@ const inviteSchema = z
const PRIVACY_POLICY_VERSION = "2026-05-01"; const PRIVACY_POLICY_VERSION = "2026-05-01";
export type InviteState = { export type InviteState = {
// Bumped per call so the client can key uncontrolled inputs and force a
// remount after a failed submit (React's <form action> wipes them
// otherwise).
attempt: number;
// Echoed checkbox states so failed submits don't lose the user's consent
// ticks. Passwords are NEVER echoed back from the server.
values?: {
acceptPrivacyPolicy?: boolean;
directoryOptIn?: boolean;
};
errors?: { errors?: {
password?: string[]; password?: string[];
confirmPassword?: string[]; confirmPassword?: string[];
@@ -43,10 +53,20 @@ export type InviteState = {
}; };
}; };
function echoCheckboxValues(formData: FormData): InviteState["values"] {
return {
acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy") === "on",
directoryOptIn: formData.get("directoryOptIn") === "on",
};
}
export async function acceptInviteAction( export async function acceptInviteAction(
_prev: InviteState, prev: InviteState,
formData: FormData, formData: FormData,
): Promise<InviteState> { ): Promise<InviteState> {
const attempt = prev.attempt + 1;
const values = echoCheckboxValues(formData);
const parsed = inviteSchema.safeParse({ const parsed = inviteSchema.safeParse({
token: formData.get("token"), token: formData.get("token"),
password: formData.get("password"), password: formData.get("password"),
@@ -56,7 +76,7 @@ export async function acceptInviteAction(
}); });
if (!parsed.success) { if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }; return { attempt, values, errors: parsed.error.flatten().fieldErrors };
} }
const { token, password, directoryOptIn } = parsed.data; const { token, password, directoryOptIn } = parsed.data;
@@ -68,10 +88,16 @@ export async function acceptInviteAction(
}); });
if (!verificationToken) { if (!verificationToken) {
return { errors: { _form: ["Einladungslink ist ungültig."] } }; return {
attempt,
values,
errors: { _form: ["Einladungslink ist ungültig."] },
};
} }
if (verificationToken.expires < now) { if (verificationToken.expires < now) {
return { return {
attempt,
values,
errors: { errors: {
_form: [ _form: [
"Dieser Einladungslink ist abgelaufen. Bitte wende dich an deinen Administrator.", "Dieser Einladungslink ist abgelaufen. Bitte wende dich an deinen Administrator.",
@@ -89,11 +115,17 @@ export async function acceptInviteAction(
}); });
if (!user) { if (!user) {
return { errors: { _form: ["Benutzer nicht gefunden."] } }; return {
attempt,
values,
errors: { _form: ["Benutzer nicht gefunden."] },
};
} }
if (user.passwordHash !== "") { if (user.passwordHash !== "") {
// Invite wurde bereits eingelöst // Invite wurde bereits eingelöst
return { return {
attempt,
values,
errors: { errors: {
_form: [ _form: [
"Dieser Einladungslink wurde bereits verwendet. Bitte melde dich an.", "Dieser Einladungslink wurde bereits verwendet. Bitte melde dich an.",
@@ -131,5 +163,5 @@ export async function acceptInviteAction(
}); });
// Unreachable signIn redirected. // Unreachable signIn redirected.
return {}; return { attempt };
} }
+54 -7
View File
@@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { acceptInviteAction, type InviteState } from "./actions"; import { acceptInviteAction, type InviteState } from "./actions";
const initialState: InviteState = {}; const initialInviteState: InviteState = { attempt: 0 };
// ===================================================================== // =====================================================================
// InviteForm · Client Component // InviteForm · Client Component
@@ -27,9 +27,14 @@ export function InviteForm({
}) { }) {
const [state, formAction, pending] = useActionState( const [state, formAction, pending] = useActionState(
acceptInviteAction, acceptInviteAction,
initialState, initialInviteState,
); );
// Bumping the key on each attempt forces uncontrolled inputs (including
// checkboxes) to remount with the echoed defaultChecked values after a
// failed submit. Passwords stay empty for security.
const attemptKey = state.attempt;
return ( return (
<form action={formAction} className="space-y-5"> <form action={formAction} className="space-y-5">
{/* Token als verstecktes Feld */} {/* Token als verstecktes Feld */}
@@ -45,17 +50,30 @@ export function InviteForm({
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="password">Passwort wählen</Label> <Label htmlFor="password">Passwort wählen</Label>
<Input <Input
key={`password-${attemptKey}`}
id="password" id="password"
name="password" name="password"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
required required
aria-invalid={!!state.errors?.password} aria-invalid={!!state.errors?.password}
aria-describedby={
state.errors?.password ? "password-error" : "password-helper"
}
/> />
{state.errors?.password?.[0] ? ( {state.errors?.password?.[0] ? (
<p className="text-xs text-destructive">{state.errors.password[0]}</p> <p
id="password-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{state.errors.password[0]}
</p>
) : ( ) : (
<p className="text-xs text-muted-foreground">Mindestens 8 Zeichen.</p> <p id="password-helper" className="text-xs text-muted-foreground">
Mindestens 8 Zeichen.
</p>
)} )}
</div> </div>
@@ -63,15 +81,26 @@ export function InviteForm({
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="confirmPassword">Passwort bestätigen</Label> <Label htmlFor="confirmPassword">Passwort bestätigen</Label>
<Input <Input
key={`confirmPassword-${attemptKey}`}
id="confirmPassword" id="confirmPassword"
name="confirmPassword" name="confirmPassword"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
required required
aria-invalid={!!state.errors?.confirmPassword} aria-invalid={!!state.errors?.confirmPassword}
aria-describedby={
state.errors?.confirmPassword
? "confirmPassword-error"
: undefined
}
/> />
{state.errors?.confirmPassword?.[0] && ( {state.errors?.confirmPassword?.[0] && (
<p className="text-xs text-destructive"> <p
id="confirmPassword-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{state.errors.confirmPassword[0]} {state.errors.confirmPassword[0]}
</p> </p>
)} )}
@@ -85,10 +114,17 @@ export function InviteForm({
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Checkbox <Checkbox
key={`acceptPrivacyPolicy-${attemptKey}`}
id="acceptPrivacyPolicy" id="acceptPrivacyPolicy"
name="acceptPrivacyPolicy" name="acceptPrivacyPolicy"
required required
defaultChecked={state.values?.acceptPrivacyPolicy === true}
aria-invalid={!!state.errors?.acceptPrivacyPolicy} aria-invalid={!!state.errors?.acceptPrivacyPolicy}
aria-describedby={
state.errors?.acceptPrivacyPolicy
? "acceptPrivacyPolicy-error"
: undefined
}
/> />
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal"> <Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal">
@@ -104,7 +140,12 @@ export function InviteForm({
gelesen und akzeptiere sie. <span className="text-destructive">*</span> gelesen und akzeptiere sie. <span className="text-destructive">*</span>
</Label> </Label>
{state.errors?.acceptPrivacyPolicy?.[0] && ( {state.errors?.acceptPrivacyPolicy?.[0] && (
<p className="text-xs text-destructive"> <p
id="acceptPrivacyPolicy-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{state.errors.acceptPrivacyPolicy[0]} {state.errors.acceptPrivacyPolicy[0]}
</p> </p>
)} )}
@@ -113,8 +154,10 @@ export function InviteForm({
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Checkbox <Checkbox
key={`directoryOptIn-${attemptKey}`}
id="directoryOptIn" id="directoryOptIn"
name="directoryOptIn" name="directoryOptIn"
defaultChecked={state.values?.directoryOptIn === true}
aria-invalid={!!state.errors?.directoryOptIn} aria-invalid={!!state.errors?.directoryOptIn}
/> />
<div className="space-y-0.5"> <div className="space-y-0.5">
@@ -128,7 +171,11 @@ export function InviteForm({
{/* Globaler Fehler */} {/* Globaler Fehler */}
{state.errors?._form?.[0] && ( {state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <p
role="alert"
aria-live="polite"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{state.errors._form[0]} {state.errors._form[0]}
</p> </p>
)} )}
+6 -10
View File
@@ -1,22 +1,18 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Manrope } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { UmamiAnalytics } from "@/components/analytics/umami-analytics"; import { UmamiAnalytics } from "@/components/analytics/umami-analytics";
const geistSans = Geist({ const manrope = Manrope({
variable: "--font-geist-sans", variable: "--font-manrope",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500", "600", "700"],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL( metadataBase: new URL(
process.env.NEXT_PUBLIC_SITE_URL ?? "https://kita-planer.example.com", process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de",
), ),
title: { title: {
default: "Der digitale Kita-Planer für Elternvereine", default: "Der digitale Kita-Planer für Elternvereine",
@@ -56,7 +52,7 @@ export default function RootLayout({
return ( return (
<html <html
lang="de" lang="de"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${manrope.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
{children} {children}
+9 -5
View File
@@ -18,6 +18,7 @@ import { signIn } from "@/auth";
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(), email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(),
password: z.string().min(1, "Passwort fehlt."), password: z.string().min(1, "Passwort fehlt."),
callbackUrl: z.string().optional(),
}); });
export type LoginState = { export type LoginState = {
@@ -28,6 +29,12 @@ export type LoginState = {
}; };
}; };
function safeCallbackUrl(value: string | undefined) {
if (!value) return "/dashboard";
if (!value.startsWith("/") || value.startsWith("//")) return "/dashboard";
return value;
}
export async function loginAction( export async function loginAction(
_prev: LoginState, _prev: LoginState,
formData: FormData, formData: FormData,
@@ -37,16 +44,13 @@ export async function loginAction(
return { errors: parsed.error.flatten().fieldErrors }; return { errors: parsed.error.flatten().fieldErrors };
} }
const { email, password } = parsed.data; const { email, password } = parsed.data;
const redirectTo = safeCallbackUrl(parsed.data.callbackUrl);
try { try {
await signIn("credentials", { await signIn("credentials", {
email, email,
password, password,
// Auth-Utils entscheiden im Folge-Render, ob /onboarding oder /dashboard. redirectTo,
// Wir landen aus Sicherheitsgründen erstmal generisch auf der Wurzel.
// Der Proxy (proxy.ts) übernimmt das Routing:
// → /onboarding wenn kein kitaId, → /dashboard wenn vorhanden.
redirectTo: "/",
}); });
} catch (err) { } catch (err) {
// signIn wirft NEXT_REDIRECT bei Erfolg → das müssen wir bewusst // signIn wirft NEXT_REDIRECT bei Erfolg → das müssen wir bewusst
+3 -1
View File
@@ -10,11 +10,13 @@ import { loginAction, type LoginState } from "./actions";
const initialState: LoginState = {}; const initialState: LoginState = {};
export function LoginForm() { export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
const [state, formAction, pending] = useActionState(loginAction, initialState); const [state, formAction, pending] = useActionState(loginAction, initialState);
return ( return (
<form action={formAction} className="space-y-4"> <form action={formAction} className="space-y-4">
<input type="hidden" name="callbackUrl" value={callbackUrl} />
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="email">E-Mail</Label> <Label htmlFor="email">E-Mail</Label>
<Input <Input
+19 -3
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getPostLoginRedirect } from "@/lib/post-login-redirect";
import { import {
Card, Card,
CardContent, CardContent,
@@ -14,11 +15,26 @@ import { LoginForm } from "./login-form";
export const metadata = { title: "Anmelden · Kita-Planer" }; export const metadata = { title: "Anmelden · Kita-Planer" };
export default async function LoginPage() { type LoginPageProps = {
searchParams?: Promise<{ callbackUrl?: string | string[] }>;
};
function getSafeCallbackUrl(value: string | string[] | undefined) {
const callbackUrl = Array.isArray(value) ? value[0] : value;
if (!callbackUrl) return "/dashboard";
if (!callbackUrl.startsWith("/") || callbackUrl.startsWith("//")) {
return "/dashboard";
}
return callbackUrl;
}
export default async function LoginPage({ searchParams }: LoginPageProps) {
const session = await auth(); const session = await auth();
if (session?.user?.id) { if (session?.user?.id) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding"); redirect(getPostLoginRedirect(session.user));
} }
const resolvedSearchParams = await searchParams;
const callbackUrl = getSafeCallbackUrl(resolvedSearchParams?.callbackUrl);
return ( return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12"> <div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
@@ -28,7 +44,7 @@ export default async function LoginPage() {
<CardDescription>Willkommen zurück.</CardDescription> <CardDescription>Willkommen zurück.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<LoginForm /> <LoginForm callbackUrl={callbackUrl} />
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
Noch keinen Account?{" "} Noch keinen Account?{" "}
<Link href="/register" className="font-medium text-foreground underline"> <Link href="/register" className="font-medium text-foreground underline">
+53 -9
View File
@@ -16,21 +16,35 @@ import { prisma } from "@/lib/prisma";
// — kein "halb-eingerichteter" Zwischenzustand. // — kein "halb-eingerichteter" Zwischenzustand.
// ===================================================================== // =====================================================================
const onboardingSchema = z.object({ const onboardingSchema = z
.object({
kitaName: z kitaName: z
.string() .string()
.min(2, "Mindestens 2 Zeichen.") .trim()
.max(120, "Maximal 120 Zeichen.") .min(2, "Bitte mindestens 2 Zeichen angeben.")
.trim(), .max(120, "Maximal 120 Zeichen."),
notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
notdienstMinPerChildPerMonth: z.coerce notdienstMinPerChildPerMonth: z.coerce
.number() .number()
.int("Bitte ganze Zahl.") .int("Bitte eine ganze Zahl angeben.")
.min(0, "Nicht negativ.") .min(0, "Bitte einen Wert von 0 oder mehr angeben.")
.max(31, "Maximal 31 Tage pro Monat."), .max(31, "Maximal 31 Tage pro Monat."),
}); })
.superRefine((data, ctx) => {
// Mindest-Verfügbarkeit nur relevant, wenn das Notdienst-Modul aktiv ist.
// Bei aktivem Modul muss mindestens 1 Tag eingetragen sein — 0 wäre
// fachlich sinnlos und würde den Planer leerlaufen lassen.
if (data.notdienstModuleEnabled && data.notdienstMinPerChildPerMonth < 1) {
ctx.addIssue({
path: ["notdienstMinPerChildPerMonth"],
code: "custom",
message:
"Bei aktivem Notdienst-Modul: mindestens 1 Tag pro Monat angeben.",
});
}
});
function checkboxToBool(value: unknown): boolean { function checkboxToBool(value: unknown): boolean {
// HTML-Checkbox: "on" wenn aktiviert, sonst nicht im FormData. // HTML-Checkbox: "on" wenn aktiviert, sonst nicht im FormData.
@@ -38,6 +52,17 @@ function checkboxToBool(value: unknown): boolean {
} }
export type OnboardingState = { export type OnboardingState = {
// Bumped after every action call so the client can key uncontrolled inputs
// and force a remount with the echoed values from a failed submit.
attempt: number;
// Echoed inputs so a failed submit doesn't wipe the form.
values?: {
kitaName?: string;
notdienstModuleEnabled?: boolean;
terminModuleEnabled?: boolean;
adressbuchModuleEnabled?: boolean;
notdienstMinPerChildPerMonth?: number;
};
errors?: { errors?: {
kitaName?: string[]; kitaName?: string[];
notdienstMinPerChildPerMonth?: string[]; notdienstMinPerChildPerMonth?: string[];
@@ -45,10 +70,23 @@ export type OnboardingState = {
}; };
}; };
function echoValues(formData: FormData): OnboardingState["values"] {
const min = formData.get("notdienstMinPerChildPerMonth");
return {
kitaName: String(formData.get("kitaName") ?? ""),
notdienstModuleEnabled: formData.get("notdienstModuleEnabled") === "on",
terminModuleEnabled: formData.get("terminModuleEnabled") === "on",
adressbuchModuleEnabled: formData.get("adressbuchModuleEnabled") === "on",
notdienstMinPerChildPerMonth:
min === null || min === "" ? undefined : Number(min),
};
}
export async function completeOnboardingAction( export async function completeOnboardingAction(
_prev: OnboardingState, prev: OnboardingState,
formData: FormData, formData: FormData,
): Promise<OnboardingState> { ): Promise<OnboardingState> {
const attempt = prev.attempt + 1;
const session = await auth(); const session = await auth();
if (!session?.user) { if (!session?.user) {
redirect("/login"); redirect("/login");
@@ -61,7 +99,11 @@ export async function completeOnboardingAction(
const parsed = onboardingSchema.safeParse(Object.fromEntries(formData)); const parsed = onboardingSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) { if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }; return {
attempt,
values: echoValues(formData),
errors: parsed.error.flatten().fieldErrors,
};
} }
const data = parsed.data; const data = parsed.data;
@@ -99,6 +141,8 @@ export async function completeOnboardingAction(
} }
if (err instanceof Prisma.PrismaClientKnownRequestError) { if (err instanceof Prisma.PrismaClientKnownRequestError) {
return { return {
attempt,
values: echoValues(formData),
errors: { _form: ["Datenbankfehler — bitte erneut versuchen."] }, errors: { _form: ["Datenbankfehler — bitte erneut versuchen."] },
}; };
} }
+83 -20
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useActionState } from "react"; import { useActionState, useId } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
@@ -9,14 +9,22 @@ import { Label } from "@/components/ui/label";
import { completeOnboardingAction, type OnboardingState } from "./actions"; import { completeOnboardingAction, type OnboardingState } from "./actions";
const initialState: OnboardingState = {}; const initialOnboardingState: OnboardingState = { attempt: 0 };
export function OnboardingForm() { export function OnboardingForm() {
const [state, formAction, pending] = useActionState( const [state, formAction, pending] = useActionState(
completeOnboardingAction, completeOnboardingAction,
initialState, initialOnboardingState,
); );
// Bumping the key on each attempt forces uncontrolled inputs to remount with
// the echoed defaultValue from the server — otherwise React's <form action>
// semantics would wipe them after a failed submit.
const attemptKey = state.attempt;
const kitaError = state.errors?.kitaName?.[0];
const minError = state.errors?.notdienstMinPerChildPerMonth?.[0];
return ( return (
<form action={formAction} className="space-y-8"> <form action={formAction} className="space-y-8">
{/* ----- Schritt 1: Name ----- */} {/* ----- Schritt 1: Name ----- */}
@@ -27,14 +35,24 @@ export function OnboardingForm() {
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="kitaName">Name des Elternvereins / der Kita</Label> <Label htmlFor="kitaName">Name des Elternvereins / der Kita</Label>
<Input <Input
key={`kitaName-${attemptKey}`}
id="kitaName" id="kitaName"
name="kitaName" name="kitaName"
required required
placeholder="z.B. Waldameisen e.V." placeholder="z.B. Waldameisen e.V."
aria-invalid={!!state.errors?.kitaName} defaultValue={state.values?.kitaName ?? ""}
aria-invalid={!!kitaError}
aria-describedby={kitaError ? "kitaName-error" : undefined}
/> />
{state.errors?.kitaName?.[0] && ( {kitaError && (
<p className="text-xs text-destructive">{state.errors.kitaName[0]}</p> <p
id="kitaName-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{kitaError}
</p>
)} )}
</div> </div>
</fieldset> </fieldset>
@@ -45,22 +63,25 @@ export function OnboardingForm() {
Schritt 2 · Module aktivieren Schritt 2 · Module aktivieren
</legend> </legend>
<ModuleCheckbox <ModuleCheckbox
key={`notdienstModuleEnabled-${attemptKey}`}
name="notdienstModuleEnabled" name="notdienstModuleEnabled"
label="Notdienst-Planung" label="Notdienst-Planung"
description="Verfügbarkeiten erfassen, Plan generieren, bei Krankheitsausfall alarmieren." description="Verfügbarkeiten erfassen, Plan generieren, bei Krankheitsausfall alarmieren."
defaultChecked defaultChecked={state.values?.notdienstModuleEnabled ?? true}
/> />
<ModuleCheckbox <ModuleCheckbox
key={`terminModuleEnabled-${attemptKey}`}
name="terminModuleEnabled" name="terminModuleEnabled"
label="Terminkalender" label="Terminkalender"
description="Kita-Feste, Schließtage und private Anfragen koordinieren." description="Kita-Feste, Schließtage und private Anfragen koordinieren."
defaultChecked defaultChecked={state.values?.terminModuleEnabled ?? true}
/> />
<ModuleCheckbox <ModuleCheckbox
key={`adressbuchModuleEnabled-${attemptKey}`}
name="adressbuchModuleEnabled" name="adressbuchModuleEnabled"
label="Eltern-Adressbuch" label="Eltern-Adressbuch"
description="Eltern können sich auf Opt-In-Basis untereinander finden." description="Eltern können sich auf Opt-In-Basis untereinander finden."
defaultChecked defaultChecked={state.values?.adressbuchModuleEnabled ?? true}
/> />
</fieldset> </fieldset>
@@ -74,29 +95,49 @@ export function OnboardingForm() {
Mindest-Verfügbarkeiten pro Kind und Monat Mindest-Verfügbarkeiten pro Kind und Monat
</Label> </Label>
<Input <Input
key={`notdienstMinPerChildPerMonth-${attemptKey}`}
id="notdienstMinPerChildPerMonth" id="notdienstMinPerChildPerMonth"
name="notdienstMinPerChildPerMonth" name="notdienstMinPerChildPerMonth"
type="number" type="number"
min={0} min={0}
max={31} max={31}
defaultValue={2} defaultValue={state.values?.notdienstMinPerChildPerMonth ?? 2}
required required
aria-invalid={!!state.errors?.notdienstMinPerChildPerMonth} aria-invalid={!!minError}
aria-describedby={
minError
? "notdienstMinPerChildPerMonth-error"
: "notdienstMinPerChildPerMonth-helper"
}
/> />
<p className="text-xs text-muted-foreground"> {!minError && (
<p
id="notdienstMinPerChildPerMonth-helper"
className="text-xs text-muted-foreground"
>
Wie viele Tage müssen Eltern pro Monat als verfügbar markieren? Wie viele Tage müssen Eltern pro Monat als verfügbar markieren?
Diesen Wert kannst du später jederzeit ändern. Diesen Wert kannst du später jederzeit ändern.
</p> </p>
{state.errors?.notdienstMinPerChildPerMonth?.[0] && ( )}
<p className="text-xs text-destructive"> {minError && (
{state.errors.notdienstMinPerChildPerMonth[0]} <p
id="notdienstMinPerChildPerMonth-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{minError}
</p> </p>
)} )}
</div> </div>
</fieldset> </fieldset>
{state.errors?._form?.[0] && ( {state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <p
role="alert"
aria-live="polite"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{state.errors._form[0]} {state.errors._form[0]}
</p> </p>
)} )}
@@ -119,12 +160,34 @@ function ModuleCheckbox({
description: string; description: string;
defaultChecked?: boolean; defaultChecked?: boolean;
}) { }) {
const id = useId();
const titleId = `${id}-title`;
const descId = `${id}-desc`;
return ( return (
<label className="flex items-start gap-3 rounded-md border p-4 transition-colors hover:bg-muted/40"> // The wrapping <label htmlFor> keeps the whole card clickable to toggle
<Checkbox name={name} defaultChecked={defaultChecked} className="mt-0.5" /> // the checkbox. aria-labelledby narrows the accessible name to just the
// module title, with the description exposed via aria-describedby so
// screen readers can present it as secondary info rather than as part of
// the primary label.
<label
htmlFor={id}
className="flex cursor-pointer items-start gap-3 rounded-md border p-4 transition-colors hover:bg-muted/40"
>
<Checkbox
id={id}
name={name}
defaultChecked={defaultChecked}
aria-labelledby={titleId}
aria-describedby={descId}
className="mt-0.5"
/>
<div className="grid gap-1 leading-none"> <div className="grid gap-1 leading-none">
<span className="text-sm font-medium">{label}</span> <span id={titleId} className="text-sm font-medium">
<span className="text-xs text-muted-foreground">{description}</span> {label}
</span>
<span id={descId} className="text-xs text-muted-foreground">
{description}
</span>
</div> </div>
</label> </label>
); );
+3 -1
View File
@@ -30,7 +30,9 @@ export default async function OnboardingPage() {
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12"> <div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-2xl"> <Card className="w-full max-w-2xl">
<CardHeader className="space-y-2"> <CardHeader className="space-y-2">
<CardTitle>Willkommen, {session.user.name?.split(" ")[0] ?? "Gründer:in"}!</CardTitle> <CardTitle as="h1">
Willkommen, {session.user.name?.split(" ")[0] ?? "Gründer:in"}!
</CardTitle>
<CardDescription> <CardDescription>
Lass uns deine Kita in 3 kurzen Schritten einrichten. Du kannst alle Lass uns deine Kita in 3 kurzen Schritten einrichten. Du kannst alle
Einstellungen später noch anpassen. Einstellungen später noch anpassen.
+438 -98
View File
@@ -1,24 +1,30 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation";
import { import {
ArrowRight, ArrowRight,
CalendarDays, Bell,
CheckCircle2, CheckCircle2,
ClipboardList,
Clock,
DatabaseZap, DatabaseZap,
FileStack,
Fingerprint, Fingerprint,
HeartHandshake,
LockKeyhole, LockKeyhole,
Mail, Mail,
Megaphone,
MessageSquareText, MessageSquareText,
ShieldAlert, MessagesSquare,
PhoneCall,
Search,
Route,
ShieldCheck, ShieldCheck,
Stethoscope, Table2,
Trash2, Trash2,
UserCog,
UsersRound, UsersRound,
} from "lucide-react"; } from "lucide-react";
import { auth } from "@/auth";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -28,16 +34,20 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { marketingFeatures } from "@/lib/marketing/features";
import { ContactForm } from "./contact-form"; import { ContactForm } from "./contact-form";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Kita-Planer für Elternvereine", title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description: description:
"Die einfache Plattform für Elternvereine, Dienste und Kommunikation: plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.", "Die einfache Plattform für Elterninitiativen, freie Kitas und Kita-Vereine: organisiert Dienste, Termine, Krankmeldungen und offizielle Kommunikation an einem Ort.",
alternates: {
canonical: "/",
},
openGraph: { openGraph: {
title: "Kita-Planer für Elternvereine", title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description: description:
"Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.", "Weniger Chat-Chaos, weniger Excel-Listen, mehr Ruhe im ehrenamtlichen Kita-Alltag.",
type: "website", type: "website",
locale: "de_DE", locale: "de_DE",
images: [ images: [
@@ -51,9 +61,9 @@ export const metadata: Metadata = {
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "Kita-Planer für Elternvereine", title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description: description:
"Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.", "Organisiert Dienste, Termine, Krankmeldungen und offizielle Kommunikation an einem Ort.",
images: ["/og-image.png"], images: ["/og-image.png"],
}, },
}; };
@@ -61,60 +71,20 @@ export const metadata: Metadata = {
const heroImage = const heroImage =
"https://images.unsplash.com/photo-1503454537195-1dcabb73ffb9?auto=format&fit=crop&w=2400&q=86"; "https://images.unsplash.com/photo-1503454537195-1dcabb73ffb9?auto=format&fit=crop&w=2400&q=86";
const features = [
{
icon: ShieldAlert,
title: "Fairer Notdienst-Planer",
description:
"Automatische Generierung, gerechte Verteilung und 1-Klick-Alarmierung, wenn morgens Ersatz gebraucht wird.",
accent: "bg-red-50 text-red-700 ring-red-100",
},
{
icon: CalendarDays,
title: "Smarter Kalender",
description:
"Feste, Termine, Raumbuchungen und digitale Mitbring-Listen in einem verbindlichen Kalender für alle Familien.",
accent: "bg-sky-50 text-sky-700 ring-sky-100",
},
{
icon: UsersRound,
title: "Sicheres Adressbuch",
description:
"Haushaltsbasiert, DSGVO-konform und mit strengem Opt-In: Kontaktdaten werden nur sichtbar, wenn Eltern zustimmen.",
accent: "bg-emerald-50 text-emerald-700 ring-emerald-100",
},
{
icon: Stethoscope,
title: "Digitale Krankmeldung",
description:
"Ein Klick statt Dauerläuten am Morgen. Eltern melden Kinder digital ab, ErzieherInnen sehen auf dem Tablet sofort den Überblick für den Morgenkreis.",
accent: "bg-amber-50 text-amber-700 ring-amber-100",
badge: "Neu",
},
{
icon: Megaphone,
title: "Schwarzes Brett",
description:
"Der WhatsApp-Killer für Vorstände: offizielle Ankündigungen mit Dateianhängen, Lesebestätigung und optionalem E-Mail-Push.",
accent: "bg-indigo-50 text-indigo-700 ring-indigo-100",
badge: "Neu",
},
];
const privacyPoints = [ const privacyPoints = [
{ {
icon: DatabaseZap, icon: DatabaseZap,
title: "Strikt isolierte Mandanten", title: "Strikt isolierte Mandanten",
text: "Jede Kita arbeitet innerhalb ihrer eigenen kitaId-Grenze. Datenabfragen sind konsequent tenant-isoliert.", text: "Jede Kita arbeitet technisch streng getrennt pro Kita. Rollen und Datenabfragen bleiben konsequent tenant-isoliert.",
}, },
{ {
icon: Fingerprint, icon: Fingerprint,
title: "Privacy by Design", title: "Kontaktfreigabe per Opt-in",
text: "Adressbuchdaten erscheinen nur nach aktivem Opt-In pro Elternteil. Nicht freigegebene Kontaktdaten verlassen den Server nicht.", text: "Adressbuchdaten erscheinen nur nach aktivem Opt-In pro Elternteil. Nicht freigegebene Kontaktdaten verlassen den Server nicht.",
}, },
{ {
icon: Trash2, icon: Trash2,
title: "Recht auf Vergessenwerden", title: "Recht auf Löschung",
text: "Eltern können ihren Account selbst löschen. Wenn sie der letzte Haushaltspartner sind, werden die Familiendaten sauber entfernt.", text: "Eltern können ihren Account selbst löschen. Wenn sie der letzte Haushaltspartner sind, werden die Familiendaten sauber entfernt.",
}, },
{ {
@@ -122,25 +92,106 @@ const privacyPoints = [
title: "Geschützte Anhänge", title: "Geschützte Anhänge",
text: "Dokumente vom Schwarzen Brett liegen nicht öffentlich und werden nur nach Session- und Kita-Prüfung ausgeliefert.", text: "Dokumente vom Schwarzen Brett liegen nicht öffentlich und werden nur nach Session- und Kita-Prüfung ausgeliefert.",
}, },
{
icon: UserCog,
title: "Rollenrechte pro Kita",
text: "Vorstand, Koordination, Eltern und Team sehen nur die Bereiche, die zu ihrer Aufgabe und Kita-Zugehörigkeit passen.",
},
{
icon: FileStack,
title: "Klare Datenpfade",
text: "Löschungen sind bewusst vorgesehen; Fragen zu Export und Übergabe werden im Setup sauber geklärt.",
},
]; ];
const proofPoints = [ const proofPoints = [
"Notdienst, Kalender und Abwesenheiten in einem System", "Für Elterninitiativen, freie Kitas und Kita-Vereine",
"Gebaut für Vorstände, Eltern und ErzieherInnen", "Dienste, Kalender, Abwesenheiten und offizielle Infos in einem System",
"Klare Rollenrechte statt Chat-Chaos", "Gebaut für Vorstände, Eltern, Koordination und Team",
]; ];
// Eingeloggte User von der Landingpage direkt weiterleiten. const replacedTools = [
export default async function LandingPage() { {
const session = await auth(); icon: MessagesSquare,
if (session?.user?.id) { title: "WhatsApp für offizielle Infos",
redirect(session.user.kitaId ? "/dashboard" : "/onboarding"); text: "Ankündigungen, Anhänge und Lesestatus gehören in einen verbindlichen Kanal, nicht zwischen spontane Rückfragen.",
} accent: "bg-emerald-50 text-emerald-700 ring-emerald-100",
},
{
icon: Table2,
title: "Excel-Listen für Dienste",
text: "Verfügbarkeiten, Mindesttage und Dienstpläne werden strukturierter als in Tabellen mit Version 17-final-final.",
accent: "bg-lime-50 text-lime-700 ring-lime-100",
},
{
icon: FileStack,
title: "Verstreute PDFs und Mail-Anhänge",
text: "Wichtige Dokumente hängen am passenden Kontext und bleiben geschützt abrufbar.",
accent: "bg-red-50 text-red-700 ring-red-100",
},
{
icon: ClipboardList,
title: "Manuelle Kontaktlisten",
text: "Familien, Kinder und freigegebene Kontaktdaten werden kontrolliert statt lose herumgereicht.",
accent: "bg-sky-50 text-sky-700 ring-sky-100",
},
{
icon: PhoneCall,
title: "Telefonchaos bei Krankmeldungen",
text: "Eltern melden Abwesenheiten digital; das Team sieht morgens schneller, wer fehlt.",
accent: "bg-amber-50 text-amber-700 ring-amber-100",
},
];
const audienceRoles = [
{
icon: UserCog,
title: "Vorstand & Admin",
text: "Kita, Familien, Termine, Einladungen und Rechte verwalten, ohne dass alles an einer privaten Ablage hängt.",
},
{
icon: UsersRound,
title: "Eltern",
text: "Termine sehen, Kinder krankmelden, Verfügbarkeiten eintragen und freigegebene Kontakte finden.",
},
{
icon: HeartHandshake,
title: "ErzieherInnen & Koordination",
text: "Tagesüberblick, Abwesenheiten, Dienste und relevante Informationen an einem Ort sehen.",
},
];
const onboardingTimeAnchors = [
"Demo-Call (30 Min)",
"Setup-Termin (1 Woche später)",
"Familien einladen (Woche 2)",
"Erste echte Dienstrunde (Woche 34)",
] as const;
const onboardingSteps = [
{
title: onboardingTimeAnchors[0],
text: "Ihr beschreibt kurz eure aktuelle Organisation und die größten Reibungspunkte.",
},
{
title: onboardingTimeAnchors[1],
text: "Wir schauen gemeinsam auf Rollen, Module, Datenschutzfragen und den sinnvollen Einstieg.",
},
{
title: onboardingTimeAnchors[2],
text: "Der Vorstand bringt Eltern, Kinder und Zuständigkeiten in eine saubere Struktur.",
},
{
title: onboardingTimeAnchors[3],
text: "Dienste, Kalender, Krankmeldungen, Adressbuch und Schwarzes Brett ersetzen nach und nach Nebenkanäle.",
},
];
export default function LandingPage() {
return ( return (
<div className="min-h-screen bg-[#f8faf8] text-slate-950"> <div className="min-h-screen bg-[#f8faf8] text-slate-950">
<header className="absolute left-0 right-0 top-0 z-20"> <header className="absolute left-0 right-0 top-0 z-20">
<div className="mx-auto flex h-20 w-full max-w-7xl items-center justify-between px-5 sm:px-6"> <div className="mx-auto flex min-h-20 w-full max-w-7xl items-center justify-between gap-3 px-5 py-4 sm:px-6">
<Link href="/" className="flex items-center gap-3 text-white"> <Link href="/" className="flex items-center gap-3 text-white">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/12 ring-1 ring-white/30"> <span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/12 ring-1 ring-white/30">
<ShieldCheck className="h-5 w-5" /> <ShieldCheck className="h-5 w-5" />
@@ -150,7 +201,7 @@ export default async function LandingPage() {
</span> </span>
</Link> </Link>
<nav className="flex items-center gap-2"> <nav className="flex flex-wrap items-center justify-end gap-2">
<Button <Button
asChild asChild
variant="ghost" variant="ghost"
@@ -162,36 +213,40 @@ export default async function LandingPage() {
asChild asChild
className="bg-white text-slate-950 shadow-sm hover:bg-white/90" className="bg-white text-slate-950 shadow-sm hover:bg-white/90"
> >
<Link href="/register">Kita registrieren</Link> <Link href="/#kontakt">Demo anfragen</Link>
</Button> </Button>
</nav> </nav>
</div> </div>
</header> </header>
<main> <main>
<section <section className="relative min-h-[calc(100svh-56px)] overflow-hidden bg-slate-950">
className="relative min-h-[calc(100svh-56px)] overflow-hidden bg-slate-950" <Image
style={{ src={heroImage}
backgroundImage: `linear-gradient(90deg, rgba(2, 6, 23, 0.9), rgba(15, 23, 42, 0.68), rgba(15, 23, 42, 0.18)), url(${heroImage})`, alt=""
backgroundPosition: "center", fill
backgroundSize: "cover", preload
}} sizes="100vw"
> className="object-cover object-center"
/>
<div
aria-hidden
className="absolute inset-0 bg-[linear-gradient(90deg,rgba(2,6,23,0.92),rgba(15,23,42,0.72)_45%,rgba(15,23,42,0.35)_75%,rgba(15,23,42,0.55))] sm:bg-[linear-gradient(90deg,rgba(2,6,23,0.9),rgba(15,23,42,0.68),rgba(15,23,42,0.18))]"
/>
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-[#f8faf8] to-transparent" /> <div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-[#f8faf8] to-transparent" />
<div className="relative z-10 mx-auto flex min-h-[calc(100svh-56px)] w-full max-w-7xl items-center px-5 pb-20 pt-28 sm:px-6"> <div className="relative z-10 mx-auto flex min-h-[calc(100svh-56px)] w-full max-w-7xl items-center px-5 pb-20 pt-28 sm:px-6">
<div className="max-w-4xl text-white"> <div className="max-w-4xl text-white">
<Badge className="mb-6 border-white/20 bg-white/12 text-white hover:bg-white/12"> <Badge className="mb-6 border-white/20 bg-white/12 text-white hover:bg-white/12">
Kita-Betriebssystem für Elternvereine Kita-Betriebssystem für Elterninitiativen und Kita-Vereine
</Badge> </Badge>
<h1 className="max-w-4xl text-balance text-5xl font-semibold leading-[1.02] sm:text-6xl lg:text-7xl"> <h1 className="max-w-4xl text-balance text-5xl font-semibold leading-[1.02] sm:text-6xl lg:text-7xl">
Die einfache Plattform für Elternvereine, Dienste und Weniger Chat-Chaos. Weniger Excel. Mehr Ruhe im Kita-Verein.
Kommunikation.
</h1> </h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-white/82 sm:text-xl"> <p className="mt-6 max-w-2xl text-lg leading-8 text-white/82 sm:text-xl">
Organisiert Notdienste, Termine, Krankmeldungen und offizielle Für Elterninitiativen, freie Kitas und Kita-Vereine, die
Kommunikation an einem Ort. Schluss mit Zettelwirtschaft und Dienste, Termine, Krankmeldungen und offizielle Kommunikation
WhatsApp-Chaos. ehrenamtlich organisieren.
</p> </p>
<div className="mt-9 flex flex-col gap-3 sm:flex-row"> <div className="mt-9 flex flex-col gap-3 sm:flex-row">
@@ -200,8 +255,8 @@ export default async function LandingPage() {
size="lg" size="lg"
className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90" className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90"
> >
<Link href="/register"> <Link href="/#kontakt">
Kita registrieren Demo anfragen
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Link> </Link>
</Button> </Button>
@@ -211,11 +266,11 @@ export default async function LandingPage() {
variant="outline" variant="outline"
className="h-12 border-white/35 bg-white/10 px-6 text-white hover:bg-white/20 hover:text-white" className="h-12 border-white/35 bg-white/10 px-6 text-white hover:bg-white/20 hover:text-white"
> >
<Link href="/login">Login</Link> <Link href="/register">Kita kostenlos registrieren</Link>
</Button> </Button>
</div> </div>
<div className="mt-10 grid max-w-3xl gap-3 text-sm text-white/78 md:grid-cols-3"> <div className="mt-10 grid max-w-4xl gap-3 text-sm text-white/78 md:grid-cols-3">
{proofPoints.map((item) => ( {proofPoints.map((item) => (
<div key={item} className="flex items-center gap-2"> <div key={item} className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-300" /> <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-300" />
@@ -227,6 +282,110 @@ export default async function LandingPage() {
</div> </div>
</section> </section>
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6 lg:py-24">
<div className="mb-10 grid gap-5 lg:grid-cols-[0.78fr_1fr] lg:items-end">
<div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
Produkt statt Versprechen
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
So sieht operative Kita-Arbeit aus, wenn sie nicht mehr im Chat
liegt.
</h2>
</div>
<p className="max-w-2xl text-sm leading-6 text-slate-600 lg:justify-self-end">
Der Notdienst-Planer sammelt Verfügbarkeiten, zeigt offene
Familien und macht aus losen Antworten einen veröffentlichbaren
Monatsplan.
</p>
</div>
<ProductBrowserMock />
</section>
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6 lg:py-24">
<div className="mb-10 grid gap-5 lg:grid-cols-[0.85fr_1.15fr] lg:items-end">
<div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
Raus aus den Nebenkanälen
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Kita-Planer ersetzt die Dinge, die Vorstände abends noch
zusammenhalten müssen.
</h2>
</div>
<p className="max-w-2xl text-sm leading-6 text-slate-600 lg:justify-self-end">
Elternvereine funktionieren nur, wenn Informationen zuverlässig
ankommen. Deshalb bündelt Kita-Planer die wiederkehrenden
Alltagslisten, Anfragen und offiziellen Nachrichten an einem Ort.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
{replacedTools.map((item) => {
const Icon = item.icon;
return (
<div
key={item.title}
className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm"
>
<div
className={`mb-4 flex h-10 w-10 items-center justify-center rounded-lg ring-1 ${item.accent}`}
>
<Icon className="h-5 w-5" />
</div>
<h3 className="font-semibold tracking-tight">{item.title}</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
{item.text}
</p>
</div>
);
})}
</div>
</section>
<section className="border-y border-slate-200 bg-white px-5 py-20 sm:px-6">
<div className="mx-auto grid w-full max-w-7xl gap-10 lg:grid-cols-[0.9fr_1.1fr] lg:items-start">
<div>
<Badge className="mb-5 bg-slate-950 text-white hover:bg-slate-950">
Für den Vereinsalltag
</Badge>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Eine Oberfläche für Vorstand, Eltern und Kita-Team.
</h2>
<p className="mt-5 max-w-xl text-sm leading-6 text-slate-600">
Kita-Planer trennt Zuständigkeiten, ohne neue Silos zu bauen.
Jede Rolle sieht die Aufgaben und Informationen, die sie im
gemeinsamen Kita-Alltag wirklich braucht.
</p>
</div>
<div className="grid gap-4">
{audienceRoles.map((role) => {
const Icon = role.icon;
return (
<div
key={role.title}
className="grid gap-4 rounded-lg border border-slate-200 bg-[#f8faf8] p-5 shadow-sm sm:grid-cols-[auto_1fr] sm:items-start"
>
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-white text-emerald-700 ring-1 ring-emerald-100">
<Icon className="h-5 w-5" />
</div>
<div>
<h3 className="font-semibold tracking-tight">
{role.title}
</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
{role.text}
</p>
</div>
</div>
);
})}
</div>
</div>
</section>
<section className="mx-auto w-full max-w-7xl px-5 py-24 sm:px-6"> <section className="mx-auto w-full max-w-7xl px-5 py-24 sm:px-6">
<div className="mb-12 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between"> <div className="mb-12 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl"> <div className="max-w-3xl">
@@ -246,7 +405,7 @@ export default async function LandingPage() {
</div> </div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-6"> <div className="grid gap-5 md:grid-cols-2 xl:grid-cols-6">
{features.map((feature, index) => { {marketingFeatures.map((feature, index) => {
const Icon = feature.icon; const Icon = feature.icon;
const isWide = index > 2; const isWide = index > 2;
return ( return (
@@ -270,20 +429,71 @@ export default async function LandingPage() {
)} )}
</div> </div>
<div> <div>
<CardTitle className="text-xl tracking-tight"> <CardTitle as="h3" className="text-xl tracking-tight">
{feature.title} {feature.title}
</CardTitle> </CardTitle>
<CardDescription className="mt-3 text-sm leading-6 text-slate-600"> <CardDescription className="mt-3 text-sm leading-6 text-slate-600">
{feature.description} {feature.description}
</CardDescription> </CardDescription>
<Button
asChild
variant="link"
className="mt-4 h-auto p-0 text-slate-950"
>
<Link href={`/features/${feature.slug}`}>
Mehr erfahren
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-0">
<FeaturePreview slug={feature.slug} />
</CardContent>
</Card> </Card>
); );
})} })}
</div> </div>
</section> </section>
<section className="mx-auto w-full max-w-7xl px-5 pb-24 sm:px-6">
<div className="grid gap-10 rounded-lg border border-slate-200 bg-white p-6 shadow-sm lg:grid-cols-[0.85fr_1.15fr] lg:p-10">
<div>
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<Route className="h-6 w-6" />
</div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
So startet ihr
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Erst verstehen, dann sauber einführen.
</h2>
<p className="mt-5 text-sm leading-6 text-slate-600">
Elternvereine haben wenig Zeit für Tool-Einführung. Der Einstieg
soll deshalb mit euren echten Abläufen beginnen, nicht mit einer
langen Konfigurationsliste.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{onboardingSteps.map((step, index) => (
<div
key={step.title}
className="rounded-lg border border-slate-200 bg-[#f8faf8] p-5"
>
<div className="mb-4 flex h-8 w-8 items-center justify-center rounded-full bg-slate-950 text-sm font-semibold text-white">
{index + 1}
</div>
<h3 className="font-semibold tracking-tight">{step.title}</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
{step.text}
</p>
</div>
))}
</div>
</div>
</section>
<section className="bg-slate-950 px-5 py-24 text-white sm:px-6"> <section className="bg-slate-950 px-5 py-24 text-white sm:px-6">
<div className="mx-auto grid w-full max-w-7xl gap-12 lg:grid-cols-[0.92fr_1.08fr] lg:items-start"> <div className="mx-auto grid w-full max-w-7xl gap-12 lg:grid-cols-[0.92fr_1.08fr] lg:items-start">
<div> <div>
@@ -297,14 +507,14 @@ export default async function LandingPage() {
Datenschutz ist nicht die Fußnote. Er ist die Architektur. Datenschutz ist nicht die Fußnote. Er ist die Architektur.
</h2> </h2>
<p className="mt-5 max-w-xl text-base leading-7 text-slate-300"> <p className="mt-5 max-w-xl text-base leading-7 text-slate-300">
Elternvereine verwalten besonders sensible Daten. Deshalb ist Kita-Vereine verwalten besonders sensible Daten. Deshalb ist
Kita-Planer so gebaut, dass Sichtbarkeit, Löschung und Kita-Planer so gebaut, dass Sichtbarkeit, Rollenrechte,
Mandantentrennung nicht vom guten Willen einzelner Chats Löschung und Mandantentrennung nicht vom guten Willen einzelner
abhängen. Chats abhängen.
</p> </p>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{privacyPoints.map((point) => { {privacyPoints.map((point) => {
const Icon = point.icon; const Icon = point.icon;
return ( return (
@@ -331,19 +541,19 @@ export default async function LandingPage() {
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div className="max-w-2xl"> <div className="max-w-2xl">
<h2 className="text-2xl font-semibold tracking-tight"> <h2 className="text-2xl font-semibold tracking-tight">
Bereit für eine Kita-Organisation ohne Nebenkanäle? Bereit für weniger Nebenkanäle im Kita-Verein?
</h2> </h2>
<p className="mt-3 text-sm leading-6 text-slate-600"> <p className="mt-3 text-sm leading-6 text-slate-600">
Registriere eure Kita, lade den Vorstand ein und starte mit Fragt eine Demo an und prüft gemeinsam, welche Abläufe zuerst
einem System, das für Elternvereine und Datenschutz gebaut ist. aus Chat, Tabelle und Telefonliste herauswandern sollten.
</p> </p>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row"> <div className="flex flex-col gap-3 sm:flex-row">
<Button asChild size="lg"> <Button asChild size="lg">
<Link href="/register">Kita registrieren</Link> <Link href="/#kontakt">Demo anfragen</Link>
</Button> </Button>
<Button asChild size="lg" variant="outline"> <Button asChild size="lg" variant="outline">
<Link href="/login">Login</Link> <Link href="/register">Kita kostenlos registrieren</Link>
</Button> </Button>
</div> </div>
</div> </div>
@@ -389,7 +599,7 @@ export default async function LandingPage() {
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100"> <div className="mb-2 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<Mail className="h-5 w-5" /> <Mail className="h-5 w-5" />
</div> </div>
<CardTitle className="text-2xl tracking-tight"> <CardTitle as="h3" className="text-2xl tracking-tight">
Anfrage senden Anfrage senden
</CardTitle> </CardTitle>
<CardDescription className="text-sm leading-6"> <CardDescription className="text-sm leading-6">
@@ -424,3 +634,133 @@ export default async function LandingPage() {
</div> </div>
); );
} }
function ProductBrowserMock() {
return (
<div className="overflow-hidden rounded-2xl border border-emerald-100 bg-white shadow-[0_24px_70px_rgba(15,23,42,0.12)]">
<div className="flex items-center justify-between border-b border-slate-200 bg-[#fbf8ef] px-4 py-3">
<div className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full bg-red-300" />
<span className="h-3 w-3 rounded-full bg-amber-300" />
<span className="h-3 w-3 rounded-full bg-emerald-300" />
</div>
<div className="rounded-full border border-slate-200 bg-white px-4 py-1 text-xs font-medium text-slate-500">
app.kita-planer.de/dashboard/notdienst
</div>
<div className="w-16" />
</div>
<div className="bg-[#fbf8ef] p-2 sm:p-4">
<Image
src="/landing/notdienst-calendar-linen.png"
alt="Notdienst-Kalender im Kita-Planer mit ausgewählten Tagen im Juni 2026"
width={2262}
height={1640}
sizes="(min-width: 1280px) 1184px, calc(100vw - 40px)"
className="h-auto w-full rounded-xl border border-slate-200 bg-[#fbf8ef]"
/>
</div>
</div>
);
}
function FeaturePreview({ slug }: { slug: string }) {
if (slug === "notdienst-planer") {
return (
<div className="rounded-xl border border-red-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center justify-between text-xs">
<span className="font-semibold text-slate-700">Juni</span>
<span className="rounded-full bg-red-50 px-2 py-1 text-red-700">
12/16
</span>
</div>
<div className="grid grid-cols-5 gap-1.5">
{["", "bg-red-100", "", "bg-red-100", "", "bg-red-100", "", "", "bg-red-100", ""].map(
(cell, index) => (
<span
key={index}
className={`h-7 rounded-md border border-red-100 bg-white ${cell}`}
/>
),
)}
</div>
</div>
);
}
if (slug === "kita-kalender") {
return (
<div className="rounded-xl border border-sky-100 bg-[#fbf8ef] p-3">
{[
["10", "Schließtag", "bg-red-400"],
["18", "Elternabend", "bg-indigo-400"],
["29", "Sommerfest", "bg-emerald-500"],
].map(([date, title, color]) => (
<div
key={title}
className="mb-2 last:mb-0 flex items-center gap-3 rounded-lg bg-white px-3 py-2 text-xs"
>
<span className="font-semibold tabular-nums text-slate-950">
{date}
</span>
<span className={`h-5 w-1 rounded-full ${color}`} />
<span className="text-slate-600">{title}</span>
</div>
))}
</div>
);
}
if (slug === "eltern-adressbuch") {
return (
<div className="rounded-xl border border-emerald-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-400">
<Search className="h-3.5 w-3.5" />
Familie suchen
</div>
{["Meier", "Mustermann"].map((name) => (
<div key={name} className="mb-2 last:mb-0 flex items-center gap-2">
<span className="h-7 w-7 rounded-lg bg-emerald-50" />
<span className="h-2 flex-1 rounded-full bg-slate-200" />
<span className="h-2 w-10 rounded-full bg-emerald-100" />
</div>
))}
</div>
);
}
if (slug === "krankmeldung") {
return (
<div className="rounded-xl border border-amber-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center gap-2 text-xs font-semibold text-slate-700">
<Clock className="h-4 w-4 text-amber-700" />
Heute fehlen 2 Kinder
</div>
{["Kai · krank", "Mia · Arzttermin"].map((item) => (
<div
key={item}
className="mb-2 last:mb-0 rounded-lg bg-white px-3 py-2 text-xs text-slate-600"
>
{item}
</div>
))}
</div>
);
}
return (
<div className="rounded-xl border border-indigo-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center gap-2 text-xs font-semibold text-slate-700">
<Bell className="h-4 w-4 text-indigo-700" />
Neue Mitteilung
</div>
<div className="space-y-2">
<span className="block h-2.5 w-4/5 rounded-full bg-slate-200" />
<span className="block h-2.5 w-3/5 rounded-full bg-slate-200" />
<span className="inline-flex rounded-full bg-indigo-50 px-2 py-1 text-xs text-indigo-700">
PDF angehängt
</span>
</div>
</div>
);
}
+29 -3
View File
@@ -32,6 +32,16 @@ const registerSchema = z.object({
const PRIVACY_POLICY_VERSION = "2026-05-01"; const PRIVACY_POLICY_VERSION = "2026-05-01";
export type RegisterState = { export type RegisterState = {
// Bumped after every action call so the client can key uncontrolled inputs
// and force a remount with the echoed values from a failed submit.
attempt: number;
// Echoed inputs (sans password) so failed submits don't wipe the form.
values?: {
firstName?: string;
lastName?: string;
email?: string;
acceptPrivacyPolicy?: boolean;
};
errors?: { errors?: {
email?: string[]; email?: string[];
password?: string[]; password?: string[];
@@ -42,13 +52,27 @@ export type RegisterState = {
}; };
}; };
function echoValues(formData: FormData): RegisterState["values"] {
return {
firstName: String(formData.get("firstName") ?? ""),
lastName: String(formData.get("lastName") ?? ""),
email: String(formData.get("email") ?? ""),
acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy") === "on",
};
}
export async function registerAction( export async function registerAction(
_prevState: RegisterState, prevState: RegisterState,
formData: FormData, formData: FormData,
): Promise<RegisterState> { ): Promise<RegisterState> {
const attempt = prevState.attempt + 1;
const parsed = registerSchema.safeParse(Object.fromEntries(formData)); const parsed = registerSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) { if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }; return {
attempt,
values: echoValues(formData),
errors: parsed.error.flatten().fieldErrors,
};
} }
const { email, password, firstName, lastName } = parsed.data; const { email, password, firstName, lastName } = parsed.data;
@@ -77,6 +101,8 @@ export async function registerAction(
err.code === "P2002" err.code === "P2002"
) { ) {
return { return {
attempt,
values: echoValues(formData),
errors: { errors: {
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."], email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
}, },
@@ -93,5 +119,5 @@ export async function registerAction(
}); });
// Unreachable signIn redirected. // Unreachable signIn redirected.
return {}; return { attempt };
} }
+2 -1
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getPostLoginRedirect } from "@/lib/post-login-redirect";
import { import {
Card, Card,
CardContent, CardContent,
@@ -17,7 +18,7 @@ export const metadata = { title: "Registrieren · Kita-Planer" };
export default async function RegisterPage() { export default async function RegisterPage() {
const session = await auth(); const session = await auth();
if (session?.user) { if (session?.user) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding"); redirect(getPostLoginRedirect(session.user));
} }
return ( return (
+66 -8
View File
@@ -9,43 +9,58 @@ import { Label } from "@/components/ui/label";
import { registerAction, type RegisterState } from "./actions"; import { registerAction, type RegisterState } from "./actions";
const initialState: RegisterState = {}; const initialRegisterState: RegisterState = { attempt: 0 };
export function RegisterForm() { export function RegisterForm() {
const [state, formAction, pending] = useActionState(registerAction, initialState); const [state, formAction, pending] = useActionState(
registerAction,
initialRegisterState,
);
// Bumping the key on each attempt forces uncontrolled inputs to remount with
// the echoed defaultValue from the server — otherwise React's <form action>
// semantics would wipe them after a failed submit.
const attemptKey = state.attempt;
return ( return (
<form action={formAction} className="space-y-4"> <form action={formAction} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<FormField <FormField
key={`firstName-${attemptKey}`}
id="firstName" id="firstName"
name="firstName" name="firstName"
label="Vorname" label="Vorname"
autoComplete="given-name" autoComplete="given-name"
required required
defaultValue={state.values?.firstName ?? ""}
error={state.errors?.firstName?.[0]} error={state.errors?.firstName?.[0]}
/> />
<FormField <FormField
key={`lastName-${attemptKey}`}
id="lastName" id="lastName"
name="lastName" name="lastName"
label="Nachname" label="Nachname"
autoComplete="family-name" autoComplete="family-name"
required required
defaultValue={state.values?.lastName ?? ""}
error={state.errors?.lastName?.[0]} error={state.errors?.lastName?.[0]}
/> />
</div> </div>
<FormField <FormField
key={`email-${attemptKey}`}
id="email" id="email"
name="email" name="email"
type="email" type="email"
label="E-Mail" label="E-Mail"
autoComplete="email" autoComplete="email"
required required
defaultValue={state.values?.email ?? ""}
error={state.errors?.email?.[0]} error={state.errors?.email?.[0]}
/> />
<FormField <FormField
key={`password-${attemptKey}`}
id="password" id="password"
name="password" name="password"
type="password" type="password"
@@ -57,23 +72,51 @@ export function RegisterForm() {
/> />
<div className="flex items-start gap-3 pt-2"> <div className="flex items-start gap-3 pt-2">
<Checkbox id="acceptPrivacyPolicy" name="acceptPrivacyPolicy" required /> <Checkbox
key={`acceptPrivacyPolicy-${attemptKey}`}
id="acceptPrivacyPolicy"
name="acceptPrivacyPolicy"
required
defaultChecked={state.values?.acceptPrivacyPolicy === true}
aria-invalid={!!state.errors?.acceptPrivacyPolicy?.[0]}
aria-describedby={
state.errors?.acceptPrivacyPolicy?.[0]
? "acceptPrivacyPolicy-error"
: undefined
}
/>
<div className="grid gap-1 leading-none"> <div className="grid gap-1 leading-none">
<Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal"> <Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal">
Ich habe die{" "} Ich habe die{" "}
<a href="/datenschutz" className="underline" target="_blank" rel="noreferrer"> <a
href="/datenschutz"
className="underline"
target="_blank"
rel="noreferrer"
>
Datenschutzerklärung Datenschutzerklärung
</a>{" "} </a>{" "}
gelesen und akzeptiere sie. gelesen und akzeptiere sie.
</Label> </Label>
{state.errors?.acceptPrivacyPolicy?.[0] && ( {state.errors?.acceptPrivacyPolicy?.[0] && (
<p className="text-xs text-destructive">{state.errors.acceptPrivacyPolicy[0]}</p> <p
id="acceptPrivacyPolicy-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{state.errors.acceptPrivacyPolicy[0]}
</p>
)} )}
</div> </div>
</div> </div>
{state.errors?._form?.[0] && ( {state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <p
role="alert"
aria-live="polite"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{state.errors._form[0]} {state.errors._form[0]}
</p> </p>
)} )}
@@ -93,6 +136,7 @@ function FormField({
required, required,
autoComplete, autoComplete,
helperText, helperText,
defaultValue,
error, error,
}: { }: {
id: string; id: string;
@@ -102,8 +146,11 @@ function FormField({
required?: boolean; required?: boolean;
autoComplete?: string; autoComplete?: string;
helperText?: string; helperText?: string;
defaultValue?: string;
error?: string; error?: string;
}) { }) {
const errorId = error ? `${id}-error` : undefined;
const helperId = !error && helperText ? `${id}-helper` : undefined;
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label> <Label htmlFor={id}>{label}</Label>
@@ -113,12 +160,23 @@ function FormField({
type={type} type={type}
required={required} required={required}
autoComplete={autoComplete} autoComplete={autoComplete}
defaultValue={defaultValue}
aria-invalid={!!error} aria-invalid={!!error}
aria-describedby={errorId ?? helperId}
/> />
{error ? ( {error ? (
<p className="text-xs text-destructive">{error}</p> <p
id={errorId}
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{error}
</p>
) : helperText ? ( ) : helperText ? (
<p className="text-xs text-muted-foreground">{helperText}</p> <p id={helperId} className="text-xs text-muted-foreground">
{helperText}
</p>
) : null} ) : null}
</div> </div>
); );
+28
View File
@@ -0,0 +1,28 @@
import type { MetadataRoute } from "next";
function getSiteUrl() {
return process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de";
}
export default function robots(): MetadataRoute.Robots {
const siteUrl = getSiteUrl();
return {
rules: {
userAgent: "*",
allow: ["/", "/features/", "/impressum", "/datenschutz"],
disallow: [
"/api/",
"/dashboard/",
"/invite/",
"/alert/",
"/login",
"/register",
"/onboarding/",
"/forbidden",
],
},
sitemap: `${siteUrl}/sitemap.xml`,
host: siteUrl,
};
}
+39
View File
@@ -0,0 +1,39 @@
import type { MetadataRoute } from "next";
import { marketingFeatures } from "@/lib/marketing/features";
function getSiteUrl() {
return process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de";
}
export default function sitemap(): MetadataRoute.Sitemap {
const siteUrl = getSiteUrl();
const lastModified = new Date();
return [
{
url: siteUrl,
lastModified,
changeFrequency: "weekly",
priority: 1,
},
{
url: `${siteUrl}/impressum`,
lastModified,
changeFrequency: "yearly",
priority: 0.2,
},
{
url: `${siteUrl}/datenschutz`,
lastModified,
changeFrequency: "yearly",
priority: 0.2,
},
...marketingFeatures.map((feature) => ({
url: `${siteUrl}/features/${feature.slug}`,
lastModified,
changeFrequency: "monthly" as const,
priority: 0.8,
})),
];
}
+37
View File
@@ -0,0 +1,37 @@
:root {
/* Surfaces */
--bg: oklch(0.965 0.012 75);
--surface: oklch(0.985 0.008 75);
--surface-2: oklch(0.975 0.010 75);
--surface-week: oklch(0.945 0.014 75);
/* Ink */
--ink: oklch(0.26 0.015 45);
--ink-soft: oklch(0.45 0.015 45);
--ink-muted: oklch(0.60 0.012 45);
--ink-faint: oklch(0.76 0.010 45);
/* Hairlines */
--hairline: oklch(0.90 0.012 75);
--hairline-soft: oklch(0.93 0.010 75);
/* Accent */
--accent: oklch(0.58 0.11 38);
--accent-deep: oklch(0.42 0.10 38);
--accent-soft: oklch(0.93 0.04 38);
--accent-mid: oklch(0.82 0.07 38);
/* Semantic */
--good: oklch(0.55 0.10 155);
--good-soft: oklch(0.94 0.03 155);
--warn: oklch(0.62 0.10 70);
--warn-soft: oklch(0.95 0.04 70);
--danger: oklch(0.55 0.13 25);
--danger-soft: oklch(0.95 0.04 25);
--focus-ring: oklch(0.85 0.07 38 / 0.30);
--paper-pattern: radial-gradient(
oklch(0.85 0.02 75 / 0.10) 1px,
transparent 1px
);
}
@@ -1,11 +1,14 @@
import Script from "next/script"; import Script from "next/script";
import { marketingFeatureSlugs } from "@/lib/marketing/features";
const publicAnalyticsPaths = new Set([ const publicAnalyticsPaths = new Set([
"/", "/",
"/impressum", "/impressum",
"/datenschutz", "/datenschutz",
"/login", "/login",
"/register", "/register",
...marketingFeatureSlugs.map((slug) => `/features/${slug}`),
]); ]);
export function UmamiAnalytics() { export function UmamiAnalytics() {
+48 -21
View File
@@ -1,6 +1,5 @@
"use client"; "use client";
import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { import {
LayoutDashboard, LayoutDashboard,
@@ -15,24 +14,28 @@ import {
Megaphone, Megaphone,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { NavItem } from "@/components/ui/nav-item";
import { UserRole } from "@prisma/client"; import { UserRole } from "@prisma/client";
const navItems = [ const navItems = [
{ {
section: "Heute",
href: "/dashboard", href: "/dashboard",
label: "Übersicht", label: "Übersicht",
icon: LayoutDashboard, icon: LayoutDashboard,
exact: true, exact: true,
}, },
{ {
section: "Heute",
href: "/dashboard/notdienst", href: "/dashboard/notdienst",
label: "Notdienst (Eltern)", label: "Notdienst (Eltern)",
icon: CalendarCheck2, icon: CalendarCheck2,
exact: true, exact: true,
badge: "4",
}, },
{ {
section: "Heute",
href: "/dashboard/notdienst/plan", href: "/dashboard/notdienst/plan",
label: "Notdienst-Planung", label: "Notdienst-Planung",
icon: CalendarCheck2, icon: CalendarCheck2,
@@ -40,18 +43,21 @@ const navItems = [
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR], allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
}, },
{ {
section: "Heute",
href: "/dashboard/kalender", href: "/dashboard/kalender",
label: "Terminkalender", label: "Terminkalender",
icon: CalendarDays, icon: CalendarDays,
exact: false, exact: false,
}, },
{ {
section: "Heute",
href: "/dashboard/adressbuch", href: "/dashboard/adressbuch",
label: "Adressbuch", label: "Adressbuch",
icon: Contact, icon: Contact,
exact: false, exact: false,
}, },
{ {
section: "Verwaltung",
href: "/dashboard/families", href: "/dashboard/families",
label: "Familienverwaltung", label: "Familienverwaltung",
icon: Users, icon: Users,
@@ -59,6 +65,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN], allowedRoles: [UserRole.ADMIN],
}, },
{ {
section: "Verwaltung",
href: "/dashboard/admin/dienste", href: "/dashboard/admin/dienste",
label: "Dienstplan", label: "Dienstplan",
icon: ClipboardList, icon: ClipboardList,
@@ -66,6 +73,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN], allowedRoles: [UserRole.ADMIN],
}, },
{ {
section: "Verwaltung",
href: "/dashboard/admin/news", href: "/dashboard/admin/news",
label: "Schwarzes Brett", label: "Schwarzes Brett",
icon: Megaphone, icon: Megaphone,
@@ -73,6 +81,15 @@ const navItems = [
allowedRoles: [UserRole.ADMIN], allowedRoles: [UserRole.ADMIN],
}, },
{ {
section: "Verwaltung",
href: "/dashboard/admin/kalender",
label: "Event-Anmeldungen",
icon: CalendarCheck2,
exact: false,
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
},
{
section: "Verwaltung",
href: "/dashboard/admin/abwesenheiten", href: "/dashboard/admin/abwesenheiten",
label: "Abwesenheiten", label: "Abwesenheiten",
icon: Stethoscope, icon: Stethoscope,
@@ -80,6 +97,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ERZIEHER], allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ERZIEHER],
}, },
{ {
section: "Verwaltung",
href: "/dashboard/erzieher", href: "/dashboard/erzieher",
label: "ErzieherInnen", label: "ErzieherInnen",
icon: GraduationCap, icon: GraduationCap,
@@ -87,6 +105,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN], allowedRoles: [UserRole.ADMIN],
}, },
{ {
section: "Verwaltung",
href: "/dashboard/profil", href: "/dashboard/profil",
label: "Mein Profil", label: "Mein Profil",
icon: UserCircle, icon: UserCircle,
@@ -96,32 +115,40 @@ const navItems = [
export function SidebarNav({ role }: { role: UserRole }) { export function SidebarNav({ role }: { role: UserRole }) {
const pathname = usePathname(); const pathname = usePathname();
const visibleItems = navItems.filter(
({ allowedRoles }) =>
!allowedRoles || (allowedRoles as UserRole[]).includes(role),
);
const sections = ["Heute", "Verwaltung"];
return ( return (
<nav className="flex flex-col gap-1 px-3"> <nav className="flex flex-col gap-8 px-5">
{navItems.map(({ href, label, icon: Icon, exact, allowedRoles }) => { {sections.map((section) => (
// Filter out items not allowed for the current role <div key={section}>
if (allowedRoles && !(allowedRoles as UserRole[]).includes(role)) { <p className="mb-3 px-2.5 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.18em]">
return null; {section}
} </p>
<div className="flex flex-col gap-1">
const isActive = exact ? pathname === href : pathname.startsWith(href); {visibleItems
.filter((item) => item.section === section)
.map(({ href, label, icon, exact, badge }) => {
const isActive = exact
? pathname === href
: pathname.startsWith(href);
return ( return (
<Link <NavItem
key={href} key={href}
href={href} href={href}
className={cn( label={label}
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors", icon={icon}
isActive active={isActive}
? "bg-primary text-primary-foreground" badge={badge}
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground", />
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
); );
})} })}
</div>
</div>
))}
</nav> </nav>
); );
} }
+7 -7
View File
@@ -4,21 +4,21 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", "inline-flex items-center rounded-full border px-2.5 py-0.5 text-[11px] font-semibold transition-colors focus:outline-none focus:ring-[3px] focus:ring-[var(--focus-ring)]",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground", "border-transparent bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)]",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground", "border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]",
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground", "border-transparent bg-[var(--danger-soft)] text-[var(--danger)]",
outline: "text-foreground", outline: "border-[var(--hairline)] text-[var(--ink-soft)]",
success: success:
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400", "border-transparent bg-[var(--good-soft)] text-[var(--good)]",
warning: warning:
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", "border-transparent bg-[var(--warn-soft)] text-[var(--warn)]",
}, },
}, },
defaultVariants: { defaultVariants: {
+14 -10
View File
@@ -5,24 +5,28 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center whitespace-nowrap text-[13.5px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] disabled:pointer-events-none disabled:opacity-50 active:translate-y-px [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:stroke-[1.7]",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default:
"rounded-lg bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)] hover:bg-[oklch(0.36_0.10_38)]",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink)] hover:border-[var(--accent-mid)]",
outline: outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground", "rounded-lg border border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost:
"rounded-lg border border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
destructive: destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90", "rounded-lg bg-[var(--danger)] text-[oklch(0.98_0.005_45)] hover:brightness-95",
link: "text-primary underline-offset-4 hover:underline", link: "rounded-lg text-[var(--accent-deep)] underline-offset-4 hover:underline",
pill:
"rounded-full border border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
}, },
size: { size: {
default: "h-10 px-4 py-2", default: "h-10 gap-2 px-[18px] py-2.5",
sm: "h-9 rounded-md px-3", sm: "h-9 gap-2 px-3",
lg: "h-11 rounded-md px-8", lg: "h-11 gap-2 px-8",
icon: "h-10 w-10", icon: "h-10 w-10",
}, },
}, },
+37 -15
View File
@@ -1,15 +1,33 @@
import * as React from "react"; import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const cardVariants = cva(
({ className, ...props }, ref) => ( "relative rounded-[14px] border border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink)]",
{
variants: {
variant: {
default: "",
featured:
"overflow-hidden border-[var(--accent-mid)] bg-linear-to-b from-[oklch(0.985_0.012_50)] to-[var(--surface)] before:absolute before:inset-x-0 before:top-0 before:h-0.5 before:bg-[var(--accent)]",
},
},
defaultVariants: {
variant: "default",
},
},
);
interface CardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardVariants> {}
const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className, variant, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn( className={cn(cardVariants({ variant, className }))}
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props} {...props}
/> />
), ),
@@ -18,16 +36,20 @@ Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} /> <div ref={ref} className={cn("flex flex-col space-y-1.5 p-[22px_24px]", className)} {...props} />
), ),
); );
CardHeader.displayName = "CardHeader"; CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( type CardTitleProps = React.HTMLAttributes<HTMLElement> & {
({ className, ...props }, ref) => ( as?: "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
<div };
ref={ref}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)} const CardTitle = React.forwardRef<HTMLElement, CardTitleProps>(
({ className, as: Comp = "div", ...props }, ref) => (
<Comp
ref={ref as React.Ref<HTMLDivElement>}
className={cn("text-lg font-medium leading-none tracking-[-0.02em]", className)}
{...props} {...props}
/> />
), ),
@@ -38,7 +60,7 @@ const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HT
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-[13.5px] text-[var(--ink-soft)]", className)}
{...props} {...props}
/> />
), ),
@@ -47,14 +69,14 @@ CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> <div ref={ref} className={cn("p-[22px_24px] pt-0", className)} {...props} />
), ),
); );
CardContent.displayName = "CardContent"; CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} /> <div ref={ref} className={cn("flex items-center p-[22px_24px] pt-0", className)} {...props} />
), ),
); );
CardFooter.displayName = "CardFooter"; CardFooter.displayName = "CardFooter";
+52
View File
@@ -0,0 +1,52 @@
import type { ComponentType, ReactNode } from "react";
import type { LucideProps } from "lucide-react";
import { cn } from "@/lib/utils";
export function DefList({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<dl className={cn("divide-y divide-[var(--hairline-soft)]", className)}>
{children}
</dl>
);
}
export function DefItem({
icon: Icon,
label,
value,
}: {
icon?: ComponentType<LucideProps>;
label: string;
value: ReactNode;
}) {
return (
<div className="grid gap-1 py-3 first:pt-0 last:pb-0">
<dt className="flex items-center gap-1.5 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
{Icon ? <Icon className="h-3.5 w-3.5 [stroke-width:1.7]" /> : null}
{label}
</dt>
<dd className="text-sm font-medium text-[var(--ink)] [overflow-wrap:anywhere] [font-variant-numeric:tabular-nums]">
{value}
</dd>
</div>
);
}
export function EmptyDefValue({
children = "Noch nicht hinterlegt",
}: {
children?: ReactNode;
}) {
return (
<span className="font-normal italic text-[var(--ink-faint)]">
{children}
</span>
);
}
+5 -5
View File
@@ -18,7 +18,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-[oklch(0.25_0.015_45_/_0.20)] backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className, className,
)} )}
{...props} {...props}
@@ -35,13 +35,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-[var(--hairline)] bg-[var(--surface)] p-6 text-[var(--ink)] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-[14px]",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <DialogPrimitive.Close className="absolute right-4 top-4 rounded-md text-[var(--ink-muted)] opacity-80 transition-opacity hover:bg-[var(--surface-2)] hover:text-[var(--ink)] hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface)] disabled:pointer-events-none data-[state=open]:bg-[var(--accent-soft)] data-[state=open]:text-[var(--accent-deep)]">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Schließen</span> <span className="sr-only">Schließen</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
@@ -85,7 +85,7 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", "text-lg font-medium leading-none tracking-[-0.02em] text-[var(--ink)]",
className, className,
)} )}
{...props} {...props}
@@ -99,7 +99,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-[var(--ink-soft)]", className)}
{...props} {...props}
/> />
)); ));
+2
View File
@@ -140,6 +140,8 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
return ( return (
<p <p
id={formMessageId} id={formMessageId}
role="alert"
aria-live="polite"
className={cn("text-sm text-destructive", className)} className={cn("text-sm text-destructive", className)}
{...props} {...props}
> >
+1 -1
View File
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2.5 text-[13.5px] text-[var(--ink)] transition file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-[var(--ink-faint)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
ref={ref} ref={ref}
+41
View File
@@ -0,0 +1,41 @@
import Link from "next/link";
import type { ComponentType } from "react";
import type { LucideProps } from "lucide-react";
import { cn } from "@/lib/utils";
type NavItemProps = {
href: string;
label: string;
icon: ComponentType<LucideProps>;
active?: boolean;
badge?: number | string;
};
export function NavItem({
href,
label,
icon: Icon,
active = false,
badge,
}: NavItemProps) {
return (
<Link
href={href}
className={cn(
"relative flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13.5px] font-medium text-[var(--ink-soft)] transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]",
"hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
active &&
"bg-[var(--accent-soft)] font-semibold text-[var(--accent-deep)] before:absolute before:left-[-14px] before:top-1.5 before:h-[calc(100%-12px)] before:w-[3px] before:rounded-full before:bg-[var(--accent)]",
)}
>
<Icon className="h-4 w-4 shrink-0 [stroke-width:1.7]" />
<span className="min-w-0 flex-1 truncate">{label}</span>
{badge ? (
<span className="rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-2 py-0.5 text-[11px] font-semibold text-[var(--ink-muted)]">
{badge}
</span>
) : null}
</Link>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { UserRole } from "@prisma/client";
import { cn } from "@/lib/utils";
type RoleBadgeProps = {
role: UserRole;
className?: string;
};
const roleLabels: Record<string, string> = {
SUPERADMIN: "Admin",
ADMIN: "Vorstand",
KOORDINATOR: "Koordinator",
ELTERN: "Eltern",
ERZIEHER: "Erzieher",
};
export function RoleBadge({ role, className }: RoleBadgeProps) {
return (
<span
className={cn(
"inline-flex w-fit rounded-full border border-[var(--accent-mid)] bg-[var(--accent-soft)] px-2.5 py-1 text-[10.5px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.1em]",
className,
)}
>
{roleLabels[role] ?? role}
</span>
);
}
+11 -7
View File
@@ -16,16 +16,20 @@ const SelectTrigger = React.forwardRef<
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
{...props}
className={cn( className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", "flex h-10 w-full appearance-none items-center justify-between rounded-lg border border-[var(--hairline)] bg-[var(--surface)] bg-[right_12px_center] bg-no-repeat px-3 py-2.5 pr-9 text-[13.5px] text-[var(--ink)] transition placeholder:text-[var(--ink-faint)] hover:border-[var(--accent-mid)] focus:border-[var(--accent)] focus:bg-[oklch(0.99_0.005_75)] focus:outline-none focus:ring-[3px] focus:ring-[var(--focus-ring)] disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className, className,
)} )}
{...props} style={{
backgroundImage:
"linear-gradient(45deg, transparent 50%, var(--ink-muted) 50%), linear-gradient(135deg, var(--ink-muted) 50%, transparent 50%)",
backgroundPosition: "calc(100% - 16px) 50%, calc(100% - 11px) 50%",
backgroundSize: "5px 5px, 5px 5px",
...props.style,
}}
> >
{children} {children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
)); ));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
@@ -66,7 +70,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink)] shadow-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className, className,
@@ -97,7 +101,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-[13.5px] outline-none focus:bg-[var(--accent-soft)] focus:text-[var(--accent-deep)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className, className,
)} )}
{...props} {...props}
+5 -4
View File
@@ -12,15 +12,16 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
position="bottom-center"
toastOptions={{ toastOptions={{
classNames: { classNames: {
toast: toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", "group toast group-[.toaster]:rounded-[10px] group-[.toaster]:border-0 group-[.toaster]:bg-[oklch(0.22_0.015_45)] group-[.toaster]:px-[18px] group-[.toaster]:py-3 group-[.toaster]:text-white group-[.toaster]:shadow-none group-data-[state=open]:animate-in group-data-[state=open]:slide-in-from-bottom-8 group-data-[state=open]:duration-200",
description: "group-[.toast]:text-muted-foreground", description: "group-[.toast]:text-white/70",
actionButton: actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", "group-[.toast]:bg-[var(--accent)] group-[.toast]:text-white",
cancelButton: cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", "group-[.toast]:bg-white/10 group-[.toast]:text-white",
}, },
}} }}
{...props} {...props}
+35
View File
@@ -0,0 +1,35 @@
import { cn } from "@/lib/utils";
type StatProps = {
label: string;
value: number | string;
suffix?: string;
hint?: string;
className?: string;
};
export function Stat({ label, value, suffix, hint, className }: StatProps) {
return (
<div
className={cn(
"relative rounded-[14px] border border-[var(--hairline)] bg-[var(--surface)] p-5 text-[var(--ink)] before:absolute before:inset-x-5 before:top-0 before:h-[1.5px] before:bg-[var(--accent)] before:opacity-40",
className,
)}
>
<p className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
{label}
</p>
<p className="mt-2 text-[30px] font-medium leading-none tracking-[-0.025em] [font-variant-numeric:tabular-nums]">
{value}
{suffix ? (
<em className="ml-1 text-sm not-italic text-[var(--ink-muted)]">
{suffix}
</em>
) : null}
</p>
{hint ? (
<p className="mt-2 text-xs text-[var(--ink-soft)]">{hint}</p>
) : null}
</div>
);
}
+16 -6
View File
@@ -9,7 +9,10 @@ const Table = React.forwardRef<
<div className="relative w-full overflow-auto"> <div className="relative w-full overflow-auto">
<table <table
ref={ref} ref={ref}
className={cn("w-full caption-bottom text-sm", className)} className={cn(
"w-full caption-bottom text-[13.5px] text-[var(--ink)]",
className,
)}
{...props} {...props}
/> />
</div> </div>
@@ -20,7 +23,11 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> <thead
ref={ref}
className={cn("[&_tr]:border-b [&_tr]:border-[var(--hairline-soft)]", className)}
{...props}
/>
)); ));
TableHeader.displayName = "TableHeader"; TableHeader.displayName = "TableHeader";
@@ -43,7 +50,7 @@ const TableRow = React.forwardRef<
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", "border-b border-[var(--hairline-soft)] transition-colors hover:bg-[var(--surface-2)] data-[state=selected]:bg-[var(--accent-soft)]",
className, className,
)} )}
{...props} {...props}
@@ -58,7 +65,7 @@ const TableHead = React.forwardRef<
<th <th
ref={ref} ref={ref}
className={cn( className={cn(
"h-12 px-4 text-left align-middle text-xs font-medium uppercase tracking-wide text-muted-foreground [&:has([role=checkbox])]:pr-0", "h-11 px-4 text-left align-middle text-[11px] font-semibold uppercase tracking-[0.12em] text-[var(--ink-muted)] [&:has([role=checkbox])]:pr-0",
className, className,
)} )}
{...props} {...props}
@@ -72,7 +79,10 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<td <td
ref={ref} ref={ref}
className={cn("px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0", className)} className={cn(
"px-4 py-3.5 align-middle text-[var(--ink)] [&:has([role=checkbox])]:pr-0",
className,
)}
{...props} {...props}
/> />
)); ));
@@ -84,7 +94,7 @@ const TableCaption = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<caption <caption
ref={ref} ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)} className={cn("mt-4 text-sm text-[var(--ink-muted)]", className)}
{...props} {...props}
/> />
)); ));
+1 -1
View File
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
<textarea <textarea
ref={ref} ref={ref}
className={cn( className={cn(
"flex min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex min-h-24 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2.5 text-[13.5px] text-[var(--ink)] transition placeholder:text-[var(--ink-faint)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
+14 -8
View File
@@ -11,6 +11,8 @@ import {
Text, Text,
} from "@react-email/components"; } from "@react-email/components";
import { fontStack } from "./_styles";
type AlertEmailProps = { type AlertEmailProps = {
date: string; date: string;
childName: string; childName: string;
@@ -25,26 +27,26 @@ export function AlertEmail({
return ( return (
<Html lang="de"> <Html lang="de">
<Head /> <Head />
<Preview>Dringender Notdienst-Alarm fuer {date}</Preview> <Preview>Dringender Notdienst-Alarm für {date}</Preview>
<Body style={styles.body}> <Body style={styles.body}>
<Container style={styles.container}> <Container style={styles.container}>
<Section style={styles.alertBar}> <Section style={styles.alertBar}>
<Text style={styles.kicker}>Dringend</Text> <Text style={styles.kicker}>Dringend</Text>
<Heading style={styles.heading}>Notdienst heute bestaetigen</Heading> <Heading style={styles.heading}>Notdienst heute bestätigen</Heading>
<Text style={styles.lead}> <Text style={styles.lead}>
Eine Fachkraft ist ausgefallen. Fuer {childName} ist ein Eine Fachkraft ist ausgefallen. Für {childName} ist ein
Notdienst-Einsatz am {date} hinterlegt. Notdienst-Einsatz am {date} hinterlegt.
</Text> </Text>
</Section> </Section>
<Section style={styles.card}> <Section style={styles.card}>
<Text style={styles.text}> <Text style={styles.text}>
Bitte bestaetige schnell, ob du den Notdienst uebernehmen kannst, Bitte bestätige schnell, ob du den Notdienst übernehmen kannst,
damit die Kita den Tag verlaesslich planen kann. damit die Kita den Tag verlässlich planen kann.
</Text> </Text>
<Button href={confirmLink} style={styles.button}> <Button href={confirmLink} style={styles.button}>
Notdienst bestaetigen Notdienst bestätigen
</Button> </Button>
<Section style={styles.detailBox}> <Section style={styles.detailBox}>
@@ -75,8 +77,7 @@ const styles = {
body: { body: {
margin: 0, margin: 0,
backgroundColor: "#fff7ed", backgroundColor: "#fff7ed",
fontFamily: fontFamily: fontStack,
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
color: "#30170f", color: "#30170f",
}, },
container: { container: {
@@ -108,12 +109,14 @@ const styles = {
fontSize: "30px", fontSize: "30px",
lineHeight: "1.16", lineHeight: "1.16",
fontWeight: 850, fontWeight: 850,
fontFamily: fontStack,
}, },
lead: { lead: {
margin: 0, margin: 0,
color: "#ffe4e6", color: "#ffe4e6",
fontSize: "16px", fontSize: "16px",
lineHeight: "1.55", lineHeight: "1.55",
fontFamily: fontStack,
}, },
card: { card: {
padding: "28px", padding: "28px",
@@ -127,6 +130,7 @@ const styles = {
color: "#44251a", color: "#44251a",
fontSize: "16px", fontSize: "16px",
lineHeight: "1.65", lineHeight: "1.65",
fontFamily: fontStack,
}, },
button: { button: {
display: "inline-block", display: "inline-block",
@@ -137,6 +141,7 @@ const styles = {
fontSize: "15px", fontSize: "15px",
fontWeight: 800, fontWeight: 800,
textDecoration: "none", textDecoration: "none",
fontFamily: fontStack,
}, },
detailBox: { detailBox: {
margin: "28px 0 0", margin: "28px 0 0",
@@ -182,5 +187,6 @@ const styles = {
fontSize: "12px", fontSize: "12px",
lineHeight: "1.5", lineHeight: "1.5",
textAlign: "center" as const, textAlign: "center" as const,
fontFamily: fontStack,
}, },
}; };
+150
View File
@@ -0,0 +1,150 @@
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
import {
contactRequestTypeLabels,
type ContactRequestInput,
} from "@/lib/contact-schema";
import { fontStack } from "./_styles";
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: fontStack,
};
const container = {
margin: "0 auto",
padding: "32px 24px",
maxWidth: "620px",
fontFamily: fontStack,
};
const heading = {
margin: "0 0 12px",
color: "#0f172a",
fontSize: "28px",
lineHeight: "34px",
fontFamily: fontStack,
};
const intro = {
margin: "0 0 24px",
color: "#475569",
fontSize: "15px",
lineHeight: "24px",
fontFamily: fontStack,
};
const summaryBox = {
border: "1px solid #d7e5dc",
borderRadius: "10px",
backgroundColor: "#ffffff",
padding: "18px",
fontFamily: fontStack,
};
const label = {
margin: "0 0 12px",
color: "#64748b",
fontSize: "12px",
fontWeight: 700,
letterSpacing: "0.04em",
textTransform: "uppercase" as const,
fontFamily: fontStack,
};
const row = {
margin: "0 0 10px",
color: "#0f172a",
fontSize: "15px",
lineHeight: "22px",
fontFamily: fontStack,
};
const messageLabel = {
margin: "14px 0 8px",
color: "#64748b",
fontSize: "12px",
fontWeight: 700,
letterSpacing: "0.04em",
textTransform: "uppercase" as const,
fontFamily: fontStack,
};
const messageText = {
margin: 0,
color: "#0f172a",
fontSize: "15px",
lineHeight: "24px",
whiteSpace: "pre-wrap" as const,
fontFamily: fontStack,
};
const footer = {
margin: "22px 0 0",
color: "#64748b",
fontSize: "13px",
lineHeight: "20px",
fontFamily: fontStack,
};
+11 -2
View File
@@ -13,6 +13,7 @@ import {
contactRequestTypeLabels, contactRequestTypeLabels,
type ContactRequestInput, type ContactRequestInput,
} from "@/lib/contact-schema"; } from "@/lib/contact-schema";
import { fontStack } from "./_styles";
type ContactRequestEmailProps = ContactRequestInput; type ContactRequestEmailProps = ContactRequestInput;
@@ -72,14 +73,14 @@ function InfoRow({ label, value }: { label: string; value: string }) {
const body = { const body = {
margin: 0, margin: 0,
backgroundColor: "#f8faf8", backgroundColor: "#f8faf8",
fontFamily: fontFamily: fontStack,
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
}; };
const container = { const container = {
margin: "0 auto", margin: "0 auto",
padding: "32px 24px", padding: "32px 24px",
maxWidth: "620px", maxWidth: "620px",
fontFamily: fontStack,
}; };
const heading = { const heading = {
@@ -87,6 +88,7 @@ const heading = {
color: "#0f172a", color: "#0f172a",
fontSize: "28px", fontSize: "28px",
lineHeight: "34px", lineHeight: "34px",
fontFamily: fontStack,
}; };
const intro = { const intro = {
@@ -94,6 +96,7 @@ const intro = {
color: "#475569", color: "#475569",
fontSize: "15px", fontSize: "15px",
lineHeight: "24px", lineHeight: "24px",
fontFamily: fontStack,
}; };
const details = { const details = {
@@ -101,6 +104,7 @@ const details = {
borderRadius: "10px", borderRadius: "10px",
backgroundColor: "#ffffff", backgroundColor: "#ffffff",
padding: "18px", padding: "18px",
fontFamily: fontStack,
}; };
const row = { const row = {
@@ -108,6 +112,7 @@ const row = {
color: "#0f172a", color: "#0f172a",
fontSize: "15px", fontSize: "15px",
lineHeight: "22px", lineHeight: "22px",
fontFamily: fontStack,
}; };
const messageBox = { const messageBox = {
@@ -116,6 +121,7 @@ const messageBox = {
borderRadius: "10px", borderRadius: "10px",
backgroundColor: "#ffffff", backgroundColor: "#ffffff",
padding: "18px", padding: "18px",
fontFamily: fontStack,
}; };
const label = { const label = {
@@ -125,6 +131,7 @@ const label = {
fontWeight: 700, fontWeight: 700,
letterSpacing: "0.04em", letterSpacing: "0.04em",
textTransform: "uppercase" as const, textTransform: "uppercase" as const,
fontFamily: fontStack,
}; };
const messageText = { const messageText = {
@@ -133,6 +140,7 @@ const messageText = {
fontSize: "15px", fontSize: "15px",
lineHeight: "24px", lineHeight: "24px",
whiteSpace: "pre-wrap" as const, whiteSpace: "pre-wrap" as const,
fontFamily: fontStack,
}; };
const footer = { const footer = {
@@ -140,4 +148,5 @@ const footer = {
color: "#64748b", color: "#64748b",
fontSize: "13px", fontSize: "13px",
lineHeight: "20px", lineHeight: "20px",
fontFamily: fontStack,
}; };
+15 -5
View File
@@ -10,6 +10,8 @@ import {
Text, Text,
} from "@react-email/components"; } from "@react-email/components";
import { fontStack } from "./_styles";
type DutyReminderEmailProps = { type DutyReminderEmailProps = {
familyName: string; familyName: string;
dutyName: string; dutyName: string;
@@ -25,15 +27,15 @@ export function DutyReminderEmail({
<Html lang="de"> <Html lang="de">
<Head /> <Head />
<Preview> <Preview>
Erinnerung: {familyName} ist diese Woche fuer {dutyName} eingeteilt. Erinnerung: {familyName} ist diese Woche für {dutyName} eingeteilt.
</Preview> </Preview>
<Body style={styles.body}> <Body style={styles.body}>
<Container style={styles.container}> <Container style={styles.container}>
<Section style={styles.header}> <Section style={styles.header}>
<Text style={styles.kicker}>Elterndienst</Text> <Text style={styles.kicker}>Elterndienst</Text>
<Heading style={styles.heading}>Danke fuer eure Hilfe</Heading> <Heading style={styles.heading}>Danke für eure Hilfe</Heading>
<Text style={styles.lead}> <Text style={styles.lead}>
Hallo {familyName}, diese Woche seid ihr fuer den Dienst{" "} Hallo {familyName}, diese Woche seid ihr für den Dienst{" "}
<strong>{dutyName}</strong> eingeteilt. <strong>{dutyName}</strong> eingeteilt.
</Text> </Text>
</Section> </Section>
@@ -43,7 +45,7 @@ export function DutyReminderEmail({
Zeitraum: <strong>{weekLabel}</strong> Zeitraum: <strong>{weekLabel}</strong>
</Text> </Text>
<Text style={styles.text}> <Text style={styles.text}>
Danke, dass ihr mithelft, den Kita-Alltag verlaesslich zu Danke, dass ihr mithelft, den Kita-Alltag verlässlich zu
organisieren. organisieren.
</Text> </Text>
</Section> </Section>
@@ -62,7 +64,7 @@ const styles = {
body: { body: {
margin: 0, margin: 0,
backgroundColor: "#f7f5ef", backgroundColor: "#f7f5ef",
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', fontFamily: fontStack,
color: "#24231f", color: "#24231f",
}, },
container: { container: {
@@ -70,11 +72,13 @@ const styles = {
maxWidth: "600px", maxWidth: "600px",
margin: "0 auto", margin: "0 auto",
padding: "32px 20px", padding: "32px 20px",
fontFamily: fontStack,
}, },
header: { header: {
padding: "28px", padding: "28px",
backgroundColor: "#27423a", backgroundColor: "#27423a",
borderRadius: "8px 8px 0 0", borderRadius: "8px 8px 0 0",
fontFamily: fontStack,
}, },
kicker: { kicker: {
margin: "0 0 20px", margin: "0 0 20px",
@@ -83,6 +87,7 @@ const styles = {
fontWeight: 800, fontWeight: 800,
letterSpacing: "0.08em", letterSpacing: "0.08em",
textTransform: "uppercase" as const, textTransform: "uppercase" as const,
fontFamily: fontStack,
}, },
heading: { heading: {
margin: "0 0 12px", margin: "0 0 12px",
@@ -90,12 +95,14 @@ const styles = {
fontSize: "30px", fontSize: "30px",
lineHeight: "1.18", lineHeight: "1.18",
fontWeight: 800, fontWeight: 800,
fontFamily: fontStack,
}, },
lead: { lead: {
margin: 0, margin: 0,
color: "#eef4eb", color: "#eef4eb",
fontSize: "16px", fontSize: "16px",
lineHeight: "1.55", lineHeight: "1.55",
fontFamily: fontStack,
}, },
card: { card: {
padding: "28px", padding: "28px",
@@ -103,12 +110,14 @@ const styles = {
border: "1px solid #dde3d7", border: "1px solid #dde3d7",
borderTop: "0", borderTop: "0",
borderRadius: "0 0 8px 8px", borderRadius: "0 0 8px 8px",
fontFamily: fontStack,
}, },
text: { text: {
margin: "0 0 16px", margin: "0 0 16px",
color: "#3d423b", color: "#3d423b",
fontSize: "16px", fontSize: "16px",
lineHeight: "1.65", lineHeight: "1.65",
fontFamily: fontStack,
}, },
hr: { hr: {
margin: "24px 0", margin: "24px 0",
@@ -120,5 +129,6 @@ const styles = {
fontSize: "12px", fontSize: "12px",
lineHeight: "1.5", lineHeight: "1.5",
textAlign: "center" as const, textAlign: "center" as const,
fontFamily: fontStack,
}, },
}; };
+75 -63
View File
@@ -4,13 +4,14 @@ import {
Container, Container,
Head, Head,
Heading, Heading,
Hr,
Html, Html,
Preview, Preview,
Section, Section,
Text, Text,
} from "@react-email/components"; } from "@react-email/components";
import { baseBodyStyle, colors, fontStack } from "./_styles";
type InviteEmailProps = { type InviteEmailProps = {
parentName: string; parentName: string;
kitaName: string; kitaName: string;
@@ -25,20 +26,21 @@ export function InviteEmail({
return ( return (
<Html lang="de"> <Html lang="de">
<Head /> <Head />
<Preview>Aktiviere deinen Kita-Planer Account fuer {kitaName}</Preview> <Preview>Aktiviere deinen Kita-Planer Account für {kitaName}</Preview>
<Body style={styles.body}> <Body style={styles.body}>
<Container style={styles.container}> <Container style={styles.container}>
<Section style={styles.header}> <Section style={styles.card}>
<Section style={styles.brandBar}>
<Text style={styles.brand}>Kita-Planer</Text> <Text style={styles.brand}>Kita-Planer</Text>
</Section>
<Text style={styles.kicker}>Einladung zur Kita-App</Text>
<Heading style={styles.heading}>Willkommen bei {kitaName}</Heading> <Heading style={styles.heading}>Willkommen bei {kitaName}</Heading>
<Text style={styles.lead}> <Text style={styles.lead}>
Hallo {parentName}, dein Eltern-Account wurde vorbereitet. Hallo {parentName}, dein Eltern-Account wurde vorbereitet.
</Text> </Text>
</Section>
<Section style={styles.card}>
<Text style={styles.text}> <Text style={styles.text}>
Ueber den folgenden Link kannst du dein Passwort setzen und deinen Über den folgenden Link kannst du dein Passwort setzen und deinen
Account aktivieren. Danach hast du Zugriff auf die Kita-Planung, Account aktivieren. Danach hast du Zugriff auf die Kita-Planung,
Termine und deine Familiendaten. Termine und deine Familiendaten.
</Text> </Text>
@@ -54,7 +56,6 @@ export function InviteEmail({
<Text style={styles.linkText}>{inviteLink}</Text> <Text style={styles.linkText}>{inviteLink}</Text>
</Section> </Section>
<Hr style={styles.hr} />
<Text style={styles.footer}> <Text style={styles.footer}>
Diese Einladung wurde von deiner Kita erstellt. Wenn du sie nicht Diese Einladung wurde von deiner Kita erstellt. Wenn du sie nicht
erwartet hast, kannst du diese E-Mail ignorieren. erwartet hast, kannst du diese E-Mail ignorieren.
@@ -66,90 +67,101 @@ export function InviteEmail({
} }
const styles = { const styles = {
body: { body: baseBodyStyle,
margin: 0,
backgroundColor: "#f6f7f2",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
color: "#1f2a24",
},
container: { container: {
width: "100%", width: "100%",
maxWidth: "600px", maxWidth: "600px",
margin: "0 auto", margin: "0 auto",
padding: "32px 20px", padding: "32px 20px",
}, fontFamily: fontStack,
header: {
padding: "28px 28px 20px",
backgroundColor: "#1f3b2d",
borderRadius: "8px 8px 0 0",
},
brand: {
margin: "0 0 28px",
color: "#d8f2bd",
fontSize: "13px",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
},
heading: {
margin: "0 0 12px",
color: "#ffffff",
fontSize: "30px",
lineHeight: "1.18",
fontWeight: 800,
},
lead: {
margin: 0,
color: "#e7f3e6",
fontSize: "16px",
lineHeight: "1.55",
}, },
card: { card: {
padding: "28px", padding: "0 28px 28px",
backgroundColor: "#ffffff", backgroundColor: colors.cardBg,
borderRadius: "0 0 8px 8px", borderRadius: "10px",
border: "1px solid #e0e5dc", border: `1px solid ${colors.border}`,
borderTop: "0", boxShadow: "0 10px 26px rgba(31, 59, 45, 0.08)",
overflow: "hidden",
fontFamily: fontStack,
},
brandBar: {
margin: "0 -28px 28px",
padding: "14px 28px",
backgroundColor: colors.brandGreen,
fontFamily: fontStack,
},
brand: {
margin: 0,
color: colors.eyebrow,
fontSize: "12px",
fontWeight: 800,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
fontFamily: fontStack,
},
kicker: {
margin: "0 0 10px",
color: colors.brandGreenAccent,
fontSize: "12px",
fontWeight: 800,
letterSpacing: "0.06em",
textTransform: "uppercase" as const,
fontFamily: fontStack,
},
heading: {
margin: "0 0 14px",
color: colors.textPrimary,
fontSize: "28px",
lineHeight: "34px",
fontWeight: 800,
fontFamily: fontStack,
},
lead: {
margin: "0 0 22px",
color: colors.textBody,
fontSize: "16px",
lineHeight: "24px",
fontFamily: fontStack,
}, },
text: { text: {
margin: "0 0 24px", margin: "0 0 24px",
color: "#344139", color: colors.textBody,
fontSize: "16px", fontSize: "15px",
lineHeight: "1.65", lineHeight: "24px",
fontFamily: fontStack,
}, },
button: { button: {
display: "inline-block", display: "inline-block",
padding: "14px 22px", padding: "14px 20px",
backgroundColor: "#f0b84b", backgroundColor: colors.brandGreen,
color: "#172119", color: "#ffffff",
borderRadius: "6px", borderRadius: "6px",
fontSize: "15px", fontSize: "15px",
fontWeight: 800, fontWeight: 700,
textDecoration: "none", textDecoration: "none",
fontFamily: fontStack,
}, },
fallbackText: { fallbackText: {
margin: "28px 0 8px", margin: "28px 0 8px",
color: "#66736b", color: colors.textMuted,
fontSize: "13px", fontSize: "13px",
lineHeight: "1.5", lineHeight: "20px",
fontFamily: fontStack,
}, },
linkText: { linkText: {
margin: 0, margin: 0,
color: "#2f6b4f", color: colors.link,
fontSize: "13px", fontSize: "13px",
lineHeight: "1.5", lineHeight: "20px",
wordBreak: "break-all" as const, wordBreak: "break-all" as const,
}, fontFamily: fontStack,
hr: {
margin: "24px 0",
borderColor: "#dfe5d9",
}, },
footer: { footer: {
margin: 0, margin: "20px 0 0",
color: "#7b857d", color: colors.textFaint,
fontSize: "12px", fontSize: "12px",
lineHeight: "1.5", lineHeight: "18px",
textAlign: "center" as const, textAlign: "center" as const,
fontFamily: fontStack,
}, },
}; };
+14 -4
View File
@@ -11,6 +11,8 @@ import {
Text, Text,
} from "@react-email/components"; } from "@react-email/components";
import { fontStack } from "./_styles";
type NewsEmailProps = { type NewsEmailProps = {
title: string; title: string;
content: string; content: string;
@@ -53,7 +55,7 @@ const styles = {
body: { body: {
margin: 0, margin: 0,
backgroundColor: "#f5f3ee", backgroundColor: "#f5f3ee",
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', fontFamily: fontStack,
color: "#25231f", color: "#25231f",
}, },
container: { container: {
@@ -61,11 +63,13 @@ const styles = {
maxWidth: "600px", maxWidth: "600px",
margin: "0 auto", margin: "0 auto",
padding: "32px 20px", padding: "32px 20px",
fontFamily: fontStack,
}, },
header: { header: {
padding: "28px", padding: "28px",
backgroundColor: "#243b36", backgroundColor: "#243b36",
borderRadius: "8px 8px 0 0", borderRadius: "8px 8px 0 0",
fontFamily: fontStack,
}, },
kicker: { kicker: {
margin: "0 0 20px", margin: "0 0 20px",
@@ -74,6 +78,7 @@ const styles = {
fontWeight: 800, fontWeight: 800,
letterSpacing: "0.08em", letterSpacing: "0.08em",
textTransform: "uppercase" as const, textTransform: "uppercase" as const,
fontFamily: fontStack,
}, },
heading: { heading: {
margin: 0, margin: 0,
@@ -81,6 +86,7 @@ const styles = {
fontSize: "28px", fontSize: "28px",
lineHeight: "1.2", lineHeight: "1.2",
fontWeight: 800, fontWeight: 800,
fontFamily: fontStack,
}, },
card: { card: {
padding: "28px", padding: "28px",
@@ -88,6 +94,7 @@ const styles = {
border: "1px solid #deded5", border: "1px solid #deded5",
borderTop: "0", borderTop: "0",
borderRadius: "0 0 8px 8px", borderRadius: "0 0 8px 8px",
fontFamily: fontStack,
}, },
text: { text: {
margin: "0 0 24px", margin: "0 0 24px",
@@ -95,16 +102,18 @@ const styles = {
fontSize: "15px", fontSize: "15px",
lineHeight: "1.65", lineHeight: "1.65",
whiteSpace: "pre-wrap" as const, whiteSpace: "pre-wrap" as const,
fontFamily: fontStack,
}, },
button: { button: {
display: "inline-block", display: "inline-block",
padding: "13px 20px", padding: "13px 20px",
backgroundColor: "#f0b84b", backgroundColor: "#243b36",
color: "#172119", color: "#ffffff",
borderRadius: "6px", borderRadius: "6px",
fontSize: "15px", fontSize: "15px",
fontWeight: 800, fontWeight: 700,
textDecoration: "none", textDecoration: "none",
fontFamily: fontStack,
}, },
hr: { hr: {
margin: "24px 0", margin: "24px 0",
@@ -116,5 +125,6 @@ const styles = {
fontSize: "12px", fontSize: "12px",
lineHeight: "1.5", lineHeight: "1.5",
textAlign: "center" as const, textAlign: "center" as const,
fontFamily: fontStack,
}, },
}; };
+39
View File
@@ -0,0 +1,39 @@
// Gemeinsame Style-Tokens für alle E-Mail-Templates.
//
// Schriftart: Mail-Clients fallen unterschiedlich zurück, wenn ein Font nicht
// verfügbar ist. Manche (z.B. einige Web-Reader) wählen dann Monospace —
// deshalb erst Arial/Helvetica voranstellen, die praktisch überall existieren.
// Außerdem wird `fontFamily` auf einzelne Elemente gesetzt statt nur auf
// `<Body>`, weil Outlook & Co. die Body-CSS oft nicht erben.
export const fontStack =
'Arial, Helvetica, "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, sans-serif';
export const colors = {
brandGreen: "#1f3b2d",
brandGreenAccent: "#2f6b4f",
bgSoft: "#f6f7f2",
bgAlt: "#f8faf8",
cardBg: "#ffffff",
border: "#e0e5dc",
hr: "#dfe5d9",
textHeader: "#ffffff",
textPrimary: "#1f2a24",
textBody: "#344139",
textMuted: "#66736b",
textFaint: "#7b857d",
link: "#2f6b4f",
eyebrow: "#d8f2bd",
lead: "#e7f3e6",
};
export const baseBodyStyle = {
margin: 0,
backgroundColor: colors.bgSoft,
fontFamily: fontStack,
color: colors.textPrimary,
} as const;
export const baseTextStyle = {
fontFamily: fontStack,
color: colors.textBody,
} as const;
+34
View File
@@ -0,0 +1,34 @@
export const CHILD_MAX_AGE_YEARS = 7;
export function formatDateInputValue(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function getChildBirthdateRange(referenceDate = new Date()) {
const max = new Date(
referenceDate.getFullYear(),
referenceDate.getMonth(),
referenceDate.getDate(),
);
const min = new Date(max);
min.setFullYear(min.getFullYear() - CHILD_MAX_AGE_YEARS);
return {
min: formatDateInputValue(min),
max: formatDateInputValue(max),
};
}
export function parseDateInput(value?: string) {
if (!value) return null;
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const date = new Date(`${value}T00:00:00`);
if (Number.isNaN(date.getTime())) return null;
return formatDateInputValue(date) === value ? date : null;
}

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