feat: implement emergency duty (Notdienst) swapping for parents
This commit is contained in:
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user