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