Apply linen design system across app
This commit is contained in:
@@ -1,23 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Info, Loader2, CheckCircle2 } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { saveNotdienstAvailabilities } from "./actions";
|
||||
|
||||
// =====================================================================
|
||||
// NotdienstForm · Client Component
|
||||
// ---------------------------------------------------------------------
|
||||
// Erlaubt das Auswählen von N Tagen aus der Liste der Werktage des
|
||||
// Zielmonats. Speichert als Array von Datum-Strings.
|
||||
// =====================================================================
|
||||
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;
|
||||
@@ -26,6 +86,7 @@ interface NotdienstFormProps {
|
||||
requiredDaysTotal: number;
|
||||
initialSelectedDates: string[];
|
||||
childrenIds: string[];
|
||||
calendarItems: NotdienstCalendarItem[];
|
||||
}
|
||||
|
||||
export function NotdienstForm({
|
||||
@@ -35,6 +96,7 @@ export function NotdienstForm({
|
||||
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.
|
||||
@@ -43,26 +105,35 @@ export function NotdienstForm({
|
||||
);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
|
||||
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 isFulfilled = selectedCount >= requiredDaysTotal;
|
||||
const hasMinimumRequirement = requiredDaysTotal > 0;
|
||||
const isFulfilled =
|
||||
!hasMinimumRequirement || selectedCount >= requiredDaysTotal;
|
||||
const selectedLabel =
|
||||
selectedCount === 1 ? "1 Tag markiert" : `${selectedCount} Tage markiert`;
|
||||
|
||||
function toggleDate(dateStr: string) {
|
||||
function handleChange(nextDates: Set<string>) {
|
||||
if (isLocked || isPending) return;
|
||||
|
||||
const newSet = new Set(selectedDates);
|
||||
if (newSet.has(dateStr)) {
|
||||
newSet.delete(dateStr);
|
||||
} else {
|
||||
newSet.add(dateStr);
|
||||
}
|
||||
setSelectedDates(newSet);
|
||||
setSelectedDates(nextDates);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (isLocked) return;
|
||||
|
||||
if (!isFulfilled) {
|
||||
if (hasMinimumRequirement && !isFulfilled) {
|
||||
toast.error(
|
||||
`Bitte wähle noch ${requiredDaysTotal - selectedCount} weitere Termine aus.`,
|
||||
);
|
||||
@@ -88,101 +159,481 @@ export function NotdienstForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Fortschritt / Sperr-Hinweis */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between",
|
||||
isLocked ? "bg-destructive/5" : "bg-card",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{isLocked ? (
|
||||
<Info className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||
) : isFulfilled ? (
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
|
||||
) : (
|
||||
<Info className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<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>
|
||||
<h3 className="font-medium">
|
||||
{isLocked
|
||||
? "Planung abgeschlossen"
|
||||
: "Deine ausgewählten Termine"}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isLocked
|
||||
? "Die Frist für diesen Monat ist abgelaufen. Änderungen sind nicht mehr möglich."
|
||||
: `Du hast ${selectedCount} von ${requiredDaysTotal} benötigten Tagen markiert.`}
|
||||
</p>
|
||||
<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>
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isLocked || isPending || !isFulfilled}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{isLocked ? "Gesperrt" : "Speichern"}
|
||||
</Button>
|
||||
<CalendarLegend />
|
||||
</div>
|
||||
|
||||
{/* Datumsliste */}
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="p-4 border-b bg-muted/30">
|
||||
<p className="text-sm font-medium">Werktage im {format(new Date(targetYear, targetMonth - 1, 1), "MMMM", { locale: de })}</p>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{workingDays.map((day) => {
|
||||
const dateStr = formatDateKey(day);
|
||||
const isSelected = selectedDates.has(dateStr);
|
||||
const isToday = day.getTime() === new Date().setHours(0, 0, 0, 0);
|
||||
<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>
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
disabled={isLocked || isPending}
|
||||
onClick={() => toggleDate(dateStr)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between px-4 py-3 text-left transition-colors",
|
||||
!isLocked && !isPending && "hover:bg-muted/50",
|
||||
isSelected && "bg-primary/5 hover:bg-primary/10",
|
||||
(isLocked || isPending) && "cursor-not-allowed opacity-70",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Custom Checkbox Indicator */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 w-5 items-center justify-center rounded border",
|
||||
isSelected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input bg-background",
|
||||
)}
|
||||
>
|
||||
{isSelected && <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
<div>
|
||||
<span className={cn("font-medium", isSelected && "text-primary")}>
|
||||
{format(day, "EEEE, dd. MMMM", { locale: de })}
|
||||
</span>
|
||||
{isToday && (
|
||||
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
Heute
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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",
|
||||
)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user