diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6f34ee3..44b650f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -84,6 +84,13 @@ enum NotdienstAlertStatus { CANCELLED } +enum NotdienstSwapStatus { + PENDING + ACCEPTED + DECLINED + CANCELLED +} + enum DutyAssignmentStatus { PLANNED DONE @@ -136,6 +143,7 @@ model Kita { notdienstAvailabilities NotdienstAvailability[] notdienstAssignments NotdienstAssignment[] notdienstAlerts NotdienstAlert[] + notdienstSwapRequests NotdienstSwapRequest[] @@map("kitas") } @@ -158,6 +166,7 @@ model Family { users User[] children Child[] dutyAssignments DutyAssignment[] + notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequesterFamily") @@index([kitaId]) @@map("families") @@ -219,6 +228,8 @@ model User { notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent") notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger") notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator") + notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequester") + notdienstSwapRequestsResponded NotdienstSwapRequest[] @relation("NotdienstSwapResponder") announcementsAuthored Announcement[] @relation("AnnouncementAuthor") announcementReads AnnouncementRead[] @@ -647,12 +658,50 @@ model NotdienstAssignment { updatedAt DateTime @updatedAt alerts NotdienstAlert[] + swapRequestsAsTarget NotdienstSwapRequest[] @relation("NotdienstSwapTargetAssignment") + swapRequestsAsOffered NotdienstSwapRequest[] @relation("NotdienstSwapOfferedAssignment") @@unique([planId, date]) @@index([kitaId, date]) @@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 /// vom Koordinator ausgelöst. Bestätigung via Magic-Link (Token). model NotdienstAlert { diff --git a/src/app/dashboard/kalender/_components/calendar-overview-client.tsx b/src/app/dashboard/kalender/_components/calendar-overview-client.tsx index 21df8d0..c8634b1 100644 --- a/src/app/dashboard/kalender/_components/calendar-overview-client.tsx +++ b/src/app/dashboard/kalender/_components/calendar-overview-client.tsx @@ -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 (
+ {!isAdmin && pendingSwapRequests.length > 0 && ( + + )} + {/* Search and Filters Header */}
@@ -282,7 +307,12 @@ export function CalendarOverviewClient({ {/* Main View Contents */} {view === "liste" && ( - + )} {view === "monat" && ( @@ -431,13 +461,26 @@ export function CalendarOverviewClient({

{isAdmin && ( )} + {!isAdmin && item.canRequestSwap && ( +
+ + Tausch anfragen + + } + /> +
+ )}
) : ( @@ -564,6 +607,106 @@ export function CalendarOverviewClient({ ); } +function NotdienstSwapRequestsPanel({ + requests, +}: { + requests: NotdienstSwapRequestDto[]; +}) { + return ( + +
+

+ Offene Notdienst-Tausche +

+
+
+ {requests.map((request) => ( + + ))} +
+
+ ); +} + +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 ( +
+
+

+ {request.direction === "incoming" ? "Anfrage an dich" : "Deine Anfrage"} +

+

+ {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}) +

+ {request.message && ( +

+ {request.message} +

+ )} +
+
+ {request.direction === "incoming" ? ( + <> + + + + ) : ( + + )} +
+
+ ); +} + // ===================================================================== // Month Grid Component // ===================================================================== diff --git a/src/app/dashboard/kalender/_components/termin-list.tsx b/src/app/dashboard/kalender/_components/termin-list.tsx index a38c0c9..ccc61e4 100644 --- a/src/app/dashboard/kalender/_components/termin-list.tsx +++ b/src/app/dashboard/kalender/_components/termin-list.tsx @@ -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" ? ( ) : item.kind === "notdienst" ? ( - + ) : ( ), @@ -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 ( + + {trigger} + + + Notdienst tauschen + + Frage Familie {target.familyName}, ob ihr den Notdienst am{" "} + {format(target.startDate, "dd.MM.yyyy", { locale: de })} tauschen könnt. + + + +
+
+ + +
+ +
+ +