feat: implement emergency duty (Notdienst) swapping for parents

This commit is contained in:
t.indorf
2026-05-23 19:18:30 +02:00
parent 4cd16773c1
commit 3ce5638449
9 changed files with 1638 additions and 119 deletions
@@ -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" />
)}