feat: implement emergency duty (Notdienst) swapping for parents

This commit is contained in:
t.indorf
2026-05-23 19:18:30 +02:00
parent 4cd16773c1
commit 3ce5638449
9 changed files with 1638 additions and 119 deletions
@@ -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 (
<div className="space-y-5">
{!isAdmin && pendingSwapRequests.length > 0 && (
<NotdienstSwapRequestsPanel requests={pendingSwapRequests} />
)}
{/* Search and Filters Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center flex-wrap gap-3">
@@ -282,7 +307,12 @@ export function CalendarOverviewClient({
{/* Main View Contents */}
{view === "liste" && (
<TerminList termine={filteredEvents} userId={userId} isAdmin={isAdmin} />
<TerminList
termine={filteredEvents}
userId={userId}
isAdmin={isAdmin}
notdienstSwapOptions={notdienstSwapOptions}
/>
)}
{view === "monat" && (
@@ -431,13 +461,26 @@ export function CalendarOverviewClient({
</p>
{isAdmin && (
<div className="mt-3 flex gap-2">
<a href="/dashboard/notdienst/plan">
<a href={`/dashboard/notdienst/plan?year=${item.planYear}&month=${item.planMonth}`}>
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
Im Planer bearbeiten
</Button>
</a>
</div>
)}
{!isAdmin && item.canRequestSwap && (
<div className="mt-3 flex gap-2">
<NotdienstSwapDialog
target={item}
options={notdienstSwapOptions}
trigger={
<Button size="sm" variant="outline" className="h-7 px-2.5 text-xs">
Tausch anfragen
</Button>
}
/>
</div>
)}
</div>
</div>
) : (
@@ -564,6 +607,106 @@ export function CalendarOverviewClient({
);
}
function NotdienstSwapRequestsPanel({
requests,
}: {
requests: NotdienstSwapRequestDto[];
}) {
return (
<Card className="p-0">
<div className="border-b border-[var(--hairline-soft)] px-5 py-4">
<h2 className="text-base font-semibold tracking-[-0.01em] text-[var(--ink)]">
Offene Notdienst-Tausche
</h2>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{requests.map((request) => (
<NotdienstSwapRequestRow key={request.id} request={request} />
))}
</div>
</Card>
);
}
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 (
<div className="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--ink)]">
{request.direction === "incoming" ? "Anfrage an dich" : "Deine Anfrage"}
</p>
<p className="mt-1 text-sm leading-5 text-[var(--ink-soft)]">
{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})
</p>
{request.message && (
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
{request.message}
</p>
)}
</div>
<div className="flex shrink-0 gap-2">
{request.direction === "incoming" ? (
<>
<Button
size="sm"
onClick={() =>
runAction(acceptNotdienstSwapRequest, "Tausch wurde angenommen.")
}
disabled={isPending}
>
Annehmen
</Button>
<Button
size="sm"
variant="ghost"
onClick={() =>
runAction(declineNotdienstSwapRequest, "Tausch wurde abgelehnt.")
}
disabled={isPending}
>
Ablehnen
</Button>
</>
) : (
<Button
size="sm"
variant="ghost"
onClick={() =>
runAction(cancelNotdienstSwapRequest, "Tauschanfrage wurde zurückgezogen.")
}
disabled={isPending}
>
Zurückziehen
</Button>
)}
</div>
</div>
);
}
// =====================================================================
// Month Grid Component
// =====================================================================
@@ -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" ? (
<DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} />
) : item.kind === "notdienst" ? (
<NotdienstRow key={`notdienst-${item.id}`} notdienst={item} isAdmin={isAdmin} />
<NotdienstRow
key={`notdienst-${item.id}`}
notdienst={item}
isAdmin={isAdmin}
notdienstSwapOptions={notdienstSwapOptions}
/>
) : (
<TerminRow key={`termin-${item.id}`} termin={item} userId={userId} isAdmin={isAdmin} />
),
@@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Notdienst tauschen</DialogTitle>
<DialogDescription>
Frage Familie {target.familyName}, ob ihr den Notdienst am{" "}
{format(target.startDate, "dd.MM.yyyy", { locale: de })} tauschen könnt.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-[var(--ink)]">
Dein Tausch-Angebot
</label>
<select
className="w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
value={offeredAssignmentId}
disabled={isPending || selectableOptions.length === 0}
onChange={(event) => setOfferedAssignmentId(event.target.value)}
>
{selectableOptions.length === 0 ? (
<option value="">Kein eigener zukünftiger Notdienst verfügbar</option>
) : (
selectableOptions.map((option) => (
<option key={option.id} value={option.id}>
{format(option.date, "dd.MM.yyyy", { locale: de })} - {option.childName}
</option>
))
)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-[var(--ink)]">
Nachricht optional
</label>
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
maxLength={500}
rows={3}
className="w-full resize-none rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--ink)] ring-offset-[var(--surface)] placeholder:text-[var(--ink-muted)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
placeholder="Zum Beispiel: Wir sind an dem Tag verreist."
disabled={isPending}
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={isPending}>
Abbrechen
</Button>
<Button
onClick={handleSubmit}
disabled={isPending || selectableOptions.length === 0}
>
Anfrage senden
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<a
href="/dashboard/notdienst/plan"
href={`/dashboard/notdienst/plan?year=${notdienst.planYear}&month=${notdienst.planMonth}`}
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]"
onClick={() => setMenuOpen(false)}
>
@@ -545,6 +679,16 @@ function NotdienstRow({ notdienst, isAdmin }: { notdienst: NotdienstCalendarItem
</>
)}
</div>
) : notdienst.canRequestSwap ? (
<NotdienstSwapDialog
target={notdienst}
options={notdienstSwapOptions}
trigger={
<Button size="sm" variant="outline" className="h-8 px-2.5 text-xs">
Tausch anfragen
</Button>
}
/>
) : (
<div className="w-9 h-9" />
)}
+648 -1
View File
@@ -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<SwapAssignmentWithFamily | null> {
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<typeof terminSchema>) {
const startDate = new Date(data.startDate);
const endDate = new Date(data.endDate);
@@ -60,6 +289,424 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
};
}
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();
+108 -5
View File
@@ -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" ? (
<PendingAnfragen termine={allPendingTermine} />
@@ -313,6 +414,8 @@ export default async function KalenderPage({
termine={[]}
userId={session.user.id}
isAdmin={isAdmin}
notdienstSwapOptions={notdienstSwapOptions}
pendingSwapRequests={pendingSwapRequests}
/>
)}
</div>
@@ -322,6 +425,8 @@ export default async function KalenderPage({
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
notdienstSwapOptions={notdienstSwapOptions}
pendingSwapRequests={pendingSwapRequests}
/>
)}
</div>
@@ -405,5 +510,3 @@ function TabLink({
</a>
);
}
+130 -55
View File
@@ -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." };
}
}
+24 -43
View File
@@ -386,7 +386,7 @@ export function PlanView({
<span className="text-sm text-[var(--ink-soft)]">
{isDraft
? "Du kannst die Liste jetzt manuell anpassen."
: "Dieser Plan ist verbindlich."}
: "Dieser Plan ist veröffentlicht und kann bei Änderungen nachbearbeitet werden."}
</span>
</div>
</Card>
@@ -524,7 +524,6 @@ function PlanPreviewTable({
const assignedChild = allChildren.find(
(child) => child.id === day.childId,
);
return (
<div
key={day.date}
@@ -604,9 +603,6 @@ function ManualAssignmentTable({
{days.map((day) => {
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 (
<div
@@ -616,44 +612,29 @@ function ManualAssignmentTable({
<div className="font-medium text-[var(--ink)]">
{format(dateObj, "EE, dd.MM.", { locale: de })}
</div>
<div>
{isDraft || day.isOriginallyUnassigned ? (
<div className="flex flex-wrap items-center gap-2">
<select
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
value={day.childId || "empty"}
disabled={isPending}
onChange={(event) =>
onAssignmentChange(day.date, event.target.value)
}
>
<option value="empty">-- Unbesetzt --</option>
{allChildren.map((child) => {
const famId = child.familyId;
const count = famId ? (familyAssignmentCounts.get(famId) ?? 0) : 0;
return (
<option key={child.id} value={child.id}>
{child.parentName} ({child.name}) — {count}x
</option>
);
})}
</select>
{!isDraft && (
<span className="text-[11px] font-semibold text-[oklch(0.60_0.18_45)] bg-[oklch(0.95_0.02_45)] border border-[oklch(0.85_0.05_45)] px-2 py-0.5 rounded-md shrink-0">
Nachträglich änderbar
</span>
)}
</div>
) : assignedChild ? (
<span className="text-sm text-[var(--ink)]">
{assignedChild.parentName}{" "}
<span className="text-[var(--ink-muted)]">
({assignedChild.name})
</span>
</span>
) : (
<span className="text-sm font-medium text-[var(--danger)]">
Unbesetzt!
<div className="flex flex-wrap items-center gap-2">
<select
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
value={day.childId || "empty"}
disabled={isPending}
onChange={(event) =>
onAssignmentChange(day.date, event.target.value)
}
>
<option value="empty">-- Unbesetzt --</option>
{allChildren.map((child) => {
const famId = child.familyId;
const count = famId ? (familyAssignmentCounts.get(famId) ?? 0) : 0;
return (
<option key={child.id} value={child.id}>
{child.parentName} ({child.name}) — {count}x
</option>
);
})}
</select>
{!isDraft && (
<span className="text-[11px] font-semibold text-[oklch(0.60_0.18_45)] bg-[oklch(0.95_0.02_45)] border border-[oklch(0.85_0.05_45)] px-2 py-0.5 rounded-md shrink-0">
Veröffentlicht
</span>
)}
</div>