Compare commits

..

4 Commits

17 changed files with 3190 additions and 958 deletions
@@ -0,0 +1,441 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { addDays, format, isWeekend, parseISO } from "date-fns";
import { de } from "date-fns/locale";
import {
ArrowLeft,
ArrowRight,
CalendarCheck2,
CalendarDays,
ChevronDown,
ChevronRight,
Stethoscope,
} from "lucide-react";
import { AbsenceReason } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { AbsenceTable, type AbsenceTableItem } from "../absence-table";
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
interface AbsencesClientProps {
initialAbsences: AbsenceTableItem[];
childCount: number;
upcomingAbsenceCount: number;
closure: { title: string; type: string } | null;
dateKey: string;
selectedDateStr: string;
isToday: boolean;
isTooFarFuture: boolean;
isClosedDay: boolean;
todayStr: string;
error: string | null;
}
export function AbsencesClient({
initialAbsences,
childCount,
upcomingAbsenceCount,
closure,
dateKey,
selectedDateStr,
isToday,
isTooFarFuture,
isClosedDay,
todayStr,
error,
}: AbsencesClientProps) {
const router = useRouter();
const selectedDate = parseISO(selectedDateStr);
const today = parseISO(todayStr);
// Filters State
const [selectedGroup, setSelectedGroup] = useState<"all" | "Krippe" | "Elementar">("all");
const [selectedReason, setSelectedReason] = useState<"all" | AbsenceReason>("all");
// Dropdown UI state
const [isGroupOpen, setIsGroupOpen] = useState(false);
const [isReasonOpen, setIsReasonOpen] = useState(false);
// Date input state
const [inputDate, setInputDate] = useState(dateKey);
// Group resolver function
const mockGroup = (childName: string) => {
const charCode = childName.charCodeAt(0);
return charCode % 2 === 0 ? "Krippe" : "Elementar";
};
// Filter computation
const filteredAbsences = useMemo(() => {
return initialAbsences.filter((item) => {
if (selectedGroup !== "all" && mockGroup(item.childName) !== selectedGroup) {
return false;
}
if (selectedReason !== "all" && item.reason !== selectedReason) {
return false;
}
return true;
});
}, [initialAbsences, selectedGroup, selectedReason]);
// Recalculate reasons count based on filtered absences
const countsByReason = useMemo(() => {
return filteredAbsences.reduce<Partial<Record<AbsenceReason, number>>>((acc, item) => {
acc[item.reason] = (acc[item.reason] ?? 0) + 1;
return acc;
}, {});
}, [filteredAbsences]);
const handleDateNavigate = (targetDate: Date) => {
const formatted = format(targetDate, "yyyy-MM-dd");
setInputDate(formatted);
router.push(`/dashboard/admin/abwesenheiten?date=${formatted}`);
};
const handleDateFormSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputDate) {
router.push(`/dashboard/admin/abwesenheiten?date=${inputDate}`);
}
};
return (
<div>
<header className="mb-7 flex items-end justify-between gap-8 max-[1100px]:flex-col max-[1100px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
TAGESÜBERSICHT
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Heute fehlen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
{filteredAbsences.length} Kinder
</em>
</h1>
<p className="mt-2 text-[15px] leading-6 text-[var(--ink-soft)]">
{format(selectedDate, "EEEE, d. MMMM yyyy", { locale: de })}
</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<Stethoscope className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{childCount - filteredAbsences.length} anwesend
</span>
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<button
onClick={() => handleDateNavigate(addDays(selectedDate, -1))}
className="inline-flex h-10 items-center gap-2 border-r border-[var(--hairline)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--surface-2)] hover:text-[var(--ink)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zurück
</button>
<button
onClick={() => handleDateNavigate(today)}
className={`inline-flex h-10 items-center gap-2 border-r border-[var(--hairline)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--surface-2)] hover:text-[var(--ink)] ${
isToday ? "bg-[var(--accent-soft)] text-[var(--accent-deep)]" : ""
}`}
>
Heute
</button>
<button
onClick={() => handleDateNavigate(addDays(selectedDate, 1))}
className="inline-flex h-10 items-center gap-2 px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--surface-2)] hover:text-[var(--ink)]"
>
Vor
<ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
<form onSubmit={handleDateFormSubmit} className="flex items-center gap-2">
<Input
type="date"
name="date"
value={inputDate}
onChange={(e) => setInputDate(e.target.value)}
className="w-[140px]"
/>
<Button type="submit">Anzeigen</Button>
</form>
</div>
</header>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{/* Group Dropdown Filter */}
<div className="relative">
<button
type="button"
onClick={() => setIsGroupOpen(!isGroupOpen)}
className={`inline-flex h-9 items-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] ${
selectedGroup !== "all"
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
{selectedGroup === "all" ? "Alle Gruppen" : selectedGroup}
<ChevronDown className="h-3.5 w-3.5" />
</button>
{isGroupOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsGroupOpen(false)} />
<div className="absolute left-0 mt-1.5 w-44 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] py-1.5 shadow-lg z-20">
<button
type="button"
onClick={() => {
setSelectedGroup("all");
setIsGroupOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedGroup === "all"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Alle Gruppen
</button>
<button
type="button"
onClick={() => {
setSelectedGroup("Krippe");
setIsGroupOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedGroup === "Krippe"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Krippe (Mock)
</button>
<button
type="button"
onClick={() => {
setSelectedGroup("Elementar");
setIsGroupOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedGroup === "Elementar"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Elementar (Mock)
</button>
</div>
</>
)}
</div>
{/* Reason Dropdown Filter */}
<div className="relative">
<button
type="button"
onClick={() => setIsReasonOpen(!isReasonOpen)}
className={`inline-flex h-9 items-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] ${
selectedReason !== "all"
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
{selectedReason === "all" ? "Alle Gründe" : reasonLabel(selectedReason)}
<ChevronDown className="h-3.5 w-3.5" />
</button>
{isReasonOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsReasonOpen(false)} />
<div className="absolute left-0 mt-1.5 w-44 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] py-1.5 shadow-lg z-20">
<button
type="button"
onClick={() => {
setSelectedReason("all");
setIsReasonOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedReason === "all"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Alle Gründe
</button>
<button
type="button"
onClick={() => {
setSelectedReason(AbsenceReason.ILLNESS);
setIsReasonOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedReason === AbsenceReason.ILLNESS
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Krankheit
</button>
<button
type="button"
onClick={() => {
setSelectedReason(AbsenceReason.VACATION);
setIsReasonOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedReason === AbsenceReason.VACATION
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Urlaub
</button>
<button
type="button"
onClick={() => {
setSelectedReason(AbsenceReason.OTHER);
setIsReasonOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedReason === AbsenceReason.OTHER
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Sonstiges
</button>
</div>
</>
)}
</div>
</div>
{filteredAbsences.length > 0 ? (
<div className="flex flex-wrap items-center gap-4 text-xs text-[var(--ink-soft)]">
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(AbsenceReason.ILLNESS) }}
/>
{reasonLabel(AbsenceReason.ILLNESS)} {countsByReason[AbsenceReason.ILLNESS] ?? 0}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(AbsenceReason.OTHER) }}
/>
{reasonLabel(AbsenceReason.OTHER)} {countsByReason[AbsenceReason.OTHER] ?? 0}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(AbsenceReason.VACATION) }}
/>
{reasonLabel(AbsenceReason.VACATION)} {countsByReason[AbsenceReason.VACATION] ?? 0}
</span>
<span>
letzte Aktualisierung {format(new Date(), "HH:mm", { locale: de })}
</span>
</div>
) : null}
</div>
<div className="mt-4">
{error ? (
<Card className="border-[var(--danger)] bg-[var(--danger-soft)]">
<CardContent className="pt-[22px] text-sm text-[var(--danger)]">
{error}
</CardContent>
</Card>
) : filteredAbsences.length > 0 ? (
<Card>
<CardHeader className="pb-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle>
{filteredAbsences.length} von {childCount} Kindern abwesend ·{" "}
{upcomingAbsenceCount} anstehend
</CardTitle>
<Badge variant="secondary">{dateKey}</Badge>
</div>
</CardHeader>
<CardContent>
<AbsenceTable absences={filteredAbsences} />
</CardContent>
</Card>
) : isTooFarFuture ? (
<EmptyState
tone="accent"
title="Noch nicht im Vorschauzeitraum"
description="Abwesenheiten werden erst 30 Tage im Voraus angezeigt."
/>
) : isClosedDay ? (
<EmptyState
tone="accent"
title={`Kita geschlossen am ${format(selectedDate, "d. MMMM", {
locale: de,
})}`}
description={
closure
? closure.title
: "Wochenende · für diesen Tag werden keine Anwesenheiten erwartet."
}
/>
) : (
<EmptyState
tone="good"
title="Keine Abwesenheiten"
description="Für diesen Tag sind aktuell keine Kinder abgemeldet."
/>
)}
</div>
</div>
);
}
function EmptyState({
tone,
title,
description,
}: {
tone: "good" | "accent";
title: string;
description: string;
}) {
const isGood = tone === "good";
return (
<Card>
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-8 py-12 text-center">
<div
className={[
"flex h-12 w-12 items-center justify-center rounded-2xl",
isGood
? "bg-[var(--good-soft)] text-[var(--good)]"
: "bg-[var(--accent-soft)] text-[var(--accent-deep)]",
].join(" ")}
>
{isGood ? (
<CalendarCheck2 className="h-6 w-6 [stroke-width:1.7]" />
) : (
<CalendarDays className="h-6 w-6 [stroke-width:1.7]" />
)}
</div>
<h2 className="mt-5 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
{title}
</h2>
<p className="mt-2 max-w-md text-sm text-[var(--ink-soft)]">
{description}
</p>
<Link
href="/dashboard/admin/abwesenheiten"
className="mt-5 inline-flex items-center gap-1 text-sm font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
Wochenübersicht öffnen
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</CardContent>
</Card>
);
}
+16 -251
View File
@@ -1,29 +1,11 @@
import Link from "next/link";
import { addDays, differenceInCalendarDays, format, isWeekend } from "date-fns";
import { de } from "date-fns/locale";
import {
ArrowLeft,
ArrowRight,
CalendarCheck2,
CalendarDays,
ChevronDown,
ChevronRight,
Stethoscope,
} from "lucide-react";
import { AbsenceReason, TerminStatus, TerminType, UserRole } from "@prisma/client";
import { differenceInCalendarDays, format, isWeekend } from "date-fns";
import { TerminStatus, TerminType, UserRole } from "@prisma/client";
import { getDailyAbsences } from "@/actions/absences";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
AbsenceTable,
type AbsenceTableItem,
} from "./absence-table";
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
import { AbsencesClient } from "./_components/absences-client";
import { type AbsenceTableItem } from "./absence-table";
export const metadata = { title: "Abwesenheiten · Kita-Planer" };
@@ -76,229 +58,23 @@ export default async function AdminAbwesenheitenPage({
startDate: absence.startDate,
endDate: absence.endDate,
}));
const countsByReason = countByReason(tableItems);
return (
<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-[1100px]:flex-col max-[1100px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
TAGESÜBERSICHT
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Heute fehlen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
{absences.length} Kinder
</em>
</h1>
<p className="mt-2 text-[15px] leading-6 text-[var(--ink-soft)]">
{format(selectedDate, "EEEE, d. MMMM yyyy", { locale: de })}
</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<Stethoscope className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{childCount - absences.length} anwesend
</span>
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<SegmentLink href={hrefForDate(addDays(selectedDate, -1))}>
<ArrowLeft className="h-3.5 w-3.5" />
Zurück
</SegmentLink>
<SegmentLink href={hrefForDate(today)} active={isToday}>
Heute
</SegmentLink>
<SegmentLink href={hrefForDate(addDays(selectedDate, 1))}>
Vor
<ArrowRight className="h-3.5 w-3.5" />
</SegmentLink>
</div>
<form className="flex items-center gap-2">
<Input
type="date"
name="date"
defaultValue={dateKey}
className="w-[140px]"
<AbsencesClient
initialAbsences={tableItems}
childCount={childCount}
upcomingAbsenceCount={upcomingAbsenceCount}
closure={closure}
dateKey={dateKey}
selectedDateStr={selectedDate.toISOString()}
isToday={isToday}
isTooFarFuture={isTooFarFuture}
isClosedDay={isClosedDay}
todayStr={today.toISOString()}
error={result.error ?? null}
/>
<Button type="submit">Anzeigen</Button>
</form>
</div>
</header>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<FilterChip label="Alle Gruppen" />
<FilterChip label="Alle Gründe" />
</div>
{tableItems.length > 0 ? (
<div className="flex flex-wrap items-center gap-4 text-xs text-[var(--ink-soft)]">
<ReasonInlineStat
reason={AbsenceReason.ILLNESS}
count={countsByReason[AbsenceReason.ILLNESS] ?? 0}
/>
<ReasonInlineStat
reason={AbsenceReason.OTHER}
count={countsByReason[AbsenceReason.OTHER] ?? 0}
/>
<ReasonInlineStat
reason={AbsenceReason.VACATION}
count={countsByReason[AbsenceReason.VACATION] ?? 0}
/>
<span>
letzte Aktualisierung {format(new Date(), "HH:mm", { locale: de })}
</span>
</div>
) : null}
</div>
<div className="mt-4">
{result.error ? (
<Card className="border-[var(--danger)] bg-[var(--danger-soft)]">
<CardContent className="pt-[22px] text-sm text-[var(--danger)]">
{result.error}
</CardContent>
</Card>
) : tableItems.length > 0 ? (
<Card>
<CardHeader className="pb-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle>
{tableItems.length} von {childCount} Kindern abwesend ·{" "}
{upcomingAbsenceCount} anstehend
</CardTitle>
<Badge variant="secondary">{dateKey}</Badge>
</div>
</CardHeader>
<CardContent>
<AbsenceTable absences={tableItems} />
</CardContent>
</Card>
) : isTooFarFuture ? (
<EmptyState
tone="accent"
title="Noch nicht im Vorschauzeitraum"
description="Abwesenheiten werden erst 30 Tage im Voraus angezeigt."
/>
) : isClosedDay ? (
<EmptyState
tone="accent"
title={`Kita geschlossen am ${format(selectedDate, "d. MMMM", {
locale: de,
})}`}
description={
closure
? closure.title
: "Wochenende · für diesen Tag werden keine Anwesenheiten erwartet."
}
/>
) : (
<EmptyState
tone="good"
title="Keine Abwesenheiten"
description="Für diesen Tag sind aktuell keine Kinder abgemeldet."
/>
)}
</div>
</div>
);
}
function SegmentLink({
href,
active,
children,
}: {
href: string;
active?: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className={[
"inline-flex h-10 items-center gap-2 border-r border-[var(--hairline)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition last:border-r-0 hover:bg-[var(--surface-2)] hover:text-[var(--ink)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]",
active ? "bg-[var(--accent-soft)] text-[var(--accent-deep)]" : "",
].join(" ")}
>
{children}
</Link>
);
}
function FilterChip({ label }: { label: string }) {
return (
<button
type="button"
className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-transparent px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--accent-soft)] hover:text-[var(--accent-deep)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
{label}
<ChevronDown className="h-3.5 w-3.5" />
</button>
);
}
function ReasonInlineStat({
reason,
count,
}: {
reason: AbsenceReason;
count: number;
}) {
return (
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(reason) }}
/>
{reasonLabel(reason)} {count}
</span>
);
}
function EmptyState({
tone,
title,
description,
}: {
tone: "good" | "accent";
title: string;
description: string;
}) {
const isGood = tone === "good";
return (
<Card>
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-8 py-12 text-center">
<div
className={[
"flex h-12 w-12 items-center justify-center rounded-2xl",
isGood
? "bg-[var(--good-soft)] text-[var(--good)]"
: "bg-[var(--accent-soft)] text-[var(--accent-deep)]",
].join(" ")}
>
{isGood ? (
<CalendarCheck2 className="h-6 w-6 [stroke-width:1.7]" />
) : (
<CalendarDays className="h-6 w-6 [stroke-width:1.7]" />
)}
</div>
<h2 className="mt-5 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
{title}
</h2>
<p className="mt-2 max-w-md text-sm text-[var(--ink-soft)]">
{description}
</p>
<Link
href="/dashboard/admin/abwesenheiten"
className="mt-5 inline-flex items-center gap-1 text-sm font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
Wochenübersicht öffnen
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</CardContent>
</Card>
);
}
@@ -308,19 +84,8 @@ function parseDateParam(value: string | undefined, fallback: Date) {
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
}
function hrefForDate(date: Date) {
return `/dashboard/admin/abwesenheiten?date=${format(date, "yyyy-MM-dd")}`;
}
function startOfLocalDay(date: Date) {
const nextDate = new Date(date);
nextDate.setHours(0, 0, 0, 0);
return nextDate;
}
function countByReason(items: AbsenceTableItem[]) {
return items.reduce<Partial<Record<AbsenceReason, number>>>((acc, item) => {
acc[item.reason] = (acc[item.reason] ?? 0) + 1;
return acc;
}, {});
}
@@ -0,0 +1,600 @@
"use client";
import { useState, useMemo } from "react";
import {
Baby,
Contact,
Mail,
Phone,
Search,
ShieldCheck,
SlidersHorizontal,
UserRound,
Home,
MapPin,
} from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export type DirectoryParentDto = {
id: string;
firstName: string;
lastName: string;
hasDirectoryOptIn: boolean;
email?: string;
phone?: string;
street?: string;
postalCode?: string;
city?: string;
duties: {
id: string;
name: string;
}[];
};
export type DirectoryFamilyDto = {
id: string;
name: string;
children: {
id: string;
firstName: string;
lastName: string;
dateOfBirth?: Date | null;
}[];
parents: DirectoryParentDto[];
};
export function DirectoryClient({
initialFamilies,
}: {
initialFamilies: DirectoryFamilyDto[];
}) {
const [searchQuery, setSearchQuery] = useState("");
const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
const [selectedFamily, setSelectedFamily] = useState<DirectoryFamilyDto | null>(null);
const [privacyDialogOpen, setPrivacyDialogOpen] = useState(false);
const toggleFilter = (filterKey: string) => {
setActiveFilters((prev) => {
const next = new Set(prev);
if (next.has(filterKey)) {
next.delete(filterKey);
} else {
next.add(filterKey);
}
return next;
});
};
const filteredFamilies = useMemo(() => {
return initialFamilies.filter((family) => {
// 1. Search Query filter
const query = searchQuery.trim().toLowerCase();
if (query) {
const familyNameMatch = family.name.toLowerCase().includes(query);
const childMatch = family.children.some(
(c) =>
c.firstName.toLowerCase().includes(query) ||
c.lastName.toLowerCase().includes(query)
);
const parentMatch = family.parents.some(
(p) =>
p.firstName.toLowerCase().includes(query) ||
p.lastName.toLowerCase().includes(query) ||
(p.email && p.email.toLowerCase().includes(query))
);
if (!familyNameMatch && !childMatch && !parentMatch) return false;
}
// 2. Custom filters
if (activeFilters.has("telefon")) {
const hasPhone = family.parents.some((p) => p.hasDirectoryOptIn && p.phone);
if (!hasPhone) return false;
}
if (activeFilters.has("aemter")) {
const hasDuties = family.parents.some((p) => p.duties.length > 0);
if (!hasDuties) return false;
}
if (activeFilters.has("optIn")) {
// Show households where all parents in the system have opted in
const allOptIn = family.parents.every((p) => p.hasDirectoryOptIn);
if (!allOptIn) return false;
}
return true;
});
}, [initialFamilies, searchQuery, activeFilters]);
// Recalculate metrics for filtered subset
const visibleParentCount = useMemo(() => {
return filteredFamilies.reduce(
(count, fam) => count + fam.parents.filter((p) => p.hasDirectoryOptIn).length,
0
);
}, [filteredFamilies]);
const childCount = useMemo(() => {
return filteredFamilies.reduce((count, fam) => count + fam.children.length, 0);
}, [filteredFamilies]);
const totalVisibleParentCount = useMemo(() => {
return initialFamilies.reduce(
(count, fam) => count + fam.parents.filter((p) => p.hasDirectoryOptIn).length,
0
);
}, [initialFamilies]);
const groupedFamilies = useMemo(() => {
const groups = new Map<string, DirectoryFamilyDto[]>();
filteredFamilies.forEach((family) => {
const initial = family.name.trim().slice(0, 1).toUpperCase();
const key = initial || "#";
groups.set(key, [...(groups.get(key) ?? []), family]);
});
return Array.from(groups.entries())
.map(([initial, families]) => ({ initial, families }))
.sort((a, b) => a.initial.localeCompare(b.initial));
}, [filteredFamilies]);
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
{/* Header */}
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
KONTAKTE · ADRESSBUCH
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Freigegebene{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
Kontakte
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Haushalte und Kontaktdaten, die explizit für das interne
Kita-Adressbuch freigegeben wurden.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
<ShieldCheck className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{totalVisibleParentCount} sichtbar
</span>
<Button variant="ghost" size="sm" onClick={() => setPrivacyDialogOpen(true)}>
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Sichtbarkeit
</Button>
</div>
</header>
{/* Main Household List or Empty State */}
{initialFamilies.length === 0 ? (
<Card className="border-dashed">
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Contact className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine freigegebenen Kontakte
</h3>
<p className="mt-2 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Sobald mindestens ein Elternteil eines Haushalts zustimmt,
erscheint die Familie hier.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-5">
{/* Metrics & Search Bar */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<DirectoryMetric label="Haushalte" value={filteredFamilies.length} />
<DirectoryMetric label="Kontakte" value={visibleParentCount} />
<DirectoryMetric label="Kinder" value={childCount} />
</div>
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input
placeholder="Familien, Namen oder E-Mail suchen"
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{/* Custom Filter Chips */}
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => setActiveFilters(new Set())}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
activeFilters.size === 0
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Alle anzeigen
</button>
<button
type="button"
onClick={() => toggleFilter("telefon")}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
activeFilters.has("telefon")
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Mit Telefon
</button>
<button
type="button"
onClick={() => toggleFilter("aemter")}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
activeFilters.has("aemter")
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Mit Ämtern
</button>
<button
type="button"
onClick={() => toggleFilter("optIn")}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
activeFilters.has("optIn")
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Haushalt komplett freigegeben
</button>
</div>
{/* Household list cards wrapper */}
{filteredFamilies.length === 0 ? (
<Card className="border-dashed">
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<SlidersHorizontal className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine passenden Kontakte gefunden
</h3>
<p className="mt-2 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Passe deine Suche oder die Filterchips an, um Einträge zu sehen.
</p>
</CardContent>
</Card>
) : (
<Card className="overflow-hidden p-0">
{groupedFamilies.map((group) => (
<section key={group.initial}>
<div className="flex items-center justify-between border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] px-5 py-3">
<h2 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.14em]">
{group.initial}
</h2>
<span className="text-[11px] font-semibold text-[var(--ink-muted)] tabular-nums">
{group.families.length}
</span>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{group.families.map((family) => {
const visibleParents = family.parents.filter(
(parent) => parent.hasDirectoryOptIn
);
const duties = family.parents.flatMap((parent) => parent.duties);
return (
<div
key={family.id}
className="grid grid-cols-[minmax(220px,0.8fr)_minmax(0,1.4fr)_minmax(220px,1fr)] gap-5 px-5 py-4 transition-colors hover:bg-[var(--surface-2)] max-[1050px]:grid-cols-1"
>
{/* Left: Family Name & Children */}
<div className="flex min-w-0 gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-sm font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{family.name}
</h3>
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-[13px] text-[var(--ink-soft)]">
<Baby className="h-3.5 w-3.5 [stroke-width:1.7]" />
{family.children.length === 0
? "Keine Kinder hinterlegt"
: family.children
.map((child) => `${child.firstName} ${child.lastName}`)
.join(", ")}
</p>
</div>
</div>
{/* Middle: Parents */}
<div className="grid gap-3">
{family.parents.map((user) => (
<div key={user.id} className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<UserRound className="h-3.5 w-3.5 text-[var(--ink-muted)] [stroke-width:1.7]" />
<p className="text-sm font-medium text-[var(--ink)]">
{user.firstName} {user.lastName}
</p>
{user.hasDirectoryOptIn ? (
<Badge variant="success">Opt-in</Badge>
) : (
<span className="text-xs italic text-[var(--ink-faint)]">
nicht freigegeben
</span>
)}
</div>
{user.hasDirectoryOptIn && (
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 pl-5 text-[13px] text-[var(--ink-soft)]">
{user.email && (
<a
href={`mailto:${user.email}`}
className="inline-flex min-w-0 items-center gap-1.5 hover:text-[var(--accent-deep)]"
>
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{user.email}
</span>
</a>
)}
{user.phone ? (
<a
href={`tel:${user.phone}`}
className="inline-flex items-center gap-1.5 hover:text-[var(--accent-deep)]"
>
<Phone className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
{user.phone}
</a>
) : (
<span className="italic text-[var(--ink-faint)]">
Keine Telefonnummer
</span>
)}
</div>
)}
</div>
))}
</div>
{/* Right: Badges & Details Action */}
<div className="flex flex-col items-end justify-between gap-3 max-[1050px]:items-start">
<div className="flex flex-wrap justify-end gap-1.5 max-[1050px]:justify-start">
<Badge variant="secondary">
{visibleParents.length} von {family.parents.length} sichtbar
</Badge>
{duties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
>
{duty.name}
</Badge>
))}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedFamily(family)}
>
Kontakt öffnen
</Button>
</div>
</div>
);
})}
</div>
</section>
))}
</Card>
)}
</div>
)}
{/* Household Detail Dialog */}
<Dialog
open={!!selectedFamily}
onOpenChange={(open) => !open && setSelectedFamily(null)}
>
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
{selectedFamily && (
<>
<DialogHeader>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)] text-xl font-bold uppercase mb-2">
{selectedFamily.name.slice(0, 1)}
</div>
<DialogTitle className="text-2xl">Familie {selectedFamily.name}</DialogTitle>
<DialogDescription>
Detaillierte Kontaktdaten und Haushalts-Zugehörigkeiten.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-6">
{/* Children Details */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--ink-muted)] mb-3 flex items-center gap-1.5">
<Baby className="h-4 w-4" />
Kinder im Haushalt
</h4>
<div className="grid gap-2.5">
{selectedFamily.children.length === 0 ? (
<p className="text-sm italic text-[var(--ink-soft)]">Keine Kinder hinterlegt.</p>
) : (
selectedFamily.children.map((child) => (
<div
key={child.id}
className="flex items-center justify-between rounded-xl border border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-3"
>
<div className="flex items-center gap-2">
<Baby className="h-4 w-4 text-[var(--accent-deep)]" />
<span className="font-semibold text-sm text-[var(--ink)]">
{child.firstName} {child.lastName}
</span>
</div>
{child.dateOfBirth && (
<span className="text-xs text-[var(--ink-soft)] font-medium">
Geboren: {format(new Date(child.dateOfBirth), "dd.MM.yyyy", { locale: de })}
</span>
)}
</div>
))
)}
</div>
</div>
{/* Parents/Users Details */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--ink-muted)] mb-3 flex items-center gap-1.5">
<UserRound className="h-4 w-4" />
Eltern & Kontakte
</h4>
<div className="space-y-4">
{selectedFamily.parents.map((parent) => (
<div
key={parent.id}
className="rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm"
>
<div className="flex items-center justify-between gap-2 border-b border-[var(--hairline-soft)] pb-2 mb-3">
<span className="font-semibold text-[15px] text-[var(--ink)] flex items-center gap-1.5">
<UserRound className="h-4 w-4 text-[var(--ink-muted)]" />
{parent.firstName} {parent.lastName}
</span>
{parent.hasDirectoryOptIn ? (
<Badge variant="success">Freigegeben</Badge>
) : (
<Badge variant="secondary">Privat</Badge>
)}
</div>
{parent.hasDirectoryOptIn ? (
<div className="space-y-2.5 text-sm text-[var(--ink-soft)]">
{parent.email && (
<a
href={`mailto:${parent.email}`}
className="flex items-center gap-2.5 hover:text-[var(--accent-deep)]"
>
<Mail className="h-4 w-4 text-[var(--ink-muted)] shrink-0" />
<span className="break-all">{parent.email}</span>
</a>
)}
{parent.phone ? (
<a
href={`tel:${parent.phone}`}
className="flex items-center gap-2.5 hover:text-[var(--accent-deep)]"
>
<Phone className="h-4 w-4 text-[var(--ink-muted)] shrink-0" />
<span>{parent.phone}</span>
</a>
) : (
<div className="flex items-center gap-2.5 italic text-[var(--ink-faint)]">
<Phone className="h-4 w-4 text-[var(--ink-muted)] shrink-0" />
<span>Keine Telefonnummer hinterlegt</span>
</div>
)}
{/* Address details */}
{(parent.street || parent.postalCode || parent.city) && (
<div className="flex items-start gap-2.5 border-t border-[var(--hairline-soft)] pt-2.5 mt-2.5">
<MapPin className="h-4 w-4 text-[var(--ink-muted)] shrink-0 mt-0.5" />
<div>
{parent.street && <p>{parent.street}</p>}
{(parent.postalCode || parent.city) && (
<p>
{parent.postalCode} {parent.city}
</p>
)}
</div>
</div>
)}
</div>
) : (
<p className="text-xs italic text-[var(--ink-faint)]">
Dieser Kontakt hat seine Daten nicht für das Adressbuch freigegeben.
</p>
)}
{parent.duties.length > 0 && (
<div className="mt-3 border-t border-[var(--hairline-soft)] pt-3 flex flex-wrap gap-1.5">
{parent.duties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)] text-[10px]"
>
{duty.name}
</Badge>
))}
</div>
)}
</div>
))}
</div>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
{/* Global Privacy settings dialog */}
<Dialog open={privacyDialogOpen} onOpenChange={setPrivacyDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-[var(--accent-deep)]" />
Sichtbarkeit & Datenschutz
</DialogTitle>
<DialogDescription className="mt-2 text-sm leading-6">
Deine Privatsphäre ist uns wichtig. In diesem Adressbuch werden Kontaktdaten
nur für Haushalte angezeigt, die dem Opt-in für das interne Kita-Adressbuch
explizit zugestimmt haben.
<br />
<br />
Du kannst deine eigene Zustimmung sowie deine E-Mail-Adresse und Telefonnummer
jederzeit in deinen persönlichen Profileinstellungen einsehen und anpassen.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-3 mt-4">
<Button variant="ghost" onClick={() => setPrivacyDialogOpen(false)}>
Schließen
</Button>
<a href="/dashboard/profil">
<Button>Zu den Profileinstellungen</Button>
</a>
</div>
</DialogContent>
</Dialog>
</div>
);
}
function DirectoryMetric({ label, value }: { label: string; value: number }) {
return (
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{value}</span>
{label}
</span>
);
}
+9 -274
View File
@@ -1,45 +1,9 @@
import {
Baby,
Contact,
Mail,
Phone,
Search,
ShieldCheck,
SlidersHorizontal,
UserRound,
} from "lucide-react";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { DirectoryClient, type DirectoryFamilyDto } from "./_components/directory-client";
export const metadata = { title: "Adressbuch · Kita-Planer" };
type DirectoryFamilyDto = {
id: string;
name: string;
children: {
id: string;
firstName: string;
lastName: string;
}[];
parents: {
id: string;
firstName: string;
lastName: string;
hasDirectoryOptIn: boolean;
email?: string;
phone?: string;
duties: {
id: string;
name: string;
}[];
}[];
};
export default async function AdressbuchPage() {
const session = await requireKitaSession();
@@ -60,6 +24,7 @@ export default async function AdressbuchPage() {
id: true,
firstName: true,
lastName: true,
dateOfBirth: true,
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
},
@@ -96,6 +61,9 @@ export default async function AdressbuchPage() {
id: true,
email: true,
phone: true,
street: true,
postalCode: true,
city: true,
},
});
const visibleContactsByUserId = new Map(
@@ -116,6 +84,9 @@ export default async function AdressbuchPage() {
hasDirectoryOptIn,
email: contact?.email,
phone: contact?.phone ?? undefined,
street: contact?.street ?? undefined,
postalCode: contact?.postalCode ?? undefined,
city: contact?.city ?? undefined,
duties: user.dutyAssignments.map((assignment) => ({
id: assignment.id,
name: assignment.duty.name,
@@ -124,241 +95,5 @@ export default async function AdressbuchPage() {
}),
}));
const visibleParentCount = families.reduce(
(count, family) =>
count + family.parents.filter((parent) => parent.hasDirectoryOptIn).length,
0,
);
const childCount = families.reduce(
(count, family) => count + family.children.length,
0,
);
const groupedFamilies = groupFamiliesByInitial(families);
return (
<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]:flex-col max-[900px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
KONTAKTE · ADRESSBUCH
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Freigegebene{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
Kontakte
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Haushalte und Kontaktdaten, die explizit für das interne
Kita-Adressbuch freigegeben wurden.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
<ShieldCheck className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{visibleParentCount} sichtbar
</span>
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Sichtbarkeit
</Button>
</div>
</header>
{families.length === 0 ? (
<Card className="border-dashed">
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Contact className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine freigegebenen Kontakte
</h3>
<p className="mt-2 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Sobald mindestens ein Elternteil eines Haushalts zustimmt,
erscheint die Familie hier.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<DirectoryMetric label="Haushalte" value={families.length} />
<DirectoryMetric label="Kontakte" value={visibleParentCount} />
<DirectoryMetric label="Kinder" value={childCount} />
</div>
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input
placeholder="Familien, Namen oder E-Mail suchen"
className="pl-9"
/>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{["Alle Gruppen", "Mit Telefon", "Mit Ämtern", "Nur Opt-in"].map(
(filter, index) => (
<span
key={filter}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium",
index === 0
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)]",
].join(" ")}
>
{filter}
</span>
),
)}
</div>
<Card className="overflow-hidden p-0">
{groupedFamilies.map((group) => (
<section key={group.initial}>
<div className="flex items-center justify-between border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] px-5 py-3">
<h2 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.14em]">
{group.initial}
</h2>
<span className="text-[11px] font-semibold text-[var(--ink-muted)] tabular-nums">
{group.families.length}
</span>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{group.families.map((family) => (
<FamilyDirectoryRow key={family.id} family={family} />
))}
</div>
</section>
))}
</Card>
</div>
)}
</div>
);
}
function FamilyDirectoryRow({ family }: { family: DirectoryFamilyDto }) {
const visibleParents = family.parents.filter(
(parent) => parent.hasDirectoryOptIn,
);
const duties = family.parents.flatMap((parent) => parent.duties);
return (
<div className="grid grid-cols-[minmax(220px,0.8fr)_minmax(0,1.4fr)_minmax(220px,1fr)] gap-5 px-5 py-4 transition-colors hover:bg-[var(--surface-2)] max-[1050px]:grid-cols-1">
<div className="flex min-w-0 gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-sm font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{family.name}
</h3>
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-[13px] text-[var(--ink-soft)]">
<Baby className="h-3.5 w-3.5 [stroke-width:1.7]" />
{family.children.length === 0
? "Keine Kinder hinterlegt"
: family.children
.map((child) => `${child.firstName} ${child.lastName}`)
.join(", ")}
</p>
</div>
</div>
<div className="grid gap-3">
{family.parents.map((user) => (
<div key={user.id} className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<UserRound className="h-3.5 w-3.5 text-[var(--ink-muted)] [stroke-width:1.7]" />
<p className="text-sm font-medium text-[var(--ink)]">
{user.firstName} {user.lastName}
</p>
{user.hasDirectoryOptIn ? (
<Badge variant="success">Opt-in</Badge>
) : (
<span className="text-xs italic text-[var(--ink-faint)]">
nicht freigegeben
</span>
)}
</div>
{user.hasDirectoryOptIn && (
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 pl-5 text-[13px] text-[var(--ink-soft)]">
{user.email && (
<a
href={`mailto:${user.email}`}
className="inline-flex min-w-0 items-center gap-1.5 hover:text-[var(--accent-deep)]"
>
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{user.email}
</span>
</a>
)}
{user.phone ? (
<a
href={`tel:${user.phone}`}
className="inline-flex items-center gap-1.5 hover:text-[var(--accent-deep)]"
>
<Phone className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
{user.phone}
</a>
) : (
<span className="italic text-[var(--ink-faint)]">
Keine Telefonnummer
</span>
)}
</div>
)}
</div>
))}
</div>
<div className="flex flex-col items-end justify-between gap-3 max-[1050px]:items-start">
<div className="flex flex-wrap justify-end gap-1.5 max-[1050px]:justify-start">
<Badge variant="secondary">
{visibleParents.length} von {family.parents.length} sichtbar
</Badge>
{duties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
>
{duty.name}
</Badge>
))}
</div>
<Button variant="ghost" size="sm">
Kontakt öffnen
</Button>
</div>
</div>
);
}
function DirectoryMetric({ label, value }: { label: string; value: number }) {
return (
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{value}</span>
{label}
</span>
);
}
function groupFamiliesByInitial(families: DirectoryFamilyDto[]) {
const groups = new Map<string, DirectoryFamilyDto[]>();
families.forEach((family) => {
const initial = family.name.trim().slice(0, 1).toLocaleUpperCase("de-DE");
const key = initial || "#";
groups.set(key, [...(groups.get(key) ?? []), family]);
});
return Array.from(groups.entries()).map(([initial, groupFamilies]) => ({
initial,
families: groupFamilies,
}));
return <DirectoryClient initialFamilies={families} />;
}
@@ -0,0 +1,418 @@
"use client";
import { useState } from "react";
import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Table as UITable,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AddFamilyDialog } from "../add-family-dialog";
import { DutyManager } from "../duty-manager";
import { EditFamilyDialog } from "../edit-family-dialog";
interface FamilyUser {
id: string;
firstName: string;
lastName: string;
email: string;
emailVerifiedAt: Date | null;
dutyAssignments: {
id: string;
dutyId: string;
duty: {
name: string;
};
}[];
}
interface FamilyChild {
id: string;
firstName: string;
lastName: string;
}
interface FamilyData {
id: string;
name: string;
users: FamilyUser[];
children: FamilyChild[];
}
interface DutyData {
id: string;
name: string;
assignments: {
id: string;
}[];
}
interface FamiliesListClientProps {
initialFamilies: FamilyData[];
allDuties: DutyData[];
canManageDuties: boolean;
}
export function FamiliesListClient({
initialFamilies,
allDuties,
canManageDuties,
}: FamiliesListClientProps) {
const [viewMode, setViewMode] = useState<"table" | "cards">("table");
const [searchQuery, setSearchQuery] = useState("");
const filteredFamilies = initialFamilies.filter((family) => {
const q = searchQuery.toLowerCase().trim();
if (!q) return true;
// Check family name
if (family.name.toLowerCase().includes(q)) return true;
// Check parents
const matchesParent = family.users.some(
(user) =>
user.firstName.toLowerCase().includes(q) ||
user.lastName.toLowerCase().includes(q) ||
user.email.toLowerCase().includes(q)
);
if (matchesParent) return true;
// Check children
const matchesChild = family.children.some(
(child) =>
child.firstName.toLowerCase().includes(q) ||
child.lastName.toLowerCase().includes(q)
);
return matchesChild;
});
const childCount = filteredFamilies.reduce(
(acc, family) => acc + family.children.length,
0
);
const pendingInviteCount = filteredFamilies.reduce(
(acc, family) =>
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
0
);
return (
<div>
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
VERWALTUNG · FAMILIEN
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Familien{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
organisieren
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort pflegen.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setViewMode(viewMode === "table" ? "cards" : "table")}
className="flex items-center gap-1.5"
>
{viewMode === "table" ? (
<>
<LayoutGrid className="h-4 w-4 [stroke-width:1.7]" />
Kartenansicht
</>
) : (
<>
<Table className="h-4 w-4 [stroke-width:1.7]" />
Tabellenansicht
</>
)}
</Button>
<AddFamilyDialog />
</div>
</header>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{filteredFamilies.length}</span>
Familien
</span>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{childCount}</span>
Kinder
</span>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{pendingInviteCount}</span>
Offene Einladungen
</span>
</div>
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input
placeholder="Familien, Eltern oder Kinder suchen"
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{filteredFamilies.length === 0 ? (
<Card className="border-dashed">
<div className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Users className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="mb-1 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine Familien gefunden
</h3>
<p className="mb-5 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Passe deine Suche an oder erstelle eine neue Familie.
</p>
{initialFamilies.length === 0 && <AddFamilyDialog />}
</div>
</Card>
) : viewMode === "table" ? (
<Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<div>
<CardTitle>Haushalte</CardTitle>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
{filteredFamilies.length} {filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
{childCount} Kinder · {pendingInviteCount} offene Einladungen
</p>
</div>
<Badge variant="secondary" className="tabular-nums">
{filteredFamilies.length}
</Badge>
</CardHeader>
<CardContent className="px-0 pb-0 overflow-x-auto">
<UITable>
<TableHeader>
<TableRow>
<TableHead className="pl-6">Familie</TableHead>
<TableHead>Elternteile</TableHead>
<TableHead>Kinder</TableHead>
<TableHead>Status</TableHead>
<TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFamilies.map((family) => {
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt
);
return (
<TableRow key={family.id}>
<TableCell className="pl-6">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[13px] font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div>
<div className="font-semibold text-[var(--ink)]">
{family.name}
</div>
<div className="mt-0.5 text-xs text-[var(--ink-muted)]">
{family.users.length} Elternteil
{family.users.length === 1 ? "" : "e"}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-2">
{family.users.map((parent) => (
<div key={parent.id} className="min-w-0">
<div className="font-medium text-[var(--ink)]">
{parent.firstName} {parent.lastName}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-[var(--ink-muted)]">
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email}
</span>
</div>
</div>
))}
</div>
</TableCell>
<TableCell>
{family.children.length === 0 ? (
<span className="text-sm italic text-[var(--ink-faint)]">
Noch keine Kinder
</span>
) : (
<div className="flex flex-wrap gap-1.5">
{family.children.map((child) => (
<span
key={child.id}
className="inline-flex items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-xs font-medium text-[var(--ink-soft)]"
>
{child.firstName} {child.lastName}
</span>
))}
</div>
)}
</TableCell>
<TableCell>
<Badge variant={hasPendingInvite ? "warning" : "success"}>
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge>
</TableCell>
<TableCell className="pr-6 text-right">
<div className="flex flex-wrap justify-end gap-1">
<EditFamilyDialog
family={{
id: family.id,
name: family.name,
parents: family.users.map((parent) => ({
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
})),
children: family.children,
}}
/>
{canManageDuties &&
family.users.map((parent) => (
<DutyManager
key={parent.id}
user={{
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
}}
allDuties={allDuties}
userAssignments={parent.dutyAssignments}
/>
))}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</UITable>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredFamilies.map((family) => {
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt
);
return (
<Card key={family.id} className="flex flex-col h-full">
<CardHeader className="flex flex-row items-center justify-between pb-3 space-y-0">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-sm font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div>
<CardTitle className="text-lg font-semibold">{family.name}</CardTitle>
<p className="text-xs text-[var(--ink-muted)]">
{family.users.length} Elternteil{family.users.length === 1 ? "" : "e"}
</p>
</div>
</div>
<Badge variant={hasPendingInvite ? "warning" : "success"}>
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between gap-4 pt-0">
<div className="space-y-4">
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-[var(--ink-muted)] mb-1">
Eltern
</p>
<div className="space-y-2.5">
{family.users.map((parent) => (
<div key={parent.id} className="text-sm">
<div className="font-semibold text-[var(--ink)]">
{parent.firstName} {parent.lastName}
</div>
<div className="flex items-center gap-1.5 text-xs text-[var(--ink-muted)] mt-0.5">
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email}
</span>
</div>
</div>
))}
</div>
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-[var(--ink-muted)] mb-1">
Kinder
</p>
{family.children.length === 0 ? (
<span className="text-xs italic text-[var(--ink-faint)]">
Noch keine Kinder
</span>
) : (
<div className="flex flex-wrap gap-1.5">
{family.children.map((child) => (
<span
key={child.id}
className="inline-flex items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-xs text-[var(--ink-soft)] font-medium"
>
{child.firstName} {child.lastName}
</span>
))}
</div>
)}
</div>
</div>
<div className="border-t border-[var(--hairline)] pt-3 mt-auto flex flex-wrap gap-1 justify-end">
<EditFamilyDialog
family={{
id: family.id,
name: family.name,
parents: family.users.map((parent) => ({
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
})),
children: family.children,
}}
/>
{canManageDuties &&
family.users.map((parent) => (
<DutyManager
key={parent.id}
user={{
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
}}
allDuties={allDuties}
userAssignments={parent.dutyAssignments}
/>
))}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}
+4 -221
View File
@@ -1,23 +1,8 @@
import { UserRole } from "@prisma/client";
import { Mail, Search, SlidersHorizontal, Users } from "lucide-react";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AddFamilyDialog } from "./add-family-dialog";
import { DutyManager } from "./duty-manager";
import { EditFamilyDialog } from "./edit-family-dialog";
import { FamiliesListClient } from "./_components/families-list-client";
export const metadata = { title: "Familienverwaltung · Kita-Planer" };
@@ -35,7 +20,6 @@ export default async function FamiliesPage() {
const kitaId = session.user.kitaId!;
// Alle Haushalte der Kita mit Elternteilen und Kindern laden.
// kitaId IMMER aus der Session — nie aus URL/Params
const families = await prisma.family.findMany({
where: { kitaId },
select: {
@@ -84,216 +68,15 @@ export default async function FamiliesPage() {
orderBy: { name: "asc" },
});
const childCount = families.reduce(
(acc, family) => acc + family.children.length,
0,
);
const canManageDuties = session.user.role === UserRole.ADMIN;
const pendingInviteCount = families.reduce(
(acc, family) =>
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
0,
);
return (
<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]:flex-col max-[900px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
VERWALTUNG · FAMILIEN
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Familien{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
organisieren
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort
pflegen.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Ansicht
</Button>
<AddFamilyDialog />
</div>
</header>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<DirectoryMetric label="Familien" value={families.length} />
<DirectoryMetric label="Kinder" value={childCount} />
<DirectoryMetric label="Offene Einladungen" value={pendingInviteCount} />
</div>
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input placeholder="Familien, Eltern oder Kinder suchen" className="pl-9" />
</div>
</div>
{families.length === 0 ? (
<EmptyState />
) : (
<Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<div>
<CardTitle>Haushalte</CardTitle>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
{families.length} {families.length === 1 ? "Familie" : "Familien"} ·{" "}
{childCount} Kinder · {pendingInviteCount} offene Einladungen
</p>
</div>
<Badge variant="secondary" className="tabular-nums">
{families.length}
</Badge>
</CardHeader>
<CardContent className="px-0 pb-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-6">Familie</TableHead>
<TableHead>Elternteile</TableHead>
<TableHead>Kinder</TableHead>
<TableHead>Status</TableHead>
<TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{families.map((family) => {
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt,
);
return (
<TableRow key={family.id}>
<TableCell className="pl-6">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[13px] font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div>
<div className="font-semibold text-[var(--ink)]">
{family.name}
</div>
<div className="mt-0.5 text-xs text-[var(--ink-muted)]">
{family.users.length} Elternteil
{family.users.length === 1 ? "" : "e"}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-2">
{family.users.map((parent) => (
<div key={parent.id} className="min-w-0">
<div className="font-medium text-[var(--ink)]">
{parent.firstName} {parent.lastName}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-[var(--ink-muted)]">
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email}
</span>
</div>
</div>
))}
</div>
</TableCell>
<TableCell>
{family.children.length === 0 ? (
<span className="text-sm italic text-[var(--ink-faint)]">
Noch keine Kinder
</span>
) : (
<div className="flex flex-wrap gap-1.5">
{family.children.map((child) => (
<span
key={child.id}
className="inline-flex items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-xs font-medium text-[var(--ink-soft)]"
>
{child.firstName} {child.lastName}
</span>
))}
</div>
)}
</TableCell>
<TableCell>
<Badge variant={hasPendingInvite ? "warning" : "success"}>
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge>
</TableCell>
<TableCell className="pr-6 text-right">
<div className="flex flex-wrap justify-end gap-1">
<EditFamilyDialog
family={{
id: family.id,
name: family.name,
parents: family.users.map((parent) => ({
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
})),
children: family.children,
}}
/>
{canManageDuties &&
family.users.map((parent) => (
<DutyManager
key={parent.id}
user={{
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
}}
<FamiliesListClient
initialFamilies={families}
allDuties={allDuties}
userAssignments={parent.dutyAssignments}
canManageDuties={canManageDuties}
/>
))}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}
function EmptyState() {
return (
<Card className="border-dashed">
<div className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Users className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="mb-1 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Noch keine Familien
</h3>
<p className="mb-5 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Lege die erste Familie an und lade das Elternteil per Link ein, sein
Passwort zu setzen.
</p>
<AddFamilyDialog />
</div>
</Card>
);
}
function DirectoryMetric({ label, value }: { label: string; value: number }) {
return (
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">
{value}
</span>
{label}
</span>
);
}
@@ -1,7 +1,7 @@
"use client";
import type { ComponentProps, FormEvent } from "react";
import { useMemo, useState, useTransition } from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { TerminType } from "@prisma/client";
import { Plus } from "lucide-react";
import { toast } from "sonner";
@@ -18,7 +18,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createTerminAdmin } from "../actions";
import { createTerminAdmin, updateTerminAdmin } from "../actions";
import { Textarea } from "@/components/ui/textarea";
type AdminTerminFormState = {
@@ -43,12 +43,22 @@ type AdminTerminModalProps = {
triggerLabel?: string;
triggerVariant?: ComponentProps<typeof Button>["variant"];
triggerClassName?: string;
existingTermin?: {
id: string;
title: string;
description: string | null;
type: TerminType;
startDate: Date;
endDate: Date;
allDay: boolean;
};
};
export function AdminTerminModal({
triggerLabel = "Termin direkt anlegen",
triggerVariant,
triggerClassName = "gap-2",
existingTermin,
}: AdminTerminModalProps) {
const [open, setOpen] = useState(false);
const [formState, setFormState] = useState<AdminTerminFormState>(initialFormState);
@@ -56,6 +66,24 @@ export function AdminTerminModal({
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
const minEndDateTime = formState.startDate || minDateTime;
// Initialize and reset form fields when modal opens
useEffect(() => {
if (open) {
if (existingTermin) {
setFormState({
title: existingTermin.title,
description: existingTermin.description || "",
type: existingTermin.type,
startDate: getLocalDateTimeInputValue(existingTermin.startDate),
endDate: getLocalDateTimeInputValue(existingTermin.endDate),
allDay: existingTermin.allDay,
});
} else {
setFormState(initialFormState);
}
}
}, [open, existingTermin]);
const updateField = <TKey extends keyof AdminTerminFormState>(
key: TKey,
value: AdminTerminFormState[TKey],
@@ -76,7 +104,8 @@ export function AdminTerminModal({
toast.error("Bitte Start und Ende ausfüllen.");
return;
}
if (formState.startDate < minDateTime) {
// Only enforce future start date when creating a new event
if (!existingTermin && formState.startDate < minDateTime) {
toast.error("Der Startzeitpunkt darf nicht in der Vergangenheit liegen.");
return;
}
@@ -95,12 +124,24 @@ export function AdminTerminModal({
};
startTransition(async () => {
const res = await createTerminAdmin(data);
let res;
if (existingTermin) {
res = await updateTerminAdmin(existingTermin.id, data);
} else {
res = await createTerminAdmin(data);
}
if (res.error) {
toast.error(res.error);
} else {
toast.success("Termin wurde direkt angelegt!");
toast.success(
existingTermin
? "Termin wurde erfolgreich aktualisiert!"
: "Termin wurde direkt angelegt!"
);
if (!existingTermin) {
setFormState(initialFormState);
}
setOpen(false);
}
});
@@ -110,16 +151,20 @@ export function AdminTerminModal({
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant={triggerVariant} className={triggerClassName}>
<Plus className="h-4 w-4" />
{!existingTermin && <Plus className="h-4 w-4" />}
{triggerLabel}
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Termin anlegen (Admin)</DialogTitle>
<DialogTitle>
{existingTermin ? "Termin bearbeiten (Admin)" : "Termin anlegen (Admin)"}
</DialogTitle>
<DialogDescription>
Dieser Termin wird sofort freigegeben und ist für alle sichtbar.
{existingTermin
? "Nimm Änderungen an diesem Termin vor. Die Änderungen werden sofort wirksam."
: "Dieser Termin wird sofort freigegeben und ist für alle sichtbar."}
</DialogDescription>
</DialogHeader>
@@ -177,7 +222,7 @@ export function AdminTerminModal({
id="startDate"
name="startDate"
type="datetime-local"
min={minDateTime}
min={existingTermin ? undefined : minDateTime}
value={formState.startDate}
onChange={(event) => updateField("startDate", event.target.value)}
required
@@ -189,7 +234,7 @@ export function AdminTerminModal({
id="endDate"
name="endDate"
type="datetime-local"
min={minEndDateTime}
min={formState.startDate || (existingTermin ? undefined : minDateTime)}
value={formState.endDate}
onChange={(event) => updateField("endDate", event.target.value)}
required
@@ -217,7 +262,7 @@ export function AdminTerminModal({
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : "Termin erstellen"}
{isPending ? "Speichern..." : existingTermin ? "Änderungen speichern" : "Termin erstellen"}
</Button>
</DialogFooter>
</form>
@@ -0,0 +1,774 @@
"use client";
import { useState, useMemo, useTransition } from "react";
import {
TerminType,
TerminStatus,
EventParticipationStatus,
DutyAssignmentStatus,
} from "@prisma/client";
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameMonth,
isSameDay,
addMonths,
subMonths,
} from "date-fns";
import { de } from "date-fns/locale";
import {
Search,
ListFilter,
ChevronLeft,
ChevronRight,
Calendar as CalendarIcon,
Clock,
MapPin,
AlertTriangle,
WashingMachine,
MoreHorizontal,
Trash2,
Edit,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { CalendarListItemDto } from "./termin-list";
import { TerminList } from "./termin-list";
import { MitbringselList } from "./mitbringsel-list";
import { AdminTerminModal } from "./admin-termin-modal";
import { deleteTerminAdmin } from "../actions";
const categories = [
{ label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" },
{ label: "Teamtag", type: TerminType.TEAMTAG, color: "var(--danger)" },
{ label: "Elternabend", type: TerminType.ELTERNABEND, color: "oklch(0.50 0.10 280)" },
{ label: "Fest", type: TerminType.KITA_FEST, color: "var(--good)" },
{ label: "Geburtstag", type: TerminType.GEBURTSTAG_INTERN, color: "oklch(0.50 0.10 320)" },
{ label: "Elterndienst", type: "elterndienst", color: "oklch(0.60 0.15 160)" },
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" },
];
export function CalendarOverviewClient({
termine,
userId,
isAdmin,
}: {
termine: CalendarListItemDto[];
userId: string;
isAdmin: boolean;
}) {
const [view, setView] = useState<"liste" | "monat" | "agenda">("liste");
const [searchQuery, setSearchQuery] = useState("");
const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set());
// Month navigation state
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
// Dialog state for day clicking in Month view
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
// Delete event states
const [deleteEventId, setDeleteEventId] = useState<string | null>(null);
const [deleteEventTitle, setDeleteEventTitle] = useState("");
const [isDeleting, startDeleteTransition] = useTransition();
const toggleType = (type: string) => {
setActiveTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
return next;
});
};
// Filter logic
const filteredEvents = useMemo(() => {
return termine.filter((item) => {
// 1. Search Query filter
const query = searchQuery.trim().toLowerCase();
if (query) {
const titleMatch = item.title.toLowerCase().includes(query);
const descMatch =
item.kind === "termin" && item.description
? item.description.toLowerCase().includes(query)
: false;
const familyMatch =
item.kind === "duty" && item.familyName
? item.familyName.toLowerCase().includes(query)
: false;
if (!titleMatch && !descMatch && !familyMatch) return false;
}
// 2. Category filter
if (activeTypes.size > 0) {
const itemType = item.kind === "duty" ? "elterndienst" : item.type;
if (!activeTypes.has(itemType)) return false;
}
return true;
});
}, [termine, searchQuery, activeTypes]);
// Check if an event falls on a specific day
const isEventOnDay = (item: CalendarListItemDto, day: Date) => {
const eventStart = new Date(item.startDate);
const eventEnd = new Date(item.endDate);
const dayTime = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
const startTime = new Date(eventStart.getFullYear(), eventStart.getMonth(), eventStart.getDate()).getTime();
const endTime = new Date(eventEnd.getFullYear(), eventEnd.getMonth(), eventEnd.getDate()).getTime();
return dayTime >= startTime && dayTime <= endTime;
};
// Get events for selected day (using all filtered events or all events? We use filtered to keep visual filters consistent!)
const selectedDayEvents = useMemo(() => {
if (!selectedDay) return [];
return filteredEvents.filter((item) => isEventOnDay(item, selectedDay));
}, [selectedDay, filteredEvents]);
// Handle event delete
const handleDeleteConfirm = () => {
if (!deleteEventId) return;
startDeleteTransition(async () => {
const res = await deleteTerminAdmin(deleteEventId);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Termin wurde erfolgreich gelöscht.");
setDeleteEventId(null);
// If deleting from the day dialog, update selectedDay if it becomes empty or keep it open
}
});
};
return (
<div className="space-y-5">
{/* Search and Filters Header */}
<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)]">
{(["liste", "monat", "agenda"] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
className={[
"h-9 px-4 text-[13px] font-semibold transition",
v !== "liste" ? "border-l border-[var(--hairline-soft)]" : "",
view === v
? "bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
].join(" ")}
>
{v.charAt(0).toUpperCase() + v.slice(1)}
</button>
))}
</div>
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
{/* Category chips */}
<div className="flex flex-wrap gap-1.5">
<span className="inline-flex h-9 items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-medium text-[var(--ink-soft)]">
<ListFilter className="h-3.5 w-3.5 [stroke-width:1.7]" />
Filter
</span>
{categories.map((cat) => {
const isActive = activeTypes.has(cat.type);
return (
<button
key={cat.label}
type="button"
onClick={() => toggleType(cat.type)}
className={[
"inline-flex h-9 items-center gap-1.5 rounded-full border px-3 text-[13px] font-medium transition",
isActive
? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
].join(" ")}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: cat.color }}
/>
{cat.label}
</button>
);
})}
</div>
<div className="relative min-w-56">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input
placeholder="Termine suchen"
className="h-9 pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
</div>
{/* Main View Contents */}
{view === "liste" && (
<TerminList termine={filteredEvents} userId={userId} isAdmin={isAdmin} />
)}
{view === "monat" && (
<MonthView
currentMonthDate={currentMonthDate}
setCurrentMonthDate={setCurrentMonthDate}
events={filteredEvents}
isEventOnDay={isEventOnDay}
onDayClick={(day) => setSelectedDay(day)}
/>
)}
{view === "agenda" && (
<AgendaView
events={filteredEvents}
isAdmin={isAdmin}
userId={userId}
onDeleteTermin={(id, title) => {
setDeleteEventId(id);
setDeleteEventTitle(title);
}}
/>
)}
{/* Day details Dialog for Month View */}
<Dialog open={!!selectedDay} onOpenChange={(open) => !open && setSelectedDay(null)}>
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-xl">
{selectedDay && format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })}
</DialogTitle>
<DialogDescription>
Übersicht aller Zuteilungen, Feste und Einträge an diesem Tag.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
{selectedDayEvents.length === 0 ? (
<p className="text-sm italic text-[var(--ink-soft)] text-center py-8">
Keine Termine oder Dienste an diesem Tag.
</p>
) : (
selectedDayEvents.map((item) => (
<div
key={item.id}
className="relative rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm"
>
{item.kind === "duty" ? (
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--good-soft)] text-[var(--good)]">
<WashingMachine className="h-5 w-5 [stroke-width:1.7]" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-semibold text-[15px] text-[var(--ink)] truncate">
{item.title}
</h4>
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)]">
Elterndienst
</Badge>
</div>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Familie: {item.familyName}
</p>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{format(new Date(item.startDate), "dd.MM.")} -{" "}
{format(new Date(item.endDate), "dd.MM.yyyy")}
</p>
{isAdmin && (
<div className="mt-3 flex gap-2">
<a href="/dashboard/admin/dienste">
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
Im Planer bearbeiten
</Button>
</a>
</div>
)}
</div>
</div>
) : (
<div className="flex items-start gap-3">
<div
className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
style={{ backgroundColor: getCategoryColor(item.type) }}
/>
<div className="pl-2 flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-semibold text-[15px] text-[var(--ink)] truncate">
{item.title}
</h4>
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold border border-[var(--hairline)]"
style={{
color: getCategoryColor(item.type),
borderColor: getCategoryColor(item.type) + "44",
backgroundColor: getCategoryColor(item.type) + "11",
}}
>
{getTypeLabel(item.type)}
</span>
</div>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{item.allDay
? "Ganztägig"
: `${format(new Date(item.startDate), "HH:mm")} - ${format(
new Date(item.endDate),
"HH:mm",
)}`}
</p>
{item.description && (
<p className="mt-2 text-sm text-[var(--ink-soft)] whitespace-pre-wrap leading-relaxed">
{item.description}
</p>
)}
{item.participantInfo && item.showParticipantInfo && (
<div className="mt-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-3 text-xs text-[var(--ink)]">
<div className="mb-0.5 flex items-center gap-1.5 font-medium">
<AlertTriangle className="h-3.5 w-3.5 text-[var(--warn)]" />
Hinweis für Teilnehmer
</div>
<p className="whitespace-pre-wrap">{item.participantInfo}</p>
</div>
)}
{item.mitbringselListEnabled && (
<div className="mt-3">
<MitbringselList
terminId={item.id}
items={item.mitbringselItems || []}
currentUserId={userId}
isAdmin={isAdmin}
/>
</div>
)}
{isAdmin && (
<div className="mt-3 flex gap-2 border-t border-[var(--hairline-soft)] pt-3">
<AdminTerminModal
existingTermin={item}
triggerLabel="Bearbeiten"
triggerVariant="outline"
triggerClassName="h-7 text-xs gap-1 px-2.5"
/>
<Button
size="sm"
variant="outline"
className="h-7 text-xs text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)] gap-1 px-2.5 border-[var(--hairline)]"
onClick={() => {
setDeleteEventId(item.id);
setDeleteEventTitle(item.title);
}}
>
<Trash2 className="h-3 w-3" />
Löschen
</Button>
</div>
)}
</div>
</div>
)}
</div>
))
)}
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteEventId} onOpenChange={(open) => !open && setDeleteEventId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
<DialogDescription>
Möchtest du den Termin &ldquo;{deleteEventTitle}&rdquo; wirklich löschen? Alle
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeleteEventId(null)}
disabled={isDeleting}
>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={isDeleting}
>
{isDeleting ? "Wird gelöscht..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// =====================================================================
// Month Grid Component
// =====================================================================
function MonthView({
currentMonthDate,
setCurrentMonthDate,
events,
isEventOnDay,
onDayClick,
}: {
currentMonthDate: Date;
setCurrentMonthDate: (date: Date) => void;
events: CalendarListItemDto[];
isEventOnDay: (item: CalendarListItemDto, day: Date) => boolean;
onDayClick: (day: Date) => void;
}) {
const monthStart = startOfMonth(currentMonthDate);
const monthEnd = endOfMonth(monthStart);
const startDate = startOfWeek(monthStart, { weekStartsOn: 1 });
const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 });
const days = useMemo(() => {
return eachDayOfInterval({ start: startDate, end: endDate });
}, [startDate, endDate]);
const weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
return (
<Card className="overflow-hidden border border-[var(--hairline)] shadow-sm">
<div className="flex items-center justify-between border-b border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-3">
<h3 className="text-[16px] font-semibold text-[var(--ink)]">
{format(currentMonthDate, "MMMM yyyy", { locale: de })}
</h3>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setCurrentMonthDate(subMonths(currentMonthDate, 1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => setCurrentMonthDate(new Date())}
className="h-8 text-xs font-semibold px-2.5"
>
Heute
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setCurrentMonthDate(addMonths(currentMonthDate, 1))}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-7 border-b border-[var(--hairline-soft)] bg-[var(--surface-3)]/20 py-2 text-center text-xs font-semibold text-[var(--ink-soft)]">
{weekdays.map((day) => (
<div key={day}>{day}</div>
))}
</div>
<div className="grid grid-cols-7 bg-[var(--hairline-soft)] gap-[1px]">
{days.map((day, idx) => {
const isCurrentMonth = isSameMonth(day, currentMonthDate);
const isToday = isSameDay(day, new Date());
const dayEvents = events.filter((item) => isEventOnDay(item, day));
return (
<div
key={day.toString() + idx}
onClick={() => onDayClick(day)}
className={[
"min-h-[105px] bg-[var(--surface)] p-2 transition-colors hover:bg-[var(--surface-2)] cursor-pointer flex flex-col justify-between",
!isCurrentMonth ? "bg-[var(--surface-3)]/30 text-[var(--ink-muted)] opacity-60" : "text-[var(--ink)]",
].join(" ")}
>
{/* Day number cell top */}
<div className="flex items-center justify-between">
<span
className={[
"text-xs font-semibold flex h-6 w-6 items-center justify-center rounded-full",
isToday
? "bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)] shadow-sm"
: "",
].join(" ")}
>
{format(day, "d")}
</span>
{dayEvents.length > 0 && (
<span className="text-[10px] font-semibold text-[var(--ink-muted)] tabular-nums">
{dayEvents.length} {dayEvents.length === 1 ? "Eintrag" : "Einträge"}
</span>
)}
</div>
{/* Event indicators/snippets */}
<div className="mt-2 space-y-1 flex-1 flex flex-col justify-end">
{dayEvents.slice(0, 3).map((item) => {
const color =
item.kind === "duty"
? "oklch(0.60 0.15 160)"
: getCategoryColor(item.type);
return (
<div
key={item.id}
className="group relative flex items-center gap-1 rounded bg-[var(--surface-2)] px-1.5 py-0.5 text-[10px] font-medium border border-[var(--hairline-soft)]"
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="truncate text-[var(--ink-soft)] max-w-full">
{item.title}
</span>
</div>
);
})}
{dayEvents.length > 3 && (
<div className="text-[9px] font-semibold text-[var(--accent-deep)] pl-1.5">
+ {dayEvents.length - 3} weitere
</div>
)}
</div>
</div>
);
})}
</div>
</Card>
);
}
// =====================================================================
// Agenda Timeline View Component
// =====================================================================
function AgendaView({
events,
isAdmin,
userId,
onDeleteTermin,
}: {
events: CalendarListItemDto[];
isAdmin: boolean;
userId: string;
onDeleteTermin: (id: string, title: string) => void;
}) {
const grouped = useMemo(() => {
const groups = new Map<string, { date: Date; items: CalendarListItemDto[] }>();
events.forEach((item) => {
const key = format(new Date(item.startDate), "yyyy-MM-dd");
const group = groups.get(key) ?? { date: new Date(item.startDate), items: [] };
group.items.push(item);
groups.set(key, group);
});
return Array.from(groups.values()).sort((a, b) => a.date.getTime() - b.date.getTime());
}, [events]);
if (events.length === 0) {
return (
<Card>
<CardContent className="flex min-h-[220px] flex-col items-center justify-center text-center p-6">
<CalendarIcon className="h-8 w-8 text-[var(--ink-muted)] mb-2" />
<h3 className="text-[16px] font-semibold text-[var(--ink)]">
Keine Termine oder Dienste gefunden
</h3>
<p className="text-sm text-[var(--ink-soft)] mt-1 max-w-xs">
Passe deine Filter an oder suche nach einem anderen Begriff.
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6 pl-4 border-l-2 border-[var(--hairline)]">
{grouped.map((group) => (
<div key={group.date.toString()} className="relative grid grid-cols-[110px_1fr] gap-4 items-start">
{/* Timeline marker */}
<div className="absolute -left-[21px] top-1.5 h-3.5 w-3.5 rounded-full border-2 border-[var(--accent-deep)] bg-[var(--surface)]" />
{/* Date Label on left */}
<div className="pt-0.5">
<div className="text-[14px] font-bold text-[var(--ink)] leading-none">
{format(group.date, "d. MMMM", { locale: de })}
</div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-[var(--ink-muted)] mt-1">
{format(group.date, "EEEE", { locale: de })}
</div>
</div>
{/* Items stack on right */}
<div className="space-y-3">
{group.items.map((item) => {
const isTermin = item.kind === "termin";
const color = isTermin ? getCategoryColor(item.type) : "oklch(0.60 0.15 160)";
return (
<div
key={item.id}
className="group relative flex items-start justify-between gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm transition-shadow hover:shadow-md"
>
<div className="flex items-start gap-3 min-w-0">
<div
className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
style={{ backgroundColor: color }}
/>
<div className="pl-1 min-w-0">
<div className="flex items-center flex-wrap gap-2">
<h4 className="font-bold text-[15px] text-[var(--ink)] truncate">
{item.title}
</h4>
{isTermin ? (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold border"
style={{
color,
borderColor: color + "44",
backgroundColor: color + "11",
}}
>
{getTypeLabel(item.type)}
</span>
) : (
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]">
Elterndienst
</Badge>
)}
{isTermin && item.status === TerminStatus.PENDING && (
<Badge variant="warning" className="text-[10px]">Ausstehend</Badge>
)}
</div>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
{isTermin
? item.allDay
? "Ganztägig"
: `${format(new Date(item.startDate), "HH:mm")} - ${format(
new Date(item.endDate),
"HH:mm",
)}`
: `${format(new Date(item.startDate), "dd.MM.")} - ${format(
new Date(item.endDate),
"dd.MM.yyyy",
)}`}
{isTermin && <MapPin className="ml-1 h-3.5 w-3.5" />}
{isTermin ? "Kita" : `Familie: ${item.familyName}`}
</p>
{isTermin && item.description && (
<p className="mt-2 text-sm text-[var(--ink-soft)] line-clamp-2 leading-relaxed">
{item.description}
</p>
)}
</div>
</div>
{/* Actions for admins */}
{isAdmin && isTermin && (
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<AdminTerminModal
existingTermin={item}
triggerLabel=""
triggerVariant="ghost"
triggerClassName="h-8 w-8 p-0"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => onDeleteTermin(item.id, item.title)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
})}
</div>
</div>
))}
</div>
);
}
// =====================================================================
// Helper functions for categories/labels
// =====================================================================
function getCategoryColor(type: TerminType): string {
switch (type) {
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "var(--good)";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "var(--danger)";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "oklch(0.50 0.10 320)";
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "oklch(0.50 0.10 280)";
default:
return "var(--ink-muted)";
}
}
function getTypeLabel(type: TerminType): string {
switch (type) {
case TerminType.KITA_FEST:
return "Kita-Fest";
case TerminType.MITMACH_TAG:
return "Mitmach-Tag";
case TerminType.SCHLIESSTAG:
return "Schließtag";
case TerminType.TEAMTAG:
return "Teamtag";
case TerminType.ELTERNABEND:
return "Elternabend";
case TerminType.MITGLIEDERVERSAMMLUNG:
return "Mitgliederversammlung";
case TerminType.ELTERNCAFE:
return "Elterncafe";
case TerminType.GEBURTSTAG_INTERN:
return "Geburtstag";
case TerminType.GEBURTSTAG_EXTERN:
return "Raumanfrage";
default:
return "Sonstiges";
}
}
@@ -1,5 +1,6 @@
"use client";
import { useState, useTransition } from "react";
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
@@ -10,7 +11,9 @@ import {
MapPin,
MoreHorizontal,
WashingMachine,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -22,9 +25,12 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from "@/components/ui/dialog";
import { AdminTerminModal } from "./admin-termin-modal";
import { TerminRequestModal } from "./termin-request-modal";
import { MitbringselList } from "./mitbringsel-list";
import { deleteTerminAdmin } from "../actions";
export type TerminListItemDto = {
kind: "termin";
@@ -62,6 +68,7 @@ export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto;
export function TerminList({
termine,
userId,
isAdmin,
}: {
termine: CalendarListItemDto[];
@@ -115,9 +122,9 @@ export function TerminList({
<div className="divide-y divide-[var(--hairline-soft)]">
{group.items.map((item) =>
item.kind === "duty" ? (
<DutyRow key={`duty-${item.id}`} duty={item} />
<DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} />
) : (
<TerminRow key={`termin-${item.id}`} termin={item} />
<TerminRow key={`termin-${item.id}`} termin={item} userId={userId} isAdmin={isAdmin} />
),
)}
</div>
@@ -128,8 +135,9 @@ export function TerminList({
);
}
function DutyRow({ duty }: { duty: DutyCalendarItemDto }) {
function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolean }) {
const color = "var(--good)";
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
@@ -147,20 +155,71 @@ function DutyRow({ duty }: { duty: DutyCalendarItemDto }) {
{duty.familyName}
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>Elterndienst</CategoryPill>
<Button variant="ghost" size="icon" aria-label="Mehr Optionen">
{isAdmin ? (
<div className="relative">
<Button
variant="ghost"
size="icon"
aria-label="Mehr Optionen"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
>
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<a
href="/dashboard/admin/dienste"
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]"
onClick={() => setMenuOpen(false)}
>
Dienst im Planer bearbeiten
</a>
</div>
</>
)}
</div>
) : (
<div className="w-9 h-9" />
)}
</div>
</div>
);
}
function TerminRow({ termin }: { termin: TerminListItemDto }) {
function TerminRow({
termin,
userId,
isAdmin,
}: {
termin: TerminListItemDto;
userId: string;
isAdmin: boolean;
}) {
const color = getTypeColor(termin.type);
const [menuOpen, setMenuOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [isDeleting, startDeleteTransition] = useTransition();
const handleDeleteConfirm = () => {
startDeleteTransition(async () => {
const res = await deleteTerminAdmin(termin.id);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Termin wurde erfolgreich gelöscht.");
setDeleteOpen(false);
}
});
};
return (
<>
<Dialog>
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
@@ -181,20 +240,58 @@ function TerminRow({ termin }: { termin: TerminListItemDto }) {
Kita
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
Details
</Button>
</DialogTrigger>
<Button variant="ghost" size="icon" aria-label="Mehr Optionen">
{isAdmin ? (
<div className="relative">
<Button
variant="ghost"
size="icon"
aria-label="Mehr Optionen"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
>
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 mt-1 w-48 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<AdminTerminModal
existingTermin={termin}
triggerLabel="Bearbeiten"
triggerVariant="ghost"
triggerClassName="flex w-full items-center justify-start rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)] font-normal h-auto bg-transparent border-0"
/>
<button
type="button"
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--danger)] hover:bg-[var(--danger-soft)] text-left font-normal"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDeleteOpen(true);
}}
>
Löschen
</button>
</div>
</>
)}
</div>
) : (
<div className="w-9 h-9" />
)}
</div>
</div>
<DialogContent>
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{termin.title}</DialogTitle>
<DialogDescription>
@@ -209,7 +306,9 @@ function TerminRow({ termin }: { termin: TerminListItemDto }) {
</DialogHeader>
<div className="grid gap-4">
<div className="flex">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
</div>
{termin.description ? (
<p className="whitespace-pre-wrap text-sm leading-6 text-[var(--ink-soft)]">
@@ -230,9 +329,50 @@ function TerminRow({ termin }: { termin: TerminListItemDto }) {
<p className="whitespace-pre-wrap">{termin.participantInfo}</p>
</div>
)}
{termin.mitbringselListEnabled && (
<div className="mt-3 border-t border-[var(--hairline-soft)] pt-3">
<MitbringselList
terminId={termin.id}
items={termin.mitbringselItems || []}
currentUserId={userId}
isAdmin={isAdmin}
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
<DialogDescription>
Möchtest du den Termin &ldquo;{termin.title}&rdquo; wirklich löschen? Alle
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeleteOpen(false)}
disabled={isDeleting}
>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={isDeleting}
>
{isDeleting ? "Wird gelöscht..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+25
View File
@@ -335,3 +335,28 @@ export async function deleteMitbringsel(itemId: string) {
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function deleteTerminAdmin(terminId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.termin.delete({
where: {
id: terminId,
kitaId,
},
});
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Löschen des Termins:", error);
return { error: "Termin konnte nicht gelöscht werden." };
}
}
+6 -94
View File
@@ -5,15 +5,15 @@ import {
TerminType,
UserRole,
} from "@prisma/client";
import { HelpCircle, ListFilter, Search } from "lucide-react";
import { HelpCircle } from "lucide-react";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Input } from "@/components/ui/input";
import { AdminTerminModal } from "./_components/admin-termin-modal";
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
import { TerminList, type CalendarListItemDto } from "./_components/termin-list";
import { type CalendarListItemDto } from "./_components/termin-list";
import { TerminRequestModal } from "./_components/termin-request-modal";
import { CalendarOverviewClient } from "./_components/calendar-overview-client";
export const metadata = { title: "Terminkalender · Kita-Planer" };
@@ -253,7 +253,7 @@ export default async function KalenderPage({
<div>
{currentTab === "übersicht" ? (
<CalendarOverview
<CalendarOverviewClient
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
@@ -261,7 +261,7 @@ export default async function KalenderPage({
) : currentTab === "anfragen" ? (
<PendingAnfragen termine={allPendingTermine} />
) : (
<CalendarOverview
<CalendarOverviewClient
termine={[]}
userId={session.user.id}
isAdmin={isAdmin}
@@ -270,7 +270,7 @@ export default async function KalenderPage({
</div>
</div>
) : (
<CalendarOverview
<CalendarOverviewClient
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
@@ -280,49 +280,7 @@ export default async function KalenderPage({
);
}
function CalendarOverview({
termine,
userId,
isAdmin,
}: {
termine: CalendarListItemDto[];
userId: string;
isAdmin: boolean;
}) {
return (
<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)]">
{["Liste", "Monat", "Agenda"].map((label, index) => (
<button
key={label}
type="button"
className={[
"h-9 px-4 text-[13px] font-semibold transition",
index > 0 ? "border-l border-[var(--hairline-soft)]" : "",
index === 0
? "bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
].join(" ")}
>
{label}
</button>
))}
</div>
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
<CategoryChips />
<div className="relative min-w-56">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input placeholder="Termine suchen" className="h-9 pl-9" />
</div>
</div>
</div>
<TerminList termine={termine} userId={userId} isAdmin={isAdmin} />
</div>
);
}
function TabBar({
currentTab,
@@ -400,50 +358,4 @@ function TabLink({
);
}
function CategoryChips() {
const categories = [
["Schließtag", TerminType.SCHLIESSTAG],
["Teamtag", TerminType.TEAMTAG],
["Elternabend", TerminType.ELTERNABEND],
["Fest", TerminType.KITA_FEST],
["Geburtstag", TerminType.GEBURTSTAG_INTERN],
["Sonstiges", TerminType.SONSTIGES],
] as const;
return (
<div className="flex flex-wrap gap-1.5">
<span className="inline-flex h-9 items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-medium text-[var(--ink-soft)]">
<ListFilter className="h-3.5 w-3.5 [stroke-width:1.7]" />
Filter
</span>
{categories.map(([label, type]) => (
<span
key={label}
className="inline-flex h-9 items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-medium text-[var(--ink-soft)]"
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: getCategoryColor(type) }}
/>
{label}
</span>
))}
</div>
);
}
function getCategoryColor(type: TerminType) {
switch (type) {
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "var(--danger)";
case TerminType.ELTERNABEND:
return "oklch(0.50 0.10 280)";
case TerminType.KITA_FEST:
return "var(--good)";
case TerminType.GEBURTSTAG_INTERN:
return "oklch(0.50 0.10 320)";
default:
return "var(--ink-muted)";
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ export default async function DashboardLayout({
return (
<div className="grid min-h-screen grid-cols-[256px_minmax(0,1fr)] bg-[var(--bg)] max-[760px]:grid-cols-1">
{/* ── Sidebar ───────────────────────────────────────────────────── */}
<aside className="sticky top-0 flex h-screen shrink-0 flex-col border-r border-[var(--hairline)] bg-[var(--surface)] max-[760px]:relative max-[760px]:h-auto">
<aside className="print:hidden sticky top-0 flex h-screen shrink-0 flex-col border-r border-[var(--hairline)] bg-[var(--surface)] max-[760px]:relative max-[760px]:h-auto">
{/* Logo / Kita-Name */}
<div className="flex h-[82px] items-center gap-3 border-b border-[var(--hairline-soft)] px-5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-deep)] text-[13px] font-bold text-white">
@@ -0,0 +1,132 @@
"use client";
import { useState, useTransition, type FormEvent } from "react";
import { SlidersHorizontal } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateKitaNotdienstSettingsAction } from "../actions";
interface SettingsModalProps {
initialMinPerChild: number;
initialReminderDays: number;
}
export function SettingsModal({
initialMinPerChild,
initialReminderDays,
}: SettingsModalProps) {
const [open, setOpen] = useState(false);
const [minPerChild, setMinPerChild] = useState(initialMinPerChild);
const [reminderDays, setReminderDays] = useState(initialReminderDays);
const [isPending, startTransition] = useTransition();
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (minPerChild < 0 || reminderDays < 0) {
toast.error("Werte dürfen nicht negativ sein.");
return;
}
startTransition(async () => {
const res = await updateKitaNotdienstSettingsAction(
minPerChild,
reminderDays
);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Regeln erfolgreich gespeichert!");
setOpen(false);
}
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Einstellungen
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Notdienst-Einstellungen</DialogTitle>
<DialogDescription>
Passe die standardmäßigen Regeln für die Notdienst-Verteilung in deiner Kita an.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="minPerChild">
Mindesteinteilungen pro Kind/Monat
</Label>
<Input
id="minPerChild"
name="minPerChild"
type="number"
min="0"
required
value={minPerChild}
onChange={(e) => setMinPerChild(parseInt(e.target.value, 10))}
/>
<p className="text-xs text-[var(--ink-muted)]">
Bestimmt das Richtmaß der automatischen Verteilung für Kinder im jeweiligen Monat.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="reminderDays">
Erinnerungsfrist (Tage vor Monatsende)
</Label>
<Input
id="reminderDays"
name="reminderDays"
type="number"
min="0"
required
value={reminderDays}
onChange={(e) => setReminderDays(parseInt(e.target.value, 10))}
/>
<p className="text-xs text-[var(--ink-muted)]">
Frist, nach der Eltern automatisch per Mail an offene Verfügbarkeiten erinnert werden.
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => {
setMinPerChild(initialMinPerChild);
setReminderDays(initialReminderDays);
setOpen(false);
}}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : "Regeln speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+187 -2
View File
@@ -1,21 +1,35 @@
"use server";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { NotdienstPlanStatus, UserRole } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
// =====================================================================
// /dashboard/notdienst/plan · Server Actions
// =====================================================================
export async function generatePlanAction() {
export async function generatePlanAction(year?: number, month?: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const { targetYear, targetMonth } = getTargetMonthData();
let targetYear: number;
let targetMonth: number;
if (year !== undefined && month !== undefined) {
targetYear = year;
targetMonth = month;
} else {
const data = getTargetMonthData();
targetYear = data.targetYear;
targetMonth = data.targetMonth;
}
// Prüfen, ob schon ein Plan existiert
const existingPlan = await prisma.notdienstPlan.findUnique({
@@ -193,3 +207,174 @@ export async function updateAssignmentAction(
};
}
}
export async function updateKitaNotdienstSettingsAction(
minPerChild: number,
reminderDays: number,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.kita.update({
where: { id: kitaId },
data: {
notdienstMinPerChildPerMonth: minPerChild,
notdienstReminderDaysBefore: reminderDays,
},
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error) {
console.error(error);
return { error: "Fehler beim Speichern der Einstellungen." };
}
}
export async function sendNotdienstRemindersAction(year: number, month: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
}
try {
// 1. Get Kita Name
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (!kita) {
return { error: "Kita nicht gefunden." };
}
// 2. Fetch all families in this Kita with at least one active child and retrieve parents
const families = await prisma.family.findMany({
where: {
kitaId,
children: { some: { active: true } },
},
include: {
users: {
where: { role: UserRole.ELTERN },
},
},
});
// 3. For target month, find availability entries
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 1);
const respondedFamilies = await prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: monthStart,
lt: monthEnd,
},
},
select: {
child: {
select: {
familyId: true,
},
},
},
});
const respondedFamilyIds = new Set(respondedFamilies.map((a) => a.child.familyId));
// 4. Find open families (not responded yet)
const openFamilies = families.filter((f) => !respondedFamilyIds.has(f.id));
if (openFamilies.length === 0) {
return { success: true, count: 0 };
}
// 5. Build emails list and send
const monthDate = new Date(year, month - 1, 1);
const monthLabel = format(monthDate, "MMMM yyyy", { locale: de });
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`;
let sentCount = 0;
for (const family of openFamilies) {
for (const parent of family.users) {
if (!parent.email) continue;
const result = await sendAppEmail({
to: parent.email,
subject: `Erinnerung: Bitte Verfügbarkeiten für den Notdienst ${monthLabel} eintragen`,
react: createElement(NotdienstAvailabilityReminderEmail, {
parentName: `${parent.firstName} ${parent.lastName}`,
kitaName: kita.name,
monthLabel,
link,
}),
});
if (result.success) {
sentCount++;
} else {
console.error(`E-Mail konnte nicht an ${parent.email} gesendet werden:`, result.error);
}
}
}
return { success: true, count: sentCount };
} catch (error) {
console.error("Fehler beim Versenden der Erinnerungen:", error);
return { error: "Fehler beim Versenden der Erinnerungen." };
}
}
export async function resetPlanAction(planId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.$transaction(async (tx) => {
// Ensure the plan belongs to this kita and is draft
const plan = await tx.notdienstPlan.findFirst({
where: { id: planId, kitaId },
});
if (!plan) {
throw new Error("Plan nicht gefunden.");
}
if (plan.status !== NotdienstPlanStatus.DRAFT) {
throw new Error("Nur Pläne im Entwurfsstatus können zurückgesetzt werden.");
}
// Delete all assignments
await tx.notdienstAssignment.deleteMany({
where: { planId },
});
// Delete the plan itself
await tx.notdienstPlan.delete({
where: { id: planId },
});
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error) {
console.error("Fehler beim Zurücksetzen des Plans:", error);
return { error: error instanceof Error ? error.message : "Fehler beim Zurücksetzen des Plans." };
}
}
+45 -16
View File
@@ -1,25 +1,39 @@
import Link from "next/link";
import { addMonths, getYear, getMonth, format } from "date-fns";
import { de } from "date-fns/locale";
import { TerminType, UserRole } from "@prisma/client";
import { ChevronLeft, ChevronRight, SlidersHorizontal } from "lucide-react";
import { ChevronLeft, ChevronRight } 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 { getWorkingDaysOfMonth } from "@/lib/date-utils";
import { PlanView } from "./plan-view";
import { SettingsModal } from "./_components/settings-modal";
export const metadata = { title: "Plan-Generierung · Kita-Planer" };
export default async function PlanungsZentralePage() {
export default async function PlanungsZentralePage({
searchParams,
}: {
searchParams: Promise<{ year?: string; month?: string }>;
}) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const { targetYear, targetMonth, monthName } = getTargetMonthData();
const resolvedSearchParams = await searchParams;
const now = new Date();
const nextMonth = addMonths(now, 1);
const targetYear = resolvedSearchParams.year ? parseInt(resolvedSearchParams.year, 10) : getYear(nextMonth);
const targetMonth = resolvedSearchParams.month ? parseInt(resolvedSearchParams.month, 10) : (getMonth(nextMonth) + 1);
const monthDate = new Date(targetYear, targetMonth - 1, 1);
const monthName = format(monthDate, "MMMM", { locale: de });
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([
const [plan, families, availabilities, closureCount, kita] = await Promise.all([
prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
@@ -79,6 +93,13 @@ export default async function PlanungsZentralePage() {
},
},
}),
prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: {
notdienstMinPerChildPerMonth: true,
notdienstReminderDaysBefore: true,
},
}),
]);
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
@@ -181,22 +202,28 @@ export default async function PlanungsZentralePage() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="print:hidden 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)]">
<Link
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, -1))}&month=${getMonth(addMonths(monthDate, -1)) + 1}`}
className="inline-flex h-9 w-9 items-center justify-center border-r border-[var(--hairline-soft)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)] transition"
>
<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)]">
</Link>
<div className="flex h-9 min-w-36 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)]">
<Link
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, 1))}&month=${getMonth(addMonths(monthDate, 1)) + 1}`}
className="inline-flex h-9 w-9 items-center justify-center border-l border-[var(--hairline-soft)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)] transition"
>
<ChevronRight className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</Link>
</div>
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Einstellungen
</Button>
<SettingsModal
initialMinPerChild={kita.notdienstMinPerChildPerMonth}
initialReminderDays={kita.notdienstReminderDaysBefore}
/>
</div>
</header>
@@ -214,6 +241,8 @@ export default async function PlanungsZentralePage() {
coverageFactor={coverageFactor}
closureCount={closureCount}
monthName={monthName}
year={targetYear}
month={targetMonth}
/>
</div>
);
+88 -6
View File
@@ -18,10 +18,20 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
generatePlanAction,
publishPlanAction,
updateAssignmentAction,
sendNotdienstRemindersAction,
resetPlanAction,
} from "./actions";
interface PlanViewProps {
@@ -54,6 +64,8 @@ interface PlanViewProps {
coverageFactor: number;
closureCount: number;
monthName: string;
year: number;
month: number;
}
export function PlanView({
@@ -70,16 +82,19 @@ export function PlanView({
coverageFactor,
closureCount,
monthName,
year,
month,
}: PlanViewProps) {
const [isPending, startTransition] = useTransition();
const [activeTab, setActiveTab] = useState<"preview" | "manual">("preview");
const [resetDialogOpen, setResetDialogOpen] = useState(false);
const responsePercent =
totalFamilies > 0 ? Math.round((respondedFamilies / totalFamilies) * 100) : 0;
const handleGenerate = () => {
startTransition(async () => {
const result = await generatePlanAction();
const result = await generatePlanAction(year, month);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
@@ -100,6 +115,30 @@ export function PlanView({
});
};
const handleSendReminders = () => {
startTransition(async () => {
const result = await sendNotdienstRemindersAction(year, month);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else if (result.success) {
toast.success(`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`);
}
});
};
const handleReset = () => {
if (!planId) return;
startTransition(async () => {
const result = await resetPlanAction(planId);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Plan wurde erfolgreich zurückgesetzt.");
setResetDialogOpen(false);
}
});
};
const handleAssignmentChange = (date: string, newChildId: string) => {
if (!planId || status !== "DRAFT") return;
const valueToSet = newChildId === "empty" ? null : newChildId;
@@ -132,8 +171,12 @@ export function PlanView({
<Button disabled={isPending} onClick={handleGenerate}>
Trotzdem generieren
</Button>
<Button variant="ghost">
<Button variant="ghost" onClick={handleSendReminders} disabled={isPending}>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : (
<Send className="h-4 w-4 [stroke-width:1.7]" />
)}
Erinnerungen verschicken
</Button>
</div>
@@ -199,8 +242,16 @@ export function PlanView({
</div>
<div className="border-t border-[var(--hairline-soft)] px-6 py-4">
<Button variant="ghost" disabled={openFamilies === 0}>
<Button
variant="ghost"
onClick={handleSendReminders}
disabled={isPending || openFamilies === 0}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : (
<Send className="h-4 w-4 [stroke-width:1.7]" />
)}
Erinnerung an offene senden ({openFamilies})
</Button>
</div>
@@ -282,7 +333,7 @@ export function PlanView({
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="print:hidden 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"}
@@ -309,13 +360,15 @@ export function PlanView({
Plan veröffentlichen
</Button>
)}
<Button variant="ghost">
<Button variant="ghost" onClick={() => window.print()}>
<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)]"
onClick={() => setResetDialogOpen(true)}
disabled={isPending}
>
<RotateCcw className="h-4 w-4 [stroke-width:1.7]" />
Plan zurücksetzen
@@ -323,7 +376,7 @@ export function PlanView({
</div>
</div>
<Card className="flex items-center justify-between p-4">
<Card className="print:hidden flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<Badge variant={isDraft ? "warning" : "success"}>
{isDraft ? "Entwurf" : "Veröffentlicht"}
@@ -347,6 +400,35 @@ export function PlanView({
onAssignmentChange={handleAssignmentChange}
/>
)}
<Dialog open={resetDialogOpen} onOpenChange={setResetDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Plan zurücksetzen?</DialogTitle>
<DialogDescription>
Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für diesen Monat gehen unwiderruflich verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setResetDialogOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button
type="button"
variant="destructive"
onClick={handleReset}
disabled={isPending}
>
{isPending ? "Zurücksetzen..." : "Ja, Plan zurücksetzen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,166 @@
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
import { baseBodyStyle, colors, fontStack } from "./_styles";
type NotdienstAvailabilityReminderEmailProps = {
parentName: string;
kitaName: string;
monthLabel: string;
link: string;
};
export function NotdienstAvailabilityReminderEmail({
parentName,
kitaName,
monthLabel,
link,
}: NotdienstAvailabilityReminderEmailProps) {
return (
<Html lang="de">
<Head />
<Preview>Erinnerung: Verfügbarkeiten für den Notdienst {monthLabel} eintragen</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.card}>
<Section style={styles.brandBar}>
<Text style={styles.brand}>Kita-Planer</Text>
</Section>
<Text style={styles.kicker}>Erinnerung Notdienst-Planung</Text>
<Heading style={styles.heading}>Verfügbarkeiten für {monthLabel} eintragen</Heading>
<Text style={styles.lead}>
Hallo {parentName},
</Text>
<Text style={styles.text}>
für den Monat <strong>{monthLabel}</strong> läuft aktuell die Planung des Notdienstes in der Kita <strong>{kitaName}</strong>.
Bitte trage deine Verfügbarkeiten im Planer ein, damit wir die Tage fair verteilen können.
</Text>
<Button href={link} style={styles.button}>
Verfügbarkeiten eintragen
</Button>
<Text style={styles.fallbackText}>
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
</Text>
<Text style={styles.linkText}>{link}</Text>
</Section>
<Text style={styles.footer}>
Diese Nachricht wurde automatisch vom Kita-Planer deiner Kita versendet.
</Text>
</Container>
</Body>
</Html>
);
}
const styles = {
body: baseBodyStyle,
container: {
width: "100%",
maxWidth: "600px",
margin: "0 auto",
padding: "32px 20px",
fontFamily: fontStack,
},
card: {
padding: "0 28px 28px",
backgroundColor: colors.cardBg,
borderRadius: "10px",
border: `1px solid ${colors.border}`,
boxShadow: "0 10px 26px rgba(31, 59, 45, 0.08)",
overflow: "hidden",
fontFamily: fontStack,
},
brandBar: {
margin: "0 -28px 28px",
padding: "14px 28px",
backgroundColor: colors.brandGreen,
fontFamily: fontStack,
},
brand: {
margin: 0,
color: colors.eyebrow,
fontSize: "12px",
fontWeight: 800,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
fontFamily: fontStack,
},
kicker: {
margin: "0 0 10px",
color: colors.brandGreenAccent,
fontSize: "12px",
fontWeight: 800,
letterSpacing: "0.06em",
textTransform: "uppercase" as const,
fontFamily: fontStack,
},
heading: {
margin: "0 0 14px",
color: colors.textPrimary,
fontSize: "28px",
lineHeight: "34px",
fontWeight: 800,
fontFamily: fontStack,
},
lead: {
margin: "0 0 22px",
color: colors.textBody,
fontSize: "16px",
lineHeight: "24px",
fontFamily: fontStack,
},
text: {
margin: "0 0 24px",
color: colors.textBody,
fontSize: "15px",
lineHeight: "24px",
fontFamily: fontStack,
},
button: {
display: "inline-block",
padding: "14px 20px",
backgroundColor: colors.brandGreen,
color: "#ffffff",
borderRadius: "6px",
fontSize: "15px",
fontWeight: 700,
textDecoration: "none",
fontFamily: fontStack,
},
fallbackText: {
margin: "28px 0 8px",
color: colors.textMuted,
fontSize: "13px",
lineHeight: "20px",
fontFamily: fontStack,
},
linkText: {
margin: 0,
color: colors.link,
fontSize: "13px",
lineHeight: "20px",
wordBreak: "break-all" as const,
fontFamily: fontStack,
},
footer: {
margin: "20px 0 0",
color: colors.textFaint,
fontSize: "12px",
lineHeight: "18px",
textAlign: "center" as const,
fontFamily: fontStack,
},
};