Add RSVP events and Unsplash image support
This commit is contained in:
+9
-1
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user