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