Apply linen design system across app

This commit is contained in:
t.indorf
2026-05-20 22:47:52 +02:00
parent 99ff978116
commit 1c5d2f10ac
56 changed files with 4693 additions and 2154 deletions
@@ -1,58 +1,29 @@
"use client";
import { useState } from "react";
import { ArrowRight, CalendarHeart } from "lucide-react";
import { Button } from "@/components/ui/button";
import { NotdienstForm } from "./notdienst-form";
import {
NotdienstForm,
type NotdienstCalendarItem,
} from "./notdienst-form";
type NotdienstEntryProps = {
hasAnyAvailability: boolean;
targetYear: number;
targetMonth: number;
isLocked: boolean;
requiredDaysTotal: number;
initialSelectedDates: string[];
childrenIds: string[];
calendarItems: NotdienstCalendarItem[];
};
export function NotdienstEntry({
hasAnyAvailability,
targetYear,
targetMonth,
isLocked,
requiredDaysTotal,
initialSelectedDates,
childrenIds,
calendarItems,
}: NotdienstEntryProps) {
const [showForm, setShowForm] = useState(hasAnyAvailability);
if (!showForm) {
return (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-gradient-to-b from-emerald-50/70 to-background p-8 text-center">
<div className="mx-auto max-w-lg">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 shadow-sm">
<CalendarHeart className="h-10 w-10" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
Willkommen beim Notdienst!
</h2>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Es sieht so aus, als hättest du noch keine Termine eingetragen.
</p>
<Button
type="button"
className="mt-6"
onClick={() => setShowForm(true)}
>
Jetzt erste Pflicht-Termine eintragen
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
return (
<NotdienstForm
targetYear={targetYear}
@@ -61,6 +32,7 @@ export function NotdienstEntry({
requiredDaysTotal={requiredDaysTotal}
initialSelectedDates={initialSelectedDates}
childrenIds={childrenIds}
calendarItems={calendarItems}
/>
);
}
+560 -109
View File
@@ -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",
)}`;
}
+61 -41
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { UserRole } from "@prisma/client";
import { Info, Calendar, ArrowRight, ShieldAlert } from "lucide-react";
import { TerminStatus, TerminType, UserRole } from "@prisma/client";
import { ArrowRight, ShieldAlert } from "lucide-react";
import { Manrope } from "next/font/google";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
@@ -10,6 +11,11 @@ import { NotdienstEntry } from "./notdienst-entry";
export const metadata = { title: "Notdienst · Kita-Planer" };
const manrope = Manrope({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export default async function NotdienstPage() {
// ELTERN können eintragen; Admins/Koordinatoren haben hier eigentlich
// andere Ansichten (Plan-Generierung), aber wir lassen sie zur Demonstration
@@ -44,7 +50,7 @@ export default async function NotdienstPage() {
: [];
const targetData = getTargetMonthData();
const { targetYear, targetMonth, monthName, isLocked } = targetData;
const { targetYear, targetMonth, isLocked } = targetData;
const targetMonthStart = new Date(Date.UTC(targetYear, targetMonth - 1, 1));
const targetMonthEnd = new Date(Date.UTC(targetYear, targetMonth, 1));
@@ -52,7 +58,7 @@ export default async function NotdienstPage() {
const childIds = children.map((child) => child.id);
// Bisherige Einträge für den Zielmonat laden, haushaltsweit über die Kinder.
const [availabilities, availabilityCountForCurrentOrNextMonth] =
const [availabilities, termine] =
children.length > 0
? await Promise.all([
prisma.notdienstAvailability.findMany({
@@ -66,74 +72,88 @@ export default async function NotdienstPage() {
},
select: { date: true },
}),
prisma.notdienstAvailability.count({
prisma.termin.findMany({
where: {
kitaId,
childId: { in: childIds },
date: {
gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
lt: new Date(new Date().getFullYear(), new Date().getMonth() + 2, 1),
},
status: TerminStatus.CONFIRMED,
startDate: { lt: targetMonthEnd },
endDate: { gte: targetMonthStart },
},
select: {
id: true,
title: true,
description: true,
type: true,
startDate: true,
endDate: true,
allDay: true,
},
orderBy: { startDate: "asc" },
}),
])
: [[], 0];
: [[], []];
const selectedDates = availabilities.map((a) => formatDateKey(a.date));
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-8">
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Calendar className="h-6 w-6 text-primary" />
Notdienst planen
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Gib hier an, an welchen Tagen du im Notdienst unterstützen kannst.
</p>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<span className="rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
Zielmonat: {monthName}
</span>
{isLocked && (
<span className="text-xs font-medium text-destructive flex items-center gap-1">
<Info className="h-3 w-3" />
Eingabefrist abgelaufen
</span>
)}
</div>
</div>
<div className={`${manrope.className} mx-auto max-w-7xl px-4 py-8 sm:px-8`}>
{children.length === 0 ? (
<NotdienstLockout />
) : (
<NotdienstEntry
hasAnyAvailability={availabilityCountForCurrentOrNextMonth > 0}
targetYear={targetYear}
targetMonth={targetMonth}
isLocked={isLocked}
requiredDaysTotal={requiredDaysTotal}
initialSelectedDates={selectedDates}
childrenIds={childIds}
calendarItems={termine.map((termin) => ({
id: termin.id,
title: termin.title,
category: getTerminCategory(termin.type),
startDate: termin.startDate,
endDate: termin.endDate,
allDay: termin.allDay,
note: termin.description,
}))}
/>
)}
</div>
);
}
function getTerminCategory(type: TerminType) {
switch (type) {
case TerminType.SCHLIESSTAG:
return "closure" as const;
case TerminType.TEAMTAG:
return "team" as const;
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "social" as const;
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "parents" as const;
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "birthday" as const;
default:
return "social" as const;
}
}
function NotdienstLockout() {
return (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-muted/20 p-8 text-center">
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-8 text-center">
<div className="mx-auto max-w-lg">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-amber-100 text-amber-700 shadow-sm">
<ShieldAlert className="h-10 w-10" />
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<ShieldAlert className="h-10 w-10 [stroke-width:1.7]" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
<h2 className="mt-6 text-2xl font-medium tracking-[-0.02em] text-[var(--ink)]">
Erst Kinder hinterlegen
</h2>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
<p className="mt-3 text-sm leading-6 text-[var(--ink-soft)]">
Bitte lege zuerst deine Kinder im Profil an, bevor du Notdienste
übernimmst.
</p>
+138 -31
View File
@@ -1,9 +1,10 @@
import { UserRole } from "@prisma/client";
import { Settings2 } from "lucide-react";
import { TerminType, UserRole } from "@prisma/client";
import { ChevronLeft, ChevronRight, SlidersHorizontal } from "lucide-react";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { Button } from "@/components/ui/button";
import { PlanView } from "./plan-view";
export const metadata = { title: "Plan-Generierung · Kita-Planer" };
@@ -14,26 +15,71 @@ export default async function PlanungsZentralePage() {
const { targetYear, targetMonth, monthName } = getTargetMonthData();
// Aktuellen Plan suchen
const plan = await prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
select: {
id: true,
status: true,
assignments: {
select: {
id: true,
childId: true,
date: true,
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
const monthStart = new Date(targetYear, targetMonth - 1, 1);
const monthEnd = new Date(targetYear, targetMonth, 1);
const [plan, families, availabilities, closureCount] = await Promise.all([
prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
select: {
id: true,
status: true,
assignments: {
select: {
id: true,
childId: true,
date: true,
},
},
},
},
});
// Alle Werktage
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
}),
prisma.family.findMany({
where: {
kitaId,
children: { some: { active: true } },
},
select: {
id: true,
name: true,
children: {
where: { active: true },
select: { id: true },
},
},
orderBy: { name: "asc" },
}),
prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: monthStart,
lt: monthEnd,
},
},
select: {
date: true,
child: {
select: {
familyId: true,
},
},
},
orderBy: { date: "asc" },
}),
prisma.termin.count({
where: {
kitaId,
type: TerminType.SCHLIESSTAG,
startDate: {
gte: monthStart,
lt: monthEnd,
},
},
}),
]);
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
@@ -71,6 +117,40 @@ export default async function PlanungsZentralePage() {
};
});
const availabilityDatesByFamily = new Map<string, Set<string>>();
availabilities.forEach((availability) => {
const familyId = availability.child.familyId;
const dates = availabilityDatesByFamily.get(familyId) ?? new Set<string>();
dates.add(availability.date.toISOString());
availabilityDatesByFamily.set(familyId, dates);
});
const familyStatuses = families.map((family) => {
const dates = Array.from(availabilityDatesByFamily.get(family.id) ?? []);
return {
id: family.id,
name: family.name,
selectedDays: dates.map((date) => ({
iso: date,
label: new Intl.DateTimeFormat("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
}).format(new Date(date)),
})),
childCount: family.children.length,
};
});
const respondedFamilies = familyStatuses.filter(
(family) => family.selectedDays.length > 0,
).length;
const openFamilies = Math.max(familyStatuses.length - respondedFamilies, 0);
const totalAvailabilitySlots = availabilities.length;
const requiredDays = workingDays.length;
const coverageFactor =
requiredDays > 0 ? totalAvailabilitySlots / requiredDays : 0;
// Wir mappen die Assignments auf die Werktage
const daysWithAssignments = workingDays.map((day) => {
const assignment = plan?.assignments.find(
@@ -84,29 +164,56 @@ export default async function PlanungsZentralePage() {
});
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-8">
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Settings2 className="h-6 w-6 text-primary" />
Notdienst-Planung
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
NOTDIENST · PLANUNG
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Notdienst{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
zuweisen
</em>
</h1>
<p className="mt-1 text-sm text-muted-foreground">
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Koordiniere und generiere den Notdienst-Plan.
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
Zielmonat: {monthName}
</span>
<div className="flex shrink-0 items-center gap-2">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-none border-r border-[var(--hairline-soft)]">
<ChevronLeft className="h-4 w-4 [stroke-width:1.7]" />
</Button>
<div className="flex h-9 min-w-32 items-center justify-center px-4 text-[13px] font-semibold text-[var(--ink)]">
{monthName} {targetYear}
</div>
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-none border-l border-[var(--hairline-soft)]">
<ChevronRight className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</div>
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Einstellungen
</Button>
</div>
</div>
</header>
<PlanView
planId={plan?.id}
status={plan?.status}
days={daysWithAssignments}
allChildren={allChildren}
familyStatuses={familyStatuses}
requiredDays={requiredDays}
respondedFamilies={respondedFamilies}
openFamilies={openFamilies}
totalFamilies={familyStatuses.length}
totalAvailabilitySlots={totalAvailabilitySlots}
coverageFactor={coverageFactor}
closureCount={closureCount}
monthName={monthName}
/>
</div>
);
+454 -96
View File
@@ -1,14 +1,23 @@
"use client";
import { useTransition } from "react";
import { useState, useTransition } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { NotdienstPlanStatus } from "@prisma/client";
import { Loader2, Wand2, CheckSquare } from "lucide-react";
import {
ArrowRight,
CheckSquare,
Download,
Loader2,
RotateCcw,
Send,
Wand2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
generatePlanAction,
publishPlanAction,
@@ -19,7 +28,7 @@ interface PlanViewProps {
planId?: string;
status?: NotdienstPlanStatus;
days: {
date: string; // ISO
date: string;
assignmentId: string | null;
childId: string | null;
}[];
@@ -28,10 +37,45 @@ interface PlanViewProps {
name: string;
parentName: string;
}[];
familyStatuses: {
id: string;
name: string;
childCount: number;
selectedDays: {
iso: string;
label: string;
}[];
}[];
requiredDays: number;
respondedFamilies: number;
openFamilies: number;
totalFamilies: number;
totalAvailabilitySlots: number;
coverageFactor: number;
closureCount: number;
monthName: string;
}
export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
export function PlanView({
planId,
status,
days,
allChildren,
familyStatuses,
requiredDays,
respondedFamilies,
openFamilies,
totalFamilies,
totalAvailabilitySlots,
coverageFactor,
closureCount,
monthName,
}: PlanViewProps) {
const [isPending, startTransition] = useTransition();
const [activeTab, setActiveTab] = useState<"preview" | "manual">("preview");
const responsePercent =
totalFamilies > 0 ? Math.round((respondedFamilies / totalFamilies) * 100) : 0;
const handleGenerate = () => {
startTransition(async () => {
@@ -70,21 +114,166 @@ export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
});
};
if (!planId && respondedFamilies === 0) {
return (
<Card className="border-dashed">
<div className="flex min-h-[280px] flex-col items-center justify-center p-8 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Wand2 className="h-7 w-7 [stroke-width:1.7]" />
</div>
<h3 className="mb-1 text-xl font-medium tracking-[-0.02em] text-[var(--ink)]">
Noch keine Verfügbarkeiten
</h3>
<p className="mb-5 max-w-md text-sm leading-6 text-[var(--ink-soft)]">
Für {monthName} hat noch keine Familie geantwortet. Verschicke
zuerst Erinnerungen, damit die automatische Planung belastbar ist.
</p>
<div className="flex flex-wrap justify-center gap-2">
<Button disabled={isPending} onClick={handleGenerate}>
Trotzdem generieren
</Button>
<Button variant="ghost">
<Send className="h-4 w-4 [stroke-width:1.7]" />
Erinnerungen verschicken
</Button>
</div>
</div>
</Card>
);
}
if (!planId) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-card p-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Wand2 className="h-6 w-6" />
<div className="space-y-5">
<div className="grid grid-cols-[minmax(0,2fr)_minmax(300px,1fr)] gap-5 max-[1050px]:grid-cols-1">
<Card className="p-0">
<div className="border-b border-[var(--hairline-soft)] px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Verfügbarkeiten Eltern
</h2>
<p className="mt-1 text-[13.5px] leading-5 text-[var(--ink-soft)]">
{respondedFamilies} von {totalFamilies} Familien haben
geantwortet · noch {openFamilies} offen
</p>
</div>
<span className="text-sm font-semibold tabular-nums text-[var(--ink-soft)]">
{responsePercent}%
</span>
</div>
<div className="mt-4 h-1 overflow-hidden rounded-full bg-[var(--accent-soft)]">
<div
className="h-full rounded-full bg-[var(--accent)]"
style={{ width: `${responsePercent}%` }}
/>
</div>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{familyStatuses.map((family) => (
<div
key={family.id}
className="group relative flex items-center gap-3 px-6 py-3.5 transition-colors hover:bg-[var(--surface-2)]"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[11px] font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-[var(--ink)]">
{family.name}
</p>
<p className="text-xs text-[var(--ink-muted)]">
{family.childCount} Kind
{family.childCount === 1 ? "" : "er"}
</p>
</div>
<FamilyStatusPill selectedDays={family.selectedDays.length} />
<div className="pointer-events-none absolute right-6 top-10 z-20 hidden max-w-xs rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-xs leading-5 text-white shadow-lg group-hover:block">
{family.selectedDays.length > 0
? family.selectedDays.map((day) => day.label).join(" · ")
: "Noch keine Tage markiert"}
</div>
</div>
))}
</div>
<div className="border-t border-[var(--hairline-soft)] px-6 py-4">
<Button variant="ghost" disabled={openFamilies === 0}>
<Send className="h-4 w-4 [stroke-width:1.7]" />
Erinnerung an offene senden ({openFamilies})
</Button>
</div>
</Card>
<Card className="p-6">
<div>
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Bedarfs-Übersicht
</h2>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Planungsgrundlage für {monthName}.
</p>
</div>
<div className="mt-5 grid gap-4">
<PlanMetric
label={`Tage benötigt in ${monthName}`}
value={requiredDays}
/>
<PlanMetric
label="Verfügbarkeiten gesamt"
value={totalAvailabilitySlots}
/>
<PlanMetric
label="Überdeckung"
value={`${coverageFactor.toFixed(1)}×`}
badge="good"
/>
<PlanMetric label="Schließtage" value={closureCount} />
</div>
<div className="mt-5 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-3 text-sm leading-6 text-[var(--ink-soft)]">
{coverageFactor >= 1
? "Du hast genug Verfügbarkeiten für einen Plan."
: "Die Verfügbarkeiten reichen noch nicht für einen belastbaren Plan."}
</div>
</Card>
</div>
<h3 className="mb-1 text-lg font-semibold">Noch kein Plan vorhanden</h3>
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
Generiere den Plan basierend auf den Verfügbarkeiten der Eltern
automatisch.
</p>
<Button onClick={handleGenerate} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Plan automatisch generieren
</Button>
<Card variant="featured" className="sticky bottom-6 z-10 p-5">
<div className="flex items-center justify-between gap-5 max-[760px]:flex-col max-[760px]:items-start">
<div className="flex gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Wand2 className="h-5 w-5 [stroke-width:1.7]" />
</div>
<div>
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Plan automatisch generieren
</h3>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Verteilt {requiredDays} Notdiensttage fair auf{" "}
{respondedFamilies} Familien.
</p>
<details className="mt-3 text-xs text-[var(--ink-soft)]">
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
Faire Verteilung berücksichtigt
</summary>
<div className="mt-2 flex flex-wrap gap-2">
<span>☑ Quote pro Familie</span>
<span>☑ Bevorzugte Tage</span>
<span>☐ Geschwister-Familien zusammen</span>
</div>
</details>
</div>
</div>
<Button onClick={handleGenerate} disabled={isPending}>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
)}
Generieren
<ArrowRight className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</div>
</Card>
</div>
);
}
@@ -92,95 +281,264 @@ export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
const isDraft = status === "DRAFT";
return (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<TabButton
active={activeTab === "preview"}
onClick={() => setActiveTab("preview")}
>
Plan-Vorschau
</TabButton>
<TabButton
active={activeTab === "manual"}
onClick={() => setActiveTab("manual")}
withDivider
>
Manuell zuweisen
</TabButton>
</div>
<div className="flex flex-wrap gap-2">
{isDraft && (
<Button onClick={handlePublish} disabled={isPending}>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
)}
<CheckSquare className="h-4 w-4 [stroke-width:1.7]" />
Plan veröffentlichen
</Button>
)}
<Button variant="ghost">
<Download className="h-4 w-4 [stroke-width:1.7]" />
Als PDF exportieren
</Button>
<Button
variant="ghost"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
>
<RotateCcw className="h-4 w-4 [stroke-width:1.7]" />
Plan zurücksetzen
</Button>
</div>
</div>
<Card className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<Badge variant={isDraft ? "warning" : "success"}>
{isDraft ? "Entwurf" : "Veröffentlicht"}
</Badge>
<span className="text-sm text-muted-foreground">
<span className="text-sm text-[var(--ink-soft)]">
{isDraft
? "Du kannst die Liste jetzt manuell anpassen."
: "Dieser Plan ist verbindlich."}
</span>
</div>
</Card>
{isDraft && (
<Button onClick={handlePublish} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<CheckSquare className="mr-2 h-4 w-4" />
Plan veröffentlichen
</Button>
)}
</div>
<div className="rounded-lg border bg-card">
<div className="grid grid-cols-[1fr_2fr] border-b bg-muted/30 p-4 text-sm font-medium">
<div>Datum</div>
<div>Eingeteilte Familie</div>
</div>
<div className="divide-y">
{days.map((day) => {
const dateObj = new Date(day.date);
const isUnbesetzt = !day.childId;
return (
<div
key={day.date}
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-muted/10"
>
<div className="font-medium">
{format(dateObj, "EE, dd.MM.", { locale: de })}
</div>
<div>
{isDraft ? (
<select
className="w-full max-w-sm rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={day.childId || "empty"}
disabled={isPending}
onChange={(e) =>
handleAssignmentChange(day.date, e.target.value)
}
>
<option value="empty" className="text-destructive">
-- Unbesetzt --
</option>
{allChildren.map((c) => (
<option key={c.id} value={c.id}>
{c.parentName} ({c.name})
</option>
))}
</select>
) : (
<div>
{isUnbesetzt ? (
<span className="text-sm font-medium text-destructive">
Unbesetzt!
</span>
) : (
<span className="text-sm">
{
allChildren.find((c) => c.id === day.childId)
?.parentName
}{" "}
<span className="text-muted-foreground">
(
{
allChildren.find((c) => c.id === day.childId)
?.name
}
)
</span>
</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{activeTab === "preview" ? (
<PlanPreviewTable days={days} allChildren={allChildren} />
) : (
<ManualAssignmentTable
days={days}
allChildren={allChildren}
isDraft={isDraft}
isPending={isPending}
onAssignmentChange={handleAssignmentChange}
/>
)}
</div>
);
}
function FamilyStatusPill({ selectedDays }: { selectedDays: number }) {
if (selectedDays === 0) {
return (
<span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
Noch nicht
</span>
);
}
return (
<span className="rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]">
{selectedDays} Tage
</span>
);
}
function PlanMetric({
label,
value,
badge,
}: {
label: string;
value: number | string;
badge?: "good";
}) {
return (
<div className="flex items-center justify-between gap-4 border-b border-[var(--hairline-soft)] pb-3 last:border-b-0 last:pb-0">
<span className="text-[13.5px] text-[var(--ink-soft)]">{label}</span>
{badge ? (
<span className="rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-sm font-semibold text-[var(--good)] tabular-nums">
{value}
</span>
) : (
<span className="text-2xl font-medium tracking-[-0.025em] text-[var(--ink)] tabular-nums">
{value}
</span>
)}
</div>
);
}
function TabButton({
active,
withDivider,
onClick,
children,
}: {
active: boolean;
withDivider?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={[
"h-9 px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]",
withDivider ? "border-l border-[var(--hairline-soft)]" : "",
active
? "bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
{children}
</button>
);
}
function PlanPreviewTable({
days,
allChildren,
}: Pick<PlanViewProps, "days" | "allChildren">) {
return (
<Card className="overflow-hidden p-0">
<div className="grid grid-cols-[1.1fr_1fr_2fr_1.6fr_1fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
<div>Datum</div>
<div>Wochentag</div>
<div>Zugewiesen</div>
<div>Backup</div>
<div>Status</div>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{days.map((day) => {
const dateObj = new Date(day.date);
const assignedChild = allChildren.find(
(child) => child.id === day.childId,
);
return (
<div
key={day.date}
className="grid cursor-pointer grid-cols-[1.1fr_1fr_2fr_1.6fr_1fr] items-center p-4 text-sm transition-colors hover:bg-[var(--surface-2)]"
>
<div className="font-medium tabular-nums text-[var(--ink)]">
{format(dateObj, "dd.MM.", { locale: de })}
</div>
<div className="text-[var(--ink-soft)]">
{format(dateObj, "EEEE", { locale: de })}
</div>
<div className="text-[var(--ink)]">
{assignedChild ? assignedChild.parentName : "Unbesetzt"}
{assignedChild && (
<span className="ml-1 text-[var(--ink-muted)]">
({assignedChild.name})
</span>
)}
</div>
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
<div>
<Badge variant={assignedChild ? "success" : "destructive"}>
{assignedChild ? "Besetzt" : "Offen"}
</Badge>
</div>
</div>
);
})}
</div>
</Card>
);
}
function ManualAssignmentTable({
days,
allChildren,
isDraft,
isPending,
onAssignmentChange,
}: Pick<PlanViewProps, "days" | "allChildren"> & {
isDraft: boolean;
isPending: boolean;
onAssignmentChange: (date: string, newChildId: string) => void;
}) {
return (
<Card className="overflow-hidden p-0">
<div className="grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
<div>Datum</div>
<div>Eingeteilte Familie</div>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{days.map((day) => {
const dateObj = new Date(day.date);
const assignedChild = allChildren.find(
(child) => child.id === day.childId,
);
return (
<div
key={day.date}
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
>
<div className="font-medium text-[var(--ink)]">
{format(dateObj, "EE, dd.MM.", { locale: de })}
</div>
<div>
{isDraft ? (
<select
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 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={day.childId || "empty"}
disabled={isPending}
onChange={(event) =>
onAssignmentChange(day.date, event.target.value)
}
>
<option value="empty">-- Unbesetzt --</option>
{allChildren.map((child) => (
<option key={child.id} value={child.id}>
{child.parentName} ({child.name})
</option>
))}
</select>
) : assignedChild ? (
<span className="text-sm text-[var(--ink)]">
{assignedChild.parentName}{" "}
<span className="text-[var(--ink-muted)]">
({assignedChild.name})
</span>
</span>
) : (
<span className="text-sm font-medium text-[var(--danger)]">
Unbesetzt!
</span>
)}
</div>
</div>
);
})}
</div>
</Card>
);
}