Improve family invitations and absence management flows
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Pencil, Undo2 } from "lucide-react";
|
||||
import { AbsenceReason } from "@prisma/client";
|
||||
import { toast } from "sonner";
|
||||
import { deleteAbsence, updateAbsence } from "@/actions/absences";
|
||||
import { absenceDate, canEditOwnAbsence } from "@/lib/absence-policy";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export type ManagedAbsence = {
|
||||
id: string;
|
||||
childName: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
reason: AbsenceReason;
|
||||
note: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function AbsenceManagementActions({
|
||||
absence,
|
||||
today,
|
||||
team = false,
|
||||
onChanged,
|
||||
}: {
|
||||
absence: ManagedAbsence;
|
||||
today: string;
|
||||
team?: boolean;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<"edit" | "withdraw" | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [startDate, setStartDate] = useState(absence.startDate);
|
||||
const [endDate, setEndDate] = useState(absence.endDate);
|
||||
const router = useRouter();
|
||||
if (!team && !canEditOwnAbsence(absence.startDate, today))
|
||||
return (
|
||||
<p className="mt-2 text-xs text-[var(--ink-muted)]">
|
||||
Bereits begonnene Meldung: Änderungen über die Kita.
|
||||
</p>
|
||||
);
|
||||
const tomorrow = new Date(absenceDate(today).getTime() + 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
function open(next: "edit" | "withdraw") {
|
||||
setStartDate(absence.startDate);
|
||||
setEndDate(absence.endDate);
|
||||
setError(null);
|
||||
setMode(next);
|
||||
}
|
||||
function submit(data?: FormData) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const identity = { id: absence.id, updatedAt: absence.updatedAt };
|
||||
const result =
|
||||
mode === "withdraw"
|
||||
? await deleteAbsence(identity)
|
||||
: await updateAbsence({
|
||||
...identity,
|
||||
startDate,
|
||||
endDate,
|
||||
reason: String(data?.get("reason") ?? ""),
|
||||
note: String(data?.get("note") ?? ""),
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
mode === "withdraw"
|
||||
? "Abwesenheitsmeldung zurückgenommen."
|
||||
: "Abwesenheitsmeldung aktualisiert.",
|
||||
);
|
||||
setMode(null);
|
||||
onChanged?.();
|
||||
} catch {
|
||||
setError(
|
||||
"Die Meldung konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => open("edit")}
|
||||
aria-label={`Abwesenheit bearbeiten: ${absence.childName}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" /> Bearbeiten
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => open("withdraw")}
|
||||
aria-label={`Abwesenheit zurücknehmen: ${absence.childName}`}
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" /> Zurücknehmen
|
||||
</Button>
|
||||
<Dialog
|
||||
open={mode !== null}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen && !pending) setMode(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "withdraw"
|
||||
? "Abwesenheit zurücknehmen"
|
||||
: "Abwesenheit bearbeiten"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{absence.childName}
|
||||
{mode === "withdraw"
|
||||
? ": Diese Meldung wird aus der Abwesenheitsübersicht entfernt."
|
||||
: ": Passe den Zeitraum, den Grund oder die Bemerkung an."}
|
||||
{!team && mode === "edit"
|
||||
? " Änderungen sind nur für zukünftige Tage möglich."
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit(new FormData(event.currentTarget));
|
||||
}}
|
||||
>
|
||||
{mode === "edit" && (
|
||||
<fieldset disabled={pending} className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`absence-start-${absence.id}`}>Von</Label>
|
||||
<Input
|
||||
id={`absence-start-${absence.id}`}
|
||||
type="date"
|
||||
required
|
||||
min={team ? undefined : tomorrow}
|
||||
value={startDate}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setStartDate(value);
|
||||
if (value > endDate) setEndDate(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`absence-end-${absence.id}`}>Bis</Label>
|
||||
<Input
|
||||
id={`absence-end-${absence.id}`}
|
||||
type="date"
|
||||
required
|
||||
min={startDate}
|
||||
value={endDate}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`absence-reason-${absence.id}`}>Grund</Label>
|
||||
<select
|
||||
id={`absence-reason-${absence.id}`}
|
||||
name="reason"
|
||||
defaultValue={absence.reason}
|
||||
className="h-10 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 text-sm"
|
||||
required
|
||||
>
|
||||
<option value={AbsenceReason.ILLNESS}>Krank</option>
|
||||
<option value={AbsenceReason.VACATION}>Urlaub</option>
|
||||
<option value={AbsenceReason.OTHER}>Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`absence-note-${absence.id}`}>
|
||||
Bemerkung (optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`absence-note-${absence.id}`}
|
||||
name="note"
|
||||
maxLength={500}
|
||||
defaultValue={absence.note ?? ""}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="my-4 rounded-lg bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]"
|
||||
>
|
||||
{error}
|
||||
{error.includes("aktualisieren") && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMode(null);
|
||||
onChanged?.();
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
Ansicht aktualisieren
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={() => setMode(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
variant={mode === "withdraw" ? "destructive" : "default"}
|
||||
>
|
||||
{pending
|
||||
? "Wird gespeichert…"
|
||||
: mode === "withdraw"
|
||||
? "Meldung zurücknehmen"
|
||||
: "Änderungen speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user