Apply linen design system across app

This commit is contained in:
t.indorf
2026-05-20 22:47:52 +02:00
parent 99ff978116
commit 1c5d2f10ac
56 changed files with 4698 additions and 2159 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

+197 -132
View File
@@ -3,10 +3,10 @@
import Link from "next/link";
import { useMemo, useTransition } from "react";
import { AbsenceReason } from "@prisma/client";
import { Baby, CalendarX2, Loader2, Trash2 } from "lucide-react";
import { Baby, CalendarX2, Check, ChevronRight, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { deleteAbsence, reportAbsence } from "@/actions/absences";
import { reportAbsence } from "@/actions/absences";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -43,7 +43,6 @@ type AbsenceListItem = {
export function AbsenceCard({
childOptions,
absences,
today,
}: {
childOptions: ChildOption[];
@@ -69,39 +68,32 @@ export function AbsenceCard({
return;
}
toast.success("Abwesenheit eingetragen.");
});
}
function handleDelete(absenceId: string) {
startTransition(async () => {
const result = await deleteAbsence(absenceId);
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Abwesenheit storniert.");
toast.success("Abwesenheit gemeldet. Die Kita ist informiert.");
});
}
return (
<Card className="mb-8 border-amber-200 bg-amber-50/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<CalendarX2 className="h-5 w-5 text-amber-700" />
Kind abmelden
</CardTitle>
<CardDescription>
Melde dein Kind für ganze Tage ab. Die Kita sieht die Meldung direkt
in der Tagesübersicht.
<Card variant="featured" className="min-w-0">
<CardHeader className="pb-5">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<CalendarX2 className="h-4 w-4 [stroke-width:1.7]" />
</div>
<div>
<CardTitle>Kind abmelden</CardTitle>
<CardDescription className="mt-2 max-w-xl">
Melde dein Kind für ganze Tage ab. Die Kita sieht die Meldung
sofort in der Tagesübersicht keine zusätzliche E-Mail nötig.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
<CardContent>
{hasChildren ? (
<form action={handleSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label>Welches Kind?</Label>
<form action={handleSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<FieldLabel label="Welches Kind?">
<Select name="childId" defaultValue={defaultChildId} required>
<SelectTrigger>
<SelectValue placeholder="Kind auswählen" />
@@ -114,11 +106,30 @@ export function AbsenceCard({
))}
</SelectContent>
</Select>
</div>
</FieldLabel>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="absence-startDate">Von</Label>
<FieldLabel label="Grund">
<Select
name="reason"
defaultValue={AbsenceReason.ILLNESS}
required
>
<SelectTrigger>
<SelectValue placeholder="Grund auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value={AbsenceReason.ILLNESS}>Krank</SelectItem>
<SelectItem value={AbsenceReason.VACATION}>
Urlaub
</SelectItem>
<SelectItem value={AbsenceReason.OTHER}>
Sonstiges
</SelectItem>
</SelectContent>
</Select>
</FieldLabel>
<FieldLabel label="Von" htmlFor="absence-startDate">
<Input
id="absence-startDate"
name="startDate"
@@ -126,9 +137,9 @@ export function AbsenceCard({
defaultValue={today}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="absence-endDate">Bis</Label>
</FieldLabel>
<FieldLabel label="Bis" htmlFor="absence-endDate">
<Input
id="absence-endDate"
name="endDate"
@@ -136,144 +147,198 @@ export function AbsenceCard({
defaultValue={today}
required
/>
</div>
</div>
</FieldLabel>
<div className="grid gap-2">
<Label>Grund</Label>
<Select name="reason" defaultValue={AbsenceReason.ILLNESS} required>
<SelectTrigger>
<SelectValue placeholder="Grund auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value={AbsenceReason.ILLNESS}>Krank</SelectItem>
<SelectItem value={AbsenceReason.VACATION}>Urlaub</SelectItem>
<SelectItem value={AbsenceReason.OTHER}>Sonstiges</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="absence-note">Bemerkung</Label>
<div className="grid gap-2 md:col-span-2">
<Label
htmlFor="absence-note"
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Bemerkung{" "}
<span className="font-medium text-[var(--ink-faint)]">
optional
</span>
</Label>
<Textarea
id="absence-note"
name="note"
rows={3}
placeholder="Optional, z.B. voraussichtlich wieder da am Freitag."
rows={4}
placeholder="z. B. voraussichtlich wieder da am Freitag."
/>
</div>
</div>
<div className="flex justify-end">
<div className="mt-7 flex items-center justify-between border-t border-[var(--hairline-soft)] pt-5">
<Button type="reset" variant="ghost">
Zurücksetzen
</Button>
<Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{isPending ? <Loader2 className="animate-spin" /> : <Check />}
Abwesenheit melden
</Button>
</div>
</form>
) : (
<div className="flex min-h-72 flex-col items-center justify-center rounded-md border border-dashed bg-background/70 p-6 text-center">
<Baby className="h-10 w-10 text-muted-foreground" />
<p className="mt-4 font-medium">Noch keine Kinder hinterlegt</p>
<p className="mt-1 text-sm text-muted-foreground">
<div className="flex min-h-72 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-center">
<Baby className="h-10 w-10 text-[var(--ink-muted)]" />
<p className="mt-4 font-medium text-[var(--ink)]">
Noch keine Kinder hinterlegt
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege zuerst deine Kinder im Profil an, dann kannst du sie hier
abmelden.
</p>
<Button asChild className="mt-4" variant="outline">
<Button asChild className="mt-4" variant="ghost">
<Link href="/dashboard/profil">Zum Profil</Link>
</Button>
</div>
)}
<div className="rounded-md border bg-background/80 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="font-medium">Aktuelle & anstehende Meldungen</p>
<Badge variant="secondary">{absences.length}</Badge>
</div>
{absences.length === 0 ? (
<p className="text-sm text-muted-foreground">
Es sind keine aktuellen oder kommenden Abwesenheiten hinterlegt.
</p>
) : (
<div className="space-y-3">
{absences.map((absence) => (
<AbsenceRow
key={absence.id}
absence={absence}
disabled={isPending}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
</CardContent>
</Card>
);
}
function AbsenceRow({
absence,
disabled,
onDelete,
export function UpcomingAbsencesCard({
absences,
}: {
absence: AbsenceListItem;
disabled: boolean;
onDelete: (id: string) => void;
absences: AbsenceListItem[];
}) {
const range = useMemo(() => {
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle>Anstehende Meldungen</CardTitle>
<CardDescription className="mt-2">
Aktuelle und kommende Abwesenheiten deiner Kinder.
</CardDescription>
</div>
<Badge variant="secondary">{absences.length}</Badge>
</div>
</CardHeader>
<CardContent>
{absences.length === 0 ? (
<div className="flex min-h-36 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--good-soft)] text-[var(--good)]">
<Check className="h-4 w-4" />
</div>
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
Keine offenen Meldungen
</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">
Für die nächsten Tage ist alles ruhig.
</p>
</div>
) : (
<div className="divide-y divide-[var(--hairline-soft)]">
{absences.map((absence) => (
<AbsenceRow key={absence.id} absence={absence} />
))}
</div>
)}
<Link
href="/dashboard/admin/abwesenheiten"
className="mt-4 inline-flex items-center gap-1 text-[13px] font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
Alle Abwesenheiten
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</CardContent>
</Card>
);
}
function FieldLabel({
label,
htmlFor,
children,
}: {
label: string;
htmlFor?: string;
children: React.ReactNode;
}) {
return (
<div className="grid gap-2">
<Label
htmlFor={htmlFor}
className="text-xs font-semibold text-[var(--ink-soft)]"
>
{label}
</Label>
{children}
</div>
);
}
function AbsenceRow({ absence }: { absence: AbsenceListItem }) {
const { day, month, detail } = useMemo(() => {
const start = new Date(`${absence.startDate}T00:00:00`);
const end = new Date(`${absence.endDate}T00:00:00`);
const startLabel = start.toLocaleDateString("de-DE", {
const range =
absence.startDate === absence.endDate
? "ganztägig"
: `${start.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
});
const endLabel = end.toLocaleDateString("de-DE", {
})}-${end.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
return absence.startDate === absence.endDate
? endLabel
: `${startLabel} - ${endLabel}`;
})}`;
return {
day: start.toLocaleDateString("de-DE", { day: "2-digit" }),
month: start
.toLocaleDateString("de-DE", { month: "short" })
.replace(".", ""),
detail: range,
};
}, [absence.endDate, absence.startDate]);
return (
<div className="rounded-md border p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 py-3">
<div className="flex h-11 w-11 shrink-0 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] leading-none">
<span className="text-base font-medium text-[var(--ink)] [font-variant-numeric:tabular-nums]">
{day}
</span>
<span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)]">
{month}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">{absence.childName}</p>
<p className="mt-0.5 text-sm text-muted-foreground">{range}</p>
<p className="truncate text-sm font-semibold text-[var(--ink)]">
{absence.childName}
</p>
<p className="mt-1 flex items-center gap-1.5 text-xs text-[var(--ink-soft)]">
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: reasonColor(absence.reason) }}
/>
{reasonLabel(absence.reason)} · {detail}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
onClick={() => onDelete(absence.id)}
disabled={disabled}
aria-label="Abwesenheit stornieren"
>
<Trash2 className="h-4 w-4" />
</Button>
<Badge variant="destructive">Bestätigt</Badge>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<ReasonBadge reason={absence.reason} />
{absence.note && (
<span className="text-xs text-muted-foreground">{absence.note}</span>
)}
{absence.note ? (
<p className="mt-1 truncate text-xs text-[var(--ink-muted)]">
{absence.note}
</p>
) : null}
</div>
</div>
);
}
function ReasonBadge({ reason }: { reason: AbsenceReason }) {
if (reason === AbsenceReason.ILLNESS) {
return <Badge variant="destructive">Krank</Badge>;
}
if (reason === AbsenceReason.VACATION) {
return <Badge variant="warning">Urlaub</Badge>;
}
return <Badge variant="outline">Sonstiges</Badge>;
function reasonLabel(reason: AbsenceReason) {
if (reason === AbsenceReason.ILLNESS) return "Krank";
if (reason === AbsenceReason.VACATION) return "Urlaub";
return "Sonstiges";
}
function reasonColor(reason: AbsenceReason) {
if (reason === AbsenceReason.ILLNESS) return "var(--danger)";
if (reason === AbsenceReason.VACATION) return "var(--warn)";
return "var(--accent)";
}
@@ -0,0 +1,204 @@
"use client";
import { useState } from "react";
import { AbsenceReason } from "@prisma/client";
import { CalendarDays, Circle, MessageSquareText, UserRound } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export type AbsenceTableItem = {
id: string;
childName: string;
familyName: string;
reason: AbsenceReason;
note: string | null;
startDate: string;
endDate: string;
};
export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
const [activeAbsence, setActiveAbsence] = useState<AbsenceTableItem | null>(
null,
);
return (
<>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Kind</TableHead>
<TableHead>Zeitraum</TableHead>
<TableHead>Grund</TableHead>
<TableHead>Bemerkung</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{absences.map((absence) => (
<TableRow
key={absence.id}
className="cursor-pointer border-[var(--hairline-soft)] hover:bg-[var(--surface-2)]"
onClick={() => setActiveAbsence(absence)}
>
<TableCell>
<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-xs font-semibold text-[var(--accent-deep)]">
{getInitials(absence.childName)}
</div>
<div className="min-w-0">
<p className="truncate font-semibold text-[var(--ink)]">
{absence.childName}
</p>
<p className="truncate text-xs text-[var(--ink-soft)]">
{absence.familyName}
</p>
</div>
</div>
</TableCell>
<TableCell className="text-[var(--ink-soft)]">
{formatDateRange(absence.startDate, absence.endDate)}
</TableCell>
<TableCell>
<span className="inline-flex items-center gap-2 text-[var(--ink)]">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(absence.reason) }}
/>
{reasonLabel(absence.reason)}
</span>
</TableCell>
<TableCell className="max-w-[280px] truncate text-[var(--ink-soft)]">
{absence.note || "Keine Bemerkung hinterlegt."}
</TableCell>
<TableCell>
<Badge variant="success">Bestätigt</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Dialog
open={!!activeAbsence}
onOpenChange={(open) => !open && setActiveAbsence(null)}
>
<DialogContent className="left-auto right-0 top-0 h-full max-w-md translate-x-0 translate-y-0 rounded-none border-y-0 border-r-0 border-[var(--hairline)] bg-[var(--surface)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right">
{activeAbsence ? (
<>
<DialogHeader>
<DialogTitle className="text-2xl font-medium tracking-[-0.02em] text-[var(--ink)]">
{activeAbsence.childName}
</DialogTitle>
<DialogDescription>{activeAbsence.familyName}</DialogDescription>
</DialogHeader>
<div className="mt-4 grid gap-4">
<DetailLine
icon={CalendarDays}
label="Zeitraum"
value={formatDateRange(
activeAbsence.startDate,
activeAbsence.endDate,
)}
/>
<DetailLine
icon={Circle}
label="Grund"
value={reasonLabel(activeAbsence.reason)}
dotColor={reasonColor(activeAbsence.reason)}
/>
<DetailLine
icon={MessageSquareText}
label="Bemerkung"
value={activeAbsence.note || "Keine Bemerkung hinterlegt."}
/>
<DetailLine icon={UserRound} label="Status" value="Bestätigt" />
</div>
</>
) : null}
</DialogContent>
</Dialog>
</>
);
}
function DetailLine({
icon: Icon,
label,
value,
dotColor,
}: {
icon: typeof CalendarDays;
label: string;
value: string;
dotColor?: string;
}) {
return (
<div className="rounded-lg border border-[var(--hairline-soft)] bg-[var(--surface-2)] p-3">
<p className="flex items-center gap-2 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
{dotColor ? (
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: dotColor }}
/>
) : (
<Icon className="h-3.5 w-3.5 [stroke-width:1.7]" />
)}
{label}
</p>
<p className="mt-2 text-sm font-medium text-[var(--ink)]">{value}</p>
</div>
);
}
function getInitials(name: string) {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
}
export function reasonLabel(reason: AbsenceReason) {
if (reason === AbsenceReason.ILLNESS) return "Krank";
if (reason === AbsenceReason.VACATION) return "Urlaub";
return "Sonstiges";
}
export function reasonColor(reason: AbsenceReason) {
if (reason === AbsenceReason.ILLNESS) return "var(--danger)";
if (reason === AbsenceReason.VACATION) return "var(--warn)";
return "var(--accent)";
}
export function formatDateRange(startDate: string, endDate: string) {
const start = new Date(`${startDate}T00:00:00`);
const end = new Date(`${endDate}T00:00:00`);
const startLabel = start.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
});
const endLabel = end.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
}
+266 -103
View File
@@ -1,164 +1,327 @@
import Link from "next/link";
import { addDays, format } from "date-fns";
import { addDays, differenceInCalendarDays, format, isWeekend } from "date-fns";
import { de } from "date-fns/locale";
import { ArrowLeft, ArrowRight, CalendarDays, Stethoscope } from "lucide-react";
import { AbsenceReason } from "@prisma/client";
import {
ArrowLeft,
ArrowRight,
CalendarCheck2,
CalendarDays,
ChevronDown,
ChevronRight,
Stethoscope,
} from "lucide-react";
import { AbsenceReason, 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,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
AbsenceTable,
reasonColor,
reasonLabel,
type AbsenceTableItem,
} from "./absence-table";
export const metadata = { title: "Abwesenheiten · Kita-Planer" };
export default async function AdminAbwesenheitenPage({
searchParams,
}: {
searchParams: { date?: string };
searchParams: Promise<{ date?: string }>;
}) {
const selectedDate = parseDateParam(searchParams.date);
const session = await requireRole([
UserRole.ADMIN,
UserRole.KOORDINATOR,
UserRole.ERZIEHER,
]);
const resolvedSearchParams = await searchParams;
const kitaId = session.user.kitaId!;
const today = startOfLocalDay(new Date());
const selectedDate = parseDateParam(resolvedSearchParams.date, today);
const dateKey = format(selectedDate, "yyyy-MM-dd");
const result = await getDailyAbsences({ date: dateKey });
const absences = result.absences ?? [];
const childCount = await prisma.child.count({
where: { kitaId, active: true },
});
const upcomingAbsenceCount = await prisma.absence.count({
where: {
kitaId,
startDate: { gt: selectedDate },
},
});
const closure = await prisma.termin.findFirst({
where: {
kitaId,
status: TerminStatus.CONFIRMED,
type: { in: [TerminType.SCHLIESSTAG, TerminType.TEAMTAG] },
startDate: { lte: selectedDate },
endDate: { gte: selectedDate },
},
select: { title: true, type: true },
orderBy: { startDate: "asc" },
});
const isToday = dateKey === format(today, "yyyy-MM-dd");
const isTooFarFuture = differenceInCalendarDays(selectedDate, today) > 30;
const isClosedDay = isWeekend(selectedDate) || !!closure;
const tableItems: AbsenceTableItem[] = absences.map((absence) => ({
id: absence.id,
childName: absence.childName,
familyName: absence.familyName,
reason: absence.reason,
note: absence.note,
startDate: absence.startDate,
endDate: absence.endDate,
}));
const countsByReason = countByReason(tableItems);
return (
<div className="min-h-full bg-slate-50 px-4 py-6 sm:px-8">
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex flex-col gap-4 rounded-lg border bg-background p-5 shadow-sm lg:flex-row lg:items-center lg:justify-between">
<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>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Stethoscope className="h-4 w-4" />
Tagesübersicht
</div>
<h1 className="mt-2 text-3xl font-bold tracking-tight">
Heute fehlen ({absences.length} Kinder)
<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-1 text-sm text-muted-foreground">
{format(selectedDate, "EEEE, dd. MMMM yyyy", { locale: de })}
<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 gap-2">
<Button asChild variant="outline" size="sm">
<Link href={hrefForDate(addDays(selectedDate, -1))}>
<ArrowLeft className="h-4 w-4" />
<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
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/dashboard/admin/abwesenheiten">Heute</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={hrefForDate(addDays(selectedDate, 1))}>
</SegmentLink>
<SegmentLink href={hrefForDate(today)} active={isToday}>
Heute
</SegmentLink>
<SegmentLink href={hrefForDate(addDays(selectedDate, 1))}>
Vor
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<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="h-9 w-40"
className="w-[140px]"
/>
<Button type="submit" size="sm">
Anzeigen
</Button>
<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-destructive/30">
<CardContent className="p-6 text-sm text-destructive">
<Card className="border-[var(--danger)] bg-[var(--danger-soft)]">
<CardContent className="pt-[22px] text-sm text-[var(--danger)]">
{result.error}
</CardContent>
</Card>
) : absences.length === 0 ? (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-background p-8 text-center">
<div className="mx-auto max-w-md">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
<CalendarDays className="h-10 w-10" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
Keine Abwesenheiten
</h2>
<p className="mt-2 text-sm text-muted-foreground">
Für diesen Tag sind aktuell keine Kinder abgemeldet.
</p>
</div>
</div>
) : (
<div className="grid gap-3">
{absences.map((absence) => (
<Card key={absence.id} className="overflow-hidden">
<CardHeader className="flex flex-col gap-3 p-5 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="text-xl">
{absence.childName}
) : 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>
<CardDescription className="mt-1">
{absence.familyName}
</CardDescription>
<Badge variant="secondary">{dateKey}</Badge>
</div>
<ReasonBadge reason={absence.reason} />
</CardHeader>
<CardContent className="grid gap-3 px-5 pb-5 sm:grid-cols-[180px_1fr]">
<div className="text-sm">
<p className="font-medium">Zeitraum</p>
<p className="text-muted-foreground">
{formatDateRange(absence.startDate, absence.endDate)}
</p>
</div>
<div className="text-sm">
<p className="font-medium">Notiz</p>
<p className="text-muted-foreground">
{absence.note || "Keine Bemerkung hinterlegt."}
</p>
</div>
<CardContent>
<AbsenceTable absences={tableItems} />
</CardContent>
</Card>
))}
</div>
) : 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 parseDateParam(value: string | undefined) {
if (!value) return new Date();
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>
);
}
function parseDateParam(value: string | undefined, fallback: Date) {
if (!value) return fallback;
const parsed = new Date(`${value}T00:00:00`);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
}
function hrefForDate(date: Date) {
return `/dashboard/admin/abwesenheiten?date=${format(date, "yyyy-MM-dd")}`;
}
function formatDateRange(startDate: string, endDate: string) {
const start = new Date(`${startDate}T00:00:00`);
const end = new Date(`${endDate}T00:00:00`);
const startLabel = format(start, "dd.MM.", { locale: de });
const endLabel = format(end, "dd.MM.yyyy", { locale: de });
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
function startOfLocalDay(date: Date) {
const nextDate = new Date(date);
nextDate.setHours(0, 0, 0, 0);
return nextDate;
}
function ReasonBadge({ reason }: { reason: AbsenceReason }) {
if (reason === AbsenceReason.ILLNESS) {
return <Badge variant="destructive">Krank</Badge>;
}
if (reason === AbsenceReason.VACATION) {
return <Badge variant="warning">Urlaub</Badge>;
}
return <Badge variant="outline">Sonstiges</Badge>;
function countByReason(items: AbsenceTableItem[]) {
return items.reduce<Partial<Record<AbsenceReason, number>>>((acc, item) => {
acc[item.reason] = (acc[item.reason] ?? 0) + 1;
return acc;
}, {});
}
@@ -116,9 +116,11 @@ export function DutyPlanner({
if (dutyTypes.length === 0) {
return (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-sm font-medium">Noch keine Diensttypen</p>
<p className="mt-1 text-sm text-muted-foreground">
<div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-8 text-center">
<p className="text-sm font-medium text-[var(--ink)]">
Noch keine Diensttypen
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege zuerst einen Diensttyp an, danach erscheint hier das Planungsgrid.
</p>
</div>
@@ -136,29 +138,29 @@ export function DutyPlanner({
disabled={isPending}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : (
<BellRing className="h-4 w-4" />
<BellRing className="h-4 w-4 [stroke-width:1.7]" />
)}
Reminder diese Woche senden
</Button>
</div>
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[760px] border-collapse text-sm">
<div className="overflow-x-auto rounded-lg border border-[var(--hairline)]">
<table className="w-full min-w-[760px] border-collapse text-[13.5px] text-[var(--ink)]">
<thead>
<tr className="border-b bg-muted/50">
<th className="sticky left-0 z-10 w-56 bg-muted/95 px-4 py-3 text-left font-medium">
<tr className="border-b border-[var(--hairline-soft)] bg-[var(--surface-2)]">
<th className="sticky left-0 z-10 w-56 bg-[var(--surface-2)] px-4 py-3 text-left text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Woche
</th>
{dutyTypes.map((dutyType) => (
<th
key={dutyType.id}
className="min-w-56 px-4 py-3 text-left font-medium"
className="min-w-56 px-4 py-3 text-left font-medium text-[var(--ink)]"
>
<div>{dutyType.name}</div>
{dutyType.description && (
<div className="mt-1 line-clamp-1 text-xs font-normal text-muted-foreground">
<div className="mt-1 line-clamp-1 text-xs font-normal text-[var(--ink-muted)]">
{dutyType.description}
</div>
)}
@@ -168,8 +170,11 @@ export function DutyPlanner({
</thead>
<tbody>
{weeks.map((week) => (
<tr key={week.key} className="border-b last:border-b-0">
<td className="sticky left-0 z-10 bg-background px-4 py-3 font-medium">
<tr
key={week.key}
className="border-b border-[var(--hairline-soft)] last:border-b-0 hover:bg-[var(--surface-2)]"
>
<td className="sticky left-0 z-10 bg-[var(--surface)] px-4 py-3 font-medium">
{week.label}
</td>
{dutyTypes.map((dutyType) => {
+48 -35
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { addWeeks, format, getISOWeek, startOfDay, startOfWeek } from "date-fns";
import { de } from "date-fns/locale";
import { CalendarRange, ClipboardList, Plus, Trash2 } from "lucide-react";
import { CalendarRange, Plus, Trash2 } from "lucide-react";
import { UserRole } from "@prisma/client";
import { Button } from "@/components/ui/button";
@@ -34,15 +34,16 @@ const MAX_WEEK_COUNT = 104;
export default async function DienstePage({
searchParams,
}: {
searchParams: { start?: string; weeks?: string };
searchParams: Promise<{ start?: string; weeks?: string }>;
}) {
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
if (!session.user.kitaId) {
throw new Error("Kein Mandant zugeordnet.");
}
const rangeStart = getRangeStart(searchParams.start);
const weekCount = getWeekCount(searchParams.weeks);
const resolvedSearchParams = await searchParams;
const rangeStart = getRangeStart(resolvedSearchParams.start);
const weekCount = getWeekCount(resolvedSearchParams.weeks);
const weeks = buildWeeks(rangeStart, weekCount);
const rangeEnd = startOfDay(new Date(weeks[weeks.length - 1].endDate));
@@ -83,43 +84,55 @@ export default async function DienstePage({
const dutyTypeDtos: DutyPlanDutyTypeDto[] = dutyTypes;
const familyDtos: DutyPlanFamilyDto[] = families;
const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map((assignment) => ({
const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map(
(assignment) => ({
id: assignment.id,
dutyTypeId: assignment.dutyTypeId,
familyId: assignment.familyId,
startDate: toDateKey(assignment.startDate),
}));
}),
);
return (
<div className="space-y-6 p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<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>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<ClipboardList className="h-4 w-4" />
Admin
</div>
<h1 className="mt-2 text-2xl font-bold tracking-tight">
Dienstplan-Manager
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
ADMIN · DIENSTPLAN
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Dienste{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
planen
</em>
</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Plane Dienste pro Kalenderwoche und Haushalt. Jede Änderung wird
direkt gespeichert.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{dutyTypes.length} Diensttypen
</span>
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{families.length} Familien
</span>
</div>
</header>
<Card className="w-full lg:max-w-xl">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<CalendarRange className="h-4 w-4" />
<Card className="mb-5">
<CardContent className="pt-[22px]">
<form className="flex flex-wrap items-end gap-3">
<div className="mr-auto min-w-64">
<div className="flex items-center gap-2 text-base font-medium tracking-[-0.02em] text-[var(--ink)]">
<CalendarRange className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
Zeitraum
</CardTitle>
<CardDescription>
Du kannst beliebig weit voraus planen, jeweils in handlichen
Ausschnitten.
</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-3 sm:flex-row sm:items-end">
</div>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
{weeks[0].label} bis {weeks[weeks.length - 1].label}
</p>
</div>
<div className="grid gap-1.5">
<Label htmlFor="start">Startwoche</Label>
<Input
@@ -129,7 +142,7 @@ export default async function DienstePage({
defaultValue={toDateInputValue(rangeStart)}
/>
</div>
<div className="grid gap-1.5">
<div className="grid w-28 gap-1.5">
<Label htmlFor="weeks">Wochen</Label>
<Input
id="weeks"
@@ -141,7 +154,7 @@ export default async function DienstePage({
/>
</div>
<Button type="submit">Anzeigen</Button>
<Button asChild type="button" variant="outline">
<Button asChild type="button" variant="ghost">
<Link
href={`/dashboard/admin/dienste?start=${toDateInputValue(
rangeStart,
@@ -153,7 +166,6 @@ export default async function DienstePage({
</form>
</CardContent>
</Card>
</div>
<div className="grid gap-6 xl:grid-cols-[340px_1fr]">
<Card>
@@ -186,19 +198,19 @@ export default async function DienstePage({
<div className="space-y-2">
{dutyTypes.length === 0 ? (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
<p className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3 text-sm italic text-[var(--ink-faint)]">
Noch keine Diensttypen angelegt.
</p>
) : (
dutyTypes.map((dutyType) => (
<div
key={dutyType.id}
className="flex items-start justify-between gap-3 rounded-md border p-3"
className="flex items-start justify-between gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
>
<div className="min-w-0">
<p className="font-medium">{dutyType.name}</p>
<p className="font-medium text-[var(--ink)]">{dutyType.name}</p>
{dutyType.description && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
<p className="mt-1 line-clamp-2 text-xs text-[var(--ink-soft)]">
{dutyType.description}
</p>
)}
@@ -213,9 +225,10 @@ export default async function DienstePage({
type="submit"
variant="ghost"
size="icon"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
aria-label={`${dutyType.name} löschen`}
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</form>
</div>
@@ -85,29 +85,59 @@ export function AdminCalendarManager({
termine: AdminTerminDto[];
childRows: ChildDto[];
}) {
const rsvpCount = termine.filter((termin) => termin.requiresRsvp).length;
const pendingCount = termine.filter(
(termin) => termin.status === TerminStatus.PENDING,
).length;
return (
<div className="flex h-full flex-col gap-6 p-6">
<div className="flex flex-col justify-between gap-4 lg:flex-row lg:items-center">
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Event-Anmeldungen & Ausflüge
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
ADMIN · EVENT-ANMELDUNGEN
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Termine{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
koordinieren
</em>
</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind
verfolgen.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{termine.length} Termine
</span>
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{rsvpCount} RSVP
</span>
{pendingCount > 0 && (
<span className="inline-flex h-9 items-center rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-3 text-[13px] font-semibold text-[var(--warn)]">
{pendingCount} ausstehend
</span>
)}
<TerminFormDialog mode="create" />
</div>
</header>
{termine.length === 0 ? (
<div className="rounded-md border border-dashed p-10 text-center">
<CalendarPlus className="mx-auto h-10 w-10 text-muted-foreground" />
<h2 className="mt-4 text-lg font-semibold">Noch keine Termine</h2>
<p className="mt-1 text-sm text-muted-foreground">
<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="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<CalendarPlus className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h2 className="mt-4 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Noch keine Termine
</h2>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
</p>
</div>
</Card>
) : (
<div className="grid gap-4">
{termine.map((termin) => (
@@ -134,7 +164,7 @@ function AdminTerminCard({
return (
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30">
<CardHeader className="border-b border-[var(--hairline-soft)] bg-[var(--surface-2)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="mb-2 flex flex-wrap gap-2">
@@ -149,8 +179,8 @@ function AdminTerminCard({
)}
</div>
<CardTitle className="text-lg">{termin.title}</CardTitle>
<CardDescription className="mt-1 flex flex-wrap items-center gap-2">
<Clock className="h-3.5 w-3.5" />
<CardDescription className="mt-1 flex flex-wrap items-center gap-2 text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(new Date(termin.startDate), "dd.MM.yyyy", {
locale: de,
})}
@@ -176,20 +206,20 @@ function AdminTerminCard({
<CardContent className="grid gap-5 pt-5">
{termin.description && (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
<p className="whitespace-pre-wrap text-sm text-[var(--ink-soft)]">
{termin.description}
</p>
)}
{termin.requiresRsvp ? (
<div className="grid gap-4">
<div className="flex flex-col gap-3 rounded-md border bg-amber-50/70 p-4 text-amber-950 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-[var(--ink)] sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
{stats.pending} Ausstehend
</p>
<p className="mt-1 text-sm text-amber-900/80">
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Frist:{" "}
{termin.rsvpDeadline
? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", {
@@ -198,11 +228,11 @@ function AdminTerminCard({
: "bis Terminbeginn"}
</p>
</div>
<Users className="h-5 w-5 shrink-0" />
<Users className="h-5 w-5 shrink-0 text-[var(--warn)] [stroke-width:1.7]" />
</div>
{termin.participantInfo && (
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-4 text-sm text-yellow-950">
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
<span className="font-medium">Hinweis für Teilnehmer: </span>
{termin.participantInfo}
</div>
@@ -233,7 +263,7 @@ function AdminTerminCard({
</Table>
</div>
) : (
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
<div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm italic text-[var(--ink-faint)]">
Für diesen Termin ist keine Teilnahme-Abfrage aktiv.
</div>
)}
@@ -304,11 +334,11 @@ function TerminFormDialog({
}}
>
<DialogTrigger asChild>
<Button variant={mode === "edit" ? "outline" : "default"} size="sm">
<Button variant={mode === "edit" ? "pill" : "default"} size="sm">
{mode === "edit" ? (
<Pencil className="h-4 w-4" />
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
) : (
<CalendarPlus className="h-4 w-4" />
<CalendarPlus className="h-4 w-4 [stroke-width:1.7]" />
)}
{mode === "edit" ? "Bearbeiten" : "Termin anlegen"}
</Button>
@@ -351,7 +381,7 @@ function TerminFormDialog({
id={`${mode}-type`}
name="type"
defaultValue={termin?.type ?? TerminType.KITA_FEST}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
required
>
{Object.values(TerminType).map((type) => (
@@ -389,13 +419,13 @@ function TerminFormDialog({
</div>
</div>
<div className="grid gap-3 rounded-md border p-4">
<div className="grid gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="allDay"
defaultChecked={termin?.allDay ?? false}
className="h-4 w-4 rounded border-input"
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
/>
Ganztägiger Termin
</label>
@@ -404,7 +434,7 @@ function TerminFormDialog({
type="checkbox"
name="mitbringselListEnabled"
defaultChecked={termin?.mitbringselListEnabled ?? false}
className="h-4 w-4 rounded border-input"
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
/>
Mitbring-Liste aktiv
</label>
@@ -414,14 +444,14 @@ function TerminFormDialog({
name="requiresRsvp"
checked={requiresRsvp}
onChange={(event) => setRequiresRsvp(event.target.checked)}
className="h-4 w-4 rounded border-input"
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
/>
Teilnahme-Abfrage (RSVP) aktivieren
</label>
</div>
{requiresRsvp && (
<div className="grid gap-4 rounded-md border border-amber-200 bg-amber-50/60 p-4">
<div className="grid gap-4 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
<div className="grid gap-2">
<Label htmlFor={`${mode}-rsvpDeadline`}>
Rückmeldefrist
@@ -495,7 +525,7 @@ function RsvpStatusBadge({
}
return (
<Badge variant="outline" className="gap-1 text-muted-foreground">
<Badge variant="outline" className="gap-1 text-[var(--ink-muted)]">
<HelpCircle className="h-3.5 w-3.5" />
Ausstehend
</Badge>
@@ -566,19 +596,19 @@ function getTypeBadgeClass(type: TerminType) {
switch (type) {
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "border-transparent bg-amber-500 text-white";
return "border-transparent bg-[var(--warn-soft)] text-[var(--warn)]";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "border-transparent bg-rose-500 text-white";
return "border-transparent bg-[var(--danger-soft)] text-[var(--danger)]";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "border-transparent bg-blue-500 text-white";
return "border-transparent bg-[oklch(0.94_0.04_280)] text-[oklch(0.48_0.08_280)]";
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "border-transparent bg-purple-500 text-white";
return "border-transparent bg-[var(--accent-soft)] text-[var(--accent-deep)]";
default:
return "border-slate-300 bg-slate-100 text-slate-800";
return "border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]";
}
}
+9 -9
View File
@@ -67,14 +67,14 @@ export function NewsForm() {
placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."}
required
/>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-[var(--ink-muted)]">
Markdown ist aktiv. HTML wird nicht interpretiert.
</p>
</div>
{content.trim() && (
<div className="rounded-md border bg-muted/30 p-4 text-sm">
<p className="mb-3 text-xs font-semibold uppercase text-muted-foreground">
<div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm">
<p className="mb-3 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Vorschau
</p>
<MarkdownContent content={content} />
@@ -83,7 +83,7 @@ export function NewsForm() {
<div className="grid gap-2">
<Label htmlFor="attachments">Anhänge</Label>
<div className="rounded-md border border-dashed bg-background p-4">
<div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<Input
id="attachments"
name="attachments"
@@ -91,14 +91,14 @@ export function NewsForm() {
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
multiple
/>
<p className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
<UploadCloud className="h-3.5 w-3.5" />
<p className="mt-2 flex items-center gap-1 text-xs text-[var(--ink-muted)]">
<UploadCloud className="h-3.5 w-3.5 [stroke-width:1.7]" />
PDF, JPG und PNG bis 10 MB pro Datei.
</p>
</div>
</div>
<div className="rounded-md border p-4">
<div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<div className="flex items-center gap-2">
<Checkbox
id="sendEmail"
@@ -130,9 +130,9 @@ export function NewsForm() {
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : (
<Send className="h-4 w-4" />
<Send className="h-4 w-4 [stroke-width:1.7]" />
)}
Veröffentlichen
</Button>
+38 -19
View File
@@ -1,6 +1,6 @@
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { FileText, Megaphone } from "lucide-react";
import { FileText, Megaphone, Send } from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
@@ -49,26 +49,37 @@ export default async function AdminNewsPage() {
});
return (
<div className="px-8 py-8">
<div className="mb-8">
<Badge className="mb-3 bg-emerald-100 text-emerald-800 hover:bg-emerald-100">
Offizielle Kita-Kommunikation
</Badge>
<h1 className="flex items-center gap-3 text-2xl font-semibold tracking-tight">
<Megaphone className="h-6 w-6 text-emerald-700" />
Digitales Schwarzes Brett
<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]">
KOMMUNIKATION · SCHWARZES BRETT
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Meldungen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
veröffentlichen
</em>
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
werden geschützt gespeichert und nur für eingeloggte Nutzer derselben
Kita ausgeliefert.
werden geschützt gespeichert und nur für eingeloggte Nutzer
derselben Kita ausgeliefert.
</p>
</div>
<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">
<Megaphone className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{announcements.length} zuletzt
</span>
</header>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<Card>
<CardHeader>
<CardTitle>Neue Ankündigung</CardTitle>
<CardTitle className="flex items-center gap-2">
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
Neue Ankündigung
</CardTitle>
<CardDescription>
Markdown wird sicher gerendert; PDF, JPG und PNG können als
geschützte Anhänge hinzugefügt werden.
@@ -88,27 +99,35 @@ export default async function AdminNewsPage() {
</CardHeader>
<CardContent className="grid gap-3">
{announcements.length === 0 ? (
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
Noch keine Ankündigungen veröffentlicht.
<div className="flex max-h-[280px] min-h-[220px] flex-col items-center justify-center rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 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)]">
<Megaphone className="h-6 w-6 [stroke-width:1.7]" />
</div>
<p className="text-sm font-medium text-[var(--ink)]">
Noch keine Ankündigungen
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Veröffentlichte Meldungen erscheinen hier.
</p>
</div>
) : (
announcements.map((announcement) => (
<div
key={announcement.id}
className="rounded-md border bg-background p-3"
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
>
<div className="mb-2 flex items-start justify-between gap-2">
<p className="font-medium leading-snug">
<p className="font-medium leading-snug text-[var(--ink)]">
{announcement.title}
</p>
{announcement.attachments.length > 0 && (
<Badge variant="secondary" className="shrink-0">
<FileText className="h-3 w-3" />
<FileText className="h-3 w-3 [stroke-width:1.7]" />
{announcement.attachments.length}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-[var(--ink-muted)]">
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
locale: de,
})}{" "}
+233 -127
View File
@@ -1,15 +1,20 @@
import { Baby, Contact, Mail, Phone, ShieldCheck, UsersRound } from "lucide-react";
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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
export const metadata = { title: "Adressbuch · Kita-Planer" };
@@ -119,140 +124,241 @@ 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="flex h-full flex-col gap-6 p-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Adressbuch</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
<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 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-background p-10 text-center">
<Contact className="mb-4 h-10 w-10 text-muted-foreground" />
<h3 className="text-lg font-semibold">Keine freigegebenen Kontakte</h3>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
<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>
</div>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{families.map((family) => (
<Card key={family.id} className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 pb-4">
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<UsersRound className="h-4 w-4 text-primary" />
{family.name}
</CardTitle>
<CardDescription className="mt-1">
{family.parents.filter((user) => user.hasDirectoryOptIn).length}{" "}
von {family.parents.length} Elternteilen sichtbar
</CardDescription>
</div>
<Badge variant="success">Freigegeben</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-5 pt-5">
<section className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Baby className="h-3.5 w-3.5" />
Kinder
</div>
{family.children.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keine Kinder hinterlegt.
</p>
) : (
<div className="flex flex-wrap gap-1.5">
{family.children.map((child) => (
<Badge key={child.id} variant="secondary">
{child.firstName} {child.lastName}
</Badge>
))}
</div>
)}
</section>
<section className="grid gap-3">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<ShieldCheck className="h-3.5 w-3.5" />
Kontakte
</div>
<div className="grid gap-3">
{family.parents.map((user) => {
return (
<div
key={user.id}
className="rounded-md border bg-background p-3"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium">
{user.firstName} {user.lastName}
</p>
{!user.hasDirectoryOptIn && (
<p className="mt-0.5 text-xs text-muted-foreground">
Kontakt nicht freigegeben.
</p>
)}
</div>
{user.hasDirectoryOptIn ? (
<Badge variant="outline">Opt-in</Badge>
) : null}
</div>
{user.hasDirectoryOptIn && (
<div className="mt-3 grid gap-2 text-sm text-muted-foreground">
{user.email && (
<a
href={`mailto:${user.email}`}
className="flex min-w-0 items-center gap-2 hover:text-primary"
>
<Mail className="h-4 w-4 shrink-0" />
<span className="truncate">{user.email}</span>
</a>
)}
{user.phone && (
<a
href={`tel:${user.phone}`}
className="flex items-center gap-2 hover:text-primary"
>
<Phone className="h-4 w-4 shrink-0" />
{user.phone}
</a>
)}
</div>
)}
{user.duties.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5 border-t pt-3">
{user.duties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="bg-primary/5 text-primary"
>
{duty.name}
</Badge>
))}
</div>
)}
</div>
);
})}
</div>
</section>
</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,
}));
}
@@ -1,7 +1,7 @@
"use client";
import { useState, useTransition } from "react";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { Plus, Pencil, Search, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -18,6 +18,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
@@ -92,11 +93,15 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<div className="flex flex-wrap items-center justify-between gap-3">
<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="Name suchen" className="pl-9" />
</div>
<Dialog open={openCreate} onOpenChange={setOpenCreate}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4" />
<Plus className="h-4 w-4 [stroke-width:1.7]" />
ErzieherIn anlegen
</Button>
</DialogTrigger>
@@ -131,39 +136,67 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
</Dialog>
</div>
<div className="rounded-md border bg-background">
<Card>
<CardHeader className="flex-row items-center justify-between">
<div>
<CardTitle>Team</CardTitle>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
{educators.length} Einträge im Personalstamm.
</p>
</div>
<Badge variant="secondary" className="tabular-nums">
{educators.filter((ed) => ed.active).length} aktiv
</Badge>
</CardHeader>
<CardContent className="px-0 pb-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Vorname</TableHead>
<TableHead className="pl-6">Vorname</TableHead>
<TableHead>Nachname</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
<TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{educators.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground h-24">
Keine Einträge gefunden.
<TableCell colSpan={4} className="h-32 text-center">
<div className="flex max-h-[240px] min-h-[200px] flex-col items-center justify-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Plus className="h-6 w-6 [stroke-width:1.7]" />
</div>
<p className="text-sm font-medium text-[var(--ink)]">
Keine Einträge gefunden
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege die erste Person im Team an.
</p>
</div>
</TableCell>
</TableRow>
) : (
educators.map((ed) => (
<TableRow key={ed.id}>
<TableCell className="font-medium">{ed.firstName}</TableCell>
<TableCell className="pl-6 font-medium">{ed.firstName}</TableCell>
<TableCell>{ed.lastName}</TableCell>
<TableCell>
<Badge variant={ed.active ? "success" : "secondary"}>
{ed.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<TableCell className="pr-6 text-right">
<Button variant="ghost" size="icon" onClick={() => setEditingEducator(ed)}>
<Pencil className="h-4 w-4" />
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
</Button>
<Button variant="ghost" size="icon" className="text-destructive hover:text-destructive" onClick={() => handleDelete(ed.id)} disabled={isPending}>
<Trash2 className="h-4 w-4" />
<Button
variant="ghost"
size="icon"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => handleDelete(ed.id)}
disabled={isPending}
>
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</TableCell>
</TableRow>
@@ -171,7 +204,8 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!editingEducator} onOpenChange={(open) => !open && setEditingEducator(null)}>
+21 -6
View File
@@ -20,21 +20,36 @@ export default async function ErzieherPage() {
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
});
const activeCount = educators.filter((educator) => educator.active).length;
return (
<div className="flex h-full flex-col gap-6 p-6">
<div className="flex items-start justify-between gap-4">
<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>
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<GraduationCap className="h-6 w-6 text-primary" />
ErzieherInnen-Verwaltung
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
VERWALTUNG · TEAM
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
ErzieherInnen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
verwalten
</em>
</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Stammdaten des Kita-Personals für interne Planung und spätere
Notdienst-Alarmierung.
</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-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<GraduationCap className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{educators.length} im Team
</span>
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{activeCount} aktiv
</span>
</div>
</header>
<ErzieherList educators={educators} />
</div>
@@ -54,7 +54,7 @@ export function AddFamilyDialog() {
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
<Plus className="h-4 w-4" />
Familie hinzufügen
</Button>
</DialogTrigger>
@@ -82,7 +82,9 @@ export function AddFamilyDialog() {
{/* ── Elternteile ────────────────────────────────────────── */}
<fieldset className="space-y-4">
<legend className="text-sm font-semibold">Elternteil 1</legend>
<legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Elternteil 1
</legend>
<div className="grid grid-cols-2 gap-3">
<FormField
@@ -112,8 +114,13 @@ export function AddFamilyDialog() {
</fieldset>
<fieldset className="space-y-4">
<legend className="text-sm font-semibold">
Elternteil 2 <span className="font-normal text-muted-foreground">(optional)</span>
<legend>
<span className="text-[11px] uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Elternteil 2
</span>{" "}
<span className="font-normal italic text-[var(--ink-faint)]">
optional
</span>
</legend>
<div className="grid grid-cols-2 gap-3">
@@ -149,7 +156,7 @@ export function AddFamilyDialog() {
{/* ── Kinder ─────────────────────────────────────────────── */}
<fieldset className="space-y-3">
<div className="flex items-center justify-between">
<legend className="text-sm font-semibold">
<legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Kinder ({childCount})
</legend>
{childCount < 10 && (
@@ -168,7 +175,7 @@ export function AddFamilyDialog() {
{Array.from({ length: childCount }).map((_, i) => (
<div
key={i}
className="relative rounded-md border p-4"
className="relative rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
>
{childCount > 1 && (
<button
@@ -178,13 +185,13 @@ export function AddFamilyDialog() {
// Felder nach Index benannt sind und der letzte entfernt wird.
setChildCount((c) => c - 1);
}}
className="absolute right-3 top-3 rounded p-0.5 text-muted-foreground hover:text-destructive"
className="absolute right-3 top-3 rounded-md p-1 text-[var(--ink-muted)] transition-colors hover:bg-[var(--danger-soft)] hover:text-[var(--danger)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-2)]"
aria-label={`Kind ${i + 1} entfernen`}
>
<Trash2 className="h-4 w-4" />
</button>
)}
<p className="mb-3 text-xs font-medium text-muted-foreground">
<p className="mb-3 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Kind {i + 1}
</p>
<div className="grid grid-cols-2 gap-3">
@@ -203,7 +210,7 @@ export function AddFamilyDialog() {
))}
{state.errors?.children?.[0] && (
<p className="text-xs text-destructive">
<p className="text-xs text-[var(--danger)]">
{state.errors.children[0]}
</p>
)}
@@ -211,7 +218,7 @@ export function AddFamilyDialog() {
{/* Globaler Fehler */}
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<p className="rounded-lg border border-[var(--danger)] bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]">
{state.errors._form[0]}
</p>
)}
@@ -219,7 +226,7 @@ export function AddFamilyDialog() {
<DialogFooter>
<Button
type="button"
variant="outline"
variant="ghost"
onClick={() => setOpen(false)}
disabled={pending}
>
@@ -268,7 +275,7 @@ function FormField({
required={required}
aria-invalid={!!error}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
{error && <p className="text-xs text-[var(--danger)]">{error}</p>}
</div>
);
}
+23 -13
View File
@@ -107,10 +107,10 @@ export function DutyManager({
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1">
<Shield className="h-4 w-4" />
<Shield className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Ämter</span>
{userAssignments.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1 py-0 h-4">
<Badge variant="secondary" className="ml-1 h-4 px-1 py-0 tabular-nums">
{userAssignments.length}
</Badge>
)}
@@ -127,16 +127,20 @@ export function DutyManager({
<div className="flex flex-col gap-6 py-4">
{/* Current Assignments */}
<div className="flex flex-col gap-2">
<h4 className="text-sm font-semibold">Aktuelle Ämter</h4>
<h4 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Aktuelle Ämter
</h4>
{userAssignments.length === 0 ? (
<p className="text-sm text-muted-foreground italic">Keine Ämter zugewiesen.</p>
<p className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-5 text-sm italic text-[var(--ink-faint)]">
Keine Ämter zugewiesen.
</p>
) : (
<div className="flex flex-wrap gap-2">
{userAssignments.map((assignment) => (
<Badge key={assignment.id} variant="default" className="gap-1 pr-1 bg-primary">
<Badge key={assignment.id} variant="default" className="gap-1 pr-1">
{assignment.duty.name}
<button
className="ml-1 rounded-full hover:bg-primary-foreground/20 p-0.5"
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-[oklch(0.98_0.005_45_/_0.18)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[oklch(0.98_0.005_45)]"
onClick={() => handleRemove(assignment.id)}
disabled={isPending}
>
@@ -149,17 +153,21 @@ export function DutyManager({
</div>
{/* Assign new duty */}
<div className="flex flex-col gap-2 border-t pt-4">
<h4 className="text-sm font-semibold">Vorhandenes Amt zuweisen</h4>
<div className="flex flex-col gap-2 border-t border-[var(--hairline-soft)] pt-4">
<h4 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Vorhandenes Amt zuweisen
</h4>
{unassignedDuties.length === 0 ? (
<p className="text-sm text-muted-foreground italic">Keine weiteren Ämter verfügbar.</p>
<p className="text-sm italic text-[var(--ink-faint)]">
Keine weiteren Ämter verfügbar.
</p>
) : (
<div className="flex flex-wrap gap-2">
{unassignedDuties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="cursor-pointer hover:bg-muted gap-1 pr-1 group relative"
className="group relative cursor-pointer gap-1 pr-1 hover:border-[var(--accent-mid)] hover:bg-[var(--accent-soft)] hover:text-[var(--accent-deep)]"
onClick={() => handleAssign(duty.id)}
>
<Plus className="h-3 w-3" />
@@ -168,7 +176,7 @@ export function DutyManager({
{/* Only show delete if no one is assigned to it across the entire DB */}
{duty.assignments.length === 0 && (
<button
className="ml-1 rounded-full hover:bg-destructive/20 text-destructive p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
className="ml-1 rounded-full p-0.5 text-[var(--danger)] opacity-0 transition-opacity hover:bg-[var(--danger-soft)] group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
handleDeleteDuty(duty.id);
@@ -186,8 +194,10 @@ export function DutyManager({
</div>
{/* Create new duty */}
<div className="flex flex-col gap-2 border-t pt-4">
<h4 className="text-sm font-semibold">Neues Amt anlegen</h4>
<div className="flex flex-col gap-2 border-t border-[var(--hairline-soft)] pt-4">
<h4 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Neues Amt anlegen
</h4>
<form action={handleCreateDuty} className="flex gap-2">
<Input name="name" placeholder="z.B. Wäschedienst" className="h-8" required />
<Button size="sm" className="h-8" disabled={isPending}>
@@ -163,7 +163,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
<Dialog onOpenChange={(open) => !open && resetState()}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1">
<Pencil className="h-4 w-4" />
<Pencil className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Details</span>
</Button>
</DialogTrigger>
@@ -189,11 +189,13 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
<fieldset className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<legend className="text-sm font-semibold">Elternteile</legend>
<legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Elternteile
</legend>
{parents.length < 2 && (
<Button
type="button"
variant="outline"
variant="pill"
size="sm"
className="h-8 gap-1"
onClick={addParent}
@@ -206,9 +208,12 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
</div>
{parents.map((parent, index) => (
<div key={parent.id} className="rounded-md border p-3">
<div
key={parent.id}
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-medium text-muted-foreground">
<p className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Elternteil {index + 1}
</p>
{parent.kind === "new" && (
@@ -216,7 +221,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-destructive hover:text-destructive"
className="h-7 gap-1 px-2 text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => removeNewParent(parent)}
disabled={isPending}
>
@@ -254,10 +259,12 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
<fieldset className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<legend className="text-sm font-semibold">Kinder</legend>
<legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Kinder
</legend>
<Button
type="button"
variant="outline"
variant="pill"
size="sm"
className="h-8 gap-1"
onClick={addChild}
@@ -269,21 +276,24 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
</div>
{children.length === 0 ? (
<p className="text-sm text-muted-foreground">
<p className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-5 text-sm italic text-[var(--ink-faint)]">
Keine Kinder verknüpft.
</p>
) : (
children.map((child, index) => (
<div key={child.id} className="rounded-md border p-3">
<div
key={child.id}
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-medium text-muted-foreground">
<p className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Kind {index + 1}
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-destructive hover:text-destructive"
className="h-7 gap-1 px-2 text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => removeChild(child)}
disabled={isPending}
>
+105 -39
View File
@@ -1,8 +1,12 @@
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,
@@ -85,36 +89,76 @@ export default async function FamiliesPage() {
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="px-8 py-8">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<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>
<h1 className="text-2xl font-semibold tracking-tight">
Familienverwaltung
<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-1 text-sm text-muted-foreground">
{families.length === 0
? "Noch keine Familien angelegt."
: `${families.length} ${families.length === 1 ? "Familie" : "Familien"} · ${childCount} Kinder`}
<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 />
) : (
<div className="rounded-lg border bg-background">
<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>Familie</TableHead>
<TableHead className="pl-6">Familie</TableHead>
<TableHead>Elternteile</TableHead>
<TableHead>Kinder</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
<TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -125,18 +169,34 @@ export default async function FamiliesPage() {
return (
<TableRow key={family.id}>
<TableCell className="font-medium">
<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-1.5">
<div className="space-y-2">
{family.users.map((parent) => (
<div key={parent.id}>
<div className="font-medium">
<div key={parent.id} className="min-w-0">
<div className="font-medium text-[var(--ink)]">
{parent.firstName} {parent.lastName}
</div>
<div className="text-xs text-muted-foreground">
<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>
))}
@@ -144,13 +204,15 @@ export default async function FamiliesPage() {
</TableCell>
<TableCell>
{family.children.length === 0 ? (
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm italic text-[var(--ink-faint)]">
Noch keine Kinder
</span>
) : (
<div className="flex flex-wrap gap-1">
<div className="flex flex-wrap gap-1.5">
{family.children.map((child) => (
<span
key={child.id}
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium"
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>
@@ -163,7 +225,7 @@ export default async function FamiliesPage() {
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<TableCell className="pr-6 text-right">
<div className="flex flex-wrap justify-end gap-1">
<EditFamilyDialog
family={{
@@ -198,7 +260,8 @@ export default async function FamiliesPage() {
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</div>
);
@@ -206,28 +269,31 @@ export default async function FamiliesPage() {
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-background px-6 py-16 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<svg
className="h-6 w-6 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
<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 font-semibold">Noch keine Familien</h3>
<p className="mb-4 max-w-sm text-sm text-muted-foreground">
<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,6 +1,7 @@
"use client";
import { useState, useTransition } from "react";
import type { ComponentProps, FormEvent } from "react";
import { useMemo, useState, useTransition } from "react";
import { TerminType } from "@prisma/client";
import { Plus } from "lucide-react";
import { toast } from "sonner";
@@ -20,18 +21,77 @@ import { Label } from "@/components/ui/label";
import { createTerminAdmin } from "../actions";
import { Textarea } from "@/components/ui/textarea";
export function AdminTerminModal() {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
type AdminTerminFormState = {
title: string;
description: string;
type: TerminType;
startDate: string;
endDate: string;
allDay: boolean;
};
const initialFormState: AdminTerminFormState = {
title: "",
description: "",
type: TerminType.KITA_FEST,
startDate: "",
endDate: "",
allDay: false,
};
type AdminTerminModalProps = {
triggerLabel?: string;
triggerVariant?: ComponentProps<typeof Button>["variant"];
triggerClassName?: string;
};
export function AdminTerminModal({
triggerLabel = "Termin direkt anlegen",
triggerVariant,
triggerClassName = "gap-2",
}: AdminTerminModalProps) {
const [open, setOpen] = useState(false);
const [formState, setFormState] = useState<AdminTerminFormState>(initialFormState);
const [isPending, startTransition] = useTransition();
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
const minEndDateTime = formState.startDate || minDateTime;
const updateField = <TKey extends keyof AdminTerminFormState>(
key: TKey,
value: AdminTerminFormState[TKey],
) => {
setFormState((current) => {
const next = { ...current, [key]: value };
if (key === "startDate" && typeof value === "string" && next.endDate && next.endDate < value) {
next.endDate = value;
}
return next;
});
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!formState.startDate || !formState.endDate) {
toast.error("Bitte Start und Ende ausfüllen.");
return;
}
if (formState.startDate < minDateTime) {
toast.error("Der Startzeitpunkt darf nicht in der Vergangenheit liegen.");
return;
}
if (formState.endDate < formState.startDate) {
toast.error("Das Ende darf nicht vor dem Start liegen.");
return;
}
const handleAction = (formData: FormData) => {
const data = {
title: formData.get("title") as string,
description: formData.get("description") as string,
type: formData.get("type") as TerminType,
startDate: new Date(formData.get("startDate") as string).toISOString(),
endDate: new Date(formData.get("endDate") as string).toISOString(),
allDay: formData.get("allDay") === "on",
title: formState.title,
description: formState.description,
type: formState.type,
startDate: new Date(formState.startDate).toISOString(),
endDate: new Date(formState.endDate).toISOString(),
allDay: formState.allDay,
};
startTransition(async () => {
@@ -40,6 +100,7 @@ export function AdminTerminModal() {
toast.error(res.error);
} else {
toast.success("Termin wurde direkt angelegt!");
setFormState(initialFormState);
setOpen(false);
}
});
@@ -48,13 +109,13 @@ export function AdminTerminModal() {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2 bg-primary">
<Button variant={triggerVariant} className={triggerClassName}>
<Plus className="h-4 w-4" />
Termin direkt anlegen
{triggerLabel}
</Button>
</DialogTrigger>
<DialogContent>
<form action={handleAction}>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Termin anlegen (Admin)</DialogTitle>
<DialogDescription>
@@ -65,12 +126,25 @@ export function AdminTerminModal() {
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="title">Titel</Label>
<Input id="title" name="title" required placeholder="z.B. Sommerfest" />
<Input
id="title"
name="title"
required
placeholder="z.B. Sommerfest"
value={formState.title}
onChange={(event) => updateField("title", event.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Beschreibung (Optional)</Label>
<Textarea id="description" name="description" placeholder="Details zum Termin..." />
<Textarea
id="description"
name="description"
placeholder="Details zum Termin..."
value={formState.description}
onChange={(event) => updateField("description", event.target.value)}
/>
</div>
<div className="grid gap-2">
@@ -78,7 +152,9 @@ export function AdminTerminModal() {
<select
id="type"
name="type"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={formState.type}
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
onChange={(event) => updateField("type", event.target.value as TerminType)}
required
>
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
@@ -97,16 +173,39 @@ export function AdminTerminModal() {
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="startDate">Start</Label>
<Input id="startDate" name="startDate" type="datetime-local" required />
<Input
id="startDate"
name="startDate"
type="datetime-local"
min={minDateTime}
value={formState.startDate}
onChange={(event) => updateField("startDate", event.target.value)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="endDate">Ende</Label>
<Input id="endDate" name="endDate" type="datetime-local" required />
<Input
id="endDate"
name="endDate"
type="datetime-local"
min={minEndDateTime}
value={formState.endDate}
onChange={(event) => updateField("endDate", event.target.value)}
required
/>
</div>
</div>
<div className="flex items-center gap-2 mt-2">
<input type="checkbox" id="allDay" name="allDay" className="rounded border-gray-300" />
<input
type="checkbox"
id="allDay"
name="allDay"
checked={formState.allDay}
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
onChange={(event) => updateField("allDay", event.target.checked)}
/>
<Label htmlFor="allDay" className="font-normal cursor-pointer">
Ganztägiger Termin
</Label>
@@ -126,3 +225,10 @@ export function AdminTerminModal() {
</Dialog>
);
}
function getLocalDateTimeInputValue(date: Date) {
const localDate = new Date(date);
localDate.setSeconds(0, 0);
const offset = localDate.getTimezoneOffset() * 60_000;
return new Date(localDate.getTime() - offset).toISOString().slice(0, 16);
}
@@ -55,23 +55,25 @@ export function MitbringselList({
};
return (
<div className="flex flex-col gap-3 rounded-md bg-muted/50 p-3">
<div className="flex items-center gap-2 font-medium text-sm mb-1">
<Utensils className="h-4 w-4 text-muted-foreground" />
<div className="flex flex-col gap-3 rounded-lg bg-[var(--surface-2)] p-3">
<div className="mb-1 flex items-center gap-2 text-sm font-medium text-[var(--ink)]">
<Utensils className="h-4 w-4 text-[var(--ink-muted)] [stroke-width:1.7]" />
Mitbring-Liste
</div>
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
{items.length === 0 ? (
<p className="text-xs text-muted-foreground italic">Noch keine Einträge vorhanden.</p>
<p className="text-xs italic text-[var(--ink-faint)]">
Noch keine Einträge vorhanden.
</p>
) : (
items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between gap-2 rounded-sm bg-background p-2 text-sm shadow-sm"
className="flex items-center justify-between gap-2 rounded-md border border-[var(--hairline-soft)] bg-[var(--surface)] p-2 text-sm text-[var(--ink)]"
>
<div className="flex flex-col">
<span className="font-medium text-xs text-primary">
<span className="text-xs font-medium text-[var(--accent-deep)]">
{item.user.firstName} {item.user.lastName}
</span>
<span>{item.content}</span>
@@ -80,11 +82,11 @@ export function MitbringselList({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive shrink-0"
className="h-6 w-6 shrink-0 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => handleDelete(item.id)}
disabled={isPending}
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button>
)}
</div>
@@ -112,7 +114,7 @@ export function MitbringselList({
onClick={handleAdd}
disabled={!content.trim() || isPending}
>
<Plus className="h-4 w-4 mr-1" />
<Plus className="mr-1 h-4 w-4 [stroke-width:1.7]" />
Hinzufügen
</Button>
</div>
@@ -3,17 +3,20 @@
import { useTransition } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Check, X, Clock, CalendarIcon } from "lucide-react";
import { CalendarIcon, Check, Clock, X } from "lucide-react";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { approveTermin, rejectTermin } from "../actions";
export type PendingTerminDto = {
id: string;
title: string;
startDate: Date;
createdAt: Date;
ageInDays: number;
allDay: boolean;
createdBy: { firstName: string; lastName: string } | null;
};
@@ -21,22 +24,28 @@ export type PendingTerminDto = {
export function PendingAnfragen({ termine }: { termine: PendingTerminDto[] }) {
if (termine.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
<CalendarIcon className="h-10 w-10 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Keine ausstehenden Anfragen</h3>
<p className="text-sm text-muted-foreground">
<Card>
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center p-8 text-center animate-in fade-in-50">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<CalendarIcon className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="text-lg font-medium text-[var(--ink)]">
Keine ausstehenden Anfragen
</h3>
<p className="mt-2 text-sm text-[var(--ink-soft)]">
Es gibt aktuell keine Terminanfragen, die bestätigt werden müssen.
</p>
</div>
</CardContent>
</Card>
);
}
return (
<div className="flex flex-col gap-4">
<Card className="overflow-hidden p-0">
{termine.map((termin) => (
<PendingTerminCard key={termin.id} termin={termin} />
))}
</div>
</Card>
);
}
@@ -67,40 +76,57 @@ function PendingTerminCard({ termin }: { termin: PendingTerminDto }) {
};
return (
<Card>
<CardContent className="flex items-center justify-between p-4 sm:p-6">
<div className="flex flex-col gap-1">
<div className="font-semibold">{termin.title}</div>
<div className="text-sm text-muted-foreground flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{format(termin.startDate, "PP", { locale: de })}
{!termin.allDay && `${format(termin.startDate, "p", { locale: de })}`}
</div>
<div className="text-sm font-medium text-primary mt-1">
Angefragt von: {termin.createdBy?.firstName} {termin.createdBy?.lastName}
</div>
<div className="grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 border-b border-[var(--hairline-soft)] px-5 py-4 last:border-b-0 hover:bg-[var(--surface-2)]">
<div className="flex h-14 w-14 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)]">
<span className="text-[22px] font-medium leading-none tracking-[-0.02em] text-[var(--ink)] tabular-nums">
{format(termin.startDate, "d", { locale: de })}
</span>
<span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.08em]">
{format(termin.startDate, "EEE", { locale: de })}
</span>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{termin.title}
</h3>
{termin.ageInDays > 7 && (
<Badge variant="warning">Älter als 7 Tage</Badge>
)}
</div>
<p className="mt-1 flex items-center gap-1 text-sm text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(termin.startDate, "PP", { locale: de })}
{!termin.allDay &&
` · ${format(termin.startDate, "p", { locale: de })}`}
</p>
<p className="mt-1 text-sm font-medium text-[var(--accent-deep)]">
Angefragt von: {termin.createdBy?.firstName}{" "}
{termin.createdBy?.lastName} · vor {termin.ageInDays} Tagen
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
variant="outline"
className="text-destructive hover:bg-destructive hover:text-destructive-foreground border-destructive/20"
variant="ghost"
size="sm"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={handleReject}
disabled={isPending}
>
<X className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Ablehnen</span>
<X className="h-4 w-4 [stroke-width:1.7]" />
Ablehnen
</Button>
<Button
className="bg-emerald-600 hover:bg-emerald-700 text-white"
size="sm"
onClick={handleApprove}
disabled={isPending}
>
<Check className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Freigeben</span>
<Check className="h-4 w-4 [stroke-width:1.7]" />
Genehmigen
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -3,17 +3,18 @@
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { AlertTriangle, AlignLeft, Calendar, Clock, WashingMachine } from "lucide-react";
import {
AlertTriangle,
Calendar,
Clock,
MapPin,
MoreHorizontal,
WashingMachine,
} from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { MitbringselList } from "./mitbringsel-list";
import { toggleMitbringselList } from "../actions";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useTransition } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@@ -22,6 +23,8 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { AdminTerminModal } from "./admin-termin-modal";
import { TerminRequestModal } from "./termin-request-modal";
export type TerminListItemDto = {
kind: "termin";
@@ -59,7 +62,6 @@ export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto;
export function TerminList({
termine,
userId,
isAdmin,
}: {
termine: CalendarListItemDto[];
@@ -68,169 +70,130 @@ export function TerminList({
}) {
if (termine.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<Calendar className="h-10 w-10 text-muted-foreground mb-4" />
<h3 className="mt-4 text-lg font-semibold">Keine Termine</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
Es stehen aktuell keine Termine an.
<Card>
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Calendar className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="mt-5 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine Termine in diesem Zeitraum
</h3>
<p className="mt-2 max-w-md text-sm leading-6 text-[var(--ink-soft)]">
Lege den ersten Termin an, damit Eltern planen können.
</p>
<div className="mt-5">
{isAdmin ? (
<AdminTerminModal triggerLabel="+ Termin anlegen" triggerClassName="" />
) : (
<TerminRequestModal
triggerLabel="+ Termin anlegen"
triggerVariant="default"
triggerClassName=""
/>
)}
</div>
</div>
</CardContent>
</Card>
);
}
const groups = groupByMonth(termine);
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{termine.map((termin) => (
termin.kind === "duty" ? (
<DutyCard key={`duty-${termin.id}`} duty={termin} />
<div className="space-y-8">
{groups.map((group) => (
<section key={group.key}>
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
{group.label}
</h2>
<span className="rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)] tabular-nums">
{group.items.length} {group.items.length === 1 ? "Termin" : "Termine"}
</span>
</div>
<Card className="overflow-hidden p-0">
<div className="divide-y divide-[var(--hairline-soft)]">
{group.items.map((item) =>
item.kind === "duty" ? (
<DutyRow key={`duty-${item.id}`} duty={item} />
) : (
<TerminCard
key={`termin-${termin.id}`}
termin={termin}
userId={userId}
isAdmin={isAdmin}
/>
)
<TerminRow key={`termin-${item.id}`} termin={item} />
),
)}
</div>
</Card>
</section>
))}
</div>
);
}
function DutyCard({ duty }: { duty: DutyCalendarItemDto }) {
function DutyRow({ duty }: { duty: DutyCalendarItemDto }) {
const color = "var(--good)";
return (
<Card className="flex flex-col overflow-hidden border-emerald-200 bg-emerald-50/60">
<CardHeader className="pb-3">
<div className="mb-2 flex items-start justify-between gap-2">
<Badge className="border-transparent bg-emerald-600 text-white hover:bg-emerald-700">
Elterndienst
</Badge>
</div>
<CardTitle className="line-clamp-2 leading-tight">
<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 }} />
<DateTile date={duty.startDate} />
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{duty.title}
</CardTitle>
<CardDescription className="mt-1 flex items-center gap-1 text-sm">
<Clock className="h-3.5 w-3.5" />
</h3>
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(duty.startDate, "dd.MM.", { locale: de })} -{" "}
{format(duty.endDate, "dd.MM.yyyy", { locale: de })}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 pb-4">
<div className="flex items-start gap-2 text-sm text-emerald-900">
<WashingMachine className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Eingeteilt: <span className="font-medium">{duty.familyName}</span>
<WashingMachine className="ml-1 h-3.5 w-3.5 [stroke-width:1.7]" />
{duty.familyName}
</p>
</div>
</CardContent>
</Card>
<div className="flex items-center gap-2">
<CategoryPill color={color}>Elterndienst</CategoryPill>
<Button variant="ghost" size="icon" aria-label="Mehr Optionen">
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</div>
</div>
);
}
function TerminCard({
termin,
userId,
isAdmin,
}: {
termin: TerminListItemDto;
userId: string;
isAdmin: boolean;
}) {
const [isPending, startTransition] = useTransition();
function TerminRow({ termin }: { termin: TerminListItemDto }) {
const color = getTypeColor(termin.type);
const handleToggleMitbringsel = (enabled: boolean) => {
startTransition(async () => {
const result = await toggleMitbringselList(termin.id, enabled);
if (result.error) {
toast.error(result.error);
} else {
toast.success(
enabled
? "Mitbring-Liste aktiviert"
: "Mitbring-Liste deaktiviert"
);
}
});
};
return (
<Card className="flex flex-col overflow-hidden relative">
{termin.status === TerminStatus.PENDING && (
<div className="absolute top-0 right-0 bg-yellow-500 text-white text-xs font-bold px-2 py-1 rounded-bl-lg z-10">
Ausstehend
</div>
)}
<CardHeader className="pb-3">
<div className="flex justify-between items-start mb-2 gap-2">
<Badge variant={getBadgeVariant(termin.type)} className={getBadgeColor(termin.type)}>
{getTypeLabel(termin.type)}
</Badge>
</div>
<CardTitle className="line-clamp-2 leading-tight">{termin.title}</CardTitle>
<CardDescription className="flex items-center gap-1 mt-1 text-sm">
<Clock className="h-3.5 w-3.5" />
{format(termin.startDate, "PP", { locale: de })}
{!termin.allDay && (
<>
{" • "}
{format(termin.startDate, "p", { locale: de })} -{" "}
{format(termin.endDate, "p", { locale: de })}
</>
)}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 pb-4">
{termin.description && (
<div className="flex items-start gap-2 text-sm text-muted-foreground mt-2">
<AlignLeft className="h-4 w-4 shrink-0 mt-0.5" />
<p className="line-clamp-3 whitespace-pre-wrap">{termin.description}</p>
</div>
)}
<TerminDetailDialog termin={termin} />
{/* Admin Toggle for Mitbringsel-List */}
{isAdmin && termin.status === TerminStatus.CONFIRMED && (
<div className="flex items-center space-x-2 mt-6 pt-4 border-t">
<Switch
id={`mitbringsel-${termin.id}`}
checked={termin.mitbringselListEnabled}
onCheckedChange={handleToggleMitbringsel}
disabled={isPending}
/>
<Label htmlFor={`mitbringsel-${termin.id}`} className="text-xs">
Mitbring-Liste aktiv
</Label>
</div>
)}
{/* Mitbringsel-List UI */}
{termin.mitbringselListEnabled && termin.status === TerminStatus.CONFIRMED && (
<div className="mt-4">
<MitbringselList
terminId={termin.id}
items={termin.mitbringselItems || []}
currentUserId={userId}
isAdmin={isAdmin}
/>
</div>
)}
</CardContent>
</Card>
);
}
function TerminDetailDialog({ termin }: { termin: TerminListItemDto }) {
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 }} />
<DateTile date={termin.startDate} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{termin.title}
</h3>
{termin.status === TerminStatus.PENDING && (
<Badge variant="warning">Ausstehend</Badge>
)}
</div>
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{formatTerminTime(termin)}
<MapPin className="ml-1 h-3.5 w-3.5 [stroke-width:1.7]" />
Kita
</p>
</div>
<div className="flex items-center gap-2">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="mt-4">
<Button variant="ghost" size="sm">
Details
</Button>
</DialogTrigger>
<Button variant="ghost" size="icon" aria-label="Mehr Optionen">
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</div>
</div>
<DialogContent>
<DialogHeader>
<DialogTitle>{termin.title}</DialogTitle>
@@ -246,24 +209,22 @@ function TerminDetailDialog({ termin }: { termin: TerminListItemDto }) {
</DialogHeader>
<div className="grid gap-4">
<Badge variant={getBadgeVariant(termin.type)} className={getBadgeColor(termin.type)}>
{getTypeLabel(termin.type)}
</Badge>
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
{termin.description ? (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
<p className="whitespace-pre-wrap text-sm leading-6 text-[var(--ink-soft)]">
{termin.description}
</p>
) : (
<p className="text-sm text-muted-foreground">
<p className="text-sm italic text-[var(--ink-faint)]">
Keine Beschreibung hinterlegt.
</p>
)}
{termin.participantInfo && termin.showParticipantInfo && (
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-4 text-sm text-yellow-950">
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
<div className="mb-1 flex items-center gap-2 font-medium">
<AlertTriangle className="h-4 w-4 text-yellow-700" />
<AlertTriangle className="h-4 w-4 text-[var(--warn)] [stroke-width:1.7]" />
Wichtiger Hinweis für Teilnehmer
</div>
<p className="whitespace-pre-wrap">{termin.participantInfo}</p>
@@ -275,42 +236,77 @@ function TerminDetailDialog({ termin }: { termin: TerminListItemDto }) {
);
}
function getBadgeVariant(type: TerminType): "default" | "secondary" | "destructive" | "outline" {
switch (type) {
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "default";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "destructive";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "secondary";
default:
return "outline";
}
function DateTile({ date }: { date: Date }) {
return (
<div className="flex h-14 w-14 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)]">
<span className="text-[22px] font-medium leading-none tracking-[-0.02em] text-[var(--ink)] tabular-nums">
{format(date, "d", { locale: de })}
</span>
<span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.08em]">
{format(date, "EEE", { locale: de })}
</span>
</div>
);
}
function getBadgeColor(type: TerminType): string {
function CategoryPill({
color,
children,
}: {
color: string;
children: React.ReactNode;
}) {
return (
<span
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)]"
style={{ color }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: color }} />
{children}
</span>
);
}
function groupByMonth(items: CalendarListItemDto[]) {
const groups = new Map<string, { key: string; label: string; items: CalendarListItemDto[] }>();
items.forEach((item) => {
const key = format(item.startDate, "yyyy-MM");
const label = format(item.startDate, "MMMM yyyy", { locale: de });
const group = groups.get(key) ?? { key, label, items: [] };
group.items.push(item);
groups.set(key, group);
});
return Array.from(groups.values());
}
function formatTerminTime(termin: TerminListItemDto) {
if (termin.allDay) return "ganztägig";
return `${format(termin.startDate, "HH:mm", { locale: de })} - ${format(
termin.endDate,
"HH:mm",
{ locale: de },
)}`;
}
function getTypeColor(type: TerminType): string {
switch (type) {
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "bg-amber-500 hover:bg-amber-600 text-white border-transparent";
return "var(--good)";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "bg-rose-500 hover:bg-rose-600 text-white border-transparent";
return "var(--danger)";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "bg-blue-500 hover:bg-blue-600 text-white border-transparent";
return "oklch(0.50 0.10 320)";
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "bg-purple-500 hover:bg-purple-600 text-white border-transparent";
return "oklch(0.50 0.10 280)";
default:
return "bg-slate-200 text-slate-800 border-slate-300 dark:bg-slate-800 dark:text-slate-200 dark:border-slate-700";
return "var(--ink-muted)";
}
}
@@ -323,7 +319,7 @@ function getTypeLabel(type: TerminType): string {
case TerminType.SCHLIESSTAG:
return "Schließtag";
case TerminType.TEAMTAG:
return "Teamtag (Kita geschlossen)";
return "Teamtag";
case TerminType.ELTERNABEND:
return "Elternabend";
case TerminType.MITGLIEDERVERSAMMLUNG:
@@ -331,9 +327,9 @@ function getTypeLabel(type: TerminType): string {
case TerminType.ELTERNCAFE:
return "Elterncafe";
case TerminType.GEBURTSTAG_INTERN:
return "Geburtstag (Intern)";
return "Geburtstag";
case TerminType.GEBURTSTAG_EXTERN:
return "Raumanfrage (Extern)";
return "Raumanfrage";
default:
return "Sonstiges";
}
@@ -1,6 +1,7 @@
"use client";
import { useState, useTransition } from "react";
import type { ComponentProps, FormEvent } from "react";
import { useMemo, useState, useTransition } from "react";
import { TerminType } from "@prisma/client";
import { CalendarPlus } from "lucide-react";
import { toast } from "sonner";
@@ -19,17 +20,74 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createTerminRequest } from "../actions";
export function TerminRequestModal() {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
type TerminRequestFormState = {
title: string;
type: TerminType;
startDate: string;
endDate: string;
allDay: boolean;
};
const initialFormState: TerminRequestFormState = {
title: "",
type: TerminType.KITA_FEST,
startDate: "",
endDate: "",
allDay: false,
};
type TerminRequestModalProps = {
triggerLabel?: string;
triggerVariant?: ComponentProps<typeof Button>["variant"];
triggerClassName?: string;
};
export function TerminRequestModal({
triggerLabel = "Termin anfragen",
triggerVariant = "ghost",
triggerClassName = "gap-2",
}: TerminRequestModalProps) {
const [open, setOpen] = useState(false);
const [formState, setFormState] = useState<TerminRequestFormState>(initialFormState);
const [isPending, startTransition] = useTransition();
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
const minEndDateTime = formState.startDate || minDateTime;
const updateField = <TKey extends keyof TerminRequestFormState>(
key: TKey,
value: TerminRequestFormState[TKey],
) => {
setFormState((current) => {
const next = { ...current, [key]: value };
if (key === "startDate" && typeof value === "string" && next.endDate && next.endDate < value) {
next.endDate = value;
}
return next;
});
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!formState.startDate || !formState.endDate) {
toast.error("Bitte Start und Ende ausfüllen.");
return;
}
if (formState.startDate < minDateTime) {
toast.error("Der Startzeitpunkt darf nicht in der Vergangenheit liegen.");
return;
}
if (formState.endDate < formState.startDate) {
toast.error("Das Ende darf nicht vor dem Start liegen.");
return;
}
const handleAction = (formData: FormData) => {
const data = {
title: formData.get("title") as string,
type: formData.get("type") as TerminType,
startDate: new Date(formData.get("startDate") as string).toISOString(),
endDate: new Date(formData.get("endDate") as string).toISOString(),
allDay: formData.get("allDay") === "on",
title: formState.title,
type: formState.type,
startDate: new Date(formState.startDate).toISOString(),
endDate: new Date(formState.endDate).toISOString(),
allDay: formState.allDay,
};
startTransition(async () => {
@@ -38,6 +96,7 @@ export function TerminRequestModal() {
toast.error(res.error);
} else {
toast.success("Terminanfrage wurde erfolgreich gestellt!");
setFormState(initialFormState);
setOpen(false);
}
});
@@ -46,13 +105,13 @@ export function TerminRequestModal() {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<CalendarPlus className="h-4 w-4" />
Termin anfragen
<Button variant={triggerVariant} className={triggerClassName}>
<CalendarPlus className="h-4 w-4 [stroke-width:1.7]" />
{triggerLabel}
</Button>
</DialogTrigger>
<DialogContent>
<form action={handleAction}>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Termin anfragen</DialogTitle>
<DialogDescription>
@@ -64,7 +123,14 @@ export function TerminRequestModal() {
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="title">Titel</Label>
<Input id="title" name="title" required placeholder="z.B. Geburtstag von Mia" />
<Input
id="title"
name="title"
required
placeholder="z.B. Geburtstag von Mia"
value={formState.title}
onChange={(event) => updateField("title", event.target.value)}
/>
</div>
<div className="grid gap-2">
@@ -72,7 +138,9 @@ export function TerminRequestModal() {
<select
id="type"
name="type"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={formState.type}
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] disabled:cursor-not-allowed disabled:opacity-50"
onChange={(event) => updateField("type", event.target.value as TerminType)}
required
>
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
@@ -91,16 +159,39 @@ export function TerminRequestModal() {
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="startDate">Start</Label>
<Input id="startDate" name="startDate" type="datetime-local" required />
<Input
id="startDate"
name="startDate"
type="datetime-local"
min={minDateTime}
value={formState.startDate}
onChange={(event) => updateField("startDate", event.target.value)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="endDate">Ende</Label>
<Input id="endDate" name="endDate" type="datetime-local" required />
<Input
id="endDate"
name="endDate"
type="datetime-local"
min={minEndDateTime}
value={formState.endDate}
onChange={(event) => updateField("endDate", event.target.value)}
required
/>
</div>
</div>
<div className="flex items-center gap-2 mt-2">
<input type="checkbox" id="allDay" name="allDay" className="rounded border-gray-300" />
<input
type="checkbox"
id="allDay"
name="allDay"
checked={formState.allDay}
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
onChange={(event) => updateField("allDay", event.target.checked)}
/>
<Label htmlFor="allDay" className="font-normal cursor-pointer">
Ganztägiger Termin
</Label>
@@ -120,3 +211,10 @@ export function TerminRequestModal() {
</Dialog>
);
}
function getLocalDateTimeInputValue(date: Date) {
const localDate = new Date(date);
localDate.setSeconds(0, 0);
const offset = localDate.getTimezoneOffset() * 60_000;
return new Date(localDate.getTime() - offset).toISOString().slice(0, 16);
}
+6
View File
@@ -27,6 +27,12 @@ const terminSchema = z.object({
function buildTerminData(data: z.infer<typeof terminSchema>) {
const startDate = new Date(data.startDate);
const endDate = new Date(data.endDate);
const now = new Date();
now.setSeconds(0, 0);
if (startDate < now) {
return { error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen." };
}
if (endDate < startDate) {
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
+225 -43
View File
@@ -2,11 +2,14 @@ import {
DutyAssignmentStatus,
EventParticipationStatus,
TerminStatus,
TerminType,
UserRole,
} from "@prisma/client";
import { HelpCircle, ListFilter, Search } 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";
@@ -17,7 +20,7 @@ export const metadata = { title: "Terminkalender · Kita-Planer" };
export default async function KalenderPage({
searchParams,
}: {
searchParams: { tab?: string };
searchParams: Promise<{ tab?: string }>;
}) {
const session = await requireKitaSession();
const isAdmin =
@@ -25,7 +28,9 @@ export default async function KalenderPage({
session.user.role === UserRole.KOORDINATOR;
const familyIdForParticipation = session.user.familyId ?? "__no_family__";
const currentTab = searchParams.tab || "übersicht";
const resolvedSearchParams = await searchParams;
const currentTab = resolvedSearchParams.tab || "übersicht";
const now = new Date();
const confirmedTermineRows = await prisma.termin.findMany({
where: {
@@ -122,7 +127,7 @@ export default async function KalenderPage({
where: {
kitaId: session.user.kitaId,
status: DutyAssignmentStatus.PLANNED,
endDate: { gte: new Date() },
endDate: { gte: now },
},
select: {
id: true,
@@ -177,7 +182,7 @@ export default async function KalenderPage({
let allPendingTermine: PendingTerminDto[] = [];
if (isAdmin) {
allPendingTermine = await prisma.termin.findMany({
const pendingRows = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.PENDING,
@@ -186,6 +191,8 @@ export default async function KalenderPage({
id: true,
title: true,
startDate: true,
createdAt: true,
type: true,
allDay: true,
createdBy: {
select: {
@@ -196,67 +203,74 @@ export default async function KalenderPage({
},
orderBy: { createdAt: "desc" },
});
allPendingTermine = pendingRows.map((termin) => ({
...termin,
ageInDays: Math.floor(
(now.getTime() - termin.createdAt.getTime()) / (1000 * 60 * 60 * 24),
),
}));
}
return (
<div className="flex h-full flex-col gap-6 p-6">
<div className="flex items-center justify-between">
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
<div>
<h1 className="text-2xl font-bold tracking-tight">Terminkalender</h1>
<p className="text-muted-foreground">
Alle anstehenden Termine, Feste und Schließtage im Überblick.
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
TERMINKALENDER
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Was steht{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
an
</em>
?
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Alle anstehenden Termine, Feste, Dienste und Schließtage im
Überblick.
</p>
</div>
<div className="flex gap-2">
<div className="flex shrink-0 flex-wrap items-center gap-2">
<TerminRequestModal />
<span className="group relative inline-flex h-9 w-9 items-center justify-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-muted)]">
<HelpCircle className="h-4 w-4 [stroke-width:1.7]" />
<span className="pointer-events-none absolute right-0 top-11 z-30 hidden w-72 rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-left text-xs leading-5 text-white shadow-lg group-hover:block">
Anfragen müssen vom Vorstand bestätigt werden, direkte Anlage
erscheint sofort im Kalender.
</span>
</span>
{isAdmin && <AdminTerminModal />}
</div>
</div>
</header>
{isAdmin ? (
<div className="flex flex-col gap-4">
<div className="flex gap-4 border-b">
<a
href="/dashboard/kalender?tab=übersicht"
className={`pb-2 text-sm font-medium transition-colors hover:text-primary ${
currentTab === "übersicht"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground"
}`}
>
Übersicht
</a>
<a
href="/dashboard/kalender?tab=anfragen"
className={`flex items-center gap-2 pb-2 text-sm font-medium transition-colors hover:text-primary ${
currentTab === "anfragen"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground"
}`}
>
Ausstehende Anfragen
{allPendingTermine.length > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
{allPendingTermine.length}
</span>
)}
</a>
</div>
<div className="flex flex-col gap-5">
<TabBar
currentTab={currentTab}
overviewCount={allUserTermine.length}
pendingCount={allPendingTermine.length}
/>
<div className="pt-2">
<div>
{currentTab === "übersicht" ? (
<TerminList
<CalendarOverview
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
/>
) : (
) : currentTab === "anfragen" ? (
<PendingAnfragen termine={allPendingTermine} />
) : (
<CalendarOverview
termine={[]}
userId={session.user.id}
isAdmin={isAdmin}
/>
)}
</div>
</div>
) : (
<TerminList
<CalendarOverview
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
@@ -265,3 +279,171 @@ export default async function KalenderPage({
</div>
);
}
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,
overviewCount,
pendingCount,
}: {
currentTab: string;
overviewCount: number;
pendingCount: number;
}) {
return (
<div className="flex border-b border-[var(--hairline)]">
<TabLink
href="/dashboard/kalender?tab=übersicht"
active={currentTab === "übersicht"}
count={overviewCount}
>
Übersicht
</TabLink>
<TabLink
href="/dashboard/kalender?tab=anfragen"
active={currentTab === "anfragen"}
count={pendingCount}
attention={pendingCount > 0}
>
Ausstehende Anfragen
</TabLink>
<TabLink
href="/dashboard/kalender?tab=vergangen"
active={currentTab === "vergangen"}
>
Vergangene
</TabLink>
</div>
);
}
function TabLink({
href,
active,
count,
attention,
children,
}: {
href: string;
active: boolean;
count?: number;
attention?: boolean;
children: React.ReactNode;
}) {
return (
<a
href={href}
className={[
"-mb-px inline-flex h-11 items-center gap-2 border-b-2 px-1.5 text-sm transition first:pl-0",
active
? "border-[var(--accent)] font-semibold text-[var(--ink)]"
: "border-transparent font-medium text-[var(--ink-soft)] hover:text-[var(--ink)]",
].join(" ")}
>
{children}
{typeof count === "number" && (
<span
className={[
"rounded-full px-2 py-0.5 text-[11px] font-semibold tabular-nums",
attention
? "bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)]"
: "border border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]",
].join(" ")}
>
{count}
</span>
)}
</a>
);
}
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)";
}
}
+31 -18
View File
@@ -1,11 +1,10 @@
import type { Metadata } from "next";
import { Baby, LogOut } from "lucide-react";
import { LogOut } from "lucide-react";
import { signOut } from "@/auth";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { SidebarNav } from "@/components/dashboard/sidebar-nav";
export const metadata: Metadata = {
@@ -34,40 +33,45 @@ export default async function DashboardLayout({
where: { id: session.user.kitaId },
select: { name: true },
});
const displayName = session.user.name ?? session.user.email ?? "Kita-Planer";
return (
<div className="flex min-h-screen bg-muted/30">
<div className="grid min-h-screen grid-cols-[256px_minmax(0,1fr)] bg-[var(--bg)] max-[760px]:grid-cols-1">
{/* ── Sidebar ───────────────────────────────────────────────────── */}
<aside className="flex w-60 shrink-0 flex-col border-r bg-background">
<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">
{/* Logo / Kita-Name */}
<div className="flex h-16 items-center gap-2.5 border-b px-5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Baby className="h-4 w-4" />
<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">
{kita.name.slice(0, 1).toUpperCase()}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-none">
<p className="truncate text-sm font-semibold leading-none text-[var(--ink)]">
{kita.name}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">Kita-Planer</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">Kita-Planer</p>
</div>
</div>
{/* Navigation */}
<div className="flex-1 overflow-y-auto py-4">
<div className="flex-1 overflow-y-auto py-6">
<SidebarNav role={session.user.role} />
</div>
{/* Footer: User-Info + Abmelden */}
<div className="border-t p-3">
<Separator className="mb-3" />
<div className="mb-2 px-3">
<p className="truncate text-xs font-medium">
{session.user.name ?? session.user.email}
<div className="border-t border-[var(--hairline-soft)] p-4">
<div className="mb-3 flex items-center gap-2.5 px-1">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
{getInitials(displayName)}
</div>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--ink)]">
{displayName}
</p>
<p className="truncate text-xs text-muted-foreground">
<p className="truncate text-[11px] font-semibold uppercase text-[var(--ink-muted)]">
{session.user.role}
</p>
</div>
</div>
<form
action={async () => {
"use server";
@@ -78,7 +82,7 @@ export default async function DashboardLayout({
type="submit"
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground"
className="w-full justify-start"
>
<LogOut className="h-4 w-4" />
Abmelden
@@ -88,9 +92,18 @@ export default async function DashboardLayout({
</aside>
{/* ── Main Content ──────────────────────────────────────────────── */}
<main className="flex-1 overflow-y-auto">
<main className="min-w-0 overflow-y-auto">
{children}
</main>
</div>
);
}
function getInitials(value: string) {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return value.slice(0, 2).toUpperCase();
}
+12 -12
View File
@@ -58,12 +58,12 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
return (
<>
<Card className="mb-8 border-emerald-200 bg-emerald-50/60">
<Card className="mb-8 border-[var(--good)] bg-[var(--good-soft)]">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<Megaphone className="h-5 w-5 text-emerald-700" />
<Megaphone className="h-5 w-5 text-[var(--good)] [stroke-width:1.7]" />
Schwarzes Brett
</CardTitle>
<CardDescription>
@@ -81,26 +81,26 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
key={item.id}
type="button"
onClick={() => openAnnouncement(item)}
className="rounded-md border bg-background p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-300 hover:shadow-md"
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-4 text-left transition hover:border-[var(--accent-mid)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
<div className="mb-3 flex items-start justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Bell className="h-3.5 w-3.5" />
<div className="flex items-center gap-2 text-xs text-[var(--ink-muted)]">
<Bell className="h-3.5 w-3.5 [stroke-width:1.7]" />
{item.createdAt}
</div>
{isUnread && (
<Badge className="bg-emerald-600 text-white">NEU</Badge>
<Badge variant="success">NEU</Badge>
)}
</div>
<h3 className="line-clamp-2 font-semibold leading-snug">
<h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
{item.title}
</h3>
<p className="mt-2 line-clamp-3 text-sm leading-6 text-muted-foreground">
<p className="mt-2 line-clamp-3 text-sm leading-6 text-[var(--ink-soft)]">
{item.content}
</p>
{item.attachments.length > 0 && (
<p className="mt-3 flex items-center gap-1 text-xs font-medium text-emerald-700">
<FileText className="h-3.5 w-3.5" />
<p className="mt-3 flex items-center gap-1 text-xs font-medium text-[var(--accent-deep)]">
<FileText className="h-3.5 w-3.5 [stroke-width:1.7]" />
{item.attachments.length} Anhang
{item.attachments.length === 1 ? "" : "e"}
</p>
@@ -122,7 +122,7 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
</DialogDescription>
</DialogHeader>
<div className="rounded-md border bg-muted/30 p-4 text-sm">
<div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm">
<MarkdownContent content={selected.content} />
</div>
@@ -138,7 +138,7 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
className="justify-start"
>
<a href={attachment.fileUrl} target="_blank" rel="noreferrer">
<FileText className="h-4 w-4" />
<FileText className="h-4 w-4 [stroke-width:1.7]" />
{attachment.fileName}
</a>
</Button>
@@ -1,58 +1,29 @@
"use client";
import { useState } from "react";
import { ArrowRight, CalendarHeart } from "lucide-react";
import { Button } from "@/components/ui/button";
import { NotdienstForm } from "./notdienst-form";
import {
NotdienstForm,
type NotdienstCalendarItem,
} from "./notdienst-form";
type NotdienstEntryProps = {
hasAnyAvailability: boolean;
targetYear: number;
targetMonth: number;
isLocked: boolean;
requiredDaysTotal: number;
initialSelectedDates: string[];
childrenIds: string[];
calendarItems: NotdienstCalendarItem[];
};
export function NotdienstEntry({
hasAnyAvailability,
targetYear,
targetMonth,
isLocked,
requiredDaysTotal,
initialSelectedDates,
childrenIds,
calendarItems,
}: NotdienstEntryProps) {
const [showForm, setShowForm] = useState(hasAnyAvailability);
if (!showForm) {
return (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-gradient-to-b from-emerald-50/70 to-background p-8 text-center">
<div className="mx-auto max-w-lg">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 shadow-sm">
<CalendarHeart className="h-10 w-10" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
Willkommen beim Notdienst!
</h2>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Es sieht so aus, als hättest du noch keine Termine eingetragen.
</p>
<Button
type="button"
className="mt-6"
onClick={() => setShowForm(true)}
>
Jetzt erste Pflicht-Termine eintragen
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
return (
<NotdienstForm
targetYear={targetYear}
@@ -61,6 +32,7 @@ export function NotdienstEntry({
requiredDaysTotal={requiredDaysTotal}
initialSelectedDates={initialSelectedDates}
childrenIds={childrenIds}
calendarItems={calendarItems}
/>
);
}
+550 -99
View File
@@ -1,23 +1,83 @@
"use client";
import { useState, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Info, Loader2, CheckCircle2 } from "lucide-react";
import { format } from "date-fns";
import {
Check,
ChevronLeft,
ChevronRight,
Clock3,
Info,
Loader2,
LockKeyhole,
X,
} from "lucide-react";
import {
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameMonth,
startOfDay,
startOfMonth,
startOfWeek,
} from "date-fns";
import { de } from "date-fns/locale";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { formatDateKey, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { Button } from "@/components/ui/button";
import { saveNotdienstAvailabilities } from "./actions";
// =====================================================================
// NotdienstForm · Client Component
// ---------------------------------------------------------------------
// Erlaubt das Auswählen von N Tagen aus der Liste der Werktage des
// Zielmonats. Speichert als Array von Datum-Strings.
// =====================================================================
export const notdienstEventCategories = {
closure: {
label: "Schließtag",
dot: "oklch(0.62 0.10 35)",
bg: "oklch(0.96 0.04 35)",
border: "oklch(0.88 0.04 35)",
text: "oklch(0.38 0.08 35)",
},
team: {
label: "Teamtag",
dot: "oklch(0.62 0.10 70)",
bg: "oklch(0.96 0.04 70)",
border: "oklch(0.88 0.04 70)",
text: "oklch(0.38 0.08 70)",
},
parents: {
label: "Elternabend",
dot: "oklch(0.62 0.10 280)",
bg: "oklch(0.96 0.04 280)",
border: "oklch(0.88 0.04 280)",
text: "oklch(0.38 0.08 280)",
},
social: {
label: "Fest",
dot: "oklch(0.62 0.10 155)",
bg: "oklch(0.96 0.04 155)",
border: "oklch(0.88 0.04 155)",
text: "oklch(0.38 0.08 155)",
},
birthday: {
label: "Geburtstag",
dot: "oklch(0.62 0.10 320)",
bg: "oklch(0.96 0.04 320)",
border: "oklch(0.88 0.04 320)",
text: "oklch(0.38 0.08 320)",
},
} as const;
type NotdienstEventCategory = keyof typeof notdienstEventCategories;
export type NotdienstCalendarItem = {
id: string;
title: string;
category: NotdienstEventCategory;
startDate: Date;
endDate: Date;
allDay: boolean;
note: string | null;
};
interface NotdienstFormProps {
targetYear: number;
@@ -26,6 +86,7 @@ interface NotdienstFormProps {
requiredDaysTotal: number;
initialSelectedDates: string[];
childrenIds: string[];
calendarItems: NotdienstCalendarItem[];
}
export function NotdienstForm({
@@ -35,6 +96,7 @@ export function NotdienstForm({
requiredDaysTotal,
initialSelectedDates,
childrenIds,
calendarItems,
}: NotdienstFormProps) {
const router = useRouter();
// Lokaler State für die Auswahl. Ein Datum liegt als stabiler yyyy-MM-dd-Schlüssel vor.
@@ -43,26 +105,35 @@ export function NotdienstForm({
);
const [isPending, startTransition] = useTransition();
const closureDateKeys = useMemo(
() => getClosureDateKeys(calendarItems),
[calendarItems],
);
const selectableDateKeys = useMemo(() => {
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
return new Set(
workingDays
.map(formatDateKey)
.filter((dateKey) => !closureDateKeys.has(dateKey)),
);
}, [closureDateKeys, targetMonth, targetYear]);
const monthStart = startOfMonth(new Date(targetYear, targetMonth - 1));
const selectedCount = selectedDates.size;
const isFulfilled = selectedCount >= requiredDaysTotal;
const hasMinimumRequirement = requiredDaysTotal > 0;
const isFulfilled =
!hasMinimumRequirement || selectedCount >= requiredDaysTotal;
const selectedLabel =
selectedCount === 1 ? "1 Tag markiert" : `${selectedCount} Tage markiert`;
function toggleDate(dateStr: string) {
function handleChange(nextDates: Set<string>) {
if (isLocked || isPending) return;
const newSet = new Set(selectedDates);
if (newSet.has(dateStr)) {
newSet.delete(dateStr);
} else {
newSet.add(dateStr);
}
setSelectedDates(newSet);
setSelectedDates(nextDates);
}
function handleSave() {
if (isLocked) return;
if (!isFulfilled) {
if (hasMinimumRequirement && !isFulfilled) {
toast.error(
`Bitte wähle noch ${requiredDaysTotal - selectedCount} weitere Termine aus.`,
);
@@ -88,101 +159,481 @@ export function NotdienstForm({
}
return (
<div className="space-y-6">
{/* Fortschritt / Sperr-Hinweis */}
<div
className={cn(
"flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between",
isLocked ? "bg-destructive/5" : "bg-card",
)}
>
<div className="flex items-start gap-3">
{isLocked ? (
<Info className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
) : isFulfilled ? (
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
) : (
<Info className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div>
<h3 className="font-medium">
{isLocked
? "Planung abgeschlossen"
: "Deine ausgewählten Termine"}
</h3>
<p className="text-sm text-muted-foreground">
{isLocked
? "Die Frist für diesen Monat ist abgelaufen. Änderungen sind nicht mehr möglich."
: `Du hast ${selectedCount} von ${requiredDaysTotal} benötigten Tagen markiert.`}
{isLocked ? (
<div className="mb-4 flex items-start gap-3 rounded-[10px] border border-[oklch(0.88_0.04_35)] bg-[oklch(0.96_0.018_35)] p-4 text-sm text-[oklch(0.38_0.08_35)]">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Die Frist für diesen Monat ist abgelaufen. Änderungen sind nicht
mehr möglich.
</p>
</div>
</div>
<Button
onClick={handleSave}
disabled={isLocked || isPending || !isFulfilled}
className="shrink-0"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{isLocked ? "Gesperrt" : "Speichern"}
</Button>
</div>
{/* Datumsliste */}
<div className="rounded-lg border bg-card">
<div className="p-4 border-b bg-muted/30">
<p className="text-sm font-medium">Werktage im {format(new Date(targetYear, targetMonth - 1, 1), "MMMM", { locale: de })}</p>
<div className="overflow-x-auto">
<NotdienstCalendar
month={monthStart}
events={calendarItems}
selected={selectedDates}
requiredDays={requiredDaysTotal}
disabled={isLocked || isPending}
selectableDateKeys={selectableDateKeys}
onChange={handleChange}
onSave={handleSave}
isSaving={isPending}
/>
</div>
<div className="divide-y">
{workingDays.map((day) => {
const dateStr = formatDateKey(day);
const isSelected = selectedDates.has(dateStr);
const isToday = day.getTime() === new Date().setHours(0, 0, 0, 0);
<p className="sr-only" aria-live="polite">
{hasMinimumRequirement
? `Du hast ${selectedCount} von ${requiredDaysTotal} benötigten Tagen markiert.`
: `${selectedLabel}. Für diese Kita ist aktuell keine Mindestanzahl hinterlegt.`}
</p>
</div>
);
}
type NotdienstCalendarProps = {
month: Date;
events: NotdienstCalendarItem[];
selected: Set<string>;
requiredDays: number;
disabled?: boolean;
selectableDateKeys: Set<string>;
isSaving?: boolean;
onChange: (selected: Set<string>) => void;
onSave: () => void;
};
export function NotdienstCalendar({
month,
events,
selected,
requiredDays,
disabled = false,
selectableDateKeys,
isSaving = false,
onChange,
onSave,
}: NotdienstCalendarProps) {
const [visibleMonth, setVisibleMonth] = useState(month);
const [activeEvent, setActiveEvent] = useState<NotdienstCalendarItem | null>(
null,
);
const visibleMonthStart = startOfMonth(visibleMonth);
const visibleMonthEnd = endOfMonth(visibleMonthStart);
const calendarStart = startOfWeek(visibleMonthStart, { weekStartsOn: 1 });
const calendarEnd = endOfWeek(visibleMonthEnd, { weekStartsOn: 1 });
const calendarDays = eachDayOfInterval({
start: calendarStart,
end: calendarEnd,
}).map((day) => startOfDay(day));
const selectedCount = selected.size;
const extraCount = Math.max(0, selectedCount - requiredDays);
const statusText =
requiredDays > 0 && selectedCount >= requiredDays
? `Fertig — alle ${requiredDays} Tage markiert`
: `${selectedCount} von ${requiredDays} Tagen`;
function toggleDate(dateKey: string) {
if (disabled || !selectableDateKeys.has(dateKey)) return;
const nextSelected = new Set(selected);
if (nextSelected.has(dateKey)) {
nextSelected.delete(dateKey);
} else {
nextSelected.add(dateKey);
}
onChange(nextSelected);
}
return (
<button
key={dateStr}
type="button"
disabled={isLocked || isPending}
onClick={() => toggleDate(dateStr)}
className={cn(
"flex w-full items-center justify-between px-4 py-3 text-left transition-colors",
!isLocked && !isPending && "hover:bg-muted/50",
isSelected && "bg-primary/5 hover:bg-primary/10",
(isLocked || isPending) && "cursor-not-allowed opacity-70",
)}
<section
className="min-w-[1040px] bg-[var(--notdienst-bg)] px-11 py-10 text-[var(--notdienst-ink)] shadow-sm [--notdienst-accent-deep:oklch(0.42_0.10_38)] [--notdienst-accent-mid:oklch(0.82_0.07_38)] [--notdienst-accent-soft:oklch(0.93_0.04_38)] [--notdienst-accent:oklch(0.58_0.11_38)] [--notdienst-bg:oklch(0.965_0.012_75)] [--notdienst-hairline-soft:oklch(0.93_0.01_75)] [--notdienst-hairline:oklch(0.90_0.012_75)] [--notdienst-ink-faint:oklch(0.76_0.01_45)] [--notdienst-ink-muted:oklch(0.60_0.012_45)] [--notdienst-ink-soft:oklch(0.45_0.015_45)] [--notdienst-ink:oklch(0.26_0.015_45)] [--notdienst-out-month:oklch(0.82_0.012_75)] [--notdienst-surface-week:oklch(0.945_0.014_75)] [--notdienst-surface:oklch(0.985_0.008_75)]"
style={{
backgroundImage:
"radial-gradient(oklch(0.85 0.02 75 / 0.10) 1px, transparent 1px)",
backgroundSize: "16px 16px",
}}
>
<div className="flex items-center gap-3">
{/* Custom Checkbox Indicator */}
<div
className={cn(
"flex h-5 w-5 items-center justify-center rounded border",
isSelected
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-background",
)}
>
{isSelected && <CheckCircle2 className="h-3.5 w-3.5" />}
</div>
<header className="flex items-start justify-between gap-8">
<div>
<span className={cn("font-medium", isSelected && "text-primary")}>
{format(day, "EEEE, dd. MMMM", { locale: de })}
</span>
{isToday && (
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
Heute
</span>
<p className="text-[11px] font-semibold uppercase leading-none text-[var(--notdienst-accent)] [letter-spacing:0.18em]">
NOTDIENST · {format(visibleMonthStart, "MMMM yyyy", { locale: de })}
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight text-[var(--notdienst-ink)] [letter-spacing:-0.025em]">
Wann magst du{" "}
<em className="font-normal italic text-[var(--notdienst-accent-deep)]">
helfen
</em>
?
</h1>
</div>
<div className="mt-3 flex items-center gap-4 rounded-full border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] px-5 py-3 shadow-[0_12px_30px_oklch(0.36_0.02_45_/_0.06)]">
<div className="flex items-center gap-1.5">
{Array.from({ length: Math.max(requiredDays, 1) }).map(
(_, index) => {
const isMarked = index < selectedCount;
return (
<span
key={index}
className={cn(
"h-2.5 w-2.5 rounded-full border border-[var(--notdienst-accent)]",
isMarked && "bg-[var(--notdienst-accent)]",
)}
/>
);
},
)}
{extraCount > 0 ? (
<span className="ml-1 text-[11px] font-semibold text-[var(--notdienst-accent-deep)]">
+{extraCount}
</span>
) : null}
</div>
</div>
<span className="text-[13px] font-semibold text-[var(--notdienst-ink-soft)]">
{statusText}
</span>
<button
type="button"
onClick={onSave}
disabled={disabled || isSaving || selectedCount < requiredDays}
className="inline-flex h-8 items-center gap-2 rounded-full bg-[var(--notdienst-accent-deep)] px-5 text-[13px] font-bold text-white transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-55"
>
{isSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
Speichern
</button>
</div>
</header>
<div className="mt-9 flex items-center justify-between gap-8">
<div className="flex items-center gap-3">
<h2 className="text-[22px] font-medium text-[var(--notdienst-ink)] [font-variant-numeric:tabular-nums]">
{format(visibleMonthStart, "MMMM yyyy", { locale: de })}
</h2>
<div className="flex items-center gap-1.5">
<button
type="button"
aria-label="Vorheriger Monat"
onClick={() =>
setVisibleMonth(
new Date(
visibleMonthStart.getFullYear(),
visibleMonthStart.getMonth() - 1,
1,
),
)
}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] text-[var(--notdienst-ink-soft)] transition hover:border-[var(--notdienst-accent-mid)] hover:text-[var(--notdienst-accent-deep)]"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
type="button"
aria-label="Nächster Monat"
onClick={() =>
setVisibleMonth(
new Date(
visibleMonthStart.getFullYear(),
visibleMonthStart.getMonth() + 1,
1,
),
)
}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] text-[var(--notdienst-ink-soft)] transition hover:border-[var(--notdienst-accent-mid)] hover:text-[var(--notdienst-accent-deep)]"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
<CalendarLegend />
</div>
<div className="mt-8 grid grid-cols-7 gap-1.5">
{[
"Montag",
"Dienstag",
"Mittwoch",
"Donnerstag",
"Freitag",
"Samstag",
"Sonntag",
].map((weekday) => (
<div
key={weekday}
className="px-2 text-[11px] font-semibold uppercase text-[var(--notdienst-ink-muted)] [letter-spacing:0.08em]"
>
{weekday}
</div>
))}
</div>
<div className="mt-2 grid grid-cols-7 gap-1.5">
{calendarDays.map((day) => {
const dateKey = formatDateKey(day);
const isInTargetMonth = isSameMonth(day, visibleMonthStart);
const isSelected = selected.has(dateKey);
const dayEvents = getCalendarItemsForDay(events, day);
const hasClosure = dayEvents.some(
(event) => event.category === "closure",
);
const isSelectable =
isInTargetMonth && selectableDateKeys.has(dateKey) && !hasClosure;
const isWeekend = day.getDay() === 0 || day.getDay() === 6;
return (
<CalendarTile
key={dateKey}
day={day}
events={dayEvents}
isInTargetMonth={isInTargetMonth}
isSelected={isSelected}
isSelectable={isSelectable}
isWeekend={isWeekend}
hasClosure={hasClosure}
disabled={disabled}
onToggle={() => toggleDate(dateKey)}
onOpenEvent={setActiveEvent}
/>
);
})}
</div>
{activeEvent ? (
<EventDialog
event={activeEvent}
onClose={() => setActiveEvent(null)}
/>
) : null}
</section>
);
}
function CalendarTile({
day,
events,
isInTargetMonth,
isSelected,
isSelectable,
isWeekend,
hasClosure,
disabled,
onToggle,
onOpenEvent,
}: {
day: Date;
events: NotdienstCalendarItem[];
isInTargetMonth: boolean;
isSelected: boolean;
isSelectable: boolean;
isWeekend: boolean;
hasClosure: boolean;
disabled: boolean;
onToggle: () => void;
onOpenEvent: (event: NotdienstCalendarItem) => void;
}) {
return (
<div
className={cn(
"relative min-h-24 rounded-[10px] border p-[10px_11px] text-left transition",
"border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)]",
isSelectable &&
!disabled &&
"cursor-pointer hover:border-[var(--notdienst-accent-mid)] hover:shadow-[0_10px_28px_oklch(0.36_0.02_45_/_0.08)]",
isSelected &&
"border-[var(--notdienst-accent-mid)] bg-[var(--notdienst-accent-soft)]",
hasClosure &&
"cursor-default border-[oklch(0.88_0.04_35)] bg-[oklch(0.96_0.018_35)]",
(isWeekend || !isInTargetMonth) &&
"cursor-default border-[var(--notdienst-hairline-soft)] bg-[var(--notdienst-surface-week)]",
)}
onClick={isSelectable && !disabled ? onToggle : undefined}
>
<div className="flex items-start justify-between gap-2">
<span
className={cn(
"font-medium leading-none [font-variant-numeric:tabular-nums] [letter-spacing:-0.02em]",
isWeekend || !isInTargetMonth
? "text-[13px] text-[var(--notdienst-out-month)]"
: "text-[17px] text-[var(--notdienst-ink)]",
isSelected && "text-[var(--notdienst-accent-deep)]",
hasClosure && "text-[oklch(0.38_0.08_35)]",
)}
>
{format(day, "d")}
</span>
{isSelected ? (
<Check className="h-2.5 w-2.5 text-[var(--notdienst-accent-deep)] [stroke-width:2.5]" />
) : null}
{hasClosure ? (
<LockKeyhole className="h-3 w-3 text-[oklch(0.38_0.08_35)]" />
) : null}
</div>
<div className="absolute inset-x-[11px] bottom-[10px] space-y-1.5">
{events.slice(0, 2).map((event) => (
<button
key={`${formatDateKey(day)}-${event.id}`}
type="button"
className="group/event relative flex w-full items-center gap-1.5 overflow-visible text-left"
onClick={(clickEvent) => {
clickEvent.stopPropagation();
onOpenEvent(event);
}}
>
<span
className="h-4 w-[3px] shrink-0 rounded-sm"
style={{
backgroundColor: notdienstEventCategories[event.category].dot,
}}
/>
<span className="truncate text-[11px] font-medium text-[var(--notdienst-ink-soft)]">
{event.title}
</span>
<span className="pointer-events-none absolute bottom-full left-0 z-20 mb-2 hidden min-w-44 rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-left text-white shadow-xl group-hover/event:block">
<span className="block text-[10px] font-semibold uppercase text-white/65 [letter-spacing:0.08em]">
{notdienstEventCategories[event.category].label}
</span>
<span className="mt-0.5 block text-[12px] font-semibold">
{event.title}
</span>
<span className="mt-1 block text-[11px] text-white/75">
{formatEventTime(event)}
</span>
</span>
</button>
))}
{events.length > 2 ? (
<p className="text-[11px] font-medium text-[var(--notdienst-ink-muted)]">
+{events.length - 2} weitere
</p>
) : null}
</div>
</div>
);
}
function CalendarLegend() {
return (
<div className="flex flex-wrap items-center justify-end gap-4 text-[11px] font-medium text-[var(--notdienst-ink-soft)]">
<span className="inline-flex items-center gap-1.5">
<span className="h-[9px] w-[9px] rounded-[2px] bg-[var(--notdienst-accent)]" />
Dein Notdienst
</span>
{Object.entries(notdienstEventCategories).map(([key, category]) => (
<span key={key} className="inline-flex items-center gap-1.5">
<span
className="h-[9px] w-[9px] rounded-[2px]"
style={{ backgroundColor: category.dot }}
/>
{category.label}
</span>
))}
</div>
);
}
function EventDialog({
event,
onClose,
}: {
event: NotdienstCalendarItem;
onClose: () => void;
}) {
const category = notdienstEventCategories[event.category];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[oklch(0.25_0.015_45_/_0.20)] p-6 backdrop-blur-[2px]"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-xl border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] p-6 text-[var(--notdienst-ink)] shadow-2xl"
onClick={(eventClick) => eventClick.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<span
className="inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold"
style={{
backgroundColor: category.bg,
borderColor: category.border,
color: category.text,
}}
>
{category.label}
</span>
<button
type="button"
aria-label="Detail schließen"
onClick={onClose}
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-[var(--notdienst-ink-muted)] transition hover:bg-[var(--notdienst-surface-week)] hover:text-[var(--notdienst-ink)]"
>
<X className="h-4 w-4" />
</button>
</div>
<h3 className="mt-4 text-2xl font-medium leading-tight [letter-spacing:-0.02em]">
{event.title}
</h3>
<p className="mt-3 flex items-center gap-2 text-sm font-medium text-[var(--notdienst-ink-soft)]">
<Clock3 className="h-4 w-4" />
{formatEventDate(event)}
</p>
{event.note ? (
<p className="mt-5 text-sm leading-6 text-[var(--notdienst-ink-soft)]">
{event.note}
</p>
) : null}
</div>
</div>
);
}
function getCalendarItemsForDay(
items: NotdienstCalendarItem[],
day: Date,
) {
const dayStart = startOfDay(day).getTime();
return items.filter((item) => {
const itemStart = startOfDay(item.startDate).getTime();
const itemEnd = startOfDay(item.endDate).getTime();
return itemStart <= dayStart && itemEnd >= dayStart;
});
}
function getClosureDateKeys(items: NotdienstCalendarItem[]) {
const closureKeys = new Set<string>();
for (const item of items) {
if (item.category !== "closure") continue;
const days = eachDayOfInterval({
start: startOfDay(item.startDate),
end: startOfDay(item.endDate),
});
for (const day of days) {
closureKeys.add(formatDateKey(day));
}
}
return closureKeys;
}
function formatEventDate(event: NotdienstCalendarItem) {
const date = format(event.startDate, "EEEE, d. MMMM yyyy", { locale: de });
return event.allDay
? `${date} · Ganztägig`
: `${date} · ${formatEventTime(event)}`;
}
function formatEventTime(event: NotdienstCalendarItem) {
if (event.allDay) return "Ganztägig";
return `${format(event.startDate, "HH:mm")} - ${format(
event.endDate,
"HH:mm",
)}`;
}
+60 -40
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { UserRole } from "@prisma/client";
import { Info, Calendar, ArrowRight, ShieldAlert } from "lucide-react";
import { TerminStatus, TerminType, UserRole } from "@prisma/client";
import { ArrowRight, ShieldAlert } from "lucide-react";
import { Manrope } from "next/font/google";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
@@ -10,6 +11,11 @@ import { NotdienstEntry } from "./notdienst-entry";
export const metadata = { title: "Notdienst · Kita-Planer" };
const manrope = Manrope({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export default async function NotdienstPage() {
// ELTERN können eintragen; Admins/Koordinatoren haben hier eigentlich
// andere Ansichten (Plan-Generierung), aber wir lassen sie zur Demonstration
@@ -44,7 +50,7 @@ export default async function NotdienstPage() {
: [];
const targetData = getTargetMonthData();
const { targetYear, targetMonth, monthName, isLocked } = targetData;
const { targetYear, targetMonth, isLocked } = targetData;
const targetMonthStart = new Date(Date.UTC(targetYear, targetMonth - 1, 1));
const targetMonthEnd = new Date(Date.UTC(targetYear, targetMonth, 1));
@@ -52,7 +58,7 @@ export default async function NotdienstPage() {
const childIds = children.map((child) => child.id);
// Bisherige Einträge für den Zielmonat laden, haushaltsweit über die Kinder.
const [availabilities, availabilityCountForCurrentOrNextMonth] =
const [availabilities, termine] =
children.length > 0
? await Promise.all([
prisma.notdienstAvailability.findMany({
@@ -66,74 +72,88 @@ export default async function NotdienstPage() {
},
select: { date: true },
}),
prisma.notdienstAvailability.count({
prisma.termin.findMany({
where: {
kitaId,
childId: { in: childIds },
date: {
gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
lt: new Date(new Date().getFullYear(), new Date().getMonth() + 2, 1),
status: TerminStatus.CONFIRMED,
startDate: { lt: targetMonthEnd },
endDate: { gte: targetMonthStart },
},
select: {
id: true,
title: true,
description: true,
type: true,
startDate: true,
endDate: true,
allDay: true,
},
orderBy: { startDate: "asc" },
}),
])
: [[], 0];
: [[], []];
const selectedDates = availabilities.map((a) => formatDateKey(a.date));
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-8">
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Calendar className="h-6 w-6 text-primary" />
Notdienst planen
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Gib hier an, an welchen Tagen du im Notdienst unterstützen kannst.
</p>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<span className="rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
Zielmonat: {monthName}
</span>
{isLocked && (
<span className="text-xs font-medium text-destructive flex items-center gap-1">
<Info className="h-3 w-3" />
Eingabefrist abgelaufen
</span>
)}
</div>
</div>
<div className={`${manrope.className} mx-auto max-w-7xl px-4 py-8 sm:px-8`}>
{children.length === 0 ? (
<NotdienstLockout />
) : (
<NotdienstEntry
hasAnyAvailability={availabilityCountForCurrentOrNextMonth > 0}
targetYear={targetYear}
targetMonth={targetMonth}
isLocked={isLocked}
requiredDaysTotal={requiredDaysTotal}
initialSelectedDates={selectedDates}
childrenIds={childIds}
calendarItems={termine.map((termin) => ({
id: termin.id,
title: termin.title,
category: getTerminCategory(termin.type),
startDate: termin.startDate,
endDate: termin.endDate,
allDay: termin.allDay,
note: termin.description,
}))}
/>
)}
</div>
);
}
function getTerminCategory(type: TerminType) {
switch (type) {
case TerminType.SCHLIESSTAG:
return "closure" as const;
case TerminType.TEAMTAG:
return "team" as const;
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "social" as const;
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "parents" as const;
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "birthday" as const;
default:
return "social" as const;
}
}
function NotdienstLockout() {
return (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-muted/20 p-8 text-center">
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-8 text-center">
<div className="mx-auto max-w-lg">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-amber-100 text-amber-700 shadow-sm">
<ShieldAlert className="h-10 w-10" />
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<ShieldAlert className="h-10 w-10 [stroke-width:1.7]" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
<h2 className="mt-6 text-2xl font-medium tracking-[-0.02em] text-[var(--ink)]">
Erst Kinder hinterlegen
</h2>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
<p className="mt-3 text-sm leading-6 text-[var(--ink-soft)]">
Bitte lege zuerst deine Kinder im Profil an, bevor du Notdienste
übernimmst.
</p>
+125 -18
View File
@@ -1,9 +1,10 @@
import { UserRole } from "@prisma/client";
import { Settings2 } from "lucide-react";
import { TerminType, UserRole } from "@prisma/client";
import { ChevronLeft, ChevronRight, SlidersHorizontal } from "lucide-react";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { Button } from "@/components/ui/button";
import { PlanView } from "./plan-view";
export const metadata = { title: "Plan-Generierung · Kita-Planer" };
@@ -14,8 +15,12 @@ export default async function PlanungsZentralePage() {
const { targetYear, targetMonth, monthName } = getTargetMonthData();
// Aktuellen Plan suchen
const plan = await prisma.notdienstPlan.findUnique({
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
const monthStart = new Date(targetYear, targetMonth - 1, 1);
const monthEnd = new Date(targetYear, targetMonth, 1);
const [plan, families, availabilities, closureCount] = await Promise.all([
prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
@@ -30,10 +35,51 @@ export default async function PlanungsZentralePage() {
},
},
},
});
// Alle Werktage
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
}),
prisma.family.findMany({
where: {
kitaId,
children: { some: { active: true } },
},
select: {
id: true,
name: true,
children: {
where: { active: true },
select: { id: true },
},
},
orderBy: { name: "asc" },
}),
prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: monthStart,
lt: monthEnd,
},
},
select: {
date: true,
child: {
select: {
familyId: true,
},
},
},
orderBy: { date: "asc" },
}),
prisma.termin.count({
where: {
kitaId,
type: TerminType.SCHLIESSTAG,
startDate: {
gte: monthStart,
lt: monthEnd,
},
},
}),
]);
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
@@ -71,6 +117,40 @@ export default async function PlanungsZentralePage() {
};
});
const availabilityDatesByFamily = new Map<string, Set<string>>();
availabilities.forEach((availability) => {
const familyId = availability.child.familyId;
const dates = availabilityDatesByFamily.get(familyId) ?? new Set<string>();
dates.add(availability.date.toISOString());
availabilityDatesByFamily.set(familyId, dates);
});
const familyStatuses = families.map((family) => {
const dates = Array.from(availabilityDatesByFamily.get(family.id) ?? []);
return {
id: family.id,
name: family.name,
selectedDays: dates.map((date) => ({
iso: date,
label: new Intl.DateTimeFormat("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
}).format(new Date(date)),
})),
childCount: family.children.length,
};
});
const respondedFamilies = familyStatuses.filter(
(family) => family.selectedDays.length > 0,
).length;
const openFamilies = Math.max(familyStatuses.length - respondedFamilies, 0);
const totalAvailabilitySlots = availabilities.length;
const requiredDays = workingDays.length;
const coverageFactor =
requiredDays > 0 ? totalAvailabilitySlots / requiredDays : 0;
// Wir mappen die Assignments auf die Werktage
const daysWithAssignments = workingDays.map((day) => {
const assignment = plan?.assignments.find(
@@ -84,29 +164,56 @@ export default async function PlanungsZentralePage() {
});
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-8">
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Settings2 className="h-6 w-6 text-primary" />
Notdienst-Planung
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
NOTDIENST · PLANUNG
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Notdienst{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
zuweisen
</em>
</h1>
<p className="mt-1 text-sm text-muted-foreground">
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Koordiniere und generiere den Notdienst-Plan.
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
Zielmonat: {monthName}
</span>
<div className="flex shrink-0 items-center gap-2">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-none border-r border-[var(--hairline-soft)]">
<ChevronLeft className="h-4 w-4 [stroke-width:1.7]" />
</Button>
<div className="flex h-9 min-w-32 items-center justify-center px-4 text-[13px] font-semibold text-[var(--ink)]">
{monthName} {targetYear}
</div>
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-none border-l border-[var(--hairline-soft)]">
<ChevronRight className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</div>
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Einstellungen
</Button>
</div>
</header>
<PlanView
planId={plan?.id}
status={plan?.status}
days={daysWithAssignments}
allChildren={allChildren}
familyStatuses={familyStatuses}
requiredDays={requiredDays}
respondedFamilies={respondedFamilies}
openFamilies={openFamilies}
totalFamilies={familyStatuses.length}
totalAvailabilitySlots={totalAvailabilitySlots}
coverageFactor={coverageFactor}
closureCount={closureCount}
monthName={monthName}
/>
</div>
);
+422 -64
View File
@@ -1,14 +1,23 @@
"use client";
import { useTransition } from "react";
import { useState, useTransition } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { NotdienstPlanStatus } from "@prisma/client";
import { Loader2, Wand2, CheckSquare } from "lucide-react";
import {
ArrowRight,
CheckSquare,
Download,
Loader2,
RotateCcw,
Send,
Wand2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
generatePlanAction,
publishPlanAction,
@@ -19,7 +28,7 @@ interface PlanViewProps {
planId?: string;
status?: NotdienstPlanStatus;
days: {
date: string; // ISO
date: string;
assignmentId: string | null;
childId: string | null;
}[];
@@ -28,10 +37,45 @@ interface PlanViewProps {
name: string;
parentName: string;
}[];
familyStatuses: {
id: string;
name: string;
childCount: number;
selectedDays: {
iso: string;
label: string;
}[];
}[];
requiredDays: number;
respondedFamilies: number;
openFamilies: number;
totalFamilies: number;
totalAvailabilitySlots: number;
coverageFactor: number;
closureCount: number;
monthName: string;
}
export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
export function PlanView({
planId,
status,
days,
allChildren,
familyStatuses,
requiredDays,
respondedFamilies,
openFamilies,
totalFamilies,
totalAvailabilitySlots,
coverageFactor,
closureCount,
monthName,
}: PlanViewProps) {
const [isPending, startTransition] = useTransition();
const [activeTab, setActiveTab] = useState<"preview" | "manual">("preview");
const responsePercent =
totalFamilies > 0 ? Math.round((respondedFamilies / totalFamilies) * 100) : 0;
const handleGenerate = () => {
startTransition(async () => {
@@ -70,117 +114,431 @@ export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
});
};
if (!planId && respondedFamilies === 0) {
return (
<Card className="border-dashed">
<div className="flex min-h-[280px] flex-col items-center justify-center p-8 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Wand2 className="h-7 w-7 [stroke-width:1.7]" />
</div>
<h3 className="mb-1 text-xl font-medium tracking-[-0.02em] text-[var(--ink)]">
Noch keine Verfügbarkeiten
</h3>
<p className="mb-5 max-w-md text-sm leading-6 text-[var(--ink-soft)]">
Für {monthName} hat noch keine Familie geantwortet. Verschicke
zuerst Erinnerungen, damit die automatische Planung belastbar ist.
</p>
<div className="flex flex-wrap justify-center gap-2">
<Button disabled={isPending} onClick={handleGenerate}>
Trotzdem generieren
</Button>
<Button variant="ghost">
<Send className="h-4 w-4 [stroke-width:1.7]" />
Erinnerungen verschicken
</Button>
</div>
</div>
</Card>
);
}
if (!planId) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-card p-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Wand2 className="h-6 w-6" />
</div>
<h3 className="mb-1 text-lg font-semibold">Noch kein Plan vorhanden</h3>
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
Generiere den Plan basierend auf den Verfügbarkeiten der Eltern
automatisch.
<div className="space-y-5">
<div className="grid grid-cols-[minmax(0,2fr)_minmax(300px,1fr)] gap-5 max-[1050px]:grid-cols-1">
<Card className="p-0">
<div className="border-b border-[var(--hairline-soft)] px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Verfügbarkeiten Eltern
</h2>
<p className="mt-1 text-[13.5px] leading-5 text-[var(--ink-soft)]">
{respondedFamilies} von {totalFamilies} Familien haben
geantwortet · noch {openFamilies} offen
</p>
<Button onClick={handleGenerate} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Plan automatisch generieren
</div>
<span className="text-sm font-semibold tabular-nums text-[var(--ink-soft)]">
{responsePercent}%
</span>
</div>
<div className="mt-4 h-1 overflow-hidden rounded-full bg-[var(--accent-soft)]">
<div
className="h-full rounded-full bg-[var(--accent)]"
style={{ width: `${responsePercent}%` }}
/>
</div>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{familyStatuses.map((family) => (
<div
key={family.id}
className="group relative flex items-center gap-3 px-6 py-3.5 transition-colors hover:bg-[var(--surface-2)]"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[11px] font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-[var(--ink)]">
{family.name}
</p>
<p className="text-xs text-[var(--ink-muted)]">
{family.childCount} Kind
{family.childCount === 1 ? "" : "er"}
</p>
</div>
<FamilyStatusPill selectedDays={family.selectedDays.length} />
<div className="pointer-events-none absolute right-6 top-10 z-20 hidden max-w-xs rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-xs leading-5 text-white shadow-lg group-hover:block">
{family.selectedDays.length > 0
? family.selectedDays.map((day) => day.label).join(" · ")
: "Noch keine Tage markiert"}
</div>
</div>
))}
</div>
<div className="border-t border-[var(--hairline-soft)] px-6 py-4">
<Button variant="ghost" disabled={openFamilies === 0}>
<Send className="h-4 w-4 [stroke-width:1.7]" />
Erinnerung an offene senden ({openFamilies})
</Button>
</div>
</Card>
<Card className="p-6">
<div>
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Bedarfs-Übersicht
</h2>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Planungsgrundlage für {monthName}.
</p>
</div>
<div className="mt-5 grid gap-4">
<PlanMetric
label={`Tage benötigt in ${monthName}`}
value={requiredDays}
/>
<PlanMetric
label="Verfügbarkeiten gesamt"
value={totalAvailabilitySlots}
/>
<PlanMetric
label="Überdeckung"
value={`${coverageFactor.toFixed(1)}×`}
badge="good"
/>
<PlanMetric label="Schließtage" value={closureCount} />
</div>
<div className="mt-5 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-3 text-sm leading-6 text-[var(--ink-soft)]">
{coverageFactor >= 1
? "Du hast genug Verfügbarkeiten für einen Plan."
: "Die Verfügbarkeiten reichen noch nicht für einen belastbaren Plan."}
</div>
</Card>
</div>
<Card variant="featured" className="sticky bottom-6 z-10 p-5">
<div className="flex items-center justify-between gap-5 max-[760px]:flex-col max-[760px]:items-start">
<div className="flex gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Wand2 className="h-5 w-5 [stroke-width:1.7]" />
</div>
<div>
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Plan automatisch generieren
</h3>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Verteilt {requiredDays} Notdiensttage fair auf{" "}
{respondedFamilies} Familien.
</p>
<details className="mt-3 text-xs text-[var(--ink-soft)]">
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
Faire Verteilung berücksichtigt
</summary>
<div className="mt-2 flex flex-wrap gap-2">
<span> Quote pro Familie</span>
<span> Bevorzugte Tage</span>
<span> Geschwister-Familien zusammen</span>
</div>
</details>
</div>
</div>
<Button onClick={handleGenerate} disabled={isPending}>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
)}
Generieren
<ArrowRight className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</div>
</Card>
</div>
);
}
const isDraft = status === "DRAFT";
return (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<TabButton
active={activeTab === "preview"}
onClick={() => setActiveTab("preview")}
>
Plan-Vorschau
</TabButton>
<TabButton
active={activeTab === "manual"}
onClick={() => setActiveTab("manual")}
withDivider
>
Manuell zuweisen
</TabButton>
</div>
<div className="flex flex-wrap gap-2">
{isDraft && (
<Button onClick={handlePublish} disabled={isPending}>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
)}
<CheckSquare className="h-4 w-4 [stroke-width:1.7]" />
Plan veröffentlichen
</Button>
)}
<Button variant="ghost">
<Download className="h-4 w-4 [stroke-width:1.7]" />
Als PDF exportieren
</Button>
<Button
variant="ghost"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
>
<RotateCcw className="h-4 w-4 [stroke-width:1.7]" />
Plan zurücksetzen
</Button>
</div>
</div>
<Card className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<Badge variant={isDraft ? "warning" : "success"}>
{isDraft ? "Entwurf" : "Veröffentlicht"}
</Badge>
<span className="text-sm text-muted-foreground">
<span className="text-sm text-[var(--ink-soft)]">
{isDraft
? "Du kannst die Liste jetzt manuell anpassen."
: "Dieser Plan ist verbindlich."}
</span>
</div>
</Card>
{isDraft && (
<Button onClick={handlePublish} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<CheckSquare className="mr-2 h-4 w-4" />
Plan veröffentlichen
</Button>
{activeTab === "preview" ? (
<PlanPreviewTable days={days} allChildren={allChildren} />
) : (
<ManualAssignmentTable
days={days}
allChildren={allChildren}
isDraft={isDraft}
isPending={isPending}
onAssignmentChange={handleAssignmentChange}
/>
)}
</div>
);
}
<div className="rounded-lg border bg-card">
<div className="grid grid-cols-[1fr_2fr] border-b bg-muted/30 p-4 text-sm font-medium">
<div>Datum</div>
<div>Eingeteilte Familie</div>
function FamilyStatusPill({ selectedDays }: { selectedDays: number }) {
if (selectedDays === 0) {
return (
<span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
Noch nicht
</span>
);
}
return (
<span className="rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]">
{selectedDays} Tage
</span>
);
}
function PlanMetric({
label,
value,
badge,
}: {
label: string;
value: number | string;
badge?: "good";
}) {
return (
<div className="flex items-center justify-between gap-4 border-b border-[var(--hairline-soft)] pb-3 last:border-b-0 last:pb-0">
<span className="text-[13.5px] text-[var(--ink-soft)]">{label}</span>
{badge ? (
<span className="rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-sm font-semibold text-[var(--good)] tabular-nums">
{value}
</span>
) : (
<span className="text-2xl font-medium tracking-[-0.025em] text-[var(--ink)] tabular-nums">
{value}
</span>
)}
</div>
<div className="divide-y">
);
}
function TabButton({
active,
withDivider,
onClick,
children,
}: {
active: boolean;
withDivider?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={[
"h-9 px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]",
withDivider ? "border-l border-[var(--hairline-soft)]" : "",
active
? "bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
{children}
</button>
);
}
function PlanPreviewTable({
days,
allChildren,
}: Pick<PlanViewProps, "days" | "allChildren">) {
return (
<Card className="overflow-hidden p-0">
<div className="grid grid-cols-[1.1fr_1fr_2fr_1.6fr_1fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
<div>Datum</div>
<div>Wochentag</div>
<div>Zugewiesen</div>
<div>Backup</div>
<div>Status</div>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{days.map((day) => {
const dateObj = new Date(day.date);
const isUnbesetzt = !day.childId;
const assignedChild = allChildren.find(
(child) => child.id === day.childId,
);
return (
<div
key={day.date}
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-muted/10"
className="grid cursor-pointer grid-cols-[1.1fr_1fr_2fr_1.6fr_1fr] items-center p-4 text-sm transition-colors hover:bg-[var(--surface-2)]"
>
<div className="font-medium">
<div className="font-medium tabular-nums text-[var(--ink)]">
{format(dateObj, "dd.MM.", { locale: de })}
</div>
<div className="text-[var(--ink-soft)]">
{format(dateObj, "EEEE", { locale: de })}
</div>
<div className="text-[var(--ink)]">
{assignedChild ? assignedChild.parentName : "Unbesetzt"}
{assignedChild && (
<span className="ml-1 text-[var(--ink-muted)]">
({assignedChild.name})
</span>
)}
</div>
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
<div>
<Badge variant={assignedChild ? "success" : "destructive"}>
{assignedChild ? "Besetzt" : "Offen"}
</Badge>
</div>
</div>
);
})}
</div>
</Card>
);
}
function ManualAssignmentTable({
days,
allChildren,
isDraft,
isPending,
onAssignmentChange,
}: Pick<PlanViewProps, "days" | "allChildren"> & {
isDraft: boolean;
isPending: boolean;
onAssignmentChange: (date: string, newChildId: string) => void;
}) {
return (
<Card className="overflow-hidden p-0">
<div className="grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
<div>Datum</div>
<div>Eingeteilte Familie</div>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{days.map((day) => {
const dateObj = new Date(day.date);
const assignedChild = allChildren.find(
(child) => child.id === day.childId,
);
return (
<div
key={day.date}
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
>
<div className="font-medium text-[var(--ink)]">
{format(dateObj, "EE, dd.MM.", { locale: de })}
</div>
<div>
{isDraft ? (
<select
className="w-full max-w-sm rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
value={day.childId || "empty"}
disabled={isPending}
onChange={(e) =>
handleAssignmentChange(day.date, e.target.value)
onChange={(event) =>
onAssignmentChange(day.date, event.target.value)
}
>
<option value="empty" className="text-destructive">
-- Unbesetzt --
</option>
{allChildren.map((c) => (
<option key={c.id} value={c.id}>
{c.parentName} ({c.name})
<option value="empty">-- Unbesetzt --</option>
{allChildren.map((child) => (
<option key={child.id} value={child.id}>
{child.parentName} ({child.name})
</option>
))}
</select>
) : assignedChild ? (
<span className="text-sm text-[var(--ink)]">
{assignedChild.parentName}{" "}
<span className="text-[var(--ink-muted)]">
({assignedChild.name})
</span>
</span>
) : (
<div>
{isUnbesetzt ? (
<span className="text-sm font-medium text-destructive">
<span className="text-sm font-medium text-[var(--danger)]">
Unbesetzt!
</span>
) : (
<span className="text-sm">
{
allChildren.find((c) => c.id === day.childId)
?.parentName
}{" "}
<span className="text-muted-foreground">
(
{
allChildren.find((c) => c.id === day.childId)
?.name
}
)
</span>
</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
</Card>
);
}
+166 -338
View File
@@ -1,83 +1,83 @@
import Link from "next/link";
import type { ComponentType } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle, ClipboardList } from "lucide-react";
import { DutyAssignmentStatus, UserRole, NotdienstPlanStatus, TerminStatus } from "@prisma/client";
import {
CalendarCheck2,
CalendarClock,
ChevronRight,
Clock3,
ListTodo,
Users,
type LucideProps,
} from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils";
import { getTargetMonthData } from "@/lib/date-utils";
import { prisma } from "@/lib/prisma";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Stat } from "@/components/ui/stat";
import { OnboardingDialog } from "@/components/OnboardingDialog";
import { AlertButton } from "./alert-button";
import { AbsenceCard } from "./absence-card";
import { NewsTicker } from "./news-ticker";
import { RsvpActionCard } from "./rsvp-action-card";
import { AbsenceCard, UpcomingAbsencesCard } from "./absence-card";
export const metadata = { title: "Übersicht · Kita-Planer" };
export default async function DashboardPage() {
const session = await requireKitaSession();
const now = new Date();
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const targetData = getTargetMonthData();
const targetMonthStart = new Date(
Date.UTC(targetData.targetYear, targetData.targetMonth - 1, 1),
);
const targetMonthEnd = new Date(
Date.UTC(targetData.targetYear, targetData.targetMonth, 1),
);
const kita = await prisma.kita.findUniqueOrThrow({
where: { id: session.user.kitaId },
select: {
name: true,
notdienstModuleEnabled: true,
notdienstMinPerChildPerMonth: true,
terminModuleEnabled: true,
adressbuchModuleEnabled: true,
},
});
const today = new Date();
today.setHours(0, 0, 0, 0);
// Schnelle Statistiken und persönliche Dienste für die Übersicht.
const [
familyCount,
childCount,
upcomingDuties,
todayAbsenceCount,
notdienstAvailabilityCount,
onboardingUser,
absenceChildren,
upcomingAbsences,
latestAnnouncements,
] = await Promise.all([
prisma.family.count({
where: { kitaId: session.user.kitaId },
}),
prisma.child.count({
where: { kitaId: session.user.kitaId },
where: { kitaId: session.user.kitaId, active: true },
}),
session.user.familyId
? prisma.dutyAssignment.findMany({
prisma.absence.count({
where: {
kitaId: session.user.kitaId,
familyId: session.user.familyId,
status: DutyAssignmentStatus.PLANNED,
startDate: { lte: today },
endDate: { gte: today },
},
select: {
id: true,
startDate: true,
endDate: true,
dutyType: {
select: {
name: true,
}),
prisma.notdienstAvailability.count({
where: {
kitaId: session.user.kitaId,
date: {
gte: targetMonthStart,
lt: targetMonthEnd,
},
},
},
orderBy: { startDate: "asc" },
take: 5,
})
: Promise.resolve([]),
}),
prisma.user.findFirstOrThrow({
where: {
id: session.user.id,
@@ -142,101 +142,25 @@ export default async function DashboardPage() {
take: 6,
})
: Promise.resolve([]),
prisma.announcement.findMany({
where: { kitaId: session.user.kitaId },
select: {
id: true,
title: true,
content: true,
createdAt: true,
author: {
select: {
firstName: true,
lastName: true,
},
},
attachments: {
select: {
id: true,
fileName: true,
fileUrl: true,
fileType: true,
},
},
reads: {
where: { userId: session.user.id },
select: { id: true },
take: 1,
},
},
orderBy: { createdAt: "desc" },
take: 3,
}),
]);
const familyChildIds = absenceChildren.map((child) => child.id);
const openRsvpTermine =
session.user.familyId && familyChildIds.length > 0
? await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.CONFIRMED,
requiresRsvp: true,
OR: [
{ rsvpDeadline: { gt: now } },
{ rsvpDeadline: null, startDate: { gt: now } },
],
},
select: {
id: true,
title: true,
startDate: true,
rsvpDeadline: true,
participations: {
where: {
childId: { in: familyChildIds },
},
select: {
childId: true,
status: true,
},
},
},
orderBy: { startDate: "asc" },
take: 5,
})
: [];
const rsvpActionEvents = openRsvpTermine
.map((termin) => {
const statusByChildId = new Map(
termin.participations.map((participation) => [
participation.childId,
participation.status,
]),
);
return {
id: termin.id,
title: termin.title,
startDate: termin.startDate.toISOString(),
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
children: absenceChildren.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
status: statusByChildId.get(child.id) ?? null,
})),
};
})
.filter((termin) => termin.children.some((child) => !child.status));
const isAdmin =
session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.SUPERADMIN;
const canReportAbsences = session.user.role !== UserRole.ERZIEHER;
const requiredNotdienstDays = Math.max(
childCount * kita.notdienstMinPerChildPerMonth,
1,
);
const openNotdienstDays = Math.max(
requiredNotdienstDays - notdienstAvailabilityCount,
0,
);
const todayPresentCount = Math.max(childCount - todayAbsenceCount, 0);
const firstName =
session.user.name?.split(" ")[0] ??
session.user.email?.split("@")[0] ??
"zusammen";
return (
<div className="px-8 py-8">
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<OnboardingDialog
user={{
phone: onboardingUser.phone,
@@ -253,44 +177,33 @@ export default async function DashboardPage() {
}
/>
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">
Willkommen, {session.user.name?.split(" ")[0] ?? "zusammen"}!
<header className="mb-9 flex items-start justify-between gap-8">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
ÜBERSICHT · {kita.name}
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Willkommen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
zurück
</em>
, {firstName}.
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Hier ist die Übersicht für <strong>{kita.name}</strong>.
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Heute, {format(now, "EEEE, d. MMMM yyyy", { locale: de })}. Hier
siehst du auf einen Blick, was bei {kita.name} ansteht und kannst
dein Kind schnell für Abwesenheiten melden.
</p>
</div>
<RsvpActionCard events={rsvpActionEvents} />
<NewsTicker
items={latestAnnouncements.map((announcement) => ({
id: announcement.id,
title: announcement.title,
content: announcement.content,
createdAt: format(announcement.createdAt, "dd.MM.yyyy", {
locale: de,
}),
authorName: `${announcement.author.firstName} ${announcement.author.lastName}`,
isUnread: announcement.reads.length === 0,
attachments: announcement.attachments.map((attachment) => ({
id: attachment.id,
fileName: attachment.fileName,
fileUrl: attachment.fileUrl,
fileType: attachment.fileType,
})),
}))}
/>
{/* Heutiger Notdienst (Nur für Admins/Koordinatoren) */}
{isAdmin && (
<div className="mb-8">
<TodaysNotdienstCard kitaId={session.user.kitaId!} />
<div className="mt-9 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 py-2 text-[13px] font-semibold text-[var(--ink-soft)]">
<Clock3 className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(now, "EEE, d. MMM yyyy", { locale: de })}
</div>
)}
</header>
{canReportAbsences && (
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
<div className="min-w-0">
{canReportAbsences ? (
<AbsenceCard
today={format(today, "yyyy-MM-dd")}
childOptions={absenceChildren.map((child) => ({
@@ -306,196 +219,111 @@ export default async function DashboardPage() {
endDate: format(absence.endDate, "yyyy-MM-dd"),
}))}
/>
)}
) : null}
</div>
{upcomingDuties.length > 0 && (
<Card className="mb-8 border-emerald-200 bg-emerald-50/50">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ClipboardList className="h-5 w-5 text-emerald-700" />
Deine anstehenden Dienste
</CardTitle>
<CardDescription>
Die nächsten Einteilungen für deinen Haushalt.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{upcomingDuties.map((duty) => (
<div key={duty.id} className="rounded-md border bg-background p-3">
<p className="font-medium">{duty.dutyType.name}</p>
<p className="mt-1 text-sm text-muted-foreground">
{format(duty.startDate, "dd.MM.", { locale: de })} bis{" "}
{format(duty.endDate, "dd.MM.yyyy", { locale: de })}
<aside>
<UpcomingAbsencesCard
absences={upcomingAbsences.map((absence) => ({
id: absence.id,
childName: `${absence.child.firstName} ${absence.child.lastName}`,
reason: absence.reason,
note: absence.note,
startDate: format(absence.startDate, "yyyy-MM-dd"),
endDate: format(absence.endDate, "yyyy-MM-dd"),
}))}
/>
</aside>
</div>
<section className="mt-5 grid grid-cols-4 gap-3.5 max-[1100px]:grid-cols-2 max-[640px]:grid-cols-1">
<Stat label="Familien" value={familyCount} hint="registrierte Familien" />
<Stat label="Kinder" value={childCount} hint="in der Einrichtung" />
<Stat
label={`Notdienst ${targetData.monthName}`}
value={notdienstAvailabilityCount}
suffix={`/ ${requiredNotdienstDays}`}
hint={`Tage abgedeckt · ${openNotdienstDays} offen`}
/>
<Stat
label="Heute anwesend"
value={todayPresentCount}
hint="Kinder in der Kita"
/>
</section>
<section className="mt-7">
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Schnellstart
</h2>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Häufige Aktionen für Vorstand und Koordination.
</p>
</div>
))}
</CardContent>
</Card>
)}
{/* Statistik-Kacheln */}
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
label="Familien"
value={familyCount}
description="registrierte Familien"
icon={<Users className="h-5 w-5 text-muted-foreground" />}
<div className="mt-4 grid grid-cols-3 gap-3 max-[1100px]:grid-cols-1">
<QuickCard
href="/dashboard/families"
icon={Users}
title="Familien verwalten"
sub="Familien anlegen, Kinder zuordnen, Kontakte pflegen."
/>
<StatCard
label="Kinder"
value={childCount}
description="in der Einrichtung"
icon={<ShieldCheck className="h-5 w-5 text-muted-foreground" />}
{kita.terminModuleEnabled ? (
<QuickCard
href="/dashboard/kalender"
icon={CalendarClock}
title="Terminkalender"
sub="Schließtage, Elternabende und Feste planen."
/>
) : null}
{kita.notdienstModuleEnabled ? (
<QuickCard
href="/dashboard/notdienst/plan"
icon={ListTodo}
title="Notdienst-Planung"
sub="Verfügbarkeiten überblicken und Dienste zuweisen."
/>
) : null}
{kita.adressbuchModuleEnabled ? (
<QuickCard
href="/dashboard/adressbuch"
icon={CalendarCheck2}
title="Adressbuch"
sub="Kontakte der Kita-Gemeinschaft schnell finden."
/>
) : null}
</div>
{/* Schnell-Aktionen für Admins */}
{isAdmin && (
<Card>
<CardHeader>
<CardTitle className="text-base">Schnellstart</CardTitle>
<CardDescription>
Die nächsten Schritte, um deine Kita einzurichten.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3">
<Button asChild size="sm">
<Link href="/dashboard/families">
<Users className="mr-2 h-4 w-4" />
Familien verwalten
</Link>
</Button>
{kita.terminModuleEnabled && (
<Button asChild size="sm" variant="outline">
<Link href="/dashboard/kalender">
<CalendarCheck2 className="mr-2 h-4 w-4" />
Terminkalender
</Link>
</Button>
)}
</CardContent>
</Card>
)}
</section>
</div>
);
}
function StatCard({
label,
value,
description,
icon,
function QuickCard({
href,
icon: Icon,
title,
sub,
}: {
label: string;
value: number;
description: string;
icon: React.ReactNode;
href: string;
icon: ComponentType<LucideProps>;
title: string;
sub: string;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{label}
</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{value}</div>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
const today = new Date();
today.setHours(0, 0, 0, 0);
// Wir suchen das Assignment für heute, aber nur wenn der Plan PUBLISHED ist
const assignment = await prisma.notdienstAssignment.findFirst({
where: {
kitaId,
date: today,
plan: { status: NotdienstPlanStatus.PUBLISHED },
},
select: {
id: true,
child: {
select: {
firstName: true,
lastName: true,
family: {
select: {
name: true,
users: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
},
alerts: {
select: {
status: true,
},
},
},
});
if (!assignment) return null;
const parentNames = assignment.child.family.users
.map((parent) => `${parent.firstName} ${parent.lastName}`)
.join(", ");
const existingAlert = assignment.alerts[0]; // Kann PENDING, CONFIRMED oder CANCELLED sein
return (
<Card className="border-primary/20 bg-primary/5">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-primary" />
Heutiger Notdienst
</CardTitle>
<CardDescription>
Laut dem veröffentlichten Plan ist heute folgende Familie eingeteilt:
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium text-base">
{parentNames || assignment.child.family.name}
</p>
<p className="text-sm text-muted-foreground">
Kind: {assignment.child.firstName} {assignment.child.lastName}
</p>
</div>
<div className="flex items-center gap-3">
{existingAlert ? (
<Badge
variant={
existingAlert.status === "CONFIRMED"
? "success"
: existingAlert.status === "PENDING"
? "warning"
: "destructive"
}
className="px-3 py-1 text-sm"
<Link
href={href}
className="group flex min-h-[86px] items-center gap-4 rounded-[14px] border border-[var(--hairline)] bg-[var(--surface)] p-4 transition hover:border-[var(--accent-mid)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
{existingAlert.status === "CONFIRMED"
? "Einsatz bestätigt"
: existingAlert.status === "PENDING"
? "Wartet auf Bestätigung"
: "Abgebrochen"}
</Badge>
) : (
<AlertButton assignmentId={assignment.id} />
)}
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Icon className="h-4.5 w-4.5 [stroke-width:1.7]" />
</div>
</CardContent>
</Card>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-[var(--ink)]">
{title}
</p>
<p className="mt-1 line-clamp-2 text-[13px] leading-5 text-[var(--ink-soft)]">
{sub}
</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--ink-faint)] transition group-hover:translate-x-1 group-hover:text-[var(--accent-deep)]" />
</Link>
);
}
@@ -45,7 +45,12 @@ export function ContactForm({ contact }: ContactFormProps) {
return (
<form action={handleSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="phone">Telefonnummer</Label>
<Label
htmlFor="phone"
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Telefonnummer
</Label>
<Input
id="phone"
name="phone"
@@ -56,7 +61,12 @@ export function ContactForm({ contact }: ContactFormProps) {
</div>
<div className="grid gap-2">
<Label htmlFor="street">Straße und Hausnummer</Label>
<Label
htmlFor="street"
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Straße und Hausnummer
</Label>
<Input
id="street"
name="street"
@@ -65,9 +75,14 @@ export function ContactForm({ contact }: ContactFormProps) {
/>
</div>
<div className="grid gap-3 sm:grid-cols-[120px_1fr]">
<div className="grid gap-3 sm:grid-cols-[100px_1fr]">
<div className="grid gap-2">
<Label htmlFor="postalCode">PLZ</Label>
<Label
htmlFor="postalCode"
className="text-xs font-semibold text-[var(--ink-soft)]"
>
PLZ
</Label>
<Input
id="postalCode"
name="postalCode"
@@ -76,7 +91,12 @@ export function ContactForm({ contact }: ContactFormProps) {
/>
</div>
<div className="grid gap-2">
<Label htmlFor="city">Ort</Label>
<Label
htmlFor="city"
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Ort
</Label>
<Input
id="city"
name="city"
@@ -86,7 +106,7 @@ export function ContactForm({ contact }: ContactFormProps) {
</div>
</div>
<div className="flex justify-end">
<div className="flex justify-end pt-1">
<Button type="submit" disabled={isPending}>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -23,7 +23,7 @@ export function DeleteAccountDialog() {
const [confirmText, setConfirmText] = useState("");
const [isPending, startTransition] = useTransition();
const isConfirmed = confirmText === "LÖSCHEN";
const isConfirmed = confirmText === "DELETE";
const handleDelete = () => {
if (!isConfirmed) return;
@@ -44,18 +44,19 @@ export function DeleteAccountDialog() {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive" className="w-full sm:w-auto">
<AlertTriangle className="h-4 w-4 mr-2" />
<AlertTriangle className="h-4 w-4" />
Account & Daten endgültig löschen
</Button>
</DialogTrigger>
<DialogContent className="border-destructive/30">
<DialogContent className="border-[var(--danger)]">
<DialogHeader>
<DialogTitle className="text-destructive flex items-center gap-2">
<DialogTitle className="flex items-center gap-2 text-[var(--danger)]">
<AlertTriangle className="h-5 w-5" />
Gefahrenzone: Account löschen
</DialogTitle>
<DialogDescription className="text-base pt-2">
Diese Aktion kann <strong className="text-foreground">nicht</strong> rückgängig gemacht werden.
<DialogDescription className="pt-2 text-base">
Diese Aktion kann <strong className="text-[var(--ink)]">nicht</strong>{" "}
rückgängig gemacht werden.
Wenn du das letzte Elternteil im Haushalt bist, wird die gesamte
Familie inklusive Kinder gelöscht. Gibt es ein weiteres Elternteil,
bleibt der Haushalt für diese Person erhalten und nur dein Account
@@ -63,16 +64,17 @@ export function DeleteAccountDialog() {
</DialogDescription>
</DialogHeader>
<div className="bg-destructive/10 text-destructive-foreground p-3 text-sm rounded-md my-2 border border-destructive/20">
Bitte tippe das Wort <strong>LÖSCHEN</strong> in das Feld unten ein, um den Vorgang zu bestätigen.
<div className="my-2 rounded-lg border border-[var(--danger)] bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]">
Bitte tippe <strong>DELETE</strong> in das Feld unten ein, um den
Vorgang zu bestätigen.
</div>
<div className="grid gap-2 py-4">
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="LÖSCHEN"
className="border-destructive/50 focus-visible:ring-destructive"
placeholder="DELETE"
className="border-[var(--danger)] focus-visible:ring-[var(--focus-ring)]"
/>
</div>
@@ -1,6 +1,6 @@
"use client";
import { useState, useTransition } from "react";
import { type FormEvent, useState, useTransition } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
@@ -16,6 +16,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getChildBirthdateRange } from "@/lib/child-birthdate";
import { createMyChild, deleteMyChild, updateMyChild } from "../actions";
export type MyChild = {
@@ -25,6 +26,18 @@ export type MyChild = {
dateOfBirth: string | null;
};
type ChildPayload = {
firstName: string;
lastName: string;
dateOfBirth: string;
};
const emptyChildPayload: ChildPayload = {
firstName: "",
lastName: "",
dateOfBirth: "",
};
export function MyChildrenManager({
items,
canManage,
@@ -33,38 +46,40 @@ export function MyChildrenManager({
canManage: boolean;
}) {
const [createOpen, setCreateOpen] = useState(false);
const [createPayload, setCreatePayload] =
useState<ChildPayload>(emptyChildPayload);
const [editingChild, setEditingChild] = useState<MyChild | null>(null);
const [editPayload, setEditPayload] =
useState<ChildPayload>(emptyChildPayload);
const [isPending, startTransition] = useTransition();
function payloadFromForm(formData: FormData) {
return {
firstName: String(formData.get("firstName") ?? ""),
lastName: String(formData.get("lastName") ?? ""),
dateOfBirth: String(formData.get("dateOfBirth") ?? ""),
};
function openEditor(child: MyChild) {
setEditingChild(child);
setEditPayload(payloadFromChild(child));
}
function handleCreate(formData: FormData) {
function handleCreate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
startTransition(async () => {
const result = await createMyChild(payloadFromForm(formData));
const result = await createMyChild(createPayload);
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Kind angelegt.");
setCreatePayload(emptyChildPayload);
setCreateOpen(false);
});
}
function handleUpdate(formData: FormData) {
function handleUpdate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!editingChild) return;
startTransition(async () => {
const result = await updateMyChild(
editingChild.id,
payloadFromForm(formData),
);
const result = await updateMyChild(editingChild.id, editPayload);
if (result.error) {
toast.error(result.error);
return;
@@ -95,31 +110,45 @@ export function MyChildrenManager({
return (
<div className="space-y-3">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Kinder verknüpft.</p>
<p className="text-sm italic text-[var(--ink-faint)]">
Keine Kinder verknüpft.
</p>
) : (
<div className="flex flex-col gap-2">
{items.map((child) => (
<div
key={child.id}
className="flex items-center justify-between gap-3 rounded-md bg-muted/50 p-2"
className="flex items-center justify-between gap-3 rounded-lg bg-[var(--surface-2)] p-[10px_12px]"
>
<div>
<p className="font-medium">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
{getInitials(child)}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--ink)]">
{child.firstName} {child.lastName}
</p>
{child.dateOfBirth && (
<p className="text-xs text-muted-foreground">
Geboren am {new Date(child.dateOfBirth).toLocaleDateString("de-DE")}
{child.dateOfBirth ? (
<p className="text-xs text-[var(--ink-soft)]">
Geboren am{" "}
{new Date(child.dateOfBirth).toLocaleDateString("de-DE")}
</p>
) : (
<p className="text-xs italic text-[var(--ink-faint)]">
Geburtsdatum fehlt
</p>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setEditingChild(child)}
className="h-7 w-7"
onClick={() => openEditor(child)}
disabled={isPending || !canManage}
aria-label="Kind bearbeiten"
>
<Pencil className="h-4 w-4" />
</Button>
@@ -127,9 +156,10 @@ export function MyChildrenManager({
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
className="h-7 w-7 text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => handleDelete(child)}
disabled={isPending || !canManage}
aria-label="Kind löschen"
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -140,22 +170,32 @@ export function MyChildrenManager({
)}
{canManage ? (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) setCreatePayload(emptyChildPayload);
}}
>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm" className="gap-2">
<Button type="button" variant="pill" size="sm" className="w-full">
<Plus className="h-4 w-4" />
Kind hinzufügen
</Button>
</DialogTrigger>
<DialogContent>
<form action={handleCreate}>
<form onSubmit={handleCreate}>
<DialogHeader>
<DialogTitle>Kind hinzufügen</DialogTitle>
<DialogDescription>
Dieses Kind wird mit deinem Haushalt verknüpft.
</DialogDescription>
</DialogHeader>
<ChildFields />
<ChildFields
idPrefix="create-child"
value={createPayload}
onChange={setCreatePayload}
/>
<DialogFooter>
<Button
type="button"
@@ -173,7 +213,7 @@ export function MyChildrenManager({
</DialogContent>
</Dialog>
) : (
<p className="text-xs text-muted-foreground">
<p className="text-xs italic text-[var(--ink-faint)]">
Dein Account ist noch keinem Haushalt zugeordnet.
</p>
)}
@@ -183,11 +223,17 @@ export function MyChildrenManager({
onOpenChange={(open) => !open && setEditingChild(null)}
>
<DialogContent>
<form action={handleUpdate}>
<form onSubmit={handleUpdate}>
<DialogHeader>
<DialogTitle>Kind bearbeiten</DialogTitle>
</DialogHeader>
{editingChild && <ChildFields child={editingChild} />}
{editingChild && (
<ChildFields
idPrefix="edit-child"
value={editPayload}
onChange={setEditPayload}
/>
)}
<DialogFooter>
<Button
type="button"
@@ -208,36 +254,84 @@ export function MyChildrenManager({
);
}
function ChildFields({ child }: { child?: MyChild }) {
function ChildFields({
idPrefix,
value,
onChange,
}: {
idPrefix: string;
value: ChildPayload;
onChange: (value: ChildPayload) => void;
}) {
const birthdateRange = getChildBirthdateRange();
return (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="child-firstName">Vorname</Label>
<Label
htmlFor={`${idPrefix}-firstName`}
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Vorname
</Label>
<Input
id="child-firstName"
id={`${idPrefix}-firstName`}
name="firstName"
defaultValue={child?.firstName ?? ""}
value={value.firstName}
onChange={(event) =>
onChange({ ...value, firstName: event.target.value })
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="child-lastName">Nachname</Label>
<Label
htmlFor={`${idPrefix}-lastName`}
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Nachname
</Label>
<Input
id="child-lastName"
id={`${idPrefix}-lastName`}
name="lastName"
defaultValue={child?.lastName ?? ""}
value={value.lastName}
onChange={(event) =>
onChange({ ...value, lastName: event.target.value })
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="child-dateOfBirth">Geburtsdatum</Label>
<Label
htmlFor={`${idPrefix}-dateOfBirth`}
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Geburtsdatum
</Label>
<Input
id="child-dateOfBirth"
id={`${idPrefix}-dateOfBirth`}
name="dateOfBirth"
type="date"
defaultValue={child?.dateOfBirth ?? ""}
min={birthdateRange.min}
max={birthdateRange.max}
value={value.dateOfBirth}
onChange={(event) =>
onChange({ ...value, dateOfBirth: event.target.value })
}
/>
</div>
</div>
);
}
function payloadFromChild(child: MyChild): ChildPayload {
return {
firstName: child.firstName,
lastName: child.lastName,
dateOfBirth: child.dateOfBirth ?? "",
};
}
function getInitials(child: MyChild) {
return `${child.firstName[0] ?? ""}${child.lastName[0] ?? ""}`.toUpperCase();
}
+33 -16
View File
@@ -2,8 +2,14 @@
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { NotdienstPlanStatus } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils";
import {
CHILD_MAX_AGE_YEARS,
getChildBirthdateRange,
parseDateInput,
} from "@/lib/child-birthdate";
import { prisma } from "@/lib/prisma";
// Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
@@ -23,8 +29,8 @@ const childSchema = z
})
.superRefine((data, ctx) => {
if (!data.dateOfBirth) return;
const parsed = new Date(`${data.dateOfBirth}T00:00:00`);
if (Number.isNaN(parsed.getTime())) {
const parsed = parseDateInput(data.dateOfBirth);
if (!parsed) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
@@ -32,22 +38,19 @@ const childSchema = z
});
return;
}
// In der Zukunft geborene Kinder existieren nicht.
const today = new Date();
today.setHours(23, 59, 59, 999);
if (parsed > today) {
const { min, max } = getChildBirthdateRange();
if (data.dateOfBirth > max) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: "Geburtsdatum darf nicht in der Zukunft liegen.",
});
}
// Schutz vor offensichtlichen Tippfehlern (1899 statt 1989 o.ä.).
if (parsed.getFullYear() < 1900) {
if (data.dateOfBirth < min) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: "Bitte ein realistisches Geburtsdatum angeben.",
message: `Kinder dürfen maximal ${CHILD_MAX_AGE_YEARS} Jahre alt sein.`,
});
}
});
@@ -79,13 +82,6 @@ const contactSchema = z.object({
city: z.string().trim().max(100).optional(),
});
function parseDateInput(value?: string) {
if (!value) return null;
const date = new Date(`${value}T00:00:00`);
return Number.isNaN(date.getTime()) ? null : date;
}
// Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
// damit der User nicht nur "Ungültige Eingabedaten" sieht.
function firstZodMessage(
@@ -290,6 +286,27 @@ export async function deleteMyAccount() {
return { error: "Account wurde nicht gefunden." };
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const activeNotdienstAssignments = user.familyId
? await prisma.notdienstAssignment.count({
where: {
kitaId: session.user.kitaId,
date: { gte: today },
plan: { status: NotdienstPlanStatus.PUBLISHED },
child: { familyId: user.familyId },
},
})
: 0;
if (activeNotdienstAssignments > 0) {
return {
error:
"Dein Account kann nicht gelöscht werden, solange aktive Notdienst-Verpflichtungen bestehen.",
};
}
if (!user.familyId) {
await prisma.user.delete({
where: { id: user.id },
+156 -100
View File
@@ -1,19 +1,28 @@
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import {
AtSign,
Baby,
CalendarHeart,
Home,
Mail,
Phone,
Shield,
Trash2,
UserCircle,
} from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DefItem, DefList, EmptyDefValue } from "@/components/ui/def-list";
import { RoleBadge } from "@/components/ui/role-badge";
import { DeleteAccountDialog } from "./_components/delete-account-dialog";
import { MyChildrenManager } from "./_components/my-children-manager";
import { CreateFamilyDialog } from "./_components/create-family-dialog";
@@ -24,7 +33,12 @@ export const metadata = { title: "Mein Profil · Kita-Planer" };
export default async function ProfilPage() {
const session = await requireKitaSession();
const user = await prisma.user.findUniqueOrThrow({
const [kita, user] = await Promise.all([
prisma.kita.findUniqueOrThrow({
where: { id: session.user.kitaId },
select: { name: true },
}),
prisma.user.findUniqueOrThrow({
where: { id: session.user.id },
select: {
id: true,
@@ -63,94 +77,94 @@ export default async function ProfilPage() {
},
},
},
});
}),
]);
const fullName = `${user.firstName} ${user.lastName}`.trim();
return (
<div className="flex h-full flex-col gap-6 p-6 max-w-4xl mx-auto w-full">
<div>
<h1 className="text-2xl font-bold tracking-tight">Mein Profil</h1>
<p className="text-muted-foreground">
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-9">
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
PROFIL · {kita.name}
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Mein{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
Profil
</em>
</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen.
</p>
</div>
</header>
<div className="grid gap-6 md:grid-cols-2">
{/* Persönliche Daten */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserCircle className="h-5 w-5" />
Persönliche Daten
</CardTitle>
<div className="grid items-start gap-5 lg:grid-cols-2">
<Card className="self-stretch">
<CardHeader className="pb-5">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<UserCircle className="h-4 w-4 [stroke-width:1.7]" />
</div>
<div>
<CardTitle>Persönliche Daten</CardTitle>
<CardDescription className="mt-2">
Read-only Stammdaten deines Accounts.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div>
<div className="text-sm text-muted-foreground">Name</div>
<div className="font-medium text-lg">
{user.firstName} {user.lastName}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground flex items-center gap-1">
<Mail className="h-3.5 w-3.5" /> E-Mail
</div>
<div className="font-medium">{user.email}</div>
</div>
<div>
<div className="text-sm text-muted-foreground flex items-center gap-1">
<Phone className="h-3.5 w-3.5" /> Telefon
</div>
<div className="font-medium">
{user.phone ?? "Noch nicht hinterlegt"}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Rolle im Verein</div>
<Badge variant={user.role === "ELTERN" ? "secondary" : "default"} className="mt-1">
{user.role}
</Badge>
</div>
<div>
<div className="text-sm text-muted-foreground flex items-center gap-1">
<Home className="h-3.5 w-3.5" /> Haushalt
</div>
{user.family ? (
<div className="font-medium">{user.family.name}</div>
<CardContent>
<DefList>
<DefItem icon={UserCircle} label="Name" value={fullName} />
<DefItem icon={Mail} label="E-Mail" value={user.email} />
<DefItem
icon={Phone}
label="Telefon"
value={user.phone ? user.phone : <EmptyDefValue />}
/>
<DefItem icon={Shield} label="Rolle" value={<RoleBadge role={user.role} />} />
<DefItem
icon={Home}
label="Haushalt"
value={
user.family ? (
user.family.name
) : (
<div className="mt-2 space-y-3 rounded-md border border-dashed p-3">
<p className="text-sm text-muted-foreground">
Dein Account ist noch keinem Haushalt zugeordnet. Lege hier
deinen eigenen Haushalt an, wenn du selbst Elternteil in der
Kita bist.
<div className="grid gap-3 rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3">
<p className="text-sm font-normal leading-5 text-[var(--ink-soft)]">
Dein Account ist noch keinem Haushalt zugeordnet.
</p>
<CreateFamilyDialog
defaultFamilyName={`Familie ${user.lastName}`}
/>
</div>
)}
</div>
<div className="pt-2 border-t mt-2">
<div className="text-sm text-muted-foreground flex items-center gap-1">
<CalendarHeart className="h-3.5 w-3.5" /> Mitglied seit
</div>
<div className="text-sm">
{format(user.createdAt, "PPP", { locale: de })}
</div>
</div>
)
}
/>
<DefItem
icon={CalendarHeart}
label="Mitglied seit"
value={format(user.createdAt, "PPP", { locale: de })}
/>
</DefList>
</CardContent>
</Card>
<div className="flex flex-col gap-6">
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Phone className="h-5 w-5" />
Kontaktdaten
</CardTitle>
<CardDescription>
Diese Angaben helfen bei Notdienst, Adressbuch und Abstimmung in
der Kita.
<CardHeader className="pb-5">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<AtSign className="h-4 w-4 [stroke-width:1.7]" />
</div>
<div>
<CardTitle>Kontaktdaten</CardTitle>
<CardDescription className="mt-2">
Für Notdienst, Adressbuch und Abstimmung in der Kita.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<ContactForm
@@ -164,13 +178,19 @@ export default async function ProfilPage() {
</CardContent>
</Card>
{/* Kinder */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Baby className="h-5 w-5" />
Meine Kinder
</CardTitle>
<CardHeader className="pb-5">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Baby className="h-4 w-4 [stroke-width:1.7]" />
</div>
<div>
<CardTitle>Meine Kinder</CardTitle>
<CardDescription className="mt-2">
Kinder deines Haushalts verwalten.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<MyChildrenManager
@@ -187,23 +207,53 @@ export default async function ProfilPage() {
</CardContent>
</Card>
{/* Ämter */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Meine Ämter & Dienste
</CardTitle>
<CardHeader className="pb-5">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Shield className="h-4 w-4 [stroke-width:1.7]" />
</div>
<div>
<CardTitle>Meine Ämter & Dienste</CardTitle>
<CardDescription className="mt-2">
Feste Aufgaben und laufende Zuständigkeiten.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{user.dutyAssignments.length === 0 ? (
<p className="text-sm text-muted-foreground">Du hast aktuell keine festen Ämter übernommen.</p>
<div className="flex min-h-32 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Shield className="h-4 w-4 [stroke-width:1.7]" />
</div>
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
Keine festen Ämter übernommen
</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">
Dienste und Rollen erscheinen hier, sobald sie zugewiesen
sind.
</p>
</div>
) : (
<div className="flex flex-wrap gap-2">
<div className="grid gap-2">
{user.dutyAssignments.map((assignment) => (
<Badge key={assignment.id} variant="default" className="text-sm py-1 px-3">
<div
key={assignment.id}
className="flex items-center justify-between gap-3 rounded-lg bg-[var(--surface-2)] p-[10px_12px]"
>
<div>
<p className="text-sm font-semibold text-[var(--ink)]">
{assignment.duty.name}
</Badge>
</p>
<p className="text-xs text-[var(--ink-soft)]">
laufender Dienst
</p>
</div>
<span className="rounded-full border border-[var(--accent-mid)] bg-[var(--accent-soft)] px-2.5 py-1 text-[10.5px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.1em]">
Aktiv
</span>
</div>
))}
</div>
)}
@@ -212,18 +262,24 @@ export default async function ProfilPage() {
</div>
</div>
<Card className="border-destructive/20 mt-8">
<CardHeader>
<CardDescription>
<Card className="mt-5 bg-[var(--surface-2)]">
<CardContent className="flex items-center justify-between gap-6 pt-[22px] max-[760px]:flex-col max-[760px]:items-start">
<div className="max-w-2xl">
<div className="flex items-center gap-2 text-[var(--danger)]">
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
<h2 className="text-lg font-medium tracking-[-0.02em]">
Danger Zone
</h2>
</div>
<p className="mt-2 text-[13.5px] leading-5 text-[var(--ink-soft)]">
Hier kannst du dein Profil und je nach Haushaltssituation auch die
zugehörigen Familiendaten DSGVO-konform löschen.
</CardDescription>
</CardHeader>
<CardContent>
zugehörigen Familiendaten DSGVO-konform löschen. Aktive
Notdienst-Verpflichtungen blockieren die Löschung.
</p>
</div>
<DeleteAccountDialog />
</CardContent>
</Card>
</div>
);
}
+16 -13
View File
@@ -43,13 +43,13 @@ export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] })
if (events.length === 0) return null;
return (
<Card className="mb-8 border-amber-200 bg-amber-50/70">
<Card className="mb-8 border-[var(--warn)] bg-[var(--warn-soft)]">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-amber-950">
<AlertTriangle className="h-5 w-5 text-amber-700" />
<CardTitle className="flex items-center gap-2 text-lg text-[var(--ink)]">
<AlertTriangle className="h-5 w-5 text-[var(--warn)] [stroke-width:1.7]" />
Aktion erforderlich
</CardTitle>
<CardDescription className="text-amber-900/80">
<CardDescription className="text-[var(--ink-soft)]">
Für diese Termine fehlt noch mindestens eine Rückmeldung deiner
Familie.
</CardDescription>
@@ -58,11 +58,11 @@ export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] })
{events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-3 rounded-md border bg-background p-4 sm:flex-row sm:items-center sm:justify-between"
className="flex flex-col gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="font-medium">{event.title}</p>
<p className="mt-1 text-sm text-muted-foreground">
<p className="font-medium text-[var(--ink)]">{event.title}</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Rückmeldung bis{" "}
{format(
new Date(event.rsvpDeadline ?? event.startDate),
@@ -141,15 +141,18 @@ function RsvpDialog({ event }: { event: DashboardRsvpEventDto }) {
<div className="grid gap-3 py-2">
{event.children.map((child) => (
<div key={child.id} className="rounded-md border p-4">
<div
key={child.id}
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<p className="font-medium">{child.name}</p>
<p className="font-medium text-[var(--ink)]">{child.name}</p>
{child.status && (
<Badge variant="outline">bereits gemeldet</Badge>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<label className="flex cursor-pointer items-center gap-2 rounded-md border p-3 text-sm hover:bg-muted/50">
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]">
<input
type="radio"
name={`rsvp-${child.id}`}
@@ -164,10 +167,10 @@ function RsvpDialog({ event }: { event: DashboardRsvpEventDto }) {
}))
}
/>
<Check className="h-4 w-4 text-emerald-600" />
<Check className="h-4 w-4 text-[var(--good)] [stroke-width:1.7]" />
Nimmt teil
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-md border p-3 text-sm hover:bg-muted/50">
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]">
<input
type="radio"
name={`rsvp-${child.id}`}
@@ -182,7 +185,7 @@ function RsvpDialog({ event }: { event: DashboardRsvpEventDto }) {
}))
}
/>
<X className="h-4 w-4 text-rose-600" />
<X className="h-4 w-4 text-[var(--danger)] [stroke-width:1.7]" />
Nimmt nicht teil
</label>
</div>
+49 -66
View File
@@ -1,86 +1,69 @@
@import "tailwindcss";
@import "./styles/tokens.css";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--bg);
--color-foreground: var(--ink);
--color-card: var(--surface);
--color-card-foreground: var(--ink);
--color-popover: var(--surface);
--color-popover-foreground: var(--ink);
--color-primary: var(--accent-deep);
--color-primary-foreground: oklch(0.98 0.005 45);
--color-secondary: var(--surface-2);
--color-secondary-foreground: var(--ink);
--color-muted: var(--surface-week);
--color-muted-foreground: var(--ink-muted);
--color-accent: var(--accent-soft);
--color-accent-foreground: var(--accent-deep);
--color-destructive: var(--danger);
--color-destructive-foreground: oklch(0.98 0.005 45);
--color-border: var(--hairline);
--color-input: var(--hairline);
--color-ring: var(--accent);
--font-sans: var(--font-manrope);
--font-mono: var(--font-manrope);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--background: var(--bg);
--foreground: var(--ink);
--card: var(--surface);
--card-foreground: var(--ink);
--popover: var(--surface);
--popover-foreground: var(--ink);
--primary: var(--accent-deep);
--primary-foreground: oklch(0.98 0.005 45);
--secondary: var(--surface-2);
--secondary-foreground: var(--ink);
--muted: var(--surface-week);
--muted-foreground: var(--ink-muted);
--accent-foreground: var(--accent-deep);
--destructive: var(--danger);
--destructive-foreground: oklch(0.98 0.005 45);
--border: var(--hairline);
--input: var(--hairline);
--ring: var(--accent);
--radius: 0.625rem;
}
@media (prefers-color-scheme: dark) {
:root {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 0.1);
--input: oklch(1 0 0 / 0.15);
--ring: oklch(0.556 0 0);
}
}
@layer base {
* {
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--foreground);
background-color: var(--bg);
background-image: var(--paper-pattern);
background-size: 16px 16px;
color: var(--ink);
font-family: var(--font-sans), system-ui, sans-serif;
}
::selection {
background: var(--accent-soft);
color: var(--accent-deep);
}
}
+5 -9
View File
@@ -1,17 +1,13 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Manrope } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
import { UmamiAnalytics } from "@/components/analytics/umami-analytics";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const manrope = Manrope({
variable: "--font-manrope",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = {
@@ -56,7 +52,7 @@ export default function RootLayout({
return (
<html
lang="de"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
className={`${manrope.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
{children}
+178 -7
View File
@@ -3,8 +3,10 @@ import Image from "next/image";
import Link from "next/link";
import {
ArrowRight,
Bell,
CheckCircle2,
ClipboardList,
Clock,
DatabaseZap,
FileStack,
Fingerprint,
@@ -14,6 +16,7 @@ import {
MessageSquareText,
MessagesSquare,
PhoneCall,
Search,
Route,
ShieldCheck,
Table2,
@@ -72,7 +75,7 @@ const privacyPoints = [
{
icon: DatabaseZap,
title: "Strikt isolierte Mandanten",
text: "Jede Kita arbeitet innerhalb ihrer eigenen kitaId-Grenze. Rollen und Datenabfragen bleiben konsequent tenant-isoliert.",
text: "Jede Kita arbeitet technisch streng getrennt pro Kita. Rollen und Datenabfragen bleiben konsequent tenant-isoliert.",
},
{
icon: Fingerprint,
@@ -81,7 +84,7 @@ const privacyPoints = [
},
{
icon: Trash2,
title: "Recht auf Vergessenwerden",
title: "Recht auf Löschung",
text: "Eltern können ihren Account selbst löschen. Wenn sie der letzte Haushaltspartner sind, werden die Familiendaten sauber entfernt.",
},
{
@@ -112,26 +115,31 @@ const replacedTools = [
icon: MessagesSquare,
title: "WhatsApp für offizielle Infos",
text: "Ankündigungen, Anhänge und Lesestatus gehören in einen verbindlichen Kanal, nicht zwischen spontane Rückfragen.",
accent: "bg-emerald-50 text-emerald-700 ring-emerald-100",
},
{
icon: Table2,
title: "Excel-Listen für Dienste",
text: "Verfügbarkeiten, Mindesttage und Dienstpläne werden strukturierter als in Tabellen mit Version 17-final-final.",
accent: "bg-lime-50 text-lime-700 ring-lime-100",
},
{
icon: FileStack,
title: "Verstreute PDFs und Mail-Anhänge",
text: "Wichtige Dokumente hängen am passenden Kontext und bleiben geschützt abrufbar.",
accent: "bg-red-50 text-red-700 ring-red-100",
},
{
icon: ClipboardList,
title: "Manuelle Kontaktlisten",
text: "Familien, Kinder und freigegebene Kontaktdaten werden kontrolliert statt lose herumgereicht.",
accent: "bg-sky-50 text-sky-700 ring-sky-100",
},
{
icon: PhoneCall,
title: "Telefonchaos bei Krankmeldungen",
text: "Eltern melden Abwesenheiten digital; das Team sieht morgens schneller, wer fehlt.",
accent: "bg-amber-50 text-amber-700 ring-amber-100",
},
];
@@ -153,21 +161,28 @@ const audienceRoles = [
},
];
const onboardingTimeAnchors = [
"Demo-Call (30 Min)",
"Setup-Termin (1 Woche später)",
"Familien einladen (Woche 2)",
"Erste echte Dienstrunde (Woche 34)",
] as const;
const onboardingSteps = [
{
title: "Demo anfragen",
title: onboardingTimeAnchors[0],
text: "Ihr beschreibt kurz eure aktuelle Organisation und die größten Reibungspunkte.",
},
{
title: "Setup mit Kita und Vorstand klären",
title: onboardingTimeAnchors[1],
text: "Wir schauen gemeinsam auf Rollen, Module, Datenschutzfragen und den sinnvollen Einstieg.",
},
{
title: "Familien einladen oder anlegen",
title: onboardingTimeAnchors[2],
text: "Der Vorstand bringt Eltern, Kinder und Zuständigkeiten in eine saubere Struktur.",
},
{
title: "Module im Alltag nutzen",
title: onboardingTimeAnchors[3],
text: "Dienste, Kalender, Krankmeldungen, Adressbuch und Schwarzes Brett ersetzen nach und nach Nebenkanäle.",
},
];
@@ -267,6 +282,27 @@ export default function LandingPage() {
</div>
</section>
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6 lg:py-24">
<div className="mb-10 grid gap-5 lg:grid-cols-[0.78fr_1fr] lg:items-end">
<div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
Produkt statt Versprechen
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
So sieht operative Kita-Arbeit aus, wenn sie nicht mehr im Chat
liegt.
</h2>
</div>
<p className="max-w-2xl text-sm leading-6 text-slate-600 lg:justify-self-end">
Der Notdienst-Planer sammelt Verfügbarkeiten, zeigt offene
Familien und macht aus losen Antworten einen veröffentlichbaren
Monatsplan.
</p>
</div>
<ProductBrowserMock />
</section>
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6 lg:py-24">
<div className="mb-10 grid gap-5 lg:grid-cols-[0.85fr_1.15fr] lg:items-end">
<div>
@@ -293,7 +329,9 @@ export default function LandingPage() {
key={item.title}
className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm"
>
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<div
className={`mb-4 flex h-10 w-10 items-center justify-center rounded-lg ring-1 ${item.accent}`}
>
<Icon className="h-5 w-5" />
</div>
<h3 className="font-semibold tracking-tight">{item.title}</h3>
@@ -409,6 +447,9 @@ export default function LandingPage() {
</Button>
</div>
</CardHeader>
<CardContent className="pt-0">
<FeaturePreview slug={feature.slug} />
</CardContent>
</Card>
);
})}
@@ -593,3 +634,133 @@ export default function LandingPage() {
</div>
);
}
function ProductBrowserMock() {
return (
<div className="overflow-hidden rounded-2xl border border-emerald-100 bg-white shadow-[0_24px_70px_rgba(15,23,42,0.12)]">
<div className="flex items-center justify-between border-b border-slate-200 bg-[#fbf8ef] px-4 py-3">
<div className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full bg-red-300" />
<span className="h-3 w-3 rounded-full bg-amber-300" />
<span className="h-3 w-3 rounded-full bg-emerald-300" />
</div>
<div className="rounded-full border border-slate-200 bg-white px-4 py-1 text-xs font-medium text-slate-500">
app.kita-planer.de/dashboard/notdienst
</div>
<div className="w-16" />
</div>
<div className="bg-[#fbf8ef] p-2 sm:p-4">
<Image
src="/landing/notdienst-calendar-linen.png"
alt="Notdienst-Kalender im Kita-Planer mit ausgewählten Tagen im Juni 2026"
width={2262}
height={1640}
sizes="(min-width: 1280px) 1184px, calc(100vw - 40px)"
className="h-auto w-full rounded-xl border border-slate-200 bg-[#fbf8ef]"
/>
</div>
</div>
);
}
function FeaturePreview({ slug }: { slug: string }) {
if (slug === "notdienst-planer") {
return (
<div className="rounded-xl border border-red-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center justify-between text-xs">
<span className="font-semibold text-slate-700">Juni</span>
<span className="rounded-full bg-red-50 px-2 py-1 text-red-700">
12/16
</span>
</div>
<div className="grid grid-cols-5 gap-1.5">
{["", "bg-red-100", "", "bg-red-100", "", "bg-red-100", "", "", "bg-red-100", ""].map(
(cell, index) => (
<span
key={index}
className={`h-7 rounded-md border border-red-100 bg-white ${cell}`}
/>
),
)}
</div>
</div>
);
}
if (slug === "kita-kalender") {
return (
<div className="rounded-xl border border-sky-100 bg-[#fbf8ef] p-3">
{[
["10", "Schließtag", "bg-red-400"],
["18", "Elternabend", "bg-indigo-400"],
["29", "Sommerfest", "bg-emerald-500"],
].map(([date, title, color]) => (
<div
key={title}
className="mb-2 last:mb-0 flex items-center gap-3 rounded-lg bg-white px-3 py-2 text-xs"
>
<span className="font-semibold tabular-nums text-slate-950">
{date}
</span>
<span className={`h-5 w-1 rounded-full ${color}`} />
<span className="text-slate-600">{title}</span>
</div>
))}
</div>
);
}
if (slug === "eltern-adressbuch") {
return (
<div className="rounded-xl border border-emerald-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-400">
<Search className="h-3.5 w-3.5" />
Familie suchen
</div>
{["Meier", "Mustermann"].map((name) => (
<div key={name} className="mb-2 last:mb-0 flex items-center gap-2">
<span className="h-7 w-7 rounded-lg bg-emerald-50" />
<span className="h-2 flex-1 rounded-full bg-slate-200" />
<span className="h-2 w-10 rounded-full bg-emerald-100" />
</div>
))}
</div>
);
}
if (slug === "krankmeldung") {
return (
<div className="rounded-xl border border-amber-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center gap-2 text-xs font-semibold text-slate-700">
<Clock className="h-4 w-4 text-amber-700" />
Heute fehlen 2 Kinder
</div>
{["Kai · krank", "Mia · Arzttermin"].map((item) => (
<div
key={item}
className="mb-2 last:mb-0 rounded-lg bg-white px-3 py-2 text-xs text-slate-600"
>
{item}
</div>
))}
</div>
);
}
return (
<div className="rounded-xl border border-indigo-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center gap-2 text-xs font-semibold text-slate-700">
<Bell className="h-4 w-4 text-indigo-700" />
Neue Mitteilung
</div>
<div className="space-y-2">
<span className="block h-2.5 w-4/5 rounded-full bg-slate-200" />
<span className="block h-2.5 w-3/5 rounded-full bg-slate-200" />
<span className="inline-flex rounded-full bg-indigo-50 px-2 py-1 text-xs text-indigo-700">
PDF angehängt
</span>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
:root {
/* Surfaces */
--bg: oklch(0.965 0.012 75);
--surface: oklch(0.985 0.008 75);
--surface-2: oklch(0.975 0.010 75);
--surface-week: oklch(0.945 0.014 75);
/* Ink */
--ink: oklch(0.26 0.015 45);
--ink-soft: oklch(0.45 0.015 45);
--ink-muted: oklch(0.60 0.012 45);
--ink-faint: oklch(0.76 0.010 45);
/* Hairlines */
--hairline: oklch(0.90 0.012 75);
--hairline-soft: oklch(0.93 0.010 75);
/* Accent */
--accent: oklch(0.58 0.11 38);
--accent-deep: oklch(0.42 0.10 38);
--accent-soft: oklch(0.93 0.04 38);
--accent-mid: oklch(0.82 0.07 38);
/* Semantic */
--good: oklch(0.55 0.10 155);
--good-soft: oklch(0.94 0.03 155);
--warn: oklch(0.62 0.10 70);
--warn-soft: oklch(0.95 0.04 70);
--danger: oklch(0.55 0.13 25);
--danger-soft: oklch(0.95 0.04 25);
--focus-ring: oklch(0.85 0.07 38 / 0.30);
--paper-pattern: radial-gradient(
oklch(0.85 0.02 75 / 0.10) 1px,
transparent 1px
);
}
+41 -21
View File
@@ -1,6 +1,5 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
@@ -15,24 +14,28 @@ import {
Megaphone,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { NavItem } from "@/components/ui/nav-item";
import { UserRole } from "@prisma/client";
const navItems = [
{
section: "Heute",
href: "/dashboard",
label: "Übersicht",
icon: LayoutDashboard,
exact: true,
},
{
section: "Heute",
href: "/dashboard/notdienst",
label: "Notdienst (Eltern)",
icon: CalendarCheck2,
exact: true,
badge: "4",
},
{
section: "Heute",
href: "/dashboard/notdienst/plan",
label: "Notdienst-Planung",
icon: CalendarCheck2,
@@ -40,18 +43,21 @@ const navItems = [
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
},
{
section: "Heute",
href: "/dashboard/kalender",
label: "Terminkalender",
icon: CalendarDays,
exact: false,
},
{
section: "Heute",
href: "/dashboard/adressbuch",
label: "Adressbuch",
icon: Contact,
exact: false,
},
{
section: "Verwaltung",
href: "/dashboard/families",
label: "Familienverwaltung",
icon: Users,
@@ -59,6 +65,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN],
},
{
section: "Verwaltung",
href: "/dashboard/admin/dienste",
label: "Dienstplan",
icon: ClipboardList,
@@ -66,6 +73,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN],
},
{
section: "Verwaltung",
href: "/dashboard/admin/news",
label: "Schwarzes Brett",
icon: Megaphone,
@@ -73,6 +81,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN],
},
{
section: "Verwaltung",
href: "/dashboard/admin/kalender",
label: "Event-Anmeldungen",
icon: CalendarCheck2,
@@ -80,6 +89,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
},
{
section: "Verwaltung",
href: "/dashboard/admin/abwesenheiten",
label: "Abwesenheiten",
icon: Stethoscope,
@@ -87,6 +97,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ERZIEHER],
},
{
section: "Verwaltung",
href: "/dashboard/erzieher",
label: "ErzieherInnen",
icon: GraduationCap,
@@ -94,6 +105,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN],
},
{
section: "Verwaltung",
href: "/dashboard/profil",
label: "Mein Profil",
icon: UserCircle,
@@ -103,32 +115,40 @@ const navItems = [
export function SidebarNav({ role }: { role: UserRole }) {
const pathname = usePathname();
const visibleItems = navItems.filter(
({ allowedRoles }) =>
!allowedRoles || (allowedRoles as UserRole[]).includes(role),
);
const sections = ["Heute", "Verwaltung"];
return (
<nav className="flex flex-col gap-1 px-3">
{navItems.map(({ href, label, icon: Icon, exact, allowedRoles }) => {
// Filter out items not allowed for the current role
if (allowedRoles && !(allowedRoles as UserRole[]).includes(role)) {
return null;
}
const isActive = exact ? pathname === href : pathname.startsWith(href);
<nav className="flex flex-col gap-8 px-5">
{sections.map((section) => (
<div key={section}>
<p className="mb-3 px-2.5 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.18em]">
{section}
</p>
<div className="flex flex-col gap-1">
{visibleItems
.filter((item) => item.section === section)
.map(({ href, label, icon, exact, badge }) => {
const isActive = exact
? pathname === href
: pathname.startsWith(href);
return (
<Link
<NavItem
key={href}
href={href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
label={label}
icon={icon}
active={isActive}
badge={badge}
/>
);
})}
</div>
</div>
))}
</nav>
);
}
+7 -7
View File
@@ -4,21 +4,21 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-[11px] font-semibold transition-colors focus:outline-none focus:ring-[3px] focus:ring-[var(--focus-ring)]",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground",
"border-transparent bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)]",
secondary:
"border-transparent bg-secondary text-secondary-foreground",
"border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]",
destructive:
"border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
"border-transparent bg-[var(--danger-soft)] text-[var(--danger)]",
outline: "border-[var(--hairline)] text-[var(--ink-soft)]",
success:
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400",
"border-transparent bg-[var(--good-soft)] text-[var(--good)]",
warning:
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
"border-transparent bg-[var(--warn-soft)] text-[var(--warn)]",
},
},
defaultVariants: {
+14 -10
View File
@@ -5,24 +5,28 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center whitespace-nowrap text-[13.5px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] disabled:pointer-events-none disabled:opacity-50 active:translate-y-px [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:stroke-[1.7]",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default:
"rounded-lg bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)] hover:bg-[oklch(0.36_0.10_38)]",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
"rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink)] hover:border-[var(--accent-mid)]",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
"rounded-lg border border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
ghost:
"rounded-lg border border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
link: "text-primary underline-offset-4 hover:underline",
"rounded-lg bg-[var(--danger)] text-[oklch(0.98_0.005_45)] hover:brightness-95",
link: "rounded-lg text-[var(--accent-deep)] underline-offset-4 hover:underline",
pill:
"rounded-full border border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
default: "h-10 gap-2 px-[18px] py-2.5",
sm: "h-9 gap-2 px-3",
lg: "h-11 gap-2 px-8",
icon: "h-10 w-10",
},
},
+29 -11
View File
@@ -1,15 +1,33 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
const cardVariants = cva(
"relative rounded-[14px] border border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink)]",
{
variants: {
variant: {
default: "",
featured:
"overflow-hidden border-[var(--accent-mid)] bg-linear-to-b from-[oklch(0.985_0.012_50)] to-[var(--surface)] before:absolute before:inset-x-0 before:top-0 before:h-0.5 before:bg-[var(--accent)]",
},
},
defaultVariants: {
variant: "default",
},
},
);
interface CardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardVariants> {}
const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className, variant, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
className={cn(cardVariants({ variant, className }))}
{...props}
/>
),
@@ -18,7 +36,7 @@ Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-[22px_24px]", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
@@ -31,7 +49,7 @@ const CardTitle = React.forwardRef<HTMLElement, CardTitleProps>(
({ className, as: Comp = "div", ...props }, ref) => (
<Comp
ref={ref as React.Ref<HTMLDivElement>}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
className={cn("text-lg font-medium leading-none tracking-[-0.02em]", className)}
{...props}
/>
),
@@ -42,7 +60,7 @@ const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HT
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-[13.5px] text-[var(--ink-soft)]", className)}
{...props}
/>
),
@@ -51,14 +69,14 @@ CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
<div ref={ref} className={cn("p-[22px_24px] pt-0", className)} {...props} />
),
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
<div ref={ref} className={cn("flex items-center p-[22px_24px] pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
+52
View File
@@ -0,0 +1,52 @@
import type { ComponentType, ReactNode } from "react";
import type { LucideProps } from "lucide-react";
import { cn } from "@/lib/utils";
export function DefList({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<dl className={cn("divide-y divide-[var(--hairline-soft)]", className)}>
{children}
</dl>
);
}
export function DefItem({
icon: Icon,
label,
value,
}: {
icon?: ComponentType<LucideProps>;
label: string;
value: ReactNode;
}) {
return (
<div className="grid gap-1 py-3 first:pt-0 last:pb-0">
<dt className="flex items-center gap-1.5 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
{Icon ? <Icon className="h-3.5 w-3.5 [stroke-width:1.7]" /> : null}
{label}
</dt>
<dd className="text-sm font-medium text-[var(--ink)] [overflow-wrap:anywhere] [font-variant-numeric:tabular-nums]">
{value}
</dd>
</div>
);
}
export function EmptyDefValue({
children = "Noch nicht hinterlegt",
}: {
children?: ReactNode;
}) {
return (
<span className="font-normal italic text-[var(--ink-faint)]">
{children}
</span>
);
}
+5 -5
View File
@@ -18,7 +18,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-[oklch(0.25_0.015_45_/_0.20)] backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
@@ -35,13 +35,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-[var(--hairline)] bg-[var(--surface)] p-6 text-[var(--ink)] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-[14px]",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md text-[var(--ink-muted)] opacity-80 transition-opacity hover:bg-[var(--surface-2)] hover:text-[var(--ink)] hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface)] disabled:pointer-events-none data-[state=open]:bg-[var(--accent-soft)] data-[state=open]:text-[var(--accent-deep)]">
<X className="h-4 w-4" />
<span className="sr-only">Schließen</span>
</DialogPrimitive.Close>
@@ -85,7 +85,7 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
"text-lg font-medium leading-none tracking-[-0.02em] text-[var(--ink)]",
className,
)}
{...props}
@@ -99,7 +99,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-sm text-[var(--ink-soft)]", className)}
{...props}
/>
));
+1 -1
View File
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2.5 text-[13.5px] text-[var(--ink)] transition file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-[var(--ink-faint)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
+41
View File
@@ -0,0 +1,41 @@
import Link from "next/link";
import type { ComponentType } from "react";
import type { LucideProps } from "lucide-react";
import { cn } from "@/lib/utils";
type NavItemProps = {
href: string;
label: string;
icon: ComponentType<LucideProps>;
active?: boolean;
badge?: number | string;
};
export function NavItem({
href,
label,
icon: Icon,
active = false,
badge,
}: NavItemProps) {
return (
<Link
href={href}
className={cn(
"relative flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13.5px] font-medium text-[var(--ink-soft)] transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]",
"hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
active &&
"bg-[var(--accent-soft)] font-semibold text-[var(--accent-deep)] before:absolute before:left-[-14px] before:top-1.5 before:h-[calc(100%-12px)] before:w-[3px] before:rounded-full before:bg-[var(--accent)]",
)}
>
<Icon className="h-4 w-4 shrink-0 [stroke-width:1.7]" />
<span className="min-w-0 flex-1 truncate">{label}</span>
{badge ? (
<span className="rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-2 py-0.5 text-[11px] font-semibold text-[var(--ink-muted)]">
{badge}
</span>
) : null}
</Link>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { UserRole } from "@prisma/client";
import { cn } from "@/lib/utils";
type RoleBadgeProps = {
role: UserRole;
className?: string;
};
const roleLabels: Record<string, string> = {
SUPERADMIN: "Admin",
ADMIN: "Vorstand",
KOORDINATOR: "Koordinator",
ELTERN: "Eltern",
ERZIEHER: "Erzieher",
};
export function RoleBadge({ role, className }: RoleBadgeProps) {
return (
<span
className={cn(
"inline-flex w-fit rounded-full border border-[var(--accent-mid)] bg-[var(--accent-soft)] px-2.5 py-1 text-[10.5px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.1em]",
className,
)}
>
{roleLabels[role] ?? role}
</span>
);
}
+11 -7
View File
@@ -16,16 +16,20 @@ const SelectTrigger = React.forwardRef<
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
{...props}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-10 w-full appearance-none items-center justify-between rounded-lg border border-[var(--hairline)] bg-[var(--surface)] bg-[right_12px_center] bg-no-repeat px-3 py-2.5 pr-9 text-[13.5px] text-[var(--ink)] transition placeholder:text-[var(--ink-faint)] hover:border-[var(--accent-mid)] focus:border-[var(--accent)] focus:bg-[oklch(0.99_0.005_75)] focus:outline-none focus:ring-[3px] focus:ring-[var(--focus-ring)] disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
style={{
backgroundImage:
"linear-gradient(45deg, transparent 50%, var(--ink-muted) 50%), linear-gradient(135deg, var(--ink-muted) 50%, transparent 50%)",
backgroundPosition: "calc(100% - 16px) 50%, calc(100% - 11px) 50%",
backgroundSize: "5px 5px, 5px 5px",
...props.style,
}}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
@@ -66,7 +70,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink)] shadow-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
@@ -97,7 +101,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-[13.5px] outline-none focus:bg-[var(--accent-soft)] focus:text-[var(--accent-deep)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
+5 -4
View File
@@ -12,15 +12,16 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
position="bottom-center"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
"group toast group-[.toaster]:rounded-[10px] group-[.toaster]:border-0 group-[.toaster]:bg-[oklch(0.22_0.015_45)] group-[.toaster]:px-[18px] group-[.toaster]:py-3 group-[.toaster]:text-white group-[.toaster]:shadow-none group-data-[state=open]:animate-in group-data-[state=open]:slide-in-from-bottom-8 group-data-[state=open]:duration-200",
description: "group-[.toast]:text-white/70",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
"group-[.toast]:bg-[var(--accent)] group-[.toast]:text-white",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
"group-[.toast]:bg-white/10 group-[.toast]:text-white",
},
}}
{...props}
+35
View File
@@ -0,0 +1,35 @@
import { cn } from "@/lib/utils";
type StatProps = {
label: string;
value: number | string;
suffix?: string;
hint?: string;
className?: string;
};
export function Stat({ label, value, suffix, hint, className }: StatProps) {
return (
<div
className={cn(
"relative rounded-[14px] border border-[var(--hairline)] bg-[var(--surface)] p-5 text-[var(--ink)] before:absolute before:inset-x-5 before:top-0 before:h-[1.5px] before:bg-[var(--accent)] before:opacity-40",
className,
)}
>
<p className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
{label}
</p>
<p className="mt-2 text-[30px] font-medium leading-none tracking-[-0.025em] [font-variant-numeric:tabular-nums]">
{value}
{suffix ? (
<em className="ml-1 text-sm not-italic text-[var(--ink-muted)]">
{suffix}
</em>
) : null}
</p>
{hint ? (
<p className="mt-2 text-xs text-[var(--ink-soft)]">{hint}</p>
) : null}
</div>
);
}
+16 -6
View File
@@ -9,7 +9,10 @@ const Table = React.forwardRef<
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
className={cn(
"w-full caption-bottom text-[13.5px] text-[var(--ink)]",
className,
)}
{...props}
/>
</div>
@@ -20,7 +23,11 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
<thead
ref={ref}
className={cn("[&_tr]:border-b [&_tr]:border-[var(--hairline-soft)]", className)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
@@ -43,7 +50,7 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
"border-b border-[var(--hairline-soft)] transition-colors hover:bg-[var(--surface-2)] data-[state=selected]:bg-[var(--accent-soft)]",
className,
)}
{...props}
@@ -58,7 +65,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle text-xs font-medium uppercase tracking-wide text-muted-foreground [&:has([role=checkbox])]:pr-0",
"h-11 px-4 text-left align-middle text-[11px] font-semibold uppercase tracking-[0.12em] text-[var(--ink-muted)] [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
@@ -72,7 +79,10 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0", className)}
className={cn(
"px-4 py-3.5 align-middle text-[var(--ink)] [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
));
@@ -84,7 +94,7 @@ const TableCaption = React.forwardRef<
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
className={cn("mt-4 text-sm text-[var(--ink-muted)]", className)}
{...props}
/>
));
+1 -1
View File
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
<textarea
ref={ref}
className={cn(
"flex min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex min-h-24 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2.5 text-[13.5px] text-[var(--ink)] transition placeholder:text-[var(--ink-faint)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
+34
View File
@@ -0,0 +1,34 @@
export const CHILD_MAX_AGE_YEARS = 7;
export function formatDateInputValue(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function getChildBirthdateRange(referenceDate = new Date()) {
const max = new Date(
referenceDate.getFullYear(),
referenceDate.getMonth(),
referenceDate.getDate(),
);
const min = new Date(max);
min.setFullYear(min.getFullYear() - CHILD_MAX_AGE_YEARS);
return {
min: formatDateInputValue(min),
max: formatDateInputValue(max),
};
}
export function parseDateInput(value?: string) {
if (!value) return null;
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const date = new Date(`${value}T00:00:00`);
if (Number.isNaN(date.getTime())) return null;
return formatDateInputValue(date) === value ? date : null;
}