Add RSVP events and Unsplash image support
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
|
||||
import { format } from "date-fns";
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
@@ -13,6 +13,15 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export type TerminListItemDto = {
|
||||
kind: "termin";
|
||||
@@ -25,6 +34,9 @@ export type TerminListItemDto = {
|
||||
endDate: Date;
|
||||
allDay: boolean;
|
||||
mitbringselListEnabled: boolean;
|
||||
requiresRsvp: boolean;
|
||||
participantInfo: string | null;
|
||||
showParticipantInfo: boolean;
|
||||
mitbringselItems?: {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -178,6 +190,8 @@ function TerminCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TerminDetailDialog termin={termin} />
|
||||
|
||||
{/* Admin Toggle for Mitbringsel-List */}
|
||||
{isAdmin && termin.status === TerminStatus.CONFIRMED && (
|
||||
<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" {
|
||||
switch (type) {
|
||||
case TerminType.KITA_FEST:
|
||||
|
||||
@@ -12,14 +12,48 @@ import { prisma } from "@/lib/prisma";
|
||||
// =====================================================================
|
||||
|
||||
const terminSchema = z.object({
|
||||
title: z.string().min(2, "Titel muss mindestens 2 Zeichen lang sein"),
|
||||
description: z.string().optional(),
|
||||
title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein"),
|
||||
description: z.string().trim().optional(),
|
||||
type: z.nativeEnum(TerminType),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
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) {
|
||||
const session = await requireKitaSession();
|
||||
|
||||
@@ -29,18 +63,23 @@ export async function createTerminRequest(rawPayload: unknown) {
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const terminData = buildTerminData(data);
|
||||
|
||||
if ("error" in terminData) {
|
||||
return { error: terminData.error };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.termin.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
createdById: session.user.id,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
type: data.type,
|
||||
startDate: new Date(data.startDate),
|
||||
endDate: new Date(data.endDate),
|
||||
allDay: data.allDay,
|
||||
title: terminData.data.title,
|
||||
description: terminData.data.description,
|
||||
type: terminData.data.type,
|
||||
startDate: terminData.data.startDate,
|
||||
endDate: terminData.data.endDate,
|
||||
allDay: terminData.data.allDay,
|
||||
status: TerminStatus.PENDING,
|
||||
},
|
||||
});
|
||||
@@ -66,18 +105,18 @@ export async function createTerminAdmin(rawPayload: unknown) {
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const terminData = buildTerminData(data);
|
||||
|
||||
if ("error" in terminData) {
|
||||
return { error: terminData.error };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.termin.create({
|
||||
data: {
|
||||
kitaId,
|
||||
createdById: session.user.id,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
type: data.type,
|
||||
startDate: new Date(data.startDate),
|
||||
endDate: new Date(data.endDate),
|
||||
allDay: data.allDay,
|
||||
...terminData.data,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
approvedById: session.user.id,
|
||||
approvedAt: new Date(),
|
||||
@@ -85,6 +124,7 @@ export async function createTerminAdmin(rawPayload: unknown) {
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/kalender");
|
||||
revalidatePath("/dashboard/admin/kalender");
|
||||
return { success: true };
|
||||
} catch (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) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId;
|
||||
@@ -113,6 +190,7 @@ export async function approveTermin(terminId: string) {
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/kalender");
|
||||
revalidatePath("/dashboard/admin/kalender");
|
||||
return { success: true };
|
||||
} catch (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/admin/kalender");
|
||||
return { success: true };
|
||||
} catch (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/admin/kalender");
|
||||
return { success: true };
|
||||
} catch (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 { prisma } from "@/lib/prisma";
|
||||
@@ -16,6 +21,7 @@ export default async function KalenderPage({
|
||||
const isAdmin =
|
||||
session.user.role === UserRole.ADMIN ||
|
||||
session.user.role === UserRole.KOORDINATOR;
|
||||
const familyIdForParticipation = session.user.familyId ?? "__no_family__";
|
||||
|
||||
const currentTab = searchParams.tab || "übersicht";
|
||||
|
||||
@@ -34,6 +40,8 @@ export default async function KalenderPage({
|
||||
endDate: true,
|
||||
allDay: true,
|
||||
mitbringselListEnabled: true,
|
||||
requiresRsvp: true,
|
||||
participantInfo: true,
|
||||
mitbringselItems: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -47,6 +55,17 @@ export default async function KalenderPage({
|
||||
},
|
||||
},
|
||||
},
|
||||
participations: {
|
||||
where: {
|
||||
child: {
|
||||
familyId: familyIdForParticipation,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
childId: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
@@ -67,6 +86,8 @@ export default async function KalenderPage({
|
||||
endDate: true,
|
||||
allDay: true,
|
||||
mitbringselListEnabled: true,
|
||||
requiresRsvp: true,
|
||||
participantInfo: true,
|
||||
mitbringselItems: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -80,6 +101,17 @@ export default async function KalenderPage({
|
||||
},
|
||||
},
|
||||
},
|
||||
participations: {
|
||||
where: {
|
||||
child: {
|
||||
familyId: familyIdForParticipation,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
childId: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
@@ -113,10 +145,22 @@ export default async function KalenderPage({
|
||||
...confirmedTermineRows.map((termin) => ({
|
||||
...termin,
|
||||
kind: "termin" as const,
|
||||
showParticipantInfo:
|
||||
isAdmin ||
|
||||
termin.participations.some(
|
||||
(participation) =>
|
||||
participation.status === EventParticipationStatus.ATTENDING,
|
||||
),
|
||||
})),
|
||||
...myPendingTermineRows.map((termin) => ({
|
||||
...termin,
|
||||
kind: "termin" as const,
|
||||
showParticipantInfo:
|
||||
isAdmin ||
|
||||
termin.participations.some(
|
||||
(participation) =>
|
||||
participation.status === EventParticipationStatus.ATTENDING,
|
||||
),
|
||||
})),
|
||||
...dutyAssignmentRows.map((assignment) => ({
|
||||
kind: "duty" as const,
|
||||
|
||||
Reference in New Issue
Block a user