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
+49
View File
@@ -84,6 +84,13 @@ enum NotdienstAlertStatus {
CANCELLED CANCELLED
} }
enum NotdienstSwapStatus {
PENDING
ACCEPTED
DECLINED
CANCELLED
}
enum DutyAssignmentStatus { enum DutyAssignmentStatus {
PLANNED PLANNED
DONE DONE
@@ -136,6 +143,7 @@ model Kita {
notdienstAvailabilities NotdienstAvailability[] notdienstAvailabilities NotdienstAvailability[]
notdienstAssignments NotdienstAssignment[] notdienstAssignments NotdienstAssignment[]
notdienstAlerts NotdienstAlert[] notdienstAlerts NotdienstAlert[]
notdienstSwapRequests NotdienstSwapRequest[]
@@map("kitas") @@map("kitas")
} }
@@ -158,6 +166,7 @@ model Family {
users User[] users User[]
children Child[] children Child[]
dutyAssignments DutyAssignment[] dutyAssignments DutyAssignment[]
notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequesterFamily")
@@index([kitaId]) @@index([kitaId])
@@map("families") @@map("families")
@@ -219,6 +228,8 @@ model User {
notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent") notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent")
notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger") notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger")
notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator") notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator")
notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequester")
notdienstSwapRequestsResponded NotdienstSwapRequest[] @relation("NotdienstSwapResponder")
announcementsAuthored Announcement[] @relation("AnnouncementAuthor") announcementsAuthored Announcement[] @relation("AnnouncementAuthor")
announcementReads AnnouncementRead[] announcementReads AnnouncementRead[]
@@ -647,12 +658,50 @@ model NotdienstAssignment {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
alerts NotdienstAlert[] alerts NotdienstAlert[]
swapRequestsAsTarget NotdienstSwapRequest[] @relation("NotdienstSwapTargetAssignment")
swapRequestsAsOffered NotdienstSwapRequest[] @relation("NotdienstSwapOfferedAssignment")
@@unique([planId, date]) @@unique([planId, date])
@@index([kitaId, date]) @@index([kitaId, date])
@@map("notdienst_assignments") @@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 /// Aktive Alarmierung — wird bei Krankmeldung einer Fachkraft
/// vom Koordinator ausgelöst. Bestätigung via Magic-Link (Token). /// vom Koordinator ausgelöst. Bestätigung via Magic-Link (Token).
model NotdienstAlert { model NotdienstAlert {
@@ -4,8 +4,6 @@ import { useState, useMemo, useTransition } from "react";
import { import {
TerminType, TerminType,
TerminStatus, TerminStatus,
EventParticipationStatus,
DutyAssignmentStatus,
} from "@prisma/client"; } from "@prisma/client";
import { import {
format, format,
@@ -30,9 +28,7 @@ import {
MapPin, MapPin,
AlertTriangle, AlertTriangle,
WashingMachine, WashingMachine,
MoreHorizontal,
Trash2, Trash2,
Edit,
} from "lucide-react"; } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -49,11 +45,20 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { toast } from "sonner"; import { toast } from "sonner";
import { CalendarListItemDto } from "./termin-list"; import {
import { TerminList } from "./termin-list"; CalendarListItemDto,
NotdienstSwapDialog,
NotdienstSwapOptionDto,
TerminList,
} from "./termin-list";
import { MitbringselList } from "./mitbringsel-list"; import { MitbringselList } from "./mitbringsel-list";
import { AdminTerminModal } from "./admin-termin-modal"; import { AdminTerminModal } from "./admin-termin-modal";
import { deleteTerminAdmin } from "../actions"; import {
acceptNotdienstSwapRequest,
cancelNotdienstSwapRequest,
declineNotdienstSwapRequest,
deleteTerminAdmin,
} from "../actions";
const categories = [ const categories = [
{ label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" }, { label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" },
@@ -68,14 +73,30 @@ const categories = [
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" }, { 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({ export function CalendarOverviewClient({
termine, termine,
userId, userId,
isAdmin, isAdmin,
notdienstSwapOptions = [],
pendingSwapRequests = [],
}: { }: {
termine: CalendarListItemDto[]; termine: CalendarListItemDto[];
userId: string; userId: string;
isAdmin: boolean; isAdmin: boolean;
notdienstSwapOptions?: NotdienstSwapOptionDto[];
pendingSwapRequests?: NotdienstSwapRequestDto[];
}) { }) {
const [view, setView] = useState<"liste" | "monat" | "agenda">("monat"); const [view, setView] = useState<"liste" | "monat" | "agenda">("monat");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -185,6 +206,10 @@ export function CalendarOverviewClient({
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{!isAdmin && pendingSwapRequests.length > 0 && (
<NotdienstSwapRequestsPanel requests={pendingSwapRequests} />
)}
{/* Search and Filters Header */} {/* Search and Filters Header */}
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center flex-wrap gap-3"> <div className="flex items-center flex-wrap gap-3">
@@ -282,7 +307,12 @@ export function CalendarOverviewClient({
{/* Main View Contents */} {/* Main View Contents */}
{view === "liste" && ( {view === "liste" && (
<TerminList termine={filteredEvents} userId={userId} isAdmin={isAdmin} /> <TerminList
termine={filteredEvents}
userId={userId}
isAdmin={isAdmin}
notdienstSwapOptions={notdienstSwapOptions}
/>
)} )}
{view === "monat" && ( {view === "monat" && (
@@ -431,13 +461,26 @@ export function CalendarOverviewClient({
</p> </p>
{isAdmin && ( {isAdmin && (
<div className="mt-3 flex gap-2"> <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"> <Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
Im Planer bearbeiten Im Planer bearbeiten
</Button> </Button>
</a> </a>
</div> </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>
</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 // Month Grid Component
// ===================================================================== // =====================================================================
@@ -1,5 +1,6 @@
"use client"; "use client";
import type { ReactNode } from "react";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client"; import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -11,8 +12,6 @@ import {
MapPin, MapPin,
MoreHorizontal, MoreHorizontal,
WashingMachine, WashingMachine,
Trash2,
ShieldAlert,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -31,7 +30,7 @@ import {
import { AdminTerminModal } from "./admin-termin-modal"; import { AdminTerminModal } from "./admin-termin-modal";
import { TerminRequestModal } from "./termin-request-modal"; import { TerminRequestModal } from "./termin-request-modal";
import { MitbringselList } from "./mitbringsel-list"; import { MitbringselList } from "./mitbringsel-list";
import { deleteTerminAdmin } from "../actions"; import { createNotdienstSwapRequest, deleteTerminAdmin } from "../actions";
export type TerminListItemDto = { export type TerminListItemDto = {
kind: "termin"; kind: "termin";
@@ -69,24 +68,38 @@ export type DutyCalendarItemDto = {
export type NotdienstCalendarItemDto = { export type NotdienstCalendarItemDto = {
kind: "notdienst"; kind: "notdienst";
id: string; id: string;
planId: string;
planYear: number;
planMonth: number;
title: string; title: string;
familyId: string;
familyName: string; familyName: string;
childName: string; childName: string;
isOwn: boolean; isOwn: boolean;
canRequestSwap: boolean;
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
}; };
export type NotdienstSwapOptionDto = {
id: string;
date: Date;
childName: string;
familyName: string;
};
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto | NotdienstCalendarItemDto; export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto | NotdienstCalendarItemDto;
export function TerminList({ export function TerminList({
termine, termine,
userId, userId,
isAdmin, isAdmin,
notdienstSwapOptions = [],
}: { }: {
termine: CalendarListItemDto[]; termine: CalendarListItemDto[];
userId: string; userId: string;
isAdmin: boolean; isAdmin: boolean;
notdienstSwapOptions?: NotdienstSwapOptionDto[];
}) { }) {
if (termine.length === 0) { if (termine.length === 0) {
return ( return (
@@ -137,7 +150,12 @@ export function TerminList({
item.kind === "duty" ? ( item.kind === "duty" ? (
<DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} /> <DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} />
) : item.kind === "notdienst" ? ( ) : 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} /> <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 color = notdienst.isOwn ? "oklch(0.60 0.18 45)" : "oklch(0.75 0.10 50)";
const [menuOpen, setMenuOpen] = useState(false); 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="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"> <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 <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)]" 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)} onClick={() => setMenuOpen(false)}
> >
@@ -545,6 +679,16 @@ function NotdienstRow({ notdienst, isAdmin }: { notdienst: NotdienstCalendarItem
</> </>
)} )}
</div> </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" /> <div className="w-9 h-9" />
)} )}
+648 -1
View File
@@ -1,10 +1,22 @@
"use server"; "use server";
import { createElement } from "react";
import { revalidatePath } from "next/cache"; 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 { z } from "zod";
import { NotdienstSwapEmail } from "@/emails/NotdienstSwapEmail";
import { requireKitaSession, requireRole } from "@/lib/auth-utils"; import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// ===================================================================== // =====================================================================
@@ -24,6 +36,223 @@ const terminSchema = z.object({
participantInfo: z.string().trim().max(1000).optional(), 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>) { function buildTerminData(data: z.infer<typeof terminSchema>) {
const startDate = new Date(data.startDate); const startDate = new Date(data.startDate);
const endDate = new Date(data.endDate); 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) { export async function createTerminRequest(rawPayload: unknown) {
const session = await requireKitaSession(); const session = await requireKitaSession();
+108 -5
View File
@@ -1,8 +1,8 @@
import { import {
DutyAssignmentStatus, DutyAssignmentStatus,
EventParticipationStatus, EventParticipationStatus,
NotdienstSwapStatus,
TerminStatus, TerminStatus,
TerminType,
UserRole, UserRole,
} from "@prisma/client"; } from "@prisma/client";
import { HelpCircle } from "lucide-react"; import { HelpCircle } from "lucide-react";
@@ -11,9 +11,12 @@ import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { AdminTerminModal } from "./_components/admin-termin-modal"; import { AdminTerminModal } from "./_components/admin-termin-modal";
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen"; 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 { 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" }; export const metadata = { title: "Terminkalender · Kita-Planer" };
@@ -31,6 +34,7 @@ export default async function KalenderPage({
const resolvedSearchParams = await searchParams; const resolvedSearchParams = await searchParams;
const currentTab = resolvedSearchParams.tab || "übersicht"; const currentTab = resolvedSearchParams.tab || "übersicht";
const now = new Date(); const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const confirmedTermineRows = await prisma.termin.findMany({ const confirmedTermineRows = await prisma.termin.findMany({
where: { where: {
@@ -159,6 +163,13 @@ export default async function KalenderPage({
select: { select: {
id: true, id: true,
date: true, date: true,
planId: true,
plan: {
select: {
year: true,
month: true,
},
},
child: { child: {
select: { select: {
firstName: true, firstName: true,
@@ -175,6 +186,56 @@ export default async function KalenderPage({
orderBy: { date: "asc" }, 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[] = [ const allUserTermine: CalendarListItemDto[] = [
...confirmedTermineRows.map((termin) => ({ ...confirmedTermineRows.map((termin) => ({
...termin, ...termin,
@@ -213,21 +274,59 @@ export default async function KalenderPage({
}), }),
...notdienstAssignmentRows.map((assignment) => { ...notdienstAssignmentRows.map((assignment) => {
const isOwn = assignment.child.familyId === familyIdForParticipation; const isOwn = assignment.child.familyId === familyIdForParticipation;
const canRequestSwap =
!isAdmin &&
!isOwn &&
!!session.user.familyId &&
assignment.date >= today;
return { return {
kind: "notdienst" as const, kind: "notdienst" as const,
id: assignment.id, id: assignment.id,
planId: assignment.planId,
planYear: assignment.plan.year,
planMonth: assignment.plan.month,
title: isOwn title: isOwn
? `Mein Notdienst (${assignment.child.firstName})` ? `Mein Notdienst (${assignment.child.firstName})`
: `Notdienst (${assignment.child.family.name})`, : `Notdienst (${assignment.child.family.name})`,
familyId: assignment.child.familyId,
familyName: assignment.child.family.name, familyName: assignment.child.family.name,
childName: `${assignment.child.firstName} ${assignment.child.lastName}`, childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
isOwn, isOwn,
canRequestSwap,
startDate: assignment.date, startDate: assignment.date,
endDate: assignment.date, endDate: assignment.date,
}; };
}), }),
].sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); ].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[] = []; let allPendingTermine: PendingTerminDto[] = [];
if (isAdmin) { if (isAdmin) {
const pendingRows = await prisma.termin.findMany({ const pendingRows = await prisma.termin.findMany({
@@ -305,6 +404,8 @@ export default async function KalenderPage({
termine={allUserTermine} termine={allUserTermine}
userId={session.user.id} userId={session.user.id}
isAdmin={isAdmin} isAdmin={isAdmin}
notdienstSwapOptions={notdienstSwapOptions}
pendingSwapRequests={pendingSwapRequests}
/> />
) : currentTab === "anfragen" ? ( ) : currentTab === "anfragen" ? (
<PendingAnfragen termine={allPendingTermine} /> <PendingAnfragen termine={allPendingTermine} />
@@ -313,6 +414,8 @@ export default async function KalenderPage({
termine={[]} termine={[]}
userId={session.user.id} userId={session.user.id}
isAdmin={isAdmin} isAdmin={isAdmin}
notdienstSwapOptions={notdienstSwapOptions}
pendingSwapRequests={pendingSwapRequests}
/> />
)} )}
</div> </div>
@@ -322,6 +425,8 @@ export default async function KalenderPage({
termine={allUserTermine} termine={allUserTermine}
userId={session.user.id} userId={session.user.id}
isAdmin={isAdmin} isAdmin={isAdmin}
notdienstSwapOptions={notdienstSwapOptions}
pendingSwapRequests={pendingSwapRequests}
/> />
)} )}
</div> </div>
@@ -405,5 +510,3 @@ function TabLink({
</a> </a>
); );
} }
+124 -49
View File
@@ -11,7 +11,7 @@ import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils"; import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail"; import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
import { NotdienstAssignmentEmail } from "@/emails/NotdienstAssignmentEmail"; import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
// ===================================================================== // =====================================================================
// /dashboard/notdienst/plan · Server Actions // /dashboard/notdienst/plan · Server Actions
@@ -160,14 +160,7 @@ export async function updateAssignmentAction(
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy)); const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
try { try {
const { shouldSendEmails, assignedChildInfo } = await prisma.$transaction(async (tx) => { const notifications = await prisma.$transaction(async (tx) => {
let txShouldSendEmails = false;
let txAssignedChildInfo: {
parentEmails: string[];
parentNames: string[];
dateLabel: string;
} | null = null;
const plan = await tx.notdienstPlan.findUnique({ const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId }, where: { id: planId, kitaId },
}); });
@@ -183,27 +176,43 @@ export async function updateAssignmentAction(
throw new Error("Plan kann nicht bearbeitet werden."); throw new Error("Plan kann nicht bearbeitet werden.");
} }
// Wenn der Plan bereits veröffentlicht ist, dürfen nur unbesetzte Termine geändert werden. if (isPublished) {
// Ein Termin gilt als "unbesetzt", wenn am Veröffentlichungstag kein Assignment existierte const today = new Date();
// bzw. das Assignment erst nach dem Veröffentlichungs-Zeitpunkt erstellt wurde. 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.");
}
}
const existingAssignment = await tx.notdienstAssignment.findFirst({ const existingAssignment = await tx.notdienstAssignment.findFirst({
where: { planId, date: parsedDate }, where: { planId, date: parsedDate },
include: {
child: {
include: {
family: {
include: {
users: {
where: { role: UserRole.ELTERN },
select: { email: true, firstName: true, lastName: true },
},
},
},
},
},
},
}); });
if (isPublished && existingAssignment) { let newChild:
if (!plan.publishedAt || existingAssignment.createdAt <= plan.publishedAt) { | {
throw new Error("In einem bereits veröffentlichten Plan können nur unbesetzte Termine geändert werden."); id: string;
} family: {
users: { email: string; firstName: string; lastName: string }[];
};
} }
| null = null;
// Altes Assignment für diesen Tag löschen
await tx.notdienstAssignment.deleteMany({
where: { planId, date: parsedDate },
});
// Neues anlegen, falls ausgewählt
if (newChildId) { if (newChildId) {
const child = await tx.child.findFirst({ newChild = await tx.child.findFirst({
where: { where: {
id: newChildId, id: newChildId,
kitaId, kitaId,
@@ -221,10 +230,27 @@ export async function updateAssignmentAction(
}, },
}); });
if (!child) { if (!newChild) {
throw new Error("Kind gehört nicht zu dieser Kita."); 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({ await tx.notdienstAssignment.create({
data: { data: {
kitaId, kitaId,
@@ -233,30 +259,75 @@ export async function updateAssignmentAction(
date: parsedDate, 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 { if (newChild && existingAssignment?.childId !== newChild.id) {
shouldSendEmails: txShouldSendEmails, const newParents = newChild.family.users.filter((u) => !!u.email);
assignedChildInfo: txAssignedChildInfo, 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 // E-Mails außerhalb der DB-Transaktion senden
if (shouldSendEmails && assignedChildInfo) { if (notifications.length > 0) {
const kita = await prisma.kita.findUnique({ const kita = await prisma.kita.findUnique({
where: { id: kitaId }, where: { id: kitaId },
select: { name: true }, 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 BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`; const link = `${BASE_URL}/dashboard/notdienst`;
for (let i = 0; i < assignedChildInfo.parentEmails.length; i++) { for (const notification of notifications) {
const email = assignedChildInfo.parentEmails[i]; for (let i = 0; i < notification.parentEmails.length; i++) {
const name = assignedChildInfo.parentNames[i]; const email = notification.parentEmails[i];
const name = notification.parentNames[i];
await sendAppEmail({ await sendAppEmail({
to: email, to: email,
subject: `Notdienst-Einteilung am ${assignedChildInfo.dateLabel}`, subject: notification.subject,
react: createElement(NotdienstAssignmentEmail, { react: createElement(NotdienstAssignmentChangeEmail, {
parentName: name, parentName: name,
kitaName: kita.name, heading: notification.heading,
dateLabel: assignedChildInfo.dateLabel, preview: notification.preview,
body: notification.body,
dateLabel: notification.dateLabel,
link, link,
}), }),
}); });
} }
} }
} }
}
revalidatePath("/dashboard/notdienst/plan"); revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/kalender");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { return {
@@ -462,4 +538,3 @@ export async function resetPlanAction(planId: string) {
return { error: error instanceof Error ? error.message : "Fehler beim Zurücksetzen des Plans." }; return { error: error instanceof Error ? error.message : "Fehler beim Zurücksetzen des Plans." };
} }
} }
+2 -21
View File
@@ -386,7 +386,7 @@ export function PlanView({
<span className="text-sm text-[var(--ink-soft)]"> <span className="text-sm text-[var(--ink-soft)]">
{isDraft {isDraft
? "Du kannst die Liste jetzt manuell anpassen." ? "Du kannst die Liste jetzt manuell anpassen."
: "Dieser Plan ist verbindlich."} : "Dieser Plan ist veröffentlicht und kann bei Änderungen nachbearbeitet werden."}
</span> </span>
</div> </div>
</Card> </Card>
@@ -524,7 +524,6 @@ function PlanPreviewTable({
const assignedChild = allChildren.find( const assignedChild = allChildren.find(
(child) => child.id === day.childId, (child) => child.id === day.childId,
); );
return ( return (
<div <div
key={day.date} key={day.date}
@@ -604,9 +603,6 @@ function ManualAssignmentTable({
{days.map((day) => { {days.map((day) => {
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number); const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
const dateObj = new Date(yr, mo - 1, dy); const dateObj = new Date(yr, mo - 1, dy);
const assignedChild = allChildren.find(
(child) => child.id === day.childId,
);
return ( return (
<div <div
@@ -616,8 +612,6 @@ function ManualAssignmentTable({
<div className="font-medium text-[var(--ink)]"> <div className="font-medium text-[var(--ink)]">
{format(dateObj, "EE, dd.MM.", { locale: de })} {format(dateObj, "EE, dd.MM.", { locale: de })}
</div> </div>
<div>
{isDraft || day.isOriginallyUnassigned ? (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<select <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)]" 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)]"
@@ -640,20 +634,7 @@ function ManualAssignmentTable({
</select> </select>
{!isDraft && ( {!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"> <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 Veröffentlicht
</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!
</span> </span>
)} )}
</div> </div>
@@ -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 (
<Html lang="de">
<Head />
<Preview>{preview}</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.card}>
<Section style={styles.brandBar}>
<Text style={styles.brand}>Kita-Planer</Text>
</Section>
<Text style={styles.kicker}>Notdienst-Einteilung</Text>
<Heading style={styles.heading}>{heading}</Heading>
<Text style={styles.lead}>Hallo {parentName},</Text>
<Text style={styles.text}>{body}</Text>
<Section style={styles.dateBadge}>
<Text style={styles.dateText}>{dateLabel}</Text>
</Section>
<Button href={link} style={styles.button}>
Zum Kita-Planer
</Button>
<Text style={styles.fallbackText}>
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
</Text>
<Text style={styles.linkText}>{link}</Text>
</Section>
<Text style={styles.footer}>
Diese Nachricht wurde automatisch vom Kita-Planer deiner Kita versendet.
</Text>
</Container>
</Body>
</Html>
);
}
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,
},
};
+199
View File
@@ -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 (
<Html lang="de">
<Head />
<Preview>{preview}</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.card}>
<Section style={styles.brandBar}>
<Text style={styles.brand}>Kita-Planer</Text>
</Section>
<Text style={styles.kicker}>Notdienst-Tausch</Text>
<Heading style={styles.heading}>{heading}</Heading>
<Text style={styles.lead}>Hallo {parentName},</Text>
<Text style={styles.text}>
in der Kita <strong>{kitaName}</strong>: {body}
</Text>
<Section style={styles.dateBox}>
<Text style={styles.dateLabel}>Angefragter Notdienst</Text>
<Text style={styles.dateText}>{targetDateLabel}</Text>
<Text style={styles.dateLabel}>Angebotener Tauschdienst</Text>
<Text style={styles.dateText}>{offeredDateLabel}</Text>
</Section>
<Button href={link} style={styles.button}>
Zum Kalender
</Button>
<Text style={styles.fallbackText}>
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
</Text>
<Text style={styles.linkText}>{link}</Text>
</Section>
<Text style={styles.footer}>
Diese Nachricht wurde automatisch vom Kita-Planer deiner Kita versendet.
</Text>
</Container>
</Body>
</Html>
);
}
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,
},
};