Add RSVP events and Unsplash image support

This commit is contained in:
t.indorf
2026-05-15 21:28:36 +02:00
parent ed6786b6ea
commit 9542def530
13 changed files with 1392 additions and 34 deletions
+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;
+35
View File
@@ -63,6 +63,11 @@ enum TerminStatus {
CANCELLED CANCELLED
} }
enum EventParticipationStatus {
ATTENDING
NOT_ATTENDING
}
enum NotdienstPlanStatus { enum NotdienstPlanStatus {
DRAFT DRAFT
PUBLISHED PUBLISHED
@@ -121,6 +126,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[]
@@ -248,6 +254,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 +497,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 +514,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
+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,
})),
};
}
@@ -0,0 +1,608 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import {
EventParticipationStatus,
TerminStatus,
TerminType,
} from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import {
CalendarPlus,
Check,
Clock,
HelpCircle,
Pencil,
Users,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import {
createTerminAdmin,
updateTerminAdmin,
} from "../../kalender/actions";
type AdminTerminDto = {
id: string;
title: string;
description: string | null;
type: TerminType;
status: TerminStatus;
startDate: string;
endDate: string;
allDay: boolean;
mitbringselListEnabled: boolean;
requiresRsvp: boolean;
rsvpDeadline: string | null;
participantInfo: string | null;
participations: {
childId: string;
status: EventParticipationStatus;
}[];
};
type ChildDto = {
id: string;
name: string;
familyName: string;
};
export function AdminCalendarManager({
termine,
childRows,
}: {
termine: AdminTerminDto[];
childRows: ChildDto[];
}) {
return (
<div className="flex h-full flex-col gap-6 p-6">
<div className="flex flex-col justify-between gap-4 lg:flex-row lg:items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Event-Anmeldungen & Ausflüge
</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind
verfolgen.
</p>
</div>
<TerminFormDialog mode="create" />
</div>
{termine.length === 0 ? (
<div className="rounded-md border border-dashed p-10 text-center">
<CalendarPlus className="mx-auto h-10 w-10 text-muted-foreground" />
<h2 className="mt-4 text-lg font-semibold">Noch keine Termine</h2>
<p className="mt-1 text-sm text-muted-foreground">
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
</p>
</div>
) : (
<div className="grid gap-4">
{termine.map((termin) => (
<AdminTerminCard
key={termin.id}
termin={termin}
childRows={childRows}
/>
))}
</div>
)}
</div>
);
}
function AdminTerminCard({
termin,
childRows,
}: {
termin: AdminTerminDto;
childRows: ChildDto[];
}) {
const stats = useMemo(() => getStats(termin, childRows), [termin, childRows]);
return (
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="mb-2 flex flex-wrap gap-2">
<Badge className={getTypeBadgeClass(termin.type)}>
{getTypeLabel(termin.type)}
</Badge>
<Badge variant={getStatusVariant(termin.status)}>
{getStatusLabel(termin.status)}
</Badge>
{termin.requiresRsvp && (
<Badge variant="warning">RSVP aktiv</Badge>
)}
</div>
<CardTitle className="text-lg">{termin.title}</CardTitle>
<CardDescription className="mt-1 flex flex-wrap items-center gap-2">
<Clock className="h-3.5 w-3.5" />
{format(new Date(termin.startDate), "dd.MM.yyyy", {
locale: de,
})}
{!termin.allDay && (
<>
<span>·</span>
<span>
{format(new Date(termin.startDate), "HH:mm", {
locale: de,
})}{" "}
bis{" "}
{format(new Date(termin.endDate), "HH:mm", {
locale: de,
})}
</span>
</>
)}
</CardDescription>
</div>
<TerminFormDialog mode="edit" termin={termin} />
</div>
</CardHeader>
<CardContent className="grid gap-5 pt-5">
{termin.description && (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
{termin.description}
</p>
)}
{termin.requiresRsvp ? (
<div className="grid gap-4">
<div className="flex flex-col gap-3 rounded-md border bg-amber-50/70 p-4 text-amber-950 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
{stats.pending} Ausstehend
</p>
<p className="mt-1 text-sm text-amber-900/80">
Frist:{" "}
{termin.rsvpDeadline
? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", {
locale: de,
})
: "bis Terminbeginn"}
</p>
</div>
<Users className="h-5 w-5 shrink-0" />
</div>
{termin.participantInfo && (
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-4 text-sm text-yellow-950">
<span className="font-medium">Hinweis für Teilnehmer: </span>
{termin.participantInfo}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>Kind</TableHead>
<TableHead>Familie</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{childRows.map((child) => {
const status = stats.statusByChildId.get(child.id) ?? null;
return (
<TableRow key={child.id}>
<TableCell className="font-medium">{child.name}</TableCell>
<TableCell>{child.familyName}</TableCell>
<TableCell>
<RsvpStatusBadge status={status} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
) : (
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
Für diesen Termin ist keine Teilnahme-Abfrage aktiv.
</div>
)}
</CardContent>
</Card>
);
}
function TerminFormDialog({
mode,
termin,
}: {
mode: "create" | "edit";
termin?: AdminTerminDto;
}) {
const [open, setOpen] = useState(false);
const [requiresRsvp, setRequiresRsvp] = useState(
termin?.requiresRsvp ?? false,
);
const [isPending, startTransition] = useTransition();
function handleAction(formData: FormData) {
const startDate = new Date(String(formData.get("startDate")));
const endDate = new Date(String(formData.get("endDate")));
const deadlineRaw = String(formData.get("rsvpDeadline") ?? "");
const hasRsvp = formData.get("requiresRsvp") === "on";
const payload = {
title: String(formData.get("title") ?? ""),
description: String(formData.get("description") ?? ""),
type: formData.get("type") as TerminType,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
allDay: formData.get("allDay") === "on",
mitbringselListEnabled: formData.get("mitbringselListEnabled") === "on",
requiresRsvp: hasRsvp,
rsvpDeadline:
hasRsvp && deadlineRaw ? new Date(deadlineRaw).toISOString() : null,
participantInfo: String(formData.get("participantInfo") ?? ""),
};
startTransition(async () => {
const result =
mode === "edit" && termin
? await updateTerminAdmin(termin.id, payload)
: await createTerminAdmin(payload);
if (result.error) {
toast.error(result.error);
return;
}
toast.success(
mode === "edit" ? "Termin aktualisiert." : "Termin angelegt.",
);
setOpen(false);
});
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen) {
setRequiresRsvp(termin?.requiresRsvp ?? false);
}
}}
>
<DialogTrigger asChild>
<Button variant={mode === "edit" ? "outline" : "default"} size="sm">
{mode === "edit" ? (
<Pencil className="h-4 w-4" />
) : (
<CalendarPlus className="h-4 w-4" />
)}
{mode === "edit" ? "Bearbeiten" : "Termin anlegen"}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<form action={handleAction}>
<DialogHeader>
<DialogTitle>
{mode === "edit" ? "Termin bearbeiten" : "Termin anlegen"}
</DialogTitle>
<DialogDescription>
RSVP kann für Ausflüge und besondere Events aktiviert werden.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor={`${mode}-title`}>Titel</Label>
<Input
id={`${mode}-title`}
name="title"
defaultValue={termin?.title ?? ""}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-description`}>Beschreibung</Label>
<Textarea
id={`${mode}-description`}
name="description"
defaultValue={termin?.description ?? ""}
rows={3}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-type`}>Kategorie</Label>
<select
id={`${mode}-type`}
name="type"
defaultValue={termin?.type ?? TerminType.KITA_FEST}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
required
>
{Object.values(TerminType).map((type) => (
<option key={type} value={type}>
{getTypeLabel(type)}
</option>
))}
</select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor={`${mode}-startDate`}>Start</Label>
<Input
id={`${mode}-startDate`}
name="startDate"
type="datetime-local"
defaultValue={
termin ? toDatetimeLocalValue(termin.startDate) : ""
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-endDate`}>Ende</Label>
<Input
id={`${mode}-endDate`}
name="endDate"
type="datetime-local"
defaultValue={
termin ? toDatetimeLocalValue(termin.endDate) : ""
}
required
/>
</div>
</div>
<div className="grid gap-3 rounded-md border p-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="allDay"
defaultChecked={termin?.allDay ?? false}
className="h-4 w-4 rounded border-input"
/>
Ganztägiger Termin
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="mitbringselListEnabled"
defaultChecked={termin?.mitbringselListEnabled ?? false}
className="h-4 w-4 rounded border-input"
/>
Mitbring-Liste aktiv
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="requiresRsvp"
checked={requiresRsvp}
onChange={(event) => setRequiresRsvp(event.target.checked)}
className="h-4 w-4 rounded border-input"
/>
Teilnahme-Abfrage (RSVP) aktivieren
</label>
</div>
{requiresRsvp && (
<div className="grid gap-4 rounded-md border border-amber-200 bg-amber-50/60 p-4">
<div className="grid gap-2">
<Label htmlFor={`${mode}-rsvpDeadline`}>
Rückmeldefrist
</Label>
<Input
id={`${mode}-rsvpDeadline`}
name="rsvpDeadline"
type="datetime-local"
defaultValue={
termin?.rsvpDeadline
? toDatetimeLocalValue(termin.rsvpDeadline)
: ""
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-participantInfo`}>
Hinweise für Teilnehmer
</Label>
<Textarea
id={`${mode}-participantInfo`}
name="participantInfo"
defaultValue={termin?.participantInfo ?? ""}
placeholder="z.B. Zweite Brotdose mitbringen"
rows={3}
/>
</div>
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function RsvpStatusBadge({
status,
}: {
status: EventParticipationStatus | null;
}) {
if (status === EventParticipationStatus.ATTENDING) {
return (
<Badge variant="success" className="gap-1">
<Check className="h-3.5 w-3.5" />
Zugesagt
</Badge>
);
}
if (status === EventParticipationStatus.NOT_ATTENDING) {
return (
<Badge variant="destructive" className="gap-1">
<X className="h-3.5 w-3.5" />
Abgesagt
</Badge>
);
}
return (
<Badge variant="outline" className="gap-1 text-muted-foreground">
<HelpCircle className="h-3.5 w-3.5" />
Ausstehend
</Badge>
);
}
function getStats(termin: AdminTerminDto, children: ChildDto[]) {
const statusByChildId = new Map(
termin.participations.map((participation) => [
participation.childId,
participation.status,
]),
);
let attending = 0;
let notAttending = 0;
children.forEach((child) => {
const status = statusByChildId.get(child.id);
if (status === EventParticipationStatus.ATTENDING) attending += 1;
if (status === EventParticipationStatus.NOT_ATTENDING) notAttending += 1;
});
return {
attending,
notAttending,
pending: Math.max(children.length - attending - notAttending, 0),
statusByChildId,
};
}
function toDatetimeLocalValue(value: string) {
const date = new Date(value);
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return localDate.toISOString().slice(0, 16);
}
function getStatusVariant(
status: TerminStatus,
): "default" | "secondary" | "destructive" | "outline" | "success" | "warning" {
switch (status) {
case TerminStatus.CONFIRMED:
return "success";
case TerminStatus.PENDING:
return "warning";
case TerminStatus.REJECTED:
case TerminStatus.CANCELLED:
return "destructive";
default:
return "outline";
}
}
function getStatusLabel(status: TerminStatus) {
switch (status) {
case TerminStatus.CONFIRMED:
return "Bestätigt";
case TerminStatus.PENDING:
return "Ausstehend";
case TerminStatus.REJECTED:
return "Abgelehnt";
case TerminStatus.CANCELLED:
return "Abgesagt";
}
}
function getTypeBadgeClass(type: TerminType) {
switch (type) {
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "border-transparent bg-amber-500 text-white";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "border-transparent bg-rose-500 text-white";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "border-transparent bg-blue-500 text-white";
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "border-transparent bg-purple-500 text-white";
default:
return "border-slate-300 bg-slate-100 text-slate-800";
}
}
function getTypeLabel(type: TerminType) {
switch (type) {
case TerminType.KITA_FEST:
return "Kita-Fest";
case TerminType.SCHLIESSTAG:
return "Schließtag";
case TerminType.TEAMTAG:
return "Teamtag";
case TerminType.MITMACH_TAG:
return "Mitmach-Tag";
case TerminType.ELTERNABEND:
return "Elternabend";
case TerminType.MITGLIEDERVERSAMMLUNG:
return "Mitgliederversammlung";
case TerminType.ELTERNCAFE:
return "Elterncafe";
case TerminType.GEBURTSTAG_INTERN:
return "Geburtstag Intern";
case TerminType.GEBURTSTAG_EXTERN:
return "Raumanfrage Extern";
case TerminType.SONSTIGES:
return "Sonstiges";
}
}
+80
View File
@@ -0,0 +1,80 @@
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { AdminCalendarManager } from "./admin-calendar-manager";
export const metadata = { title: "Event-Anmeldungen · Kita-Planer" };
export default async function AdminKalenderPage() {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return <div className="p-8">Kein Mandant zugeordnet.</div>;
}
const [termine, children] = await Promise.all([
prisma.termin.findMany({
where: { kitaId },
select: {
id: true,
title: true,
description: true,
type: true,
status: true,
startDate: true,
endDate: true,
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
rsvpDeadline: true,
participantInfo: true,
participations: {
select: {
childId: true,
status: true,
},
},
},
orderBy: { startDate: "desc" },
}),
prisma.child.findMany({
where: {
kitaId,
active: true,
},
select: {
id: true,
firstName: true,
lastName: true,
family: {
select: {
name: true,
},
},
},
orderBy: [
{ family: { name: "asc" } },
{ lastName: "asc" },
{ firstName: "asc" },
],
}),
]);
return (
<AdminCalendarManager
termine={termine.map((termin) => ({
...termin,
startDate: termin.startDate.toISOString(),
endDate: termin.endDate.toISOString(),
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
}))}
childRows={children.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
familyName: child.family.name,
}))}
/>
);
}
@@ -3,7 +3,7 @@
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client"; import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { AlignLeft, Calendar, Clock, WashingMachine } from "lucide-react"; import { AlertTriangle, AlignLeft, Calendar, Clock, WashingMachine } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -13,6 +13,15 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useTransition } from "react"; import { useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export type TerminListItemDto = { export type TerminListItemDto = {
kind: "termin"; kind: "termin";
@@ -25,6 +34,9 @@ export type TerminListItemDto = {
endDate: Date; endDate: Date;
allDay: boolean; allDay: boolean;
mitbringselListEnabled: boolean; mitbringselListEnabled: boolean;
requiresRsvp: boolean;
participantInfo: string | null;
showParticipantInfo: boolean;
mitbringselItems?: { mitbringselItems?: {
id: string; id: string;
userId: string; userId: string;
@@ -178,6 +190,8 @@ function TerminCard({
</div> </div>
)} )}
<TerminDetailDialog termin={termin} />
{/* Admin Toggle for Mitbringsel-List */} {/* Admin Toggle for Mitbringsel-List */}
{isAdmin && termin.status === TerminStatus.CONFIRMED && ( {isAdmin && termin.status === TerminStatus.CONFIRMED && (
<div className="flex items-center space-x-2 mt-6 pt-4 border-t"> <div className="flex items-center space-x-2 mt-6 pt-4 border-t">
@@ -209,6 +223,58 @@ function TerminCard({
); );
} }
function TerminDetailDialog({ termin }: { termin: TerminListItemDto }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="mt-4">
Details
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{termin.title}</DialogTitle>
<DialogDescription>
{format(termin.startDate, "PP", { locale: de })}
{!termin.allDay &&
` · ${format(termin.startDate, "p", { locale: de })} - ${format(
termin.endDate,
"p",
{ locale: de },
)}`}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<Badge variant={getBadgeVariant(termin.type)} className={getBadgeColor(termin.type)}>
{getTypeLabel(termin.type)}
</Badge>
{termin.description ? (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
{termin.description}
</p>
) : (
<p className="text-sm text-muted-foreground">
Keine Beschreibung hinterlegt.
</p>
)}
{termin.participantInfo && termin.showParticipantInfo && (
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-4 text-sm text-yellow-950">
<div className="mb-1 flex items-center gap-2 font-medium">
<AlertTriangle className="h-4 w-4 text-yellow-700" />
Wichtiger Hinweis für Teilnehmer
</div>
<p className="whitespace-pre-wrap">{termin.participantInfo}</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
function getBadgeVariant(type: TerminType): "default" | "secondary" | "destructive" | "outline" { function getBadgeVariant(type: TerminType): "default" | "secondary" | "destructive" | "outline" {
switch (type) { switch (type) {
case TerminType.KITA_FEST: case TerminType.KITA_FEST:
+94 -14
View File
@@ -12,14 +12,48 @@ import { prisma } from "@/lib/prisma";
// ===================================================================== // =====================================================================
const terminSchema = z.object({ const terminSchema = z.object({
title: z.string().min(2, "Titel muss mindestens 2 Zeichen lang sein"), title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein"),
description: z.string().optional(), description: z.string().trim().optional(),
type: z.nativeEnum(TerminType), type: z.nativeEnum(TerminType),
startDate: z.string().datetime(), startDate: z.string().datetime(),
endDate: z.string().datetime(), endDate: z.string().datetime(),
allDay: z.boolean().default(false), allDay: z.boolean().default(false),
mitbringselListEnabled: z.boolean().default(false),
requiresRsvp: z.boolean().default(false),
rsvpDeadline: z.string().datetime().nullable().optional(),
participantInfo: z.string().trim().max(1000).optional(),
}); });
function buildTerminData(data: z.infer<typeof terminSchema>) {
const startDate = new Date(data.startDate);
const endDate = new Date(data.endDate);
if (endDate < startDate) {
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
}
return {
data: {
title: data.title,
description: data.description || null,
type: data.type,
startDate,
endDate,
allDay: data.allDay,
mitbringselListEnabled: data.mitbringselListEnabled,
requiresRsvp: data.requiresRsvp,
rsvpDeadline:
data.requiresRsvp && data.rsvpDeadline
? new Date(data.rsvpDeadline)
: null,
participantInfo:
data.requiresRsvp && data.participantInfo
? data.participantInfo
: null,
},
};
}
export async function createTerminRequest(rawPayload: unknown) { export async function createTerminRequest(rawPayload: unknown) {
const session = await requireKitaSession(); const session = await requireKitaSession();
@@ -29,18 +63,23 @@ export async function createTerminRequest(rawPayload: unknown) {
} }
const data = parsed.data; const data = parsed.data;
const terminData = buildTerminData(data);
if ("error" in terminData) {
return { error: terminData.error };
}
try { try {
await prisma.termin.create({ await prisma.termin.create({
data: { data: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
createdById: session.user.id, createdById: session.user.id,
title: data.title, title: terminData.data.title,
description: data.description, description: terminData.data.description,
type: data.type, type: terminData.data.type,
startDate: new Date(data.startDate), startDate: terminData.data.startDate,
endDate: new Date(data.endDate), endDate: terminData.data.endDate,
allDay: data.allDay, allDay: terminData.data.allDay,
status: TerminStatus.PENDING, status: TerminStatus.PENDING,
}, },
}); });
@@ -66,18 +105,18 @@ export async function createTerminAdmin(rawPayload: unknown) {
} }
const data = parsed.data; const data = parsed.data;
const terminData = buildTerminData(data);
if ("error" in terminData) {
return { error: terminData.error };
}
try { try {
await prisma.termin.create({ await prisma.termin.create({
data: { data: {
kitaId, kitaId,
createdById: session.user.id, createdById: session.user.id,
title: data.title, ...terminData.data,
description: data.description,
type: data.type,
startDate: new Date(data.startDate),
endDate: new Date(data.endDate),
allDay: data.allDay,
status: TerminStatus.CONFIRMED, status: TerminStatus.CONFIRMED,
approvedById: session.user.id, approvedById: session.user.id,
approvedAt: new Date(), approvedAt: new Date(),
@@ -85,6 +124,7 @@ export async function createTerminAdmin(rawPayload: unknown) {
}); });
revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Erstellen des Termins:", error); console.error("Fehler beim Erstellen des Termins:", error);
@@ -92,6 +132,43 @@ export async function createTerminAdmin(rawPayload: unknown) {
} }
} }
export async function updateTerminAdmin(terminId: string, rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const terminData = buildTerminData(parsed.data);
if ("error" in terminData) {
return { error: terminData.error };
}
try {
await prisma.termin.update({
where: {
id: terminId,
kitaId,
},
data: terminData.data,
});
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren des Termins:", error);
return { error: "Termin konnte nicht aktualisiert werden." };
}
}
export async function approveTermin(terminId: string) { export async function approveTermin(terminId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId; const kitaId = session.user.kitaId;
@@ -113,6 +190,7 @@ export async function approveTermin(terminId: string) {
}); });
revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Bestätigen des Termins:", error); console.error("Fehler beim Bestätigen des Termins:", error);
@@ -142,6 +220,7 @@ export async function rejectTermin(terminId: string, reason?: string) {
}); });
revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Ablehnen des Termins:", error); console.error("Fehler beim Ablehnen des Termins:", error);
@@ -168,6 +247,7 @@ export async function toggleMitbringselList(terminId: string, enabled: boolean)
}); });
revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Umschalten der Mitbring-Liste:", error); console.error("Fehler beim Umschalten der Mitbring-Liste:", error);
+45 -1
View File
@@ -1,4 +1,9 @@
import { DutyAssignmentStatus, TerminStatus, UserRole } from "@prisma/client"; import {
DutyAssignmentStatus,
EventParticipationStatus,
TerminStatus,
UserRole,
} from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -16,6 +21,7 @@ export default async function KalenderPage({
const isAdmin = const isAdmin =
session.user.role === UserRole.ADMIN || session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.KOORDINATOR; session.user.role === UserRole.KOORDINATOR;
const familyIdForParticipation = session.user.familyId ?? "__no_family__";
const currentTab = searchParams.tab || "übersicht"; const currentTab = searchParams.tab || "übersicht";
@@ -34,6 +40,8 @@ export default async function KalenderPage({
endDate: true, endDate: true,
allDay: true, allDay: true,
mitbringselListEnabled: true, mitbringselListEnabled: true,
requiresRsvp: true,
participantInfo: true,
mitbringselItems: { mitbringselItems: {
select: { select: {
id: true, id: true,
@@ -47,6 +55,17 @@ export default async function KalenderPage({
}, },
}, },
}, },
participations: {
where: {
child: {
familyId: familyIdForParticipation,
},
},
select: {
childId: true,
status: true,
},
},
}, },
orderBy: { startDate: "asc" }, orderBy: { startDate: "asc" },
}); });
@@ -67,6 +86,8 @@ export default async function KalenderPage({
endDate: true, endDate: true,
allDay: true, allDay: true,
mitbringselListEnabled: true, mitbringselListEnabled: true,
requiresRsvp: true,
participantInfo: true,
mitbringselItems: { mitbringselItems: {
select: { select: {
id: true, id: true,
@@ -80,6 +101,17 @@ export default async function KalenderPage({
}, },
}, },
}, },
participations: {
where: {
child: {
familyId: familyIdForParticipation,
},
},
select: {
childId: true,
status: true,
},
},
}, },
orderBy: { startDate: "asc" }, orderBy: { startDate: "asc" },
}); });
@@ -113,10 +145,22 @@ export default async function KalenderPage({
...confirmedTermineRows.map((termin) => ({ ...confirmedTermineRows.map((termin) => ({
...termin, ...termin,
kind: "termin" as const, kind: "termin" as const,
showParticipantInfo:
isAdmin ||
termin.participations.some(
(participation) =>
participation.status === EventParticipationStatus.ATTENDING,
),
})), })),
...myPendingTermineRows.map((termin) => ({ ...myPendingTermineRows.map((termin) => ({
...termin, ...termin,
kind: "termin" as const, kind: "termin" as const,
showParticipantInfo:
isAdmin ||
termin.participations.some(
(participation) =>
participation.status === EventParticipationStatus.ATTENDING,
),
})), })),
...dutyAssignmentRows.map((assignment) => ({ ...dutyAssignmentRows.map((assignment) => ({
kind: "duty" as const, kind: "duty" as const,
+63 -3
View File
@@ -2,7 +2,7 @@ import Link from "next/link";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle, ClipboardList } from "lucide-react"; import { Users, CalendarCheck2, ShieldCheck, AlertTriangle, ClipboardList } from "lucide-react";
import { DutyAssignmentStatus, UserRole, NotdienstPlanStatus } from "@prisma/client"; import { DutyAssignmentStatus, UserRole, NotdienstPlanStatus, TerminStatus } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -19,11 +19,13 @@ import { OnboardingDialog } from "@/components/OnboardingDialog";
import { AlertButton } from "./alert-button"; import { AlertButton } from "./alert-button";
import { AbsenceCard } from "./absence-card"; import { AbsenceCard } from "./absence-card";
import { NewsTicker } from "./news-ticker"; import { NewsTicker } from "./news-ticker";
import { RsvpActionCard } from "./rsvp-action-card";
export const metadata = { title: "Übersicht · Kita-Planer" }; export const metadata = { title: "Übersicht · Kita-Planer" };
export default async function DashboardPage() { export default async function DashboardPage() {
const session = await requireKitaSession(); const session = await requireKitaSession();
const now = new Date();
const kita = await prisma.kita.findUniqueOrThrow({ const kita = await prisma.kita.findUniqueOrThrow({
where: { id: session.user.kitaId }, where: { id: session.user.kitaId },
@@ -172,6 +174,62 @@ export default async function DashboardPage() {
}), }),
]); ]);
const familyChildIds = absenceChildren.map((child) => child.id);
const openRsvpTermine =
session.user.familyId && familyChildIds.length > 0
? await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.CONFIRMED,
requiresRsvp: true,
OR: [
{ rsvpDeadline: { gt: now } },
{ rsvpDeadline: null, startDate: { gt: now } },
],
},
select: {
id: true,
title: true,
startDate: true,
rsvpDeadline: true,
participations: {
where: {
childId: { in: familyChildIds },
},
select: {
childId: true,
status: true,
},
},
},
orderBy: { startDate: "asc" },
take: 5,
})
: [];
const rsvpActionEvents = openRsvpTermine
.map((termin) => {
const statusByChildId = new Map(
termin.participations.map((participation) => [
participation.childId,
participation.status,
]),
);
return {
id: termin.id,
title: termin.title,
startDate: termin.startDate.toISOString(),
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
children: absenceChildren.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
status: statusByChildId.get(child.id) ?? null,
})),
};
})
.filter((termin) => termin.children.some((child) => !child.status));
const isAdmin = const isAdmin =
session.user.role === UserRole.ADMIN || session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.SUPERADMIN; session.user.role === UserRole.SUPERADMIN;
@@ -204,6 +262,8 @@ export default async function DashboardPage() {
</p> </p>
</div> </div>
<RsvpActionCard events={rsvpActionEvents} />
<NewsTicker <NewsTicker
items={latestAnnouncements.map((announcement) => ({ items={latestAnnouncements.map((announcement) => ({
id: announcement.id, id: announcement.id,
@@ -307,9 +367,9 @@ export default async function DashboardPage() {
</Button> </Button>
{kita.terminModuleEnabled && ( {kita.terminModuleEnabled && (
<Button asChild size="sm" variant="outline"> <Button asChild size="sm" variant="outline">
<Link href="/dashboard/termine"> <Link href="/dashboard/kalender">
<CalendarCheck2 className="mr-2 h-4 w-4" /> <CalendarCheck2 className="mr-2 h-4 w-4" />
Termine (bald verfügbar) Terminkalender
</Link> </Link>
</Button> </Button>
)} )}
+204
View File
@@ -0,0 +1,204 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { EventParticipationStatus } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { AlertTriangle, Check, X } from "lucide-react";
import { toast } from "sonner";
import { submitEventRsvp } from "@/actions/rsvp";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export type DashboardRsvpEventDto = {
id: string;
title: string;
startDate: string;
rsvpDeadline: string | null;
children: {
id: string;
name: string;
status: EventParticipationStatus | null;
}[];
};
export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] }) {
if (events.length === 0) return null;
return (
<Card className="mb-8 border-amber-200 bg-amber-50/70">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-amber-950">
<AlertTriangle className="h-5 w-5 text-amber-700" />
Aktion erforderlich
</CardTitle>
<CardDescription className="text-amber-900/80">
Für diese Termine fehlt noch mindestens eine Rückmeldung deiner
Familie.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
{events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-3 rounded-md border bg-background p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="font-medium">{event.title}</p>
<p className="mt-1 text-sm text-muted-foreground">
Rückmeldung bis{" "}
{format(
new Date(event.rsvpDeadline ?? event.startDate),
"dd.MM.yyyy HH:mm",
{ locale: de },
)}
</p>
</div>
<RsvpDialog event={event} />
</div>
))}
</CardContent>
</Card>
);
}
function RsvpDialog({ event }: { event: DashboardRsvpEventDto }) {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const initialSelection = useMemo(
() =>
Object.fromEntries(
event.children.map((child) => [child.id, child.status ?? ""]),
) as Record<string, EventParticipationStatus | "">,
[event.children],
);
const [selection, setSelection] = useState(initialSelection);
function submit() {
const missingChild = event.children.find((child) => !selection[child.id]);
if (missingChild) {
toast.error(`Bitte Rückmeldung für ${missingChild.name} auswählen.`);
return;
}
startTransition(async () => {
for (const child of event.children) {
const status = selection[child.id];
if (!status) continue;
const result = await submitEventRsvp({
eventId: event.id,
childId: child.id,
status,
});
if (result.error) {
toast.error(result.error);
return;
}
}
toast.success("Rückmeldung gespeichert.");
setOpen(false);
});
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen) setSelection(initialSelection);
}}
>
<DialogTrigger asChild>
<Button size="sm">Jetzt abstimmen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Rückmeldung für {event.title}</DialogTitle>
<DialogDescription>
Wähle pro Kind aus, ob es am Termin teilnimmt.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
{event.children.map((child) => (
<div key={child.id} className="rounded-md border p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="font-medium">{child.name}</p>
{child.status && (
<Badge variant="outline">bereits gemeldet</Badge>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<label className="flex cursor-pointer items-center gap-2 rounded-md border p-3 text-sm hover:bg-muted/50">
<input
type="radio"
name={`rsvp-${child.id}`}
checked={
selection[child.id] ===
EventParticipationStatus.ATTENDING
}
onChange={() =>
setSelection((current) => ({
...current,
[child.id]: EventParticipationStatus.ATTENDING,
}))
}
/>
<Check className="h-4 w-4 text-emerald-600" />
Nimmt teil
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-md border p-3 text-sm hover:bg-muted/50">
<input
type="radio"
name={`rsvp-${child.id}`}
checked={
selection[child.id] ===
EventParticipationStatus.NOT_ATTENDING
}
onChange={() =>
setSelection((current) => ({
...current,
[child.id]: EventParticipationStatus.NOT_ATTENDING,
}))
}
/>
<X className="h-4 w-4 text-rose-600" />
Nimmt nicht teil
</label>
</div>
</div>
))}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Abbrechen
</Button>
<Button type="button" disabled={isPending} onClick={submit}>
{isPending ? "Speichern..." : "Rückmeldung speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+16 -10
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { import {
@@ -125,14 +126,19 @@ export default async function LandingPage() {
</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">
@@ -226,7 +232,7 @@ 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">
@@ -355,7 +361,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">
+7
View File
@@ -72,6 +72,13 @@ const navItems = [
exact: false, exact: false,
allowedRoles: [UserRole.ADMIN], allowedRoles: [UserRole.ADMIN],
}, },
{
href: "/dashboard/admin/kalender",
label: "Event-Anmeldungen",
icon: CalendarCheck2,
exact: false,
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
},
{ {
href: "/dashboard/admin/abwesenheiten", href: "/dashboard/admin/abwesenheiten",
label: "Abwesenheiten", label: "Abwesenheiten",
+8 -4
View File
@@ -23,10 +23,14 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
); );
CardHeader.displayName = "CardHeader"; CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( type CardTitleProps = React.HTMLAttributes<HTMLElement> & {
({ className, ...props }, ref) => ( as?: "div" | "h2" | "h3" | "h4" | "h5" | "h6";
<div };
ref={ref}
const CardTitle = React.forwardRef<HTMLElement, CardTitleProps>(
({ className, as: Comp = "div", ...props }, ref) => (
<Comp
ref={ref as React.Ref<HTMLDivElement>}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)} className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props} {...props}
/> />