640 lines
21 KiB
TypeScript
640 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState, useTransition } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import {
|
|
Check,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Clock3,
|
|
Info,
|
|
Loader2,
|
|
LockKeyhole,
|
|
X,
|
|
} from "lucide-react";
|
|
import {
|
|
eachDayOfInterval,
|
|
endOfMonth,
|
|
endOfWeek,
|
|
format,
|
|
isSameMonth,
|
|
startOfDay,
|
|
startOfMonth,
|
|
startOfWeek,
|
|
} from "date-fns";
|
|
import { de } from "date-fns/locale";
|
|
import { toast } from "sonner";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import { formatDateKey, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
|
import { saveNotdienstAvailabilities } from "./actions";
|
|
|
|
export const notdienstEventCategories = {
|
|
closure: {
|
|
label: "Schließtag",
|
|
dot: "oklch(0.62 0.10 35)",
|
|
bg: "oklch(0.96 0.04 35)",
|
|
border: "oklch(0.88 0.04 35)",
|
|
text: "oklch(0.38 0.08 35)",
|
|
},
|
|
team: {
|
|
label: "Teamtag",
|
|
dot: "oklch(0.62 0.10 70)",
|
|
bg: "oklch(0.96 0.04 70)",
|
|
border: "oklch(0.88 0.04 70)",
|
|
text: "oklch(0.38 0.08 70)",
|
|
},
|
|
parents: {
|
|
label: "Elternabend",
|
|
dot: "oklch(0.62 0.10 280)",
|
|
bg: "oklch(0.96 0.04 280)",
|
|
border: "oklch(0.88 0.04 280)",
|
|
text: "oklch(0.38 0.08 280)",
|
|
},
|
|
social: {
|
|
label: "Fest",
|
|
dot: "oklch(0.62 0.10 155)",
|
|
bg: "oklch(0.96 0.04 155)",
|
|
border: "oklch(0.88 0.04 155)",
|
|
text: "oklch(0.38 0.08 155)",
|
|
},
|
|
birthday: {
|
|
label: "Geburtstag",
|
|
dot: "oklch(0.62 0.10 320)",
|
|
bg: "oklch(0.96 0.04 320)",
|
|
border: "oklch(0.88 0.04 320)",
|
|
text: "oklch(0.38 0.08 320)",
|
|
},
|
|
} as const;
|
|
|
|
type NotdienstEventCategory = keyof typeof notdienstEventCategories;
|
|
|
|
export type NotdienstCalendarItem = {
|
|
id: string;
|
|
title: string;
|
|
category: NotdienstEventCategory;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
allDay: boolean;
|
|
note: string | null;
|
|
};
|
|
|
|
interface NotdienstFormProps {
|
|
targetYear: number;
|
|
targetMonth: number;
|
|
isLocked: boolean;
|
|
requiredDaysTotal: number;
|
|
initialSelectedDates: string[];
|
|
childrenIds: string[];
|
|
calendarItems: NotdienstCalendarItem[];
|
|
}
|
|
|
|
export function NotdienstForm({
|
|
targetYear,
|
|
targetMonth,
|
|
isLocked,
|
|
requiredDaysTotal,
|
|
initialSelectedDates,
|
|
childrenIds,
|
|
calendarItems,
|
|
}: NotdienstFormProps) {
|
|
const router = useRouter();
|
|
// Lokaler State für die Auswahl. Ein Datum liegt als stabiler yyyy-MM-dd-Schlüssel vor.
|
|
const [selectedDates, setSelectedDates] = useState<Set<string>>(
|
|
new Set(initialSelectedDates),
|
|
);
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
const closureDateKeys = useMemo(
|
|
() => getClosureDateKeys(calendarItems),
|
|
[calendarItems],
|
|
);
|
|
const selectableDateKeys = useMemo(() => {
|
|
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
|
|
return new Set(
|
|
workingDays
|
|
.map(formatDateKey)
|
|
.filter((dateKey) => !closureDateKeys.has(dateKey)),
|
|
);
|
|
}, [closureDateKeys, targetMonth, targetYear]);
|
|
const monthStart = startOfMonth(new Date(targetYear, targetMonth - 1));
|
|
const selectedCount = selectedDates.size;
|
|
const hasMinimumRequirement = requiredDaysTotal > 0;
|
|
const isFulfilled =
|
|
!hasMinimumRequirement || selectedCount >= requiredDaysTotal;
|
|
const selectedLabel =
|
|
selectedCount === 1 ? "1 Tag markiert" : `${selectedCount} Tage markiert`;
|
|
|
|
function handleChange(nextDates: Set<string>) {
|
|
if (isLocked || isPending) return;
|
|
setSelectedDates(nextDates);
|
|
}
|
|
|
|
function handleSave() {
|
|
if (isLocked) return;
|
|
|
|
if (hasMinimumRequirement && !isFulfilled) {
|
|
toast.error(
|
|
`Bitte wähle noch ${requiredDaysTotal - selectedCount} weitere Termine aus.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
startTransition(async () => {
|
|
const payload = {
|
|
dates: Array.from(selectedDates),
|
|
childrenIds,
|
|
};
|
|
|
|
const result = await saveNotdienstAvailabilities(payload);
|
|
|
|
if (result?.error) {
|
|
toast.error(result.error);
|
|
} else {
|
|
setSelectedDates(new Set(result.dates));
|
|
toast.success("Verfügbarkeiten erfolgreich gespeichert.");
|
|
router.refresh();
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{isLocked ? (
|
|
<div className="mb-4 flex items-start gap-3 rounded-[10px] border border-[oklch(0.88_0.04_35)] bg-[oklch(0.96_0.018_35)] p-4 text-sm text-[oklch(0.38_0.08_35)]">
|
|
<Info className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<p>
|
|
Die Frist für diesen Monat ist abgelaufen. Änderungen sind nicht
|
|
mehr möglich.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="overflow-x-auto">
|
|
<NotdienstCalendar
|
|
month={monthStart}
|
|
events={calendarItems}
|
|
selected={selectedDates}
|
|
requiredDays={requiredDaysTotal}
|
|
disabled={isLocked || isPending}
|
|
selectableDateKeys={selectableDateKeys}
|
|
onChange={handleChange}
|
|
onSave={handleSave}
|
|
isSaving={isPending}
|
|
/>
|
|
</div>
|
|
<p className="sr-only" aria-live="polite">
|
|
{hasMinimumRequirement
|
|
? `Du hast ${selectedCount} von ${requiredDaysTotal} benötigten Tagen markiert.`
|
|
: `${selectedLabel}. Für diese Kita ist aktuell keine Mindestanzahl hinterlegt.`}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type NotdienstCalendarProps = {
|
|
month: Date;
|
|
events: NotdienstCalendarItem[];
|
|
selected: Set<string>;
|
|
requiredDays: number;
|
|
disabled?: boolean;
|
|
selectableDateKeys: Set<string>;
|
|
isSaving?: boolean;
|
|
onChange: (selected: Set<string>) => void;
|
|
onSave: () => void;
|
|
};
|
|
|
|
export function NotdienstCalendar({
|
|
month,
|
|
events,
|
|
selected,
|
|
requiredDays,
|
|
disabled = false,
|
|
selectableDateKeys,
|
|
isSaving = false,
|
|
onChange,
|
|
onSave,
|
|
}: NotdienstCalendarProps) {
|
|
const [visibleMonth, setVisibleMonth] = useState(month);
|
|
const [activeEvent, setActiveEvent] = useState<NotdienstCalendarItem | null>(
|
|
null,
|
|
);
|
|
const visibleMonthStart = startOfMonth(visibleMonth);
|
|
const visibleMonthEnd = endOfMonth(visibleMonthStart);
|
|
const calendarStart = startOfWeek(visibleMonthStart, { weekStartsOn: 1 });
|
|
const calendarEnd = endOfWeek(visibleMonthEnd, { weekStartsOn: 1 });
|
|
const calendarDays = eachDayOfInterval({
|
|
start: calendarStart,
|
|
end: calendarEnd,
|
|
}).map((day) => startOfDay(day));
|
|
const selectedCount = selected.size;
|
|
const extraCount = Math.max(0, selectedCount - requiredDays);
|
|
const statusText =
|
|
requiredDays > 0 && selectedCount >= requiredDays
|
|
? `Fertig — alle ${requiredDays} Tage markiert`
|
|
: `${selectedCount} von ${requiredDays} Tagen`;
|
|
|
|
function toggleDate(dateKey: string) {
|
|
if (disabled || !selectableDateKeys.has(dateKey)) return;
|
|
|
|
const nextSelected = new Set(selected);
|
|
if (nextSelected.has(dateKey)) {
|
|
nextSelected.delete(dateKey);
|
|
} else {
|
|
nextSelected.add(dateKey);
|
|
}
|
|
onChange(nextSelected);
|
|
}
|
|
|
|
return (
|
|
<section
|
|
className="min-w-[1040px] bg-[var(--notdienst-bg)] px-11 py-10 text-[var(--notdienst-ink)] shadow-sm [--notdienst-accent-deep:oklch(0.42_0.10_38)] [--notdienst-accent-mid:oklch(0.82_0.07_38)] [--notdienst-accent-soft:oklch(0.93_0.04_38)] [--notdienst-accent:oklch(0.58_0.11_38)] [--notdienst-bg:oklch(0.965_0.012_75)] [--notdienst-hairline-soft:oklch(0.93_0.01_75)] [--notdienst-hairline:oklch(0.90_0.012_75)] [--notdienst-ink-faint:oklch(0.76_0.01_45)] [--notdienst-ink-muted:oklch(0.60_0.012_45)] [--notdienst-ink-soft:oklch(0.45_0.015_45)] [--notdienst-ink:oklch(0.26_0.015_45)] [--notdienst-out-month:oklch(0.82_0.012_75)] [--notdienst-surface-week:oklch(0.945_0.014_75)] [--notdienst-surface:oklch(0.985_0.008_75)]"
|
|
style={{
|
|
backgroundImage:
|
|
"radial-gradient(oklch(0.85 0.02 75 / 0.10) 1px, transparent 1px)",
|
|
backgroundSize: "16px 16px",
|
|
}}
|
|
>
|
|
<header className="flex items-start justify-between gap-8">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase leading-none text-[var(--notdienst-accent)] [letter-spacing:0.18em]">
|
|
NOTDIENST · {format(visibleMonthStart, "MMMM yyyy", { locale: de })}
|
|
</p>
|
|
<h1 className="mt-2 text-[36px] font-normal leading-tight text-[var(--notdienst-ink)] [letter-spacing:-0.025em]">
|
|
Wann magst du{" "}
|
|
<em className="font-normal italic text-[var(--notdienst-accent-deep)]">
|
|
helfen
|
|
</em>
|
|
?
|
|
</h1>
|
|
</div>
|
|
|
|
<div className="mt-3 flex items-center gap-4 rounded-full border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] px-5 py-3 shadow-[0_12px_30px_oklch(0.36_0.02_45_/_0.06)]">
|
|
<div className="flex items-center gap-1.5">
|
|
{Array.from({ length: Math.max(requiredDays, 1) }).map(
|
|
(_, index) => {
|
|
const isMarked = index < selectedCount;
|
|
return (
|
|
<span
|
|
key={index}
|
|
className={cn(
|
|
"h-2.5 w-2.5 rounded-full border border-[var(--notdienst-accent)]",
|
|
isMarked && "bg-[var(--notdienst-accent)]",
|
|
)}
|
|
/>
|
|
);
|
|
},
|
|
)}
|
|
{extraCount > 0 ? (
|
|
<span className="ml-1 text-[11px] font-semibold text-[var(--notdienst-accent-deep)]">
|
|
+{extraCount}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<span className="text-[13px] font-semibold text-[var(--notdienst-ink-soft)]">
|
|
{statusText}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={onSave}
|
|
disabled={disabled || isSaving || selectedCount < requiredDays}
|
|
className="inline-flex h-8 items-center gap-2 rounded-full bg-[var(--notdienst-accent-deep)] px-5 text-[13px] font-bold text-white transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-55"
|
|
>
|
|
{isSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
|
Speichern
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="mt-9 flex items-center justify-between gap-8">
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="text-[22px] font-medium text-[var(--notdienst-ink)] [font-variant-numeric:tabular-nums]">
|
|
{format(visibleMonthStart, "MMMM yyyy", { locale: de })}
|
|
</h2>
|
|
<div className="flex items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
aria-label="Vorheriger Monat"
|
|
onClick={() =>
|
|
setVisibleMonth(
|
|
new Date(
|
|
visibleMonthStart.getFullYear(),
|
|
visibleMonthStart.getMonth() - 1,
|
|
1,
|
|
),
|
|
)
|
|
}
|
|
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] text-[var(--notdienst-ink-soft)] transition hover:border-[var(--notdienst-accent-mid)] hover:text-[var(--notdienst-accent-deep)]"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="Nächster Monat"
|
|
onClick={() =>
|
|
setVisibleMonth(
|
|
new Date(
|
|
visibleMonthStart.getFullYear(),
|
|
visibleMonthStart.getMonth() + 1,
|
|
1,
|
|
),
|
|
)
|
|
}
|
|
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] text-[var(--notdienst-ink-soft)] transition hover:border-[var(--notdienst-accent-mid)] hover:text-[var(--notdienst-accent-deep)]"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<CalendarLegend />
|
|
</div>
|
|
|
|
<div className="mt-8 grid grid-cols-7 gap-1.5">
|
|
{[
|
|
"Montag",
|
|
"Dienstag",
|
|
"Mittwoch",
|
|
"Donnerstag",
|
|
"Freitag",
|
|
"Samstag",
|
|
"Sonntag",
|
|
].map((weekday) => (
|
|
<div
|
|
key={weekday}
|
|
className="px-2 text-[11px] font-semibold uppercase text-[var(--notdienst-ink-muted)] [letter-spacing:0.08em]"
|
|
>
|
|
{weekday}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-2 grid grid-cols-7 gap-1.5">
|
|
{calendarDays.map((day) => {
|
|
const dateKey = formatDateKey(day);
|
|
const isInTargetMonth = isSameMonth(day, visibleMonthStart);
|
|
const isSelected = selected.has(dateKey);
|
|
const dayEvents = getCalendarItemsForDay(events, day);
|
|
const hasClosure = dayEvents.some(
|
|
(event) => event.category === "closure",
|
|
);
|
|
const isSelectable =
|
|
isInTargetMonth && selectableDateKeys.has(dateKey) && !hasClosure;
|
|
const isWeekend = day.getDay() === 0 || day.getDay() === 6;
|
|
|
|
return (
|
|
<CalendarTile
|
|
key={dateKey}
|
|
day={day}
|
|
events={dayEvents}
|
|
isInTargetMonth={isInTargetMonth}
|
|
isSelected={isSelected}
|
|
isSelectable={isSelectable}
|
|
isWeekend={isWeekend}
|
|
hasClosure={hasClosure}
|
|
disabled={disabled}
|
|
onToggle={() => toggleDate(dateKey)}
|
|
onOpenEvent={setActiveEvent}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{activeEvent ? (
|
|
<EventDialog
|
|
event={activeEvent}
|
|
onClose={() => setActiveEvent(null)}
|
|
/>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function CalendarTile({
|
|
day,
|
|
events,
|
|
isInTargetMonth,
|
|
isSelected,
|
|
isSelectable,
|
|
isWeekend,
|
|
hasClosure,
|
|
disabled,
|
|
onToggle,
|
|
onOpenEvent,
|
|
}: {
|
|
day: Date;
|
|
events: NotdienstCalendarItem[];
|
|
isInTargetMonth: boolean;
|
|
isSelected: boolean;
|
|
isSelectable: boolean;
|
|
isWeekend: boolean;
|
|
hasClosure: boolean;
|
|
disabled: boolean;
|
|
onToggle: () => void;
|
|
onOpenEvent: (event: NotdienstCalendarItem) => void;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative min-h-24 rounded-[10px] border p-[10px_11px] text-left transition",
|
|
"border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)]",
|
|
isSelectable &&
|
|
!disabled &&
|
|
"cursor-pointer hover:border-[var(--notdienst-accent-mid)] hover:shadow-[0_10px_28px_oklch(0.36_0.02_45_/_0.08)]",
|
|
isSelected &&
|
|
"border-[var(--notdienst-accent-mid)] bg-[var(--notdienst-accent-soft)]",
|
|
hasClosure &&
|
|
"cursor-default border-[oklch(0.88_0.04_35)] bg-[oklch(0.96_0.018_35)]",
|
|
(isWeekend || !isInTargetMonth) &&
|
|
"cursor-default border-[var(--notdienst-hairline-soft)] bg-[var(--notdienst-surface-week)]",
|
|
)}
|
|
onClick={isSelectable && !disabled ? onToggle : undefined}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<span
|
|
className={cn(
|
|
"font-medium leading-none [font-variant-numeric:tabular-nums] [letter-spacing:-0.02em]",
|
|
isWeekend || !isInTargetMonth
|
|
? "text-[13px] text-[var(--notdienst-out-month)]"
|
|
: "text-[17px] text-[var(--notdienst-ink)]",
|
|
isSelected && "text-[var(--notdienst-accent-deep)]",
|
|
hasClosure && "text-[oklch(0.38_0.08_35)]",
|
|
)}
|
|
>
|
|
{format(day, "d")}
|
|
</span>
|
|
{isSelected ? (
|
|
<Check className="h-2.5 w-2.5 text-[var(--notdienst-accent-deep)] [stroke-width:2.5]" />
|
|
) : null}
|
|
{hasClosure ? (
|
|
<LockKeyhole className="h-3 w-3 text-[oklch(0.38_0.08_35)]" />
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="absolute inset-x-[11px] bottom-[10px] space-y-1.5">
|
|
{events.slice(0, 2).map((event) => (
|
|
<button
|
|
key={`${formatDateKey(day)}-${event.id}`}
|
|
type="button"
|
|
className="group/event relative flex w-full items-center gap-1.5 overflow-visible text-left"
|
|
onClick={(clickEvent) => {
|
|
clickEvent.stopPropagation();
|
|
onOpenEvent(event);
|
|
}}
|
|
>
|
|
<span
|
|
className="h-4 w-[3px] shrink-0 rounded-sm"
|
|
style={{
|
|
backgroundColor: notdienstEventCategories[event.category].dot,
|
|
}}
|
|
/>
|
|
<span className="truncate text-[11px] font-medium text-[var(--notdienst-ink-soft)]">
|
|
{event.title}
|
|
</span>
|
|
<span className="pointer-events-none absolute bottom-full left-0 z-20 mb-2 hidden min-w-44 rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-left text-white shadow-xl group-hover/event:block">
|
|
<span className="block text-[10px] font-semibold uppercase text-white/65 [letter-spacing:0.08em]">
|
|
{notdienstEventCategories[event.category].label}
|
|
</span>
|
|
<span className="mt-0.5 block text-[12px] font-semibold">
|
|
{event.title}
|
|
</span>
|
|
<span className="mt-1 block text-[11px] text-white/75">
|
|
{formatEventTime(event)}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
))}
|
|
{events.length > 2 ? (
|
|
<p className="text-[11px] font-medium text-[var(--notdienst-ink-muted)]">
|
|
+{events.length - 2} weitere
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CalendarLegend() {
|
|
return (
|
|
<div className="flex flex-wrap items-center justify-end gap-4 text-[11px] font-medium text-[var(--notdienst-ink-soft)]">
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<span className="h-[9px] w-[9px] rounded-[2px] bg-[var(--notdienst-accent)]" />
|
|
Dein Notdienst
|
|
</span>
|
|
{Object.entries(notdienstEventCategories).map(([key, category]) => (
|
|
<span key={key} className="inline-flex items-center gap-1.5">
|
|
<span
|
|
className="h-[9px] w-[9px] rounded-[2px]"
|
|
style={{ backgroundColor: category.dot }}
|
|
/>
|
|
{category.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EventDialog({
|
|
event,
|
|
onClose,
|
|
}: {
|
|
event: NotdienstCalendarItem;
|
|
onClose: () => void;
|
|
}) {
|
|
const category = notdienstEventCategories[event.category];
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-[oklch(0.25_0.015_45_/_0.20)] p-6 backdrop-blur-[2px]"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="w-full max-w-md rounded-xl border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] p-6 text-[var(--notdienst-ink)] shadow-2xl"
|
|
onClick={(eventClick) => eventClick.stopPropagation()}
|
|
>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<span
|
|
className="inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold"
|
|
style={{
|
|
backgroundColor: category.bg,
|
|
borderColor: category.border,
|
|
color: category.text,
|
|
}}
|
|
>
|
|
{category.label}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
aria-label="Detail schließen"
|
|
onClick={onClose}
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-[var(--notdienst-ink-muted)] transition hover:bg-[var(--notdienst-surface-week)] hover:text-[var(--notdienst-ink)]"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<h3 className="mt-4 text-2xl font-medium leading-tight [letter-spacing:-0.02em]">
|
|
{event.title}
|
|
</h3>
|
|
<p className="mt-3 flex items-center gap-2 text-sm font-medium text-[var(--notdienst-ink-soft)]">
|
|
<Clock3 className="h-4 w-4" />
|
|
{formatEventDate(event)}
|
|
</p>
|
|
{event.note ? (
|
|
<p className="mt-5 text-sm leading-6 text-[var(--notdienst-ink-soft)]">
|
|
{event.note}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getCalendarItemsForDay(
|
|
items: NotdienstCalendarItem[],
|
|
day: Date,
|
|
) {
|
|
const dayStart = startOfDay(day).getTime();
|
|
|
|
return items.filter((item) => {
|
|
const itemStart = startOfDay(item.startDate).getTime();
|
|
const itemEnd = startOfDay(item.endDate).getTime();
|
|
|
|
return itemStart <= dayStart && itemEnd >= dayStart;
|
|
});
|
|
}
|
|
|
|
function getClosureDateKeys(items: NotdienstCalendarItem[]) {
|
|
const closureKeys = new Set<string>();
|
|
|
|
for (const item of items) {
|
|
if (item.category !== "closure") continue;
|
|
|
|
const days = eachDayOfInterval({
|
|
start: startOfDay(item.startDate),
|
|
end: startOfDay(item.endDate),
|
|
});
|
|
|
|
for (const day of days) {
|
|
closureKeys.add(formatDateKey(day));
|
|
}
|
|
}
|
|
|
|
return closureKeys;
|
|
}
|
|
|
|
function formatEventDate(event: NotdienstCalendarItem) {
|
|
const date = format(event.startDate, "EEEE, d. MMMM yyyy", { locale: de });
|
|
return event.allDay
|
|
? `${date} · Ganztägig`
|
|
: `${date} · ${formatEventTime(event)}`;
|
|
}
|
|
|
|
function formatEventTime(event: NotdienstCalendarItem) {
|
|
if (event.allDay) return "Ganztägig";
|
|
|
|
return `${format(event.startDate, "HH:mm")} - ${format(
|
|
event.endDate,
|
|
"HH:mm",
|
|
)}`;
|
|
}
|