diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 6f34ee3..44b650f 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -84,6 +84,13 @@ enum NotdienstAlertStatus {
CANCELLED
}
+enum NotdienstSwapStatus {
+ PENDING
+ ACCEPTED
+ DECLINED
+ CANCELLED
+}
+
enum DutyAssignmentStatus {
PLANNED
DONE
@@ -136,6 +143,7 @@ model Kita {
notdienstAvailabilities NotdienstAvailability[]
notdienstAssignments NotdienstAssignment[]
notdienstAlerts NotdienstAlert[]
+ notdienstSwapRequests NotdienstSwapRequest[]
@@map("kitas")
}
@@ -158,6 +166,7 @@ model Family {
users User[]
children Child[]
dutyAssignments DutyAssignment[]
+ notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequesterFamily")
@@index([kitaId])
@@map("families")
@@ -219,6 +228,8 @@ model User {
notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent")
notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger")
notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator")
+ notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequester")
+ notdienstSwapRequestsResponded NotdienstSwapRequest[] @relation("NotdienstSwapResponder")
announcementsAuthored Announcement[] @relation("AnnouncementAuthor")
announcementReads AnnouncementRead[]
@@ -647,12 +658,50 @@ model NotdienstAssignment {
updatedAt DateTime @updatedAt
alerts NotdienstAlert[]
+ swapRequestsAsTarget NotdienstSwapRequest[] @relation("NotdienstSwapTargetAssignment")
+ swapRequestsAsOffered NotdienstSwapRequest[] @relation("NotdienstSwapOfferedAssignment")
@@unique([planId, date])
@@index([kitaId, date])
@@map("notdienst_assignments")
}
+/// Tauschanfrage zwischen Eltern fuer zwei bereits veroeffentlichte
+/// zukuenftige Notdienst-Zuteilungen.
+model NotdienstSwapRequest {
+ id String @id @default(cuid())
+ kitaId String
+ kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
+
+ requesterUserId String
+ requesterUser User @relation("NotdienstSwapRequester", fields: [requesterUserId], references: [id], onDelete: Cascade)
+
+ requesterFamilyId String
+ requesterFamily Family @relation("NotdienstSwapRequesterFamily", fields: [requesterFamilyId], references: [id], onDelete: Cascade)
+
+ targetAssignmentId String
+ targetAssignment NotdienstAssignment @relation("NotdienstSwapTargetAssignment", fields: [targetAssignmentId], references: [id], onDelete: Cascade)
+
+ offeredAssignmentId String
+ offeredAssignment NotdienstAssignment @relation("NotdienstSwapOfferedAssignment", fields: [offeredAssignmentId], references: [id], onDelete: Cascade)
+
+ status NotdienstSwapStatus @default(PENDING)
+ message String?
+
+ respondedById String?
+ respondedBy User? @relation("NotdienstSwapResponder", fields: [respondedById], references: [id], onDelete: SetNull)
+ respondedAt DateTime?
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([kitaId, status])
+ @@index([requesterFamilyId, status])
+ @@index([targetAssignmentId, status])
+ @@index([offeredAssignmentId, status])
+ @@map("notdienst_swap_requests")
+}
+
/// Aktive Alarmierung — wird bei Krankmeldung einer Fachkraft
/// vom Koordinator ausgelöst. Bestätigung via Magic-Link (Token).
model NotdienstAlert {
diff --git a/src/app/dashboard/kalender/_components/calendar-overview-client.tsx b/src/app/dashboard/kalender/_components/calendar-overview-client.tsx
index 21df8d0..c8634b1 100644
--- a/src/app/dashboard/kalender/_components/calendar-overview-client.tsx
+++ b/src/app/dashboard/kalender/_components/calendar-overview-client.tsx
@@ -4,8 +4,6 @@ import { useState, useMemo, useTransition } from "react";
import {
TerminType,
TerminStatus,
- EventParticipationStatus,
- DutyAssignmentStatus,
} from "@prisma/client";
import {
format,
@@ -30,9 +28,7 @@ import {
MapPin,
AlertTriangle,
WashingMachine,
- MoreHorizontal,
Trash2,
- Edit,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -49,11 +45,20 @@ import {
} from "@/components/ui/dialog";
import { toast } from "sonner";
-import { CalendarListItemDto } from "./termin-list";
-import { TerminList } from "./termin-list";
+import {
+ CalendarListItemDto,
+ NotdienstSwapDialog,
+ NotdienstSwapOptionDto,
+ TerminList,
+} from "./termin-list";
import { MitbringselList } from "./mitbringsel-list";
import { AdminTerminModal } from "./admin-termin-modal";
-import { deleteTerminAdmin } from "../actions";
+import {
+ acceptNotdienstSwapRequest,
+ cancelNotdienstSwapRequest,
+ declineNotdienstSwapRequest,
+ deleteTerminAdmin,
+} from "../actions";
const categories = [
{ label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" },
@@ -68,14 +73,30 @@ const categories = [
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" },
];
+export type NotdienstSwapRequestDto = {
+ id: string;
+ direction: "incoming" | "outgoing";
+ targetDate: Date;
+ targetFamilyName: string;
+ targetChildName: string;
+ offeredDate: Date;
+ offeredFamilyName: string;
+ offeredChildName: string;
+ message: string | null;
+};
+
export function CalendarOverviewClient({
termine,
userId,
isAdmin,
+ notdienstSwapOptions = [],
+ pendingSwapRequests = [],
}: {
termine: CalendarListItemDto[];
userId: string;
isAdmin: boolean;
+ notdienstSwapOptions?: NotdienstSwapOptionDto[];
+ pendingSwapRequests?: NotdienstSwapRequestDto[];
}) {
const [view, setView] = useState<"liste" | "monat" | "agenda">("monat");
const [searchQuery, setSearchQuery] = useState("");
@@ -185,6 +206,10 @@ export function CalendarOverviewClient({
return (
+ {!isAdmin && pendingSwapRequests.length > 0 && (
+
+ )}
+
{/* Search and Filters Header */}
@@ -282,7 +307,12 @@ export function CalendarOverviewClient({
{/* Main View Contents */}
{view === "liste" && (
-
+
)}
{view === "monat" && (
@@ -431,13 +461,26 @@ export function CalendarOverviewClient({
{isAdmin && (
)}
+ {!isAdmin && item.canRequestSwap && (
+
+
+ Tausch anfragen
+
+ }
+ />
+
+ )}
) : (
@@ -564,6 +607,106 @@ export function CalendarOverviewClient({
);
}
+function NotdienstSwapRequestsPanel({
+ requests,
+}: {
+ requests: NotdienstSwapRequestDto[];
+}) {
+ return (
+
+
+
+ Offene Notdienst-Tausche
+
+
+
+ {requests.map((request) => (
+
+ ))}
+
+
+ );
+}
+
+function NotdienstSwapRequestRow({
+ request,
+}: {
+ request: NotdienstSwapRequestDto;
+}) {
+ const [isPending, startTransition] = useTransition();
+
+ const runAction = (
+ action: (requestId: string) => Promise<{ success?: boolean; error?: string }>,
+ successMessage: string,
+ ) => {
+ startTransition(async () => {
+ const result = await action(request.id);
+ if (result.error) {
+ toast.error(result.error);
+ return;
+ }
+ toast.success(successMessage);
+ });
+ };
+
+ return (
+
+
+
+ {request.direction === "incoming" ? "Anfrage an dich" : "Deine Anfrage"}
+
+
+ {format(request.targetDate, "dd.MM.yyyy", { locale: de })}{" "}
+ ({request.targetFamilyName}, {request.targetChildName}) gegen{" "}
+ {format(request.offeredDate, "dd.MM.yyyy", { locale: de })}{" "}
+ ({request.offeredFamilyName}, {request.offeredChildName})
+
+ {request.message && (
+
+ {request.message}
+
+ )}
+
+
+ {request.direction === "incoming" ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+ );
+}
+
// =====================================================================
// Month Grid Component
// =====================================================================
diff --git a/src/app/dashboard/kalender/_components/termin-list.tsx b/src/app/dashboard/kalender/_components/termin-list.tsx
index a38c0c9..ccc61e4 100644
--- a/src/app/dashboard/kalender/_components/termin-list.tsx
+++ b/src/app/dashboard/kalender/_components/termin-list.tsx
@@ -1,5 +1,6 @@
"use client";
+import type { ReactNode } from "react";
import { useState, useTransition } from "react";
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
import { format } from "date-fns";
@@ -11,8 +12,6 @@ import {
MapPin,
MoreHorizontal,
WashingMachine,
- Trash2,
- ShieldAlert,
} from "lucide-react";
import { toast } from "sonner";
@@ -31,7 +30,7 @@ import {
import { AdminTerminModal } from "./admin-termin-modal";
import { TerminRequestModal } from "./termin-request-modal";
import { MitbringselList } from "./mitbringsel-list";
-import { deleteTerminAdmin } from "../actions";
+import { createNotdienstSwapRequest, deleteTerminAdmin } from "../actions";
export type TerminListItemDto = {
kind: "termin";
@@ -69,24 +68,38 @@ export type DutyCalendarItemDto = {
export type NotdienstCalendarItemDto = {
kind: "notdienst";
id: string;
+ planId: string;
+ planYear: number;
+ planMonth: number;
title: string;
+ familyId: string;
familyName: string;
childName: string;
isOwn: boolean;
+ canRequestSwap: boolean;
startDate: Date;
endDate: Date;
};
+export type NotdienstSwapOptionDto = {
+ id: string;
+ date: Date;
+ childName: string;
+ familyName: string;
+};
+
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto | NotdienstCalendarItemDto;
export function TerminList({
termine,
userId,
isAdmin,
+ notdienstSwapOptions = [],
}: {
termine: CalendarListItemDto[];
userId: string;
isAdmin: boolean;
+ notdienstSwapOptions?: NotdienstSwapOptionDto[];
}) {
if (termine.length === 0) {
return (
@@ -137,7 +150,12 @@ export function TerminList({
item.kind === "duty" ? (
) : item.kind === "notdienst" ? (
-
+
) : (
),
@@ -492,7 +510,123 @@ function getTypeLabel(type: TerminType): string {
}
}
-function NotdienstRow({ notdienst, isAdmin }: { notdienst: NotdienstCalendarItemDto; isAdmin: boolean }) {
+export function NotdienstSwapDialog({
+ target,
+ options,
+ trigger,
+}: {
+ target: NotdienstCalendarItemDto;
+ options: NotdienstSwapOptionDto[];
+ trigger: ReactNode;
+}) {
+ const selectableOptions = options.filter((option) => option.id !== target.id);
+ const [open, setOpen] = useState(false);
+ const [offeredAssignmentId, setOfferedAssignmentId] = useState(
+ selectableOptions[0]?.id ?? "",
+ );
+ const [message, setMessage] = useState("");
+ const [isPending, startTransition] = useTransition();
+
+ const handleSubmit = () => {
+ if (!offeredAssignmentId) {
+ toast.error("Bitte wähle einen eigenen Notdienst als Tausch-Angebot.");
+ return;
+ }
+
+ startTransition(async () => {
+ const result = await createNotdienstSwapRequest({
+ targetAssignmentId: target.id,
+ offeredAssignmentId,
+ message,
+ });
+
+ if ("error" in result && result.error) {
+ toast.error(result.error);
+ return;
+ }
+
+ toast.success("Tauschanfrage wurde versendet.");
+ setOpen(false);
+ setMessage("");
+ });
+ };
+
+ return (
+
+ );
+}
+
+function NotdienstRow({
+ notdienst,
+ isAdmin,
+ notdienstSwapOptions,
+}: {
+ notdienst: NotdienstCalendarItemDto;
+ isAdmin: boolean;
+ notdienstSwapOptions: NotdienstSwapOptionDto[];
+}) {
const color = notdienst.isOwn ? "oklch(0.60 0.18 45)" : "oklch(0.75 0.10 50)";
const [menuOpen, setMenuOpen] = useState(false);
@@ -535,7 +669,7 @@ function NotdienstRow({ notdienst, isAdmin }: { notdienst: NotdienstCalendarItem
setMenuOpen(false)} />
+ ) : notdienst.canRequestSwap ? (
+
+ Tausch anfragen
+
+ }
+ />
) : (
)}
diff --git a/src/app/dashboard/kalender/actions.ts b/src/app/dashboard/kalender/actions.ts
index 5007614..28c79c6 100644
--- a/src/app/dashboard/kalender/actions.ts
+++ b/src/app/dashboard/kalender/actions.ts
@@ -1,10 +1,22 @@
"use server";
+import { createElement } from "react";
import { revalidatePath } from "next/cache";
-import { TerminStatus, TerminType, UserRole } from "@prisma/client";
+import {
+ NotdienstAlertStatus,
+ NotdienstPlanStatus,
+ NotdienstSwapStatus,
+ TerminStatus,
+ TerminType,
+ UserRole,
+} from "@prisma/client";
+import { format } from "date-fns";
+import { de } from "date-fns/locale";
import { z } from "zod";
+import { NotdienstSwapEmail } from "@/emails/NotdienstSwapEmail";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
+import { sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
// =====================================================================
@@ -24,6 +36,223 @@ const terminSchema = z.object({
participantInfo: z.string().trim().max(1000).optional(),
});
+const swapRequestSchema = z.object({
+ targetAssignmentId: z.string().min(1),
+ offeredAssignmentId: z.string().min(1),
+ message: z.string().trim().max(500).optional(),
+});
+
+const swapIdSchema = z.string().min(1);
+
+const activeNotdienstAlertStatuses = [
+ NotdienstAlertStatus.PENDING,
+ NotdienstAlertStatus.CONFIRMED,
+];
+
+function getTodayUtcDateOnly() {
+ const today = new Date();
+ return new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
+}
+
+function getBaseUrl() {
+ return process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
+}
+
+function formatNotdienstDate(date: Date) {
+ return format(date, "EEEE, dd. MMMM yyyy", { locale: de });
+}
+
+function revalidateCalendarAndNotdienst() {
+ revalidatePath("/dashboard/kalender");
+ revalidatePath("/dashboard/notdienst/plan");
+ revalidatePath("/dashboard/notdienst");
+ revalidatePath("/dashboard");
+}
+
+type SwapAssignmentWithFamily = {
+ id: string;
+ childId: string;
+ date: Date;
+ plan: {
+ status: NotdienstPlanStatus;
+ };
+ child: {
+ familyId: string;
+ family: {
+ name: string;
+ users: {
+ email: string;
+ firstName: string;
+ lastName: string;
+ }[];
+ };
+ };
+ alerts: {
+ id: string;
+ }[];
+};
+
+async function getSwapAssignment(
+ assignmentId: string,
+ kitaId: string,
+): Promise {
+ return prisma.notdienstAssignment.findFirst({
+ where: { id: assignmentId, kitaId },
+ select: {
+ id: true,
+ childId: true,
+ date: true,
+ plan: { select: { status: true } },
+ child: {
+ select: {
+ familyId: true,
+ family: {
+ select: {
+ name: true,
+ users: {
+ where: { role: UserRole.ELTERN },
+ select: {
+ email: true,
+ firstName: true,
+ lastName: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ alerts: {
+ where: { status: { in: activeNotdienstAlertStatuses } },
+ select: { id: true },
+ },
+ },
+ });
+}
+
+function validateSwapAssignmentPair({
+ targetAssignment,
+ offeredAssignment,
+ requesterFamilyId,
+ today,
+}: {
+ targetAssignment: SwapAssignmentWithFamily | null;
+ offeredAssignment: SwapAssignmentWithFamily | null;
+ requesterFamilyId: string;
+ today: Date;
+}) {
+ if (!targetAssignment || !offeredAssignment) {
+ return "Notdienst nicht gefunden.";
+ }
+
+ if (targetAssignment.id === offeredAssignment.id) {
+ return "Bitte wähle zwei unterschiedliche Notdienste aus.";
+ }
+
+ if (
+ targetAssignment.plan.status !== NotdienstPlanStatus.PUBLISHED ||
+ offeredAssignment.plan.status !== NotdienstPlanStatus.PUBLISHED
+ ) {
+ return "Es können nur veröffentlichte Notdienste getauscht werden.";
+ }
+
+ if (targetAssignment.date < today || offeredAssignment.date < today) {
+ return "Vergangene Notdienste können nicht getauscht werden.";
+ }
+
+ if (offeredAssignment.child.familyId !== requesterFamilyId) {
+ return "Du kannst nur eigene Notdienste als Tausch anbieten.";
+ }
+
+ if (targetAssignment.child.familyId === requesterFamilyId) {
+ return "Du kannst keinen Tausch mit deinem eigenen Haushalt anfragen.";
+ }
+
+ if (targetAssignment.alerts.length > 0 || offeredAssignment.alerts.length > 0) {
+ return "Notdienste mit aktiver Alarmierung können nicht getauscht werden.";
+ }
+
+ return null;
+}
+
+async function sendSwapMail({
+ to,
+ parentName,
+ subject,
+ heading,
+ preview,
+ kitaName,
+ body,
+ targetDate,
+ offeredDate,
+}: {
+ to: string;
+ parentName: string;
+ subject: string;
+ heading: string;
+ preview: string;
+ kitaName: string;
+ body: string;
+ targetDate: Date;
+ offeredDate: Date;
+}) {
+ const result = await sendAppEmail({
+ to,
+ subject,
+ react: createElement(NotdienstSwapEmail, {
+ parentName,
+ heading,
+ preview,
+ kitaName,
+ body,
+ targetDateLabel: formatNotdienstDate(targetDate),
+ offeredDateLabel: formatNotdienstDate(offeredDate),
+ link: `${getBaseUrl()}/dashboard/kalender`,
+ }),
+ });
+
+ if (!result.success) {
+ console.error("Notdienst-Tausch-Mail konnte nicht versendet werden:", result.error);
+ }
+}
+
+async function notifyFamilyAboutSwap({
+ family,
+ subject,
+ heading,
+ preview,
+ kitaName,
+ body,
+ targetDate,
+ offeredDate,
+}: {
+ family: SwapAssignmentWithFamily["child"]["family"];
+ subject: string;
+ heading: string;
+ preview: string;
+ kitaName: string;
+ body: string;
+ targetDate: Date;
+ offeredDate: Date;
+}) {
+ await Promise.all(
+ family.users
+ .filter((parent) => !!parent.email)
+ .map((parent) =>
+ sendSwapMail({
+ to: parent.email,
+ parentName: `${parent.firstName} ${parent.lastName}`,
+ subject,
+ heading,
+ preview,
+ kitaName,
+ body,
+ targetDate,
+ offeredDate,
+ }),
+ ),
+ );
+}
+
function buildTerminData(data: z.infer) {
const startDate = new Date(data.startDate);
const endDate = new Date(data.endDate);
@@ -60,6 +289,424 @@ function buildTerminData(data: z.infer) {
};
}
+export async function createNotdienstSwapRequest(rawPayload: unknown) {
+ const session = await requireKitaSession();
+ if (session.user.role !== UserRole.ELTERN) {
+ return { error: "Nur Eltern können Notdienst-Tausche anfragen." };
+ }
+ if (!session.user.familyId) {
+ return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
+ }
+
+ const parsed = swapRequestSchema.safeParse(rawPayload);
+ if (!parsed.success) {
+ return { error: "Ungültige Eingabedaten." };
+ }
+
+ const { targetAssignmentId, offeredAssignmentId, message } = parsed.data;
+ const today = getTodayUtcDateOnly();
+
+ try {
+ const [targetAssignment, offeredAssignment, kita, existingRequest] =
+ await Promise.all([
+ getSwapAssignment(targetAssignmentId, session.user.kitaId),
+ getSwapAssignment(offeredAssignmentId, session.user.kitaId),
+ prisma.kita.findUnique({
+ where: { id: session.user.kitaId },
+ select: { name: true },
+ }),
+ prisma.notdienstSwapRequest.findFirst({
+ where: {
+ kitaId: session.user.kitaId,
+ status: NotdienstSwapStatus.PENDING,
+ OR: [
+ { targetAssignmentId },
+ { offeredAssignmentId },
+ { targetAssignmentId: offeredAssignmentId },
+ { offeredAssignmentId: targetAssignmentId },
+ ],
+ },
+ select: { id: true },
+ }),
+ ]);
+
+ const validationError = validateSwapAssignmentPair({
+ targetAssignment,
+ offeredAssignment,
+ requesterFamilyId: session.user.familyId,
+ today,
+ });
+ if (validationError) {
+ return { error: validationError };
+ }
+ if (!targetAssignment || !offeredAssignment || !kita) {
+ return { error: "Notdienst nicht gefunden." };
+ }
+ if (existingRequest) {
+ return { error: "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage." };
+ }
+
+ await prisma.notdienstSwapRequest.create({
+ data: {
+ kitaId: session.user.kitaId,
+ requesterUserId: session.user.id,
+ requesterFamilyId: session.user.familyId,
+ targetAssignmentId,
+ offeredAssignmentId,
+ message: message || null,
+ },
+ });
+
+ await notifyFamilyAboutSwap({
+ family: targetAssignment.child.family,
+ subject: `Notdienst-Tauschanfrage für ${formatNotdienstDate(targetAssignment.date)}`,
+ heading: "Neue Tauschanfrage",
+ preview: "Du hast eine neue Notdienst-Tauschanfrage.",
+ kitaName: kita.name,
+ body: `Ein Elternteil aus einem anderen Haushalt möchte einen Notdienst mit dir tauschen.${message ? ` Nachricht: ${message}` : ""}`,
+ targetDate: targetAssignment.date,
+ offeredDate: offeredAssignment.date,
+ });
+
+ revalidateCalendarAndNotdienst();
+ return { success: true };
+ } catch (error) {
+ console.error("Fehler beim Erstellen der Notdienst-Tauschanfrage:", error);
+ return { error: "Tauschanfrage konnte nicht erstellt werden." };
+ }
+}
+
+export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
+ const session = await requireKitaSession();
+ if (!session.user.familyId) {
+ return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
+ }
+
+ const parsed = swapIdSchema.safeParse(requestIdRaw);
+ if (!parsed.success) {
+ return { error: "Ungültige Eingabedaten." };
+ }
+
+ const today = getTodayUtcDateOnly();
+
+ try {
+ const result = await prisma.$transaction(async (tx) => {
+ const request = await tx.notdienstSwapRequest.findFirst({
+ where: {
+ id: parsed.data,
+ kitaId: session.user.kitaId,
+ status: NotdienstSwapStatus.PENDING,
+ },
+ select: {
+ id: true,
+ requesterFamilyId: true,
+ targetAssignmentId: true,
+ offeredAssignmentId: true,
+ targetAssignment: {
+ select: {
+ id: true,
+ childId: true,
+ date: true,
+ plan: { select: { status: true } },
+ child: {
+ select: {
+ familyId: true,
+ family: {
+ select: {
+ name: true,
+ users: {
+ where: { role: UserRole.ELTERN },
+ select: { email: true, firstName: true, lastName: true },
+ },
+ },
+ },
+ },
+ },
+ alerts: {
+ where: { status: { in: activeNotdienstAlertStatuses } },
+ select: { id: true },
+ },
+ },
+ },
+ offeredAssignment: {
+ select: {
+ id: true,
+ childId: true,
+ date: true,
+ plan: { select: { status: true } },
+ child: {
+ select: {
+ familyId: true,
+ family: {
+ select: {
+ name: true,
+ users: {
+ where: { role: UserRole.ELTERN },
+ select: { email: true, firstName: true, lastName: true },
+ },
+ },
+ },
+ },
+ },
+ alerts: {
+ where: { status: { in: activeNotdienstAlertStatuses } },
+ select: { id: true },
+ },
+ },
+ },
+ },
+ });
+
+ if (!request) {
+ throw new Error("Tauschanfrage nicht gefunden oder nicht mehr offen.");
+ }
+
+ if (request.targetAssignment.child.familyId !== session.user.familyId) {
+ throw new Error("Nur der angefragte Haushalt kann diese Anfrage annehmen.");
+ }
+
+ const validationError = validateSwapAssignmentPair({
+ targetAssignment: request.targetAssignment,
+ offeredAssignment: request.offeredAssignment,
+ requesterFamilyId: request.requesterFamilyId,
+ today,
+ });
+ if (validationError) {
+ throw new Error(validationError);
+ }
+
+ await tx.notdienstAssignment.update({
+ where: { id: request.targetAssignmentId },
+ data: { childId: request.offeredAssignment.childId },
+ });
+ await tx.notdienstAssignment.update({
+ where: { id: request.offeredAssignmentId },
+ data: { childId: request.targetAssignment.childId },
+ });
+ await tx.notdienstSwapRequest.update({
+ where: { id: request.id },
+ data: {
+ status: NotdienstSwapStatus.ACCEPTED,
+ respondedById: session.user.id,
+ respondedAt: new Date(),
+ },
+ });
+ await tx.notdienstSwapRequest.updateMany({
+ where: {
+ id: { not: request.id },
+ kitaId: session.user.kitaId,
+ status: NotdienstSwapStatus.PENDING,
+ OR: [
+ { targetAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } },
+ { offeredAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } },
+ ],
+ },
+ data: {
+ status: NotdienstSwapStatus.CANCELLED,
+ respondedAt: new Date(),
+ },
+ });
+
+ return {
+ targetAssignment: request.targetAssignment,
+ offeredAssignment: request.offeredAssignment,
+ };
+ });
+
+ const kita = await prisma.kita.findUnique({
+ where: { id: session.user.kitaId },
+ select: { name: true },
+ });
+ if (kita) {
+ await Promise.all([
+ notifyFamilyAboutSwap({
+ family: result.targetAssignment.child.family,
+ subject: "Notdienst-Tausch bestätigt",
+ heading: "Tausch bestätigt",
+ preview: "Ein Notdienst-Tausch wurde bestätigt.",
+ kitaName: kita.name,
+ body: "die Tauschanfrage wurde angenommen. Der Notdienst-Plan wurde automatisch aktualisiert.",
+ targetDate: result.targetAssignment.date,
+ offeredDate: result.offeredAssignment.date,
+ }),
+ notifyFamilyAboutSwap({
+ family: result.offeredAssignment.child.family,
+ subject: "Notdienst-Tausch bestätigt",
+ heading: "Tausch bestätigt",
+ preview: "Ein Notdienst-Tausch wurde bestätigt.",
+ kitaName: kita.name,
+ body: "deine Tauschanfrage wurde angenommen. Der Notdienst-Plan wurde automatisch aktualisiert.",
+ targetDate: result.targetAssignment.date,
+ offeredDate: result.offeredAssignment.date,
+ }),
+ ]);
+ }
+
+ revalidateCalendarAndNotdienst();
+ return { success: true };
+ } catch (error) {
+ return {
+ error: error instanceof Error ? error.message : "Tauschanfrage konnte nicht angenommen werden.",
+ };
+ }
+}
+
+export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
+ const session = await requireKitaSession();
+ if (!session.user.familyId) {
+ return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
+ }
+
+ const parsed = swapIdSchema.safeParse(requestIdRaw);
+ if (!parsed.success) {
+ return { error: "Ungültige Eingabedaten." };
+ }
+
+ try {
+ const request = await prisma.notdienstSwapRequest.findFirst({
+ where: {
+ id: parsed.data,
+ kitaId: session.user.kitaId,
+ status: NotdienstSwapStatus.PENDING,
+ },
+ select: {
+ id: true,
+ targetAssignment: {
+ select: {
+ date: true,
+ child: {
+ select: {
+ familyId: true,
+ family: {
+ select: {
+ name: true,
+ users: {
+ where: { role: UserRole.ELTERN },
+ select: { email: true, firstName: true, lastName: true },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ offeredAssignment: {
+ select: {
+ date: true,
+ child: {
+ select: {
+ family: {
+ select: {
+ name: true,
+ users: {
+ where: { role: UserRole.ELTERN },
+ select: { email: true, firstName: true, lastName: true },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!request) {
+ return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." };
+ }
+ if (request.targetAssignment.child.familyId !== session.user.familyId) {
+ return { error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen." };
+ }
+
+ await prisma.notdienstSwapRequest.update({
+ where: { id: request.id },
+ data: {
+ status: NotdienstSwapStatus.DECLINED,
+ respondedById: session.user.id,
+ respondedAt: new Date(),
+ },
+ });
+
+ const kita = await prisma.kita.findUnique({
+ where: { id: session.user.kitaId },
+ select: { name: true },
+ });
+ if (kita) {
+ await Promise.all([
+ notifyFamilyAboutSwap({
+ family: request.targetAssignment.child.family,
+ subject: "Notdienst-Tauschanfrage abgelehnt",
+ heading: "Tauschanfrage abgelehnt",
+ preview: "Eine Notdienst-Tauschanfrage wurde abgelehnt.",
+ kitaName: kita.name,
+ body: "du hast die Tauschanfrage abgelehnt. Der Notdienst-Plan bleibt unverändert.",
+ targetDate: request.targetAssignment.date,
+ offeredDate: request.offeredAssignment.date,
+ }),
+ notifyFamilyAboutSwap({
+ family: request.offeredAssignment.child.family,
+ subject: "Notdienst-Tauschanfrage abgelehnt",
+ heading: "Tauschanfrage abgelehnt",
+ preview: "Eine Notdienst-Tauschanfrage wurde abgelehnt.",
+ kitaName: kita.name,
+ body: "deine Tauschanfrage wurde abgelehnt. Der Notdienst-Plan bleibt unverändert.",
+ targetDate: request.targetAssignment.date,
+ offeredDate: request.offeredAssignment.date,
+ }),
+ ]);
+ }
+
+ revalidateCalendarAndNotdienst();
+ return { success: true };
+ } catch (error) {
+ console.error("Fehler beim Ablehnen der Notdienst-Tauschanfrage:", error);
+ return { error: "Tauschanfrage konnte nicht abgelehnt werden." };
+ }
+}
+
+export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
+ const session = await requireKitaSession();
+ if (!session.user.familyId) {
+ return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
+ }
+
+ const parsed = swapIdSchema.safeParse(requestIdRaw);
+ if (!parsed.success) {
+ return { error: "Ungültige Eingabedaten." };
+ }
+
+ try {
+ const request = await prisma.notdienstSwapRequest.findFirst({
+ where: {
+ id: parsed.data,
+ kitaId: session.user.kitaId,
+ requesterFamilyId: session.user.familyId,
+ status: NotdienstSwapStatus.PENDING,
+ },
+ select: { id: true },
+ });
+
+ if (!request) {
+ return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." };
+ }
+
+ await prisma.notdienstSwapRequest.update({
+ where: { id: request.id },
+ data: {
+ status: NotdienstSwapStatus.CANCELLED,
+ respondedById: session.user.id,
+ respondedAt: new Date(),
+ },
+ });
+
+ revalidateCalendarAndNotdienst();
+ return { success: true };
+ } catch (error) {
+ console.error("Fehler beim Zurückziehen der Notdienst-Tauschanfrage:", error);
+ return { error: "Tauschanfrage konnte nicht zurückgezogen werden." };
+ }
+}
+
export async function createTerminRequest(rawPayload: unknown) {
const session = await requireKitaSession();
diff --git a/src/app/dashboard/kalender/page.tsx b/src/app/dashboard/kalender/page.tsx
index 917f836..ad21750 100644
--- a/src/app/dashboard/kalender/page.tsx
+++ b/src/app/dashboard/kalender/page.tsx
@@ -1,8 +1,8 @@
import {
DutyAssignmentStatus,
EventParticipationStatus,
+ NotdienstSwapStatus,
TerminStatus,
- TerminType,
UserRole,
} from "@prisma/client";
import { HelpCircle } from "lucide-react";
@@ -11,9 +11,12 @@ import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { AdminTerminModal } from "./_components/admin-termin-modal";
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
-import { type CalendarListItemDto } from "./_components/termin-list";
+import { type CalendarListItemDto, type NotdienstSwapOptionDto } from "./_components/termin-list";
import { TerminRequestModal } from "./_components/termin-request-modal";
-import { CalendarOverviewClient } from "./_components/calendar-overview-client";
+import {
+ CalendarOverviewClient,
+ type NotdienstSwapRequestDto,
+} from "./_components/calendar-overview-client";
export const metadata = { title: "Terminkalender · Kita-Planer" };
@@ -31,6 +34,7 @@ export default async function KalenderPage({
const resolvedSearchParams = await searchParams;
const currentTab = resolvedSearchParams.tab || "übersicht";
const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const confirmedTermineRows = await prisma.termin.findMany({
where: {
@@ -159,6 +163,13 @@ export default async function KalenderPage({
select: {
id: true,
date: true,
+ planId: true,
+ plan: {
+ select: {
+ year: true,
+ month: true,
+ },
+ },
child: {
select: {
firstName: true,
@@ -175,6 +186,56 @@ export default async function KalenderPage({
orderBy: { date: "asc" },
});
+ const pendingSwapRows =
+ !isAdmin && session.user.familyId
+ ? await prisma.notdienstSwapRequest.findMany({
+ where: {
+ kitaId: session.user.kitaId,
+ status: NotdienstSwapStatus.PENDING,
+ OR: [
+ { requesterFamilyId: session.user.familyId },
+ {
+ targetAssignment: {
+ child: {
+ familyId: session.user.familyId,
+ },
+ },
+ },
+ ],
+ },
+ select: {
+ id: true,
+ requesterFamilyId: true,
+ message: true,
+ targetAssignment: {
+ select: {
+ date: true,
+ child: {
+ select: {
+ firstName: true,
+ lastName: true,
+ family: { select: { name: true } },
+ },
+ },
+ },
+ },
+ offeredAssignment: {
+ select: {
+ date: true,
+ child: {
+ select: {
+ firstName: true,
+ lastName: true,
+ family: { select: { name: true } },
+ },
+ },
+ },
+ },
+ },
+ orderBy: { createdAt: "desc" },
+ })
+ : [];
+
const allUserTermine: CalendarListItemDto[] = [
...confirmedTermineRows.map((termin) => ({
...termin,
@@ -213,21 +274,59 @@ export default async function KalenderPage({
}),
...notdienstAssignmentRows.map((assignment) => {
const isOwn = assignment.child.familyId === familyIdForParticipation;
+ const canRequestSwap =
+ !isAdmin &&
+ !isOwn &&
+ !!session.user.familyId &&
+ assignment.date >= today;
return {
kind: "notdienst" as const,
id: assignment.id,
+ planId: assignment.planId,
+ planYear: assignment.plan.year,
+ planMonth: assignment.plan.month,
title: isOwn
? `Mein Notdienst (${assignment.child.firstName})`
: `Notdienst (${assignment.child.family.name})`,
+ familyId: assignment.child.familyId,
familyName: assignment.child.family.name,
childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
isOwn,
+ canRequestSwap,
startDate: assignment.date,
endDate: assignment.date,
};
}),
].sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
+ const notdienstSwapOptions: NotdienstSwapOptionDto[] = notdienstAssignmentRows
+ .filter(
+ (assignment) =>
+ assignment.child.familyId === familyIdForParticipation &&
+ assignment.date >= today,
+ )
+ .map((assignment) => ({
+ id: assignment.id,
+ date: assignment.date,
+ childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
+ familyName: assignment.child.family.name,
+ }));
+
+ const pendingSwapRequests: NotdienstSwapRequestDto[] = pendingSwapRows.map(
+ (request) => ({
+ id: request.id,
+ direction:
+ request.requesterFamilyId === session.user.familyId ? "outgoing" : "incoming",
+ targetDate: request.targetAssignment.date,
+ targetFamilyName: request.targetAssignment.child.family.name,
+ targetChildName: `${request.targetAssignment.child.firstName} ${request.targetAssignment.child.lastName}`,
+ offeredDate: request.offeredAssignment.date,
+ offeredFamilyName: request.offeredAssignment.child.family.name,
+ offeredChildName: `${request.offeredAssignment.child.firstName} ${request.offeredAssignment.child.lastName}`,
+ message: request.message,
+ }),
+ );
+
let allPendingTermine: PendingTerminDto[] = [];
if (isAdmin) {
const pendingRows = await prisma.termin.findMany({
@@ -305,6 +404,8 @@ export default async function KalenderPage({
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
+ notdienstSwapOptions={notdienstSwapOptions}
+ pendingSwapRequests={pendingSwapRequests}
/>
) : currentTab === "anfragen" ? (
@@ -313,6 +414,8 @@ export default async function KalenderPage({
termine={[]}
userId={session.user.id}
isAdmin={isAdmin}
+ notdienstSwapOptions={notdienstSwapOptions}
+ pendingSwapRequests={pendingSwapRequests}
/>
)}
@@ -322,6 +425,8 @@ export default async function KalenderPage({
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
+ notdienstSwapOptions={notdienstSwapOptions}
+ pendingSwapRequests={pendingSwapRequests}
/>
)}
@@ -405,5 +510,3 @@ function TabLink({
);
}
-
-
diff --git a/src/app/dashboard/notdienst/plan/actions.ts b/src/app/dashboard/notdienst/plan/actions.ts
index 9af8398..7a92654 100644
--- a/src/app/dashboard/notdienst/plan/actions.ts
+++ b/src/app/dashboard/notdienst/plan/actions.ts
@@ -11,7 +11,7 @@ import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
-import { NotdienstAssignmentEmail } from "@/emails/NotdienstAssignmentEmail";
+import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
// =====================================================================
// /dashboard/notdienst/plan · Server Actions
@@ -160,14 +160,7 @@ export async function updateAssignmentAction(
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
try {
- const { shouldSendEmails, assignedChildInfo } = await prisma.$transaction(async (tx) => {
- let txShouldSendEmails = false;
- let txAssignedChildInfo: {
- parentEmails: string[];
- parentNames: string[];
- dateLabel: string;
- } | null = null;
-
+ const notifications = await prisma.$transaction(async (tx) => {
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
});
@@ -183,27 +176,43 @@ export async function updateAssignmentAction(
throw new Error("Plan kann nicht bearbeitet werden.");
}
- // Wenn der Plan bereits veröffentlicht ist, dürfen nur unbesetzte Termine geändert werden.
- // Ein Termin gilt als "unbesetzt", wenn am Veröffentlichungstag kein Assignment existierte
- // bzw. das Assignment erst nach dem Veröffentlichungs-Zeitpunkt erstellt wurde.
- const existingAssignment = await tx.notdienstAssignment.findFirst({
- where: { planId, date: parsedDate },
- });
-
- if (isPublished && existingAssignment) {
- if (!plan.publishedAt || existingAssignment.createdAt <= plan.publishedAt) {
- throw new Error("In einem bereits veröffentlichten Plan können nur unbesetzte Termine geändert werden.");
+ if (isPublished) {
+ const today = new Date();
+ const todayUtc = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
+ if (parsedDate < todayUtc) {
+ throw new Error("Vergangene Notdienste können nicht geändert werden.");
}
}
- // Altes Assignment für diesen Tag löschen
- await tx.notdienstAssignment.deleteMany({
+ const existingAssignment = await tx.notdienstAssignment.findFirst({
where: { planId, date: parsedDate },
+ include: {
+ child: {
+ include: {
+ family: {
+ include: {
+ users: {
+ where: { role: UserRole.ELTERN },
+ select: { email: true, firstName: true, lastName: true },
+ },
+ },
+ },
+ },
+ },
+ },
});
- // Neues anlegen, falls ausgewählt
+ let newChild:
+ | {
+ id: string;
+ family: {
+ users: { email: string; firstName: string; lastName: string }[];
+ };
+ }
+ | null = null;
+
if (newChildId) {
- const child = await tx.child.findFirst({
+ newChild = await tx.child.findFirst({
where: {
id: newChildId,
kitaId,
@@ -221,10 +230,27 @@ export async function updateAssignmentAction(
},
});
- if (!child) {
+ if (!newChild) {
throw new Error("Kind gehört nicht zu dieser Kita.");
}
+ }
+ if (existingAssignment?.childId === newChildId) {
+ return [];
+ }
+
+ if (!newChildId) {
+ if (existingAssignment) {
+ await tx.notdienstAssignment.delete({
+ where: { id: existingAssignment.id },
+ });
+ }
+ } else if (existingAssignment) {
+ await tx.notdienstAssignment.update({
+ where: { id: existingAssignment.id },
+ data: { childId: newChildId },
+ });
+ } else {
await tx.notdienstAssignment.create({
data: {
kitaId,
@@ -233,30 +259,75 @@ export async function updateAssignmentAction(
date: parsedDate,
},
});
+ }
- // Wenn der Plan bereits veröffentlicht ist, bereiten wir E-Mail-Benachrichtigungen vor
- if (isPublished) {
- const parents = child.family.users.filter((u) => !!u.email);
- if (parents.length > 0) {
- const localFormatDate = new Date(yr, mo - 1, dy);
- txAssignedChildInfo = {
- parentEmails: parents.map((p) => p.email!),
- parentNames: parents.map((p) => `${p.firstName} ${p.lastName}`),
- dateLabel: format(localFormatDate, "EEEE, dd. MMMM yyyy", { locale: de }),
- };
- txShouldSendEmails = true;
- }
+ if (existingAssignment) {
+ await tx.notdienstSwapRequest.updateMany({
+ where: {
+ kitaId,
+ status: "PENDING",
+ OR: [
+ { targetAssignmentId: existingAssignment.id },
+ { offeredAssignmentId: existingAssignment.id },
+ ],
+ },
+ data: {
+ status: "CANCELLED",
+ respondedAt: new Date(),
+ },
+ });
+ }
+
+ if (!isPublished) {
+ return [];
+ }
+
+ const dateLabel = format(new Date(yr, mo - 1, dy), "EEEE, dd. MMMM yyyy", { locale: de });
+ const messages: {
+ parentEmails: string[];
+ parentNames: string[];
+ subject: string;
+ heading: string;
+ preview: string;
+ body: string;
+ dateLabel: string;
+ }[] = [];
+
+ if (existingAssignment) {
+ const oldParents = existingAssignment.child.family.users.filter((u) => !!u.email);
+ if (oldParents.length > 0) {
+ messages.push({
+ parentEmails: oldParents.map((p) => p.email),
+ parentNames: oldParents.map((p) => `${p.firstName} ${p.lastName}`),
+ subject: `Notdienst-Einteilung geändert am ${dateLabel}`,
+ heading: "Notdienst wurde geändert",
+ preview: `Deine Notdienst-Einteilung am ${dateLabel} wurde geändert.`,
+ body: "Du bist für diesen Notdienst nicht mehr eingeteilt.",
+ dateLabel,
+ });
}
}
- return {
- shouldSendEmails: txShouldSendEmails,
- assignedChildInfo: txAssignedChildInfo,
- };
+ if (newChild && existingAssignment?.childId !== newChild.id) {
+ const newParents = newChild.family.users.filter((u) => !!u.email);
+ if (newParents.length > 0) {
+ messages.push({
+ parentEmails: newParents.map((p) => p.email),
+ parentNames: newParents.map((p) => `${p.firstName} ${p.lastName}`),
+ subject: `Notdienst-Einteilung am ${dateLabel}`,
+ heading: "Neuer Notdienst-Termin zugewiesen",
+ preview: `Du wurdest für den Notdienst am ${dateLabel} eingeteilt.`,
+ body: "Du wurdest für folgenden Tag für den Notdienst eingeteilt.",
+ dateLabel,
+ });
+ }
+ }
+
+ return messages;
});
// E-Mails außerhalb der DB-Transaktion senden
- if (shouldSendEmails && assignedChildInfo) {
+ if (notifications.length > 0) {
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
@@ -266,25 +337,30 @@ export async function updateAssignmentAction(
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`;
- for (let i = 0; i < assignedChildInfo.parentEmails.length; i++) {
- const email = assignedChildInfo.parentEmails[i];
- const name = assignedChildInfo.parentNames[i];
+ for (const notification of notifications) {
+ for (let i = 0; i < notification.parentEmails.length; i++) {
+ const email = notification.parentEmails[i];
+ const name = notification.parentNames[i];
- await sendAppEmail({
- to: email,
- subject: `Notdienst-Einteilung am ${assignedChildInfo.dateLabel}`,
- react: createElement(NotdienstAssignmentEmail, {
- parentName: name,
- kitaName: kita.name,
- dateLabel: assignedChildInfo.dateLabel,
- link,
- }),
- });
+ await sendAppEmail({
+ to: email,
+ subject: notification.subject,
+ react: createElement(NotdienstAssignmentChangeEmail, {
+ parentName: name,
+ heading: notification.heading,
+ preview: notification.preview,
+ body: notification.body,
+ dateLabel: notification.dateLabel,
+ link,
+ }),
+ });
+ }
}
}
}
revalidatePath("/dashboard/notdienst/plan");
+ revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
return {
@@ -462,4 +538,3 @@ export async function resetPlanAction(planId: string) {
return { error: error instanceof Error ? error.message : "Fehler beim Zurücksetzen des Plans." };
}
}
-
diff --git a/src/app/dashboard/notdienst/plan/plan-view.tsx b/src/app/dashboard/notdienst/plan/plan-view.tsx
index 3854908..db87a07 100644
--- a/src/app/dashboard/notdienst/plan/plan-view.tsx
+++ b/src/app/dashboard/notdienst/plan/plan-view.tsx
@@ -386,7 +386,7 @@ export function PlanView({
{
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
const dateObj = new Date(yr, mo - 1, dy);
- const assignedChild = allChildren.find(
- (child) => child.id === day.childId,
- );
return (
{format(dateObj, "EE, dd.MM.", { locale: de })}
-
- {isDraft || day.isOriginallyUnassigned ? (
-
-
- {!isDraft && (
-
- Nachträglich änderbar
-
- )}
-
- ) : assignedChild ? (
-
- {assignedChild.parentName}{" "}
-
- ({assignedChild.name})
-
-
- ) : (
-
- Unbesetzt!
+
+
+ {!isDraft && (
+
+ Veröffentlicht
)}
diff --git a/src/emails/NotdienstAssignmentChangeEmail.tsx b/src/emails/NotdienstAssignmentChangeEmail.tsx
new file mode 100644
index 0000000..f5aa9be
--- /dev/null
+++ b/src/emails/NotdienstAssignmentChangeEmail.tsx
@@ -0,0 +1,178 @@
+import {
+ Body,
+ Button,
+ Container,
+ Head,
+ Heading,
+ Html,
+ Preview,
+ Section,
+ Text,
+} from "@react-email/components";
+
+import { baseBodyStyle, colors, fontStack } from "./_styles";
+
+type NotdienstAssignmentChangeEmailProps = {
+ parentName: string;
+ heading: string;
+ preview: string;
+ body: string;
+ dateLabel: string;
+ link: string;
+};
+
+export function NotdienstAssignmentChangeEmail({
+ parentName,
+ heading,
+ preview,
+ body,
+ dateLabel,
+ link,
+}: NotdienstAssignmentChangeEmailProps) {
+ return (
+
+
+
{preview}
+
+
+
+
+ Notdienst-Einteilung
+ {heading}
+ Hallo {parentName},
+ {body}
+
+
+
+ Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
+
+ {link}
+
+
+ Diese Nachricht wurde automatisch vom Kita-Planer deiner Kita versendet.
+
+
+
+
+ );
+}
+
+const styles = {
+ body: baseBodyStyle,
+ container: {
+ width: "100%",
+ maxWidth: "600px",
+ margin: "0 auto",
+ padding: "32px 20px",
+ fontFamily: fontStack,
+ },
+ card: {
+ padding: "0 28px 28px",
+ backgroundColor: colors.cardBg,
+ borderRadius: "10px",
+ border: `1px solid ${colors.border}`,
+ boxShadow: "0 10px 26px rgba(31, 59, 45, 0.08)",
+ overflow: "hidden",
+ fontFamily: fontStack,
+ },
+ brandBar: {
+ margin: "0 -28px 28px",
+ padding: "14px 28px",
+ backgroundColor: colors.brandGreen,
+ fontFamily: fontStack,
+ },
+ brand: {
+ margin: 0,
+ color: colors.eyebrow,
+ fontSize: "12px",
+ fontWeight: 800,
+ letterSpacing: "0.08em",
+ textTransform: "uppercase" as const,
+ fontFamily: fontStack,
+ },
+ kicker: {
+ margin: "0 0 10px",
+ color: colors.brandGreenAccent,
+ fontSize: "12px",
+ fontWeight: 800,
+ letterSpacing: "0.06em",
+ textTransform: "uppercase" as const,
+ fontFamily: fontStack,
+ },
+ heading: {
+ margin: "0 0 14px",
+ color: colors.textPrimary,
+ fontSize: "28px",
+ lineHeight: "34px",
+ fontWeight: 800,
+ fontFamily: fontStack,
+ },
+ lead: {
+ margin: "0 0 18px",
+ color: colors.textBody,
+ fontSize: "16px",
+ lineHeight: "24px",
+ fontFamily: fontStack,
+ },
+ text: {
+ margin: "0 0 22px",
+ color: colors.textBody,
+ fontSize: "15px",
+ lineHeight: "24px",
+ fontFamily: fontStack,
+ },
+ dateBadge: {
+ margin: "0 0 24px",
+ padding: "16px 18px",
+ backgroundColor: colors.bgAlt,
+ border: `1px solid ${colors.border}`,
+ borderRadius: "8px",
+ },
+ dateText: {
+ margin: 0,
+ color: colors.textPrimary,
+ fontSize: "17px",
+ lineHeight: "24px",
+ fontWeight: 800,
+ fontFamily: fontStack,
+ },
+ button: {
+ display: "inline-block",
+ padding: "12px 18px",
+ backgroundColor: colors.brandGreenAccent,
+ color: "#ffffff",
+ borderRadius: "8px",
+ textDecoration: "none",
+ fontWeight: 700,
+ fontFamily: fontStack,
+ },
+ fallbackText: {
+ margin: "22px 0 6px",
+ color: colors.textMuted,
+ fontSize: "12px",
+ lineHeight: "18px",
+ fontFamily: fontStack,
+ },
+ linkText: {
+ margin: 0,
+ color: colors.link,
+ fontSize: "12px",
+ lineHeight: "18px",
+ wordBreak: "break-all" as const,
+ fontFamily: fontStack,
+ },
+ footer: {
+ margin: "18px 0 0",
+ color: colors.textFaint,
+ fontSize: "12px",
+ lineHeight: "18px",
+ textAlign: "center" as const,
+ fontFamily: fontStack,
+ },
+};
diff --git a/src/emails/NotdienstSwapEmail.tsx b/src/emails/NotdienstSwapEmail.tsx
new file mode 100644
index 0000000..fb2f23b
--- /dev/null
+++ b/src/emails/NotdienstSwapEmail.tsx
@@ -0,0 +1,199 @@
+import {
+ Body,
+ Button,
+ Container,
+ Head,
+ Heading,
+ Html,
+ Preview,
+ Section,
+ Text,
+} from "@react-email/components";
+
+import { baseBodyStyle, colors, fontStack } from "./_styles";
+
+type NotdienstSwapEmailProps = {
+ parentName: string;
+ heading: string;
+ preview: string;
+ kitaName: string;
+ body: string;
+ targetDateLabel: string;
+ offeredDateLabel: string;
+ link: string;
+};
+
+export function NotdienstSwapEmail({
+ parentName,
+ heading,
+ preview,
+ kitaName,
+ body,
+ targetDateLabel,
+ offeredDateLabel,
+ link,
+}: NotdienstSwapEmailProps) {
+ return (
+
+
+
{preview}
+
+
+
+
+ Notdienst-Tausch
+ {heading}
+ Hallo {parentName},
+
+ in der Kita {kitaName}: {body}
+
+
+
+ Angefragter Notdienst
+ {targetDateLabel}
+ Angebotener Tauschdienst
+ {offeredDateLabel}
+
+
+
+
+
+ Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
+
+ {link}
+
+
+ Diese Nachricht wurde automatisch vom Kita-Planer deiner Kita versendet.
+
+
+
+
+ );
+}
+
+const styles = {
+ body: baseBodyStyle,
+ container: {
+ width: "100%",
+ maxWidth: "600px",
+ margin: "0 auto",
+ padding: "32px 20px",
+ fontFamily: fontStack,
+ },
+ card: {
+ padding: "0 28px 28px",
+ backgroundColor: colors.cardBg,
+ borderRadius: "10px",
+ border: `1px solid ${colors.border}`,
+ boxShadow: "0 10px 26px rgba(31, 59, 45, 0.08)",
+ overflow: "hidden",
+ fontFamily: fontStack,
+ },
+ brandBar: {
+ margin: "0 -28px 28px",
+ padding: "14px 28px",
+ backgroundColor: colors.brandGreen,
+ fontFamily: fontStack,
+ },
+ brand: {
+ margin: 0,
+ color: colors.eyebrow,
+ fontSize: "12px",
+ fontWeight: 800,
+ letterSpacing: "0.08em",
+ textTransform: "uppercase" as const,
+ fontFamily: fontStack,
+ },
+ kicker: {
+ margin: "0 0 10px",
+ color: colors.brandGreenAccent,
+ fontSize: "12px",
+ fontWeight: 800,
+ letterSpacing: "0.06em",
+ textTransform: "uppercase" as const,
+ fontFamily: fontStack,
+ },
+ heading: {
+ margin: "0 0 14px",
+ color: colors.textPrimary,
+ fontSize: "28px",
+ lineHeight: "34px",
+ fontWeight: 800,
+ fontFamily: fontStack,
+ },
+ lead: {
+ margin: "0 0 18px",
+ color: colors.textBody,
+ fontSize: "16px",
+ lineHeight: "24px",
+ fontFamily: fontStack,
+ },
+ text: {
+ margin: "0 0 22px",
+ color: colors.textBody,
+ fontSize: "15px",
+ lineHeight: "24px",
+ fontFamily: fontStack,
+ },
+ dateBox: {
+ margin: "0 0 24px",
+ padding: "18px",
+ backgroundColor: colors.bgAlt,
+ border: `1px solid ${colors.border}`,
+ borderRadius: "8px",
+ },
+ dateLabel: {
+ margin: "0 0 4px",
+ color: colors.textMuted,
+ fontSize: "12px",
+ fontWeight: 700,
+ textTransform: "uppercase" as const,
+ letterSpacing: "0.04em",
+ fontFamily: fontStack,
+ },
+ dateText: {
+ margin: "0 0 16px",
+ color: colors.textPrimary,
+ fontSize: "16px",
+ lineHeight: "24px",
+ fontWeight: 700,
+ fontFamily: fontStack,
+ },
+ button: {
+ display: "inline-block",
+ padding: "12px 18px",
+ backgroundColor: colors.brandGreenAccent,
+ color: "#ffffff",
+ borderRadius: "8px",
+ textDecoration: "none",
+ fontWeight: 700,
+ fontFamily: fontStack,
+ },
+ fallbackText: {
+ margin: "22px 0 6px",
+ color: colors.textMuted,
+ fontSize: "12px",
+ lineHeight: "18px",
+ fontFamily: fontStack,
+ },
+ linkText: {
+ margin: 0,
+ color: colors.link,
+ fontSize: "12px",
+ lineHeight: "18px",
+ wordBreak: "break-all" as const,
+ fontFamily: fontStack,
+ },
+ footer: {
+ margin: "18px 0 0",
+ color: colors.textFaint,
+ fontSize: "12px",
+ lineHeight: "18px",
+ textAlign: "center" as const,
+ fontFamily: fontStack,
+ },
+};