feat: implement emergency duty (Notdienst) swapping for parents

This commit is contained in:
t.indorf
2026-05-23 19:18:30 +02:00
parent 4cd16773c1
commit 3ce5638449
9 changed files with 1638 additions and 119 deletions
@@ -4,8 +4,6 @@ import { useState, useMemo, useTransition } from "react";
import {
TerminType,
TerminStatus,
EventParticipationStatus,
DutyAssignmentStatus,
} from "@prisma/client";
import {
format,
@@ -30,9 +28,7 @@ import {
MapPin,
AlertTriangle,
WashingMachine,
MoreHorizontal,
Trash2,
Edit,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -49,11 +45,20 @@ import {
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { CalendarListItemDto } from "./termin-list";
import { TerminList } from "./termin-list";
import {
CalendarListItemDto,
NotdienstSwapDialog,
NotdienstSwapOptionDto,
TerminList,
} from "./termin-list";
import { MitbringselList } from "./mitbringsel-list";
import { AdminTerminModal } from "./admin-termin-modal";
import { deleteTerminAdmin } from "../actions";
import {
acceptNotdienstSwapRequest,
cancelNotdienstSwapRequest,
declineNotdienstSwapRequest,
deleteTerminAdmin,
} from "../actions";
const categories = [
{ label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" },
@@ -68,14 +73,30 @@ const categories = [
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" },
];
export type NotdienstSwapRequestDto = {
id: string;
direction: "incoming" | "outgoing";
targetDate: Date;
targetFamilyName: string;
targetChildName: string;
offeredDate: Date;
offeredFamilyName: string;
offeredChildName: string;
message: string | null;
};
export function CalendarOverviewClient({
termine,
userId,
isAdmin,
notdienstSwapOptions = [],
pendingSwapRequests = [],
}: {
termine: CalendarListItemDto[];
userId: string;
isAdmin: boolean;
notdienstSwapOptions?: NotdienstSwapOptionDto[];
pendingSwapRequests?: NotdienstSwapRequestDto[];
}) {
const [view, setView] = useState<"liste" | "monat" | "agenda">("monat");
const [searchQuery, setSearchQuery] = useState("");
@@ -185,6 +206,10 @@ export function CalendarOverviewClient({
return (
<div className="space-y-5">
{!isAdmin && pendingSwapRequests.length > 0 && (
<NotdienstSwapRequestsPanel requests={pendingSwapRequests} />
)}
{/* Search and Filters Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center flex-wrap gap-3">
@@ -282,7 +307,12 @@ export function CalendarOverviewClient({
{/* Main View Contents */}
{view === "liste" && (
<TerminList termine={filteredEvents} userId={userId} isAdmin={isAdmin} />
<TerminList
termine={filteredEvents}
userId={userId}
isAdmin={isAdmin}
notdienstSwapOptions={notdienstSwapOptions}
/>
)}
{view === "monat" && (
@@ -431,13 +461,26 @@ export function CalendarOverviewClient({
</p>
{isAdmin && (
<div className="mt-3 flex gap-2">
<a href="/dashboard/notdienst/plan">
<a href={`/dashboard/notdienst/plan?year=${item.planYear}&month=${item.planMonth}`}>
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
Im Planer bearbeiten
</Button>
</a>
</div>
)}
{!isAdmin && item.canRequestSwap && (
<div className="mt-3 flex gap-2">
<NotdienstSwapDialog
target={item}
options={notdienstSwapOptions}
trigger={
<Button size="sm" variant="outline" className="h-7 px-2.5 text-xs">
Tausch anfragen
</Button>
}
/>
</div>
)}
</div>
</div>
) : (
@@ -564,6 +607,106 @@ export function CalendarOverviewClient({
);
}
function NotdienstSwapRequestsPanel({
requests,
}: {
requests: NotdienstSwapRequestDto[];
}) {
return (
<Card className="p-0">
<div className="border-b border-[var(--hairline-soft)] px-5 py-4">
<h2 className="text-base font-semibold tracking-[-0.01em] text-[var(--ink)]">
Offene Notdienst-Tausche
</h2>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{requests.map((request) => (
<NotdienstSwapRequestRow key={request.id} request={request} />
))}
</div>
</Card>
);
}
function NotdienstSwapRequestRow({
request,
}: {
request: NotdienstSwapRequestDto;
}) {
const [isPending, startTransition] = useTransition();
const runAction = (
action: (requestId: string) => Promise<{ success?: boolean; error?: string }>,
successMessage: string,
) => {
startTransition(async () => {
const result = await action(request.id);
if (result.error) {
toast.error(result.error);
return;
}
toast.success(successMessage);
});
};
return (
<div className="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--ink)]">
{request.direction === "incoming" ? "Anfrage an dich" : "Deine Anfrage"}
</p>
<p className="mt-1 text-sm leading-5 text-[var(--ink-soft)]">
{format(request.targetDate, "dd.MM.yyyy", { locale: de })}{" "}
({request.targetFamilyName}, {request.targetChildName}) gegen{" "}
{format(request.offeredDate, "dd.MM.yyyy", { locale: de })}{" "}
({request.offeredFamilyName}, {request.offeredChildName})
</p>
{request.message && (
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
{request.message}
</p>
)}
</div>
<div className="flex shrink-0 gap-2">
{request.direction === "incoming" ? (
<>
<Button
size="sm"
onClick={() =>
runAction(acceptNotdienstSwapRequest, "Tausch wurde angenommen.")
}
disabled={isPending}
>
Annehmen
</Button>
<Button
size="sm"
variant="ghost"
onClick={() =>
runAction(declineNotdienstSwapRequest, "Tausch wurde abgelehnt.")
}
disabled={isPending}
>
Ablehnen
</Button>
</>
) : (
<Button
size="sm"
variant="ghost"
onClick={() =>
runAction(cancelNotdienstSwapRequest, "Tauschanfrage wurde zurückgezogen.")
}
disabled={isPending}
>
Zurückziehen
</Button>
)}
</div>
</div>
);
}
// =====================================================================
// Month Grid Component
// =====================================================================