"use client"; import { CircleAlertIcon, CopyIcon, LoaderCircleIcon, Trash2Icon, XIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { useAdminUser } from "@/components/admin/AdminSession"; import { EventForm, fieldId } from "@/components/admin/EventForm"; import { useMediaQuery } from "@/components/admin/useMediaQuery"; import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { goToAdminLogin } from "@/lib/admin-auth"; import { api } from "@/lib/api"; import { describeApiError, isSessionExpired } from "@/lib/api-errors"; import { calendarById } from "@/lib/calendars"; import { formatDateTime } from "@/lib/dates"; import { contentChanged, duplicateForm, eventFromPayload, formFromEvent, isFormDirty, newEventForm, toPayload, validateEventForm, type EventForm as EventFormState, type EventFormField, } from "@/lib/event-form"; import type { CalendarEvent } from "@/lib/types"; export type SheetMode = | { kind: "new"; calendarId: number } | { kind: "edit"; event: CalendarEvent } | { kind: "duplicate"; event: CalendarEvent }; const TITLES: Record = { new: "Neuer Termin", edit: "Termin bearbeiten", duplicate: "Termin duplizieren", }; // Top to bottom, so focus lands on the first problem the user would see. const FIELD_ORDER: EventFormField[] = ["calendarId", "name", "startDate", "startTime", "endDate", "endTime", "url"]; /** * Create, edit or duplicate one event. A bottom sheet on phones, a side panel * from `sm` up. * * Nothing closes before the API has answered - the Angular row snapped shut * the moment Save was clicked, so a failed save looked saved. On a failure the * sheet stays open with the edits and says what went wrong; a 401 is the one * failure that leaves, to the login page. * * The parent gives it a fresh `key` per opening, so state never leaks from one * event into the next. */ export function EventSheet({ open, mode, now, onClose, onClosed, onSaved, onDeleted, onDuplicate, }: { open: boolean; mode: SheetMode | null; now: Date; /** Asked to close; the sheet has already dealt with unsaved changes. */ onClose: () => void; /** The closing animation finished - safe to drop `mode`. */ onClosed: () => void; onSaved: (event: CalendarEvent, previous: CalendarEvent | null) => void; onDeleted: (event: CalendarEvent) => void; onDuplicate: (event: CalendarEvent) => void; }) { const user = useAdminUser(); const desktop = useMediaQuery("(min-width: 640px)"); // What "unchanged" means. Moves forward after a partial save (moved, but the // update failed), so a retry doesn't try to move again. const [original, setOriginal] = useState(mode?.kind === "edit" ? mode.event : null); const [initial, setInitial] = useState(() => (mode ? initialForm(mode, now) : null)); const [form, setForm] = useState(initial); const [showErrors, setShowErrors] = useState(false); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); const [saveError, setSaveError] = useState(null); const [confirmDiscard, setConfirmDiscard] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const errors = useMemo(() => (form ? validateEventForm(form) : {}), [form]); const dirty = form !== null && initial !== null && isFormDirty(initial, form); const busy = saving || deleting; // Leaving the page with unsaved edits - reload, closing the tab - asks too. useEffect(() => { if (!open || !dirty) return; const warn = (e: BeforeUnloadEvent) => e.preventDefault(); window.addEventListener("beforeunload", warn); return () => window.removeEventListener("beforeunload", warn); }, [open, dirty]); const requestClose = () => { if (busy) return; if (dirty) { setConfirmDiscard(true); return; } onClose(); }; const leaveForLogin = () => { toast.error("Deine Sitzung ist abgelaufen. Du wirst zur Anmeldung weitergeleitet …"); goToAdminLogin(); }; const save = async () => { if (!form || !initial || !mode) return; setShowErrors(true); setSaveError(null); const firstError = FIELD_ORDER.find((field) => errors[field]); if (firstError) { document.getElementById(fieldId(firstError))?.focus(); return; } const payload = toPayload(form); setSaving(true); let moved = false; try { if (!original) { const eventId = await api.createEvent(payload); onSaved(eventFromPayload(eventId, payload, user.fullName, new Date()), null); toast.success(`„${payload.name}“ ist angelegt.`); onClose(); return; } // Calendar first, then content: a move only rewrites the calendar, so if // the second call fails the event is at least where it was sent, and the // message can say exactly that. if (payload.calendarId !== original.calendarId) { await api.moveEvent(original.eventId, payload); moved = true; } if (contentChanged(initial, form)) { await api.updateEvent(original.eventId, payload); } onSaved(eventFromPayload(original.eventId, payload, user.fullName, new Date(), original), original); toast.success(`„${payload.name}“ ist gespeichert.`); onClose(); } catch (e) { if (isSessionExpired(e)) { leaveForLogin(); return; } if (moved && original) { // Half done: record the move, keep the other edits on screen. const movedEvent = { ...original, calendarId: payload.calendarId }; onSaved(movedEvent, original); setOriginal(movedEvent); setInitial({ ...initial, calendarId: payload.calendarId }); setSaveError( `Der Termin wurde nach „${calendarById(payload.calendarId)?.label}“ verschoben, die übrigen Änderungen aber nicht gespeichert: ${describeApiError(e)}`, ); } else { setSaveError(describeApiError(e)); } } finally { setSaving(false); } }; const remove = async () => { if (!original) return; setDeleting(true); try { await api.deleteEvent(original.eventId); setConfirmDelete(false); onDeleted(original); toast.success(`„${original.name}“ ist gelöscht.`); onClose(); } catch (e) { if (isSessionExpired(e)) { leaveForLogin(); return; } setConfirmDelete(false); setSaveError(`Löschen fehlgeschlagen: ${describeApiError(e)}`); } finally { setDeleting(false); } }; return ( <> { if (!next) requestClose(); }} onOpenChangeComplete={(isOpen) => { if (!isOpen) onClosed(); }} > {mode && form && (
{ e.preventDefault(); void save(); }} className="flex min-h-0 flex-1 flex-col" >
{TITLES[mode.kind]} {mode.kind === "edit" ? mode.event.name : mode.kind === "duplicate" ? `Kopie von „${mode.event.name}“ – wird als neuer Entwurf angelegt.` : "Wird als Entwurf angelegt, wenn du den Status nicht änderst."}
{original && }
{saveError && (

{saveError}

)} {/* Labels follow the footer's own width (container query), not the window's: the side panel is capped, so a wide window says nothing about the room in here. */}
{original && ( <> )}
)}
Änderungen verwerfen? Was du hier geändert hast, ist noch nicht gespeichert. Weiter bearbeiten !deleting && setConfirmDelete(next)}> „{original?.name}“ löschen? Der Termin verschwindet aus dem Kalender und aus allen Abos. Das lässt sich hier nicht rückgängig machen. Abbrechen ); } function initialForm(mode: SheetMode, now: Date): EventFormState { switch (mode.kind) { case "new": return newEventForm(now, mode.calendarId); case "edit": return formFromEvent(mode.event); case "duplicate": return duplicateForm(mode.event); } } /** Who made and last changed the event. Legacy rows can lack either name. */ function Metadata({ event }: { event: CalendarEvent }) { const line = (label: string, who: string, when: Date | null) => when || who ? (

{label} {who && ` von ${who}`} {when && ` · ${formatDateTime(when)}`}

) : null; return (
{line("Erstellt", event.createdBy, event.created)} {line("Zuletzt geändert", event.lastModifiedBy, event.lastModified)}
); }