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 Link from "next/link";
import { useMemo, useTransition } from "react"; import { useMemo, useTransition } from "react";
import { AbsenceReason } from "@prisma/client"; 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 { toast } from "sonner";
import { deleteAbsence, reportAbsence } from "@/actions/absences"; import { reportAbsence } from "@/actions/absences";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -43,7 +43,6 @@ type AbsenceListItem = {
export function AbsenceCard({ export function AbsenceCard({
childOptions, childOptions,
absences,
today, today,
}: { }: {
childOptions: ChildOption[]; childOptions: ChildOption[];
@@ -69,39 +68,32 @@ export function AbsenceCard({
return; return;
} }
toast.success("Abwesenheit eingetragen."); toast.success("Abwesenheit gemeldet. Die Kita ist informiert.");
});
}
function handleDelete(absenceId: string) {
startTransition(async () => {
const result = await deleteAbsence(absenceId);
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Abwesenheit storniert.");
}); });
} }
return ( return (
<Card className="mb-8 border-amber-200 bg-amber-50/50"> <Card variant="featured" className="min-w-0">
<CardHeader> <CardHeader className="pb-5">
<CardTitle className="flex items-center gap-2 text-lg"> <div className="flex items-start gap-3">
<CalendarX2 className="h-5 w-5 text-amber-700" /> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
Kind abmelden <CalendarX2 className="h-4 w-4 [stroke-width:1.7]" />
</CardTitle> </div>
<CardDescription> <div>
Melde dein Kind für ganze Tage ab. Die Kita sieht die Meldung direkt <CardTitle>Kind abmelden</CardTitle>
in der Tagesübersicht. <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> </CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
<CardContent>
{hasChildren ? ( {hasChildren ? (
<form action={handleSubmit} className="grid gap-4"> <form action={handleSubmit}>
<div className="grid gap-2"> <div className="grid gap-4 md:grid-cols-2">
<Label>Welches Kind?</Label> <FieldLabel label="Welches Kind?">
<Select name="childId" defaultValue={defaultChildId} required> <Select name="childId" defaultValue={defaultChildId} required>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Kind auswählen" /> <SelectValue placeholder="Kind auswählen" />
@@ -114,11 +106,30 @@ export function AbsenceCard({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </FieldLabel>
<div className="grid gap-3 sm:grid-cols-2"> <FieldLabel label="Grund">
<div className="grid gap-2"> <Select
<Label htmlFor="absence-startDate">Von</Label> 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 <Input
id="absence-startDate" id="absence-startDate"
name="startDate" name="startDate"
@@ -126,9 +137,9 @@ export function AbsenceCard({
defaultValue={today} defaultValue={today}
required required
/> />
</div> </FieldLabel>
<div className="grid gap-2">
<Label htmlFor="absence-endDate">Bis</Label> <FieldLabel label="Bis" htmlFor="absence-endDate">
<Input <Input
id="absence-endDate" id="absence-endDate"
name="endDate" name="endDate"
@@ -136,144 +147,198 @@ export function AbsenceCard({
defaultValue={today} defaultValue={today}
required required
/> />
</div> </FieldLabel>
</div>
<div className="grid gap-2"> <div className="grid gap-2 md:col-span-2">
<Label>Grund</Label> <Label
<Select name="reason" defaultValue={AbsenceReason.ILLNESS} required> htmlFor="absence-note"
<SelectTrigger> className="text-xs font-semibold text-[var(--ink-soft)]"
<SelectValue placeholder="Grund auswählen" /> >
</SelectTrigger> Bemerkung{" "}
<SelectContent> <span className="font-medium text-[var(--ink-faint)]">
<SelectItem value={AbsenceReason.ILLNESS}>Krank</SelectItem> optional
<SelectItem value={AbsenceReason.VACATION}>Urlaub</SelectItem> </span>
<SelectItem value={AbsenceReason.OTHER}>Sonstiges</SelectItem> </Label>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="absence-note">Bemerkung</Label>
<Textarea <Textarea
id="absence-note" id="absence-note"
name="note" name="note"
rows={3} rows={4}
placeholder="Optional, z.B. voraussichtlich wieder da am Freitag." placeholder="z. B. voraussichtlich wieder da am Freitag."
/> />
</div> </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}> <Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />} {isPending ? <Loader2 className="animate-spin" /> : <Check />}
Abwesenheit melden Abwesenheit melden
</Button> </Button>
</div> </div>
</form> </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"> <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-muted-foreground" /> <Baby className="h-10 w-10 text-[var(--ink-muted)]" />
<p className="mt-4 font-medium">Noch keine Kinder hinterlegt</p> <p className="mt-4 font-medium text-[var(--ink)]">
<p className="mt-1 text-sm text-muted-foreground"> 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 Lege zuerst deine Kinder im Profil an, dann kannst du sie hier
abmelden. abmelden.
</p> </p>
<Button asChild className="mt-4" variant="outline"> <Button asChild className="mt-4" variant="ghost">
<Link href="/dashboard/profil">Zum Profil</Link> <Link href="/dashboard/profil">Zum Profil</Link>
</Button> </Button>
</div> </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> </CardContent>
</Card> </Card>
); );
} }
function AbsenceRow({ export function UpcomingAbsencesCard({
absence, absences,
disabled,
onDelete,
}: { }: {
absence: AbsenceListItem; absences: AbsenceListItem[];
disabled: boolean;
onDelete: (id: string) => void;
}) { }) {
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 start = new Date(`${absence.startDate}T00:00:00`);
const end = new Date(`${absence.endDate}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", day: "2-digit",
month: "2-digit", month: "2-digit",
}); })}-${end.toLocaleDateString("de-DE", {
const endLabel = end.toLocaleDateString("de-DE", {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "numeric", })}`;
});
return absence.startDate === absence.endDate return {
? endLabel day: start.toLocaleDateString("de-DE", { day: "2-digit" }),
: `${startLabel} - ${endLabel}`; month: start
.toLocaleDateString("de-DE", { month: "short" })
.replace(".", ""),
detail: range,
};
}, [absence.endDate, absence.startDate]); }, [absence.endDate, absence.startDate]);
return ( return (
<div className="rounded-md border p-3"> <div className="flex items-center gap-3 py-3">
<div className="flex items-start justify-between gap-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"> <div className="min-w-0">
<p className="font-medium">{absence.childName}</p> <p className="truncate text-sm font-semibold text-[var(--ink)]">
<p className="mt-0.5 text-sm text-muted-foreground">{range}</p> {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> </div>
<Button <Badge variant="destructive">Bestätigt</Badge>
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>
</div> </div>
<div className="mt-2 flex flex-wrap items-center gap-2"> {absence.note ? (
<ReasonBadge reason={absence.reason} /> <p className="mt-1 truncate text-xs text-[var(--ink-muted)]">
{absence.note && ( {absence.note}
<span className="text-xs text-muted-foreground">{absence.note}</span> </p>
)} ) : null}
</div> </div>
</div> </div>
); );
} }
function ReasonBadge({ reason }: { reason: AbsenceReason }) { function reasonLabel(reason: AbsenceReason) {
if (reason === AbsenceReason.ILLNESS) { if (reason === AbsenceReason.ILLNESS) return "Krank";
return <Badge variant="destructive">Krank</Badge>; if (reason === AbsenceReason.VACATION) return "Urlaub";
} return "Sonstiges";
}
if (reason === AbsenceReason.VACATION) {
return <Badge variant="warning">Urlaub</Badge>; function reasonColor(reason: AbsenceReason) {
} if (reason === AbsenceReason.ILLNESS) return "var(--danger)";
if (reason === AbsenceReason.VACATION) return "var(--warn)";
return <Badge variant="outline">Sonstiges</Badge>; 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 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 { de } from "date-fns/locale";
import { ArrowLeft, ArrowRight, CalendarDays, Stethoscope } from "lucide-react"; import {
import { AbsenceReason } from "@prisma/client"; ArrowLeft,
ArrowRight,
CalendarCheck2,
CalendarDays,
ChevronDown,
ChevronRight,
Stethoscope,
} from "lucide-react";
import { AbsenceReason, TerminStatus, TerminType, UserRole } from "@prisma/client";
import { getDailyAbsences } from "@/actions/absences"; import { getDailyAbsences } from "@/actions/absences";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
AbsenceTable,
reasonColor,
reasonLabel,
type AbsenceTableItem,
} from "./absence-table";
export const metadata = { title: "Abwesenheiten · Kita-Planer" }; export const metadata = { title: "Abwesenheiten · Kita-Planer" };
export default async function AdminAbwesenheitenPage({ export default async function AdminAbwesenheitenPage({
searchParams, 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 dateKey = format(selectedDate, "yyyy-MM-dd");
const result = await getDailyAbsences({ date: dateKey }); const result = await getDailyAbsences({ date: dateKey });
const absences = result.absences ?? []; 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 ( return (
<div className="min-h-full bg-slate-50 px-4 py-6 sm:px-8"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="mx-auto max-w-5xl space-y-6"> <header className="mb-7 flex items-end justify-between gap-8 max-[1100px]:flex-col max-[1100px]:items-start">
<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> <div>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<Stethoscope className="h-4 w-4" /> TAGESÜBERSICHT
Tagesübersicht </p>
</div> <h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
<h1 className="mt-2 text-3xl font-bold tracking-tight"> Heute fehlen{" "}
Heute fehlen ({absences.length} Kinder) <em className="font-normal italic text-[var(--accent-deep)]">
{absences.length} Kinder
</em>
</h1> </h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-2 text-[15px] leading-6 text-[var(--ink-soft)]">
{format(selectedDate, "EEEE, dd. MMMM yyyy", { locale: de })} {format(selectedDate, "EEEE, d. MMMM yyyy", { locale: de })}
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center justify-end gap-3">
<Button asChild variant="outline" size="sm"> <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)]">
<Link href={hrefForDate(addDays(selectedDate, -1))}> <Stethoscope className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
<ArrowLeft className="h-4 w-4" /> {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 Zurück
</Link> </SegmentLink>
</Button> <SegmentLink href={hrefForDate(today)} active={isToday}>
<Button asChild variant="outline" size="sm"> Heute
<Link href="/dashboard/admin/abwesenheiten">Heute</Link> </SegmentLink>
</Button> <SegmentLink href={hrefForDate(addDays(selectedDate, 1))}>
<Button asChild variant="outline" size="sm">
<Link href={hrefForDate(addDays(selectedDate, 1))}>
Vor Vor
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-3.5 w-3.5" />
</Link> </SegmentLink>
</Button> </div>
<form className="flex items-center gap-2"> <form className="flex items-center gap-2">
<Input <Input
type="date" type="date"
name="date" name="date"
defaultValue={dateKey} defaultValue={dateKey}
className="h-9 w-40" className="w-[140px]"
/> />
<Button type="submit" size="sm"> <Button type="submit">Anzeigen</Button>
Anzeigen
</Button>
</form> </form>
</div> </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>
<div className="mt-4">
{result.error ? ( {result.error ? (
<Card className="border-destructive/30"> <Card className="border-[var(--danger)] bg-[var(--danger-soft)]">
<CardContent className="p-6 text-sm text-destructive"> <CardContent className="pt-[22px] text-sm text-[var(--danger)]">
{result.error} {result.error}
</CardContent> </CardContent>
</Card> </Card>
) : absences.length === 0 ? ( ) : tableItems.length > 0 ? (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-background p-8 text-center"> <Card>
<div className="mx-auto max-w-md"> <CardHeader className="pb-4">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700"> <div className="flex flex-wrap items-center justify-between gap-3">
<CalendarDays className="h-10 w-10" /> <CardTitle>
</div> {tableItems.length} von {childCount} Kindern abwesend ·{" "}
<h2 className="mt-6 text-2xl font-semibold tracking-tight"> {upcomingAbsenceCount} anstehend
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}
</CardTitle> </CardTitle>
<CardDescription className="mt-1"> <Badge variant="secondary">{dateKey}</Badge>
{absence.familyName}
</CardDescription>
</div> </div>
<ReasonBadge reason={absence.reason} />
</CardHeader> </CardHeader>
<CardContent className="grid gap-3 px-5 pb-5 sm:grid-cols-[180px_1fr]"> <CardContent>
<div className="text-sm"> <AbsenceTable absences={tableItems} />
<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> </CardContent>
</Card> </Card>
))} ) : isTooFarFuture ? (
</div> <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>
</div> </div>
); );
} }
function parseDateParam(value: string | undefined) { function SegmentLink({
if (!value) return new Date(); 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`); 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) { function hrefForDate(date: Date) {
return `/dashboard/admin/abwesenheiten?date=${format(date, "yyyy-MM-dd")}`; return `/dashboard/admin/abwesenheiten?date=${format(date, "yyyy-MM-dd")}`;
} }
function formatDateRange(startDate: string, endDate: string) { function startOfLocalDay(date: Date) {
const start = new Date(`${startDate}T00:00:00`); const nextDate = new Date(date);
const end = new Date(`${endDate}T00:00:00`); nextDate.setHours(0, 0, 0, 0);
const startLabel = format(start, "dd.MM.", { locale: de }); return nextDate;
const endLabel = format(end, "dd.MM.yyyy", { locale: de });
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
} }
function ReasonBadge({ reason }: { reason: AbsenceReason }) { function countByReason(items: AbsenceTableItem[]) {
if (reason === AbsenceReason.ILLNESS) { return items.reduce<Partial<Record<AbsenceReason, number>>>((acc, item) => {
return <Badge variant="destructive">Krank</Badge>; acc[item.reason] = (acc[item.reason] ?? 0) + 1;
} return acc;
}, {});
if (reason === AbsenceReason.VACATION) {
return <Badge variant="warning">Urlaub</Badge>;
}
return <Badge variant="outline">Sonstiges</Badge>;
} }
@@ -116,9 +116,11 @@ export function DutyPlanner({
if (dutyTypes.length === 0) { if (dutyTypes.length === 0) {
return ( return (
<div className="rounded-md border border-dashed p-8 text-center"> <div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-8 text-center">
<p className="text-sm font-medium">Noch keine Diensttypen</p> <p className="text-sm font-medium text-[var(--ink)]">
<p className="mt-1 text-sm text-muted-foreground"> Noch keine Diensttypen
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege zuerst einen Diensttyp an, danach erscheint hier das Planungsgrid. Lege zuerst einen Diensttyp an, danach erscheint hier das Planungsgrid.
</p> </p>
</div> </div>
@@ -136,29 +138,29 @@ export function DutyPlanner({
disabled={isPending} disabled={isPending}
> >
{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 Reminder diese Woche senden
</Button> </Button>
</div> </div>
<div className="overflow-x-auto rounded-md border"> <div className="overflow-x-auto rounded-lg border border-[var(--hairline)]">
<table className="w-full min-w-[760px] border-collapse text-sm"> <table className="w-full min-w-[760px] border-collapse text-[13.5px] text-[var(--ink)]">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b border-[var(--hairline-soft)] bg-[var(--surface-2)]">
<th className="sticky left-0 z-10 w-56 bg-muted/95 px-4 py-3 text-left font-medium"> <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 Woche
</th> </th>
{dutyTypes.map((dutyType) => ( {dutyTypes.map((dutyType) => (
<th <th
key={dutyType.id} 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> <div>{dutyType.name}</div>
{dutyType.description && ( {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} {dutyType.description}
</div> </div>
)} )}
@@ -168,8 +170,11 @@ export function DutyPlanner({
</thead> </thead>
<tbody> <tbody>
{weeks.map((week) => ( {weeks.map((week) => (
<tr key={week.key} className="border-b last:border-b-0"> <tr
<td className="sticky left-0 z-10 bg-background px-4 py-3 font-medium"> 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} {week.label}
</td> </td>
{dutyTypes.map((dutyType) => { {dutyTypes.map((dutyType) => {
+48 -35
View File
@@ -1,7 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { addWeeks, format, getISOWeek, startOfDay, startOfWeek } from "date-fns"; import { addWeeks, format, getISOWeek, startOfDay, startOfWeek } from "date-fns";
import { de } from "date-fns/locale"; 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 { UserRole } from "@prisma/client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -34,15 +34,16 @@ const MAX_WEEK_COUNT = 104;
export default async function DienstePage({ export default async function DienstePage({
searchParams, searchParams,
}: { }: {
searchParams: { start?: string; weeks?: string }; searchParams: Promise<{ start?: string; weeks?: string }>;
}) { }) {
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]); const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
if (!session.user.kitaId) { if (!session.user.kitaId) {
throw new Error("Kein Mandant zugeordnet."); throw new Error("Kein Mandant zugeordnet.");
} }
const rangeStart = getRangeStart(searchParams.start); const resolvedSearchParams = await searchParams;
const weekCount = getWeekCount(searchParams.weeks); const rangeStart = getRangeStart(resolvedSearchParams.start);
const weekCount = getWeekCount(resolvedSearchParams.weeks);
const weeks = buildWeeks(rangeStart, weekCount); const weeks = buildWeeks(rangeStart, weekCount);
const rangeEnd = startOfDay(new Date(weeks[weeks.length - 1].endDate)); const rangeEnd = startOfDay(new Date(weeks[weeks.length - 1].endDate));
@@ -83,43 +84,55 @@ export default async function DienstePage({
const dutyTypeDtos: DutyPlanDutyTypeDto[] = dutyTypes; const dutyTypeDtos: DutyPlanDutyTypeDto[] = dutyTypes;
const familyDtos: DutyPlanFamilyDto[] = families; const familyDtos: DutyPlanFamilyDto[] = families;
const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map((assignment) => ({ const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map(
(assignment) => ({
id: assignment.id, id: assignment.id,
dutyTypeId: assignment.dutyTypeId, dutyTypeId: assignment.dutyTypeId,
familyId: assignment.familyId, familyId: assignment.familyId,
startDate: toDateKey(assignment.startDate), startDate: toDateKey(assignment.startDate),
})); }),
);
return ( return (
<div className="space-y-6 p-6"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div> <div>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<ClipboardList className="h-4 w-4" /> ADMIN · DIENSTPLAN
Admin </p>
</div> <h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
<h1 className="mt-2 text-2xl font-bold tracking-tight"> Dienste{" "}
Dienstplan-Manager <em className="font-normal italic text-[var(--accent-deep)]">
planen
</em>
</h1> </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 Plane Dienste pro Kalenderwoche und Haushalt. Jede Änderung wird
direkt gespeichert. direkt gespeichert.
</p> </p>
</div> </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"> <Card className="mb-5">
<CardHeader className="pb-3"> <CardContent className="pt-[22px]">
<CardTitle className="flex items-center gap-2 text-base"> <form className="flex flex-wrap items-end gap-3">
<CalendarRange className="h-4 w-4" /> <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 Zeitraum
</CardTitle> </div>
<CardDescription> <p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Du kannst beliebig weit voraus planen, jeweils in handlichen {weeks[0].label} bis {weeks[weeks.length - 1].label}
Ausschnitten. </p>
</CardDescription> </div>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label htmlFor="start">Startwoche</Label> <Label htmlFor="start">Startwoche</Label>
<Input <Input
@@ -129,7 +142,7 @@ export default async function DienstePage({
defaultValue={toDateInputValue(rangeStart)} defaultValue={toDateInputValue(rangeStart)}
/> />
</div> </div>
<div className="grid gap-1.5"> <div className="grid w-28 gap-1.5">
<Label htmlFor="weeks">Wochen</Label> <Label htmlFor="weeks">Wochen</Label>
<Input <Input
id="weeks" id="weeks"
@@ -141,7 +154,7 @@ export default async function DienstePage({
/> />
</div> </div>
<Button type="submit">Anzeigen</Button> <Button type="submit">Anzeigen</Button>
<Button asChild type="button" variant="outline"> <Button asChild type="button" variant="ghost">
<Link <Link
href={`/dashboard/admin/dienste?start=${toDateInputValue( href={`/dashboard/admin/dienste?start=${toDateInputValue(
rangeStart, rangeStart,
@@ -153,7 +166,6 @@ export default async function DienstePage({
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
</div>
<div className="grid gap-6 xl:grid-cols-[340px_1fr]"> <div className="grid gap-6 xl:grid-cols-[340px_1fr]">
<Card> <Card>
@@ -186,19 +198,19 @@ export default async function DienstePage({
<div className="space-y-2"> <div className="space-y-2">
{dutyTypes.length === 0 ? ( {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. Noch keine Diensttypen angelegt.
</p> </p>
) : ( ) : (
dutyTypes.map((dutyType) => ( dutyTypes.map((dutyType) => (
<div <div
key={dutyType.id} 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"> <div className="min-w-0">
<p className="font-medium">{dutyType.name}</p> <p className="font-medium text-[var(--ink)]">{dutyType.name}</p>
{dutyType.description && ( {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} {dutyType.description}
</p> </p>
)} )}
@@ -213,9 +225,10 @@ export default async function DienstePage({
type="submit" type="submit"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
aria-label={`${dutyType.name} löschen`} aria-label={`${dutyType.name} löschen`}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button> </Button>
</form> </form>
</div> </div>
@@ -85,29 +85,59 @@ export function AdminCalendarManager({
termine: AdminTerminDto[]; termine: AdminTerminDto[];
childRows: ChildDto[]; childRows: ChildDto[];
}) { }) {
const rsvpCount = termine.filter((termin) => termin.requiresRsvp).length;
const pendingCount = termine.filter(
(termin) => termin.status === TerminStatus.PENDING,
).length;
return ( return (
<div className="flex h-full flex-col gap-6 p-6"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="flex flex-col justify-between gap-4 lg:flex-row lg:items-center"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight"> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
Event-Anmeldungen & Ausflüge 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> </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 Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind
verfolgen. verfolgen.
</p> </p>
</div> </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" /> <TerminFormDialog mode="create" />
</div> </div>
</header>
{termine.length === 0 ? ( {termine.length === 0 ? (
<div className="rounded-md border border-dashed p-10 text-center"> <Card className="border-dashed">
<CalendarPlus className="mx-auto h-10 w-10 text-muted-foreground" /> <div className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<h2 className="mt-4 text-lg font-semibold">Noch keine Termine</h2> <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<p className="mt-1 text-sm text-muted-foreground"> <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. Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
</p> </p>
</div> </div>
</Card>
) : ( ) : (
<div className="grid gap-4"> <div className="grid gap-4">
{termine.map((termin) => ( {termine.map((termin) => (
@@ -134,7 +164,7 @@ function AdminTerminCard({
return ( return (
<Card className="overflow-hidden"> <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="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="mb-2 flex flex-wrap gap-2"> <div className="mb-2 flex flex-wrap gap-2">
@@ -149,8 +179,8 @@ function AdminTerminCard({
)} )}
</div> </div>
<CardTitle className="text-lg">{termin.title}</CardTitle> <CardTitle className="text-lg">{termin.title}</CardTitle>
<CardDescription className="mt-1 flex flex-wrap items-center gap-2"> <CardDescription className="mt-1 flex flex-wrap items-center gap-2 text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5" /> <Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(new Date(termin.startDate), "dd.MM.yyyy", { {format(new Date(termin.startDate), "dd.MM.yyyy", {
locale: de, locale: de,
})} })}
@@ -176,20 +206,20 @@ function AdminTerminCard({
<CardContent className="grid gap-5 pt-5"> <CardContent className="grid gap-5 pt-5">
{termin.description && ( {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} {termin.description}
</p> </p>
)} )}
{termin.requiresRsvp ? ( {termin.requiresRsvp ? (
<div className="grid gap-4"> <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> <div>
<p className="font-medium"> <p className="font-medium">
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "} {stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
{stats.pending} Ausstehend {stats.pending} Ausstehend
</p> </p>
<p className="mt-1 text-sm text-amber-900/80"> <p className="mt-1 text-sm text-[var(--ink-soft)]">
Frist:{" "} Frist:{" "}
{termin.rsvpDeadline {termin.rsvpDeadline
? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", { ? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", {
@@ -198,11 +228,11 @@ function AdminTerminCard({
: "bis Terminbeginn"} : "bis Terminbeginn"}
</p> </p>
</div> </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> </div>
{termin.participantInfo && ( {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> <span className="font-medium">Hinweis für Teilnehmer: </span>
{termin.participantInfo} {termin.participantInfo}
</div> </div>
@@ -233,7 +263,7 @@ function AdminTerminCard({
</Table> </Table>
</div> </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. Für diesen Termin ist keine Teilnahme-Abfrage aktiv.
</div> </div>
)} )}
@@ -304,11 +334,11 @@ function TerminFormDialog({
}} }}
> >
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant={mode === "edit" ? "outline" : "default"} size="sm"> <Button variant={mode === "edit" ? "pill" : "default"} size="sm">
{mode === "edit" ? ( {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"} {mode === "edit" ? "Bearbeiten" : "Termin anlegen"}
</Button> </Button>
@@ -351,7 +381,7 @@ function TerminFormDialog({
id={`${mode}-type`} id={`${mode}-type`}
name="type" name="type"
defaultValue={termin?.type ?? TerminType.KITA_FEST} 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 required
> >
{Object.values(TerminType).map((type) => ( {Object.values(TerminType).map((type) => (
@@ -389,13 +419,13 @@ function TerminFormDialog({
</div> </div>
</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"> <label className="flex items-center gap-2 text-sm">
<input <input
type="checkbox" type="checkbox"
name="allDay" name="allDay"
defaultChecked={termin?.allDay ?? false} 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 Ganztägiger Termin
</label> </label>
@@ -404,7 +434,7 @@ function TerminFormDialog({
type="checkbox" type="checkbox"
name="mitbringselListEnabled" name="mitbringselListEnabled"
defaultChecked={termin?.mitbringselListEnabled ?? false} 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 Mitbring-Liste aktiv
</label> </label>
@@ -414,14 +444,14 @@ function TerminFormDialog({
name="requiresRsvp" name="requiresRsvp"
checked={requiresRsvp} checked={requiresRsvp}
onChange={(event) => setRequiresRsvp(event.target.checked)} 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 Teilnahme-Abfrage (RSVP) aktivieren
</label> </label>
</div> </div>
{requiresRsvp && ( {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"> <div className="grid gap-2">
<Label htmlFor={`${mode}-rsvpDeadline`}> <Label htmlFor={`${mode}-rsvpDeadline`}>
Rückmeldefrist Rückmeldefrist
@@ -495,7 +525,7 @@ function RsvpStatusBadge({
} }
return ( 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" /> <HelpCircle className="h-3.5 w-3.5" />
Ausstehend Ausstehend
</Badge> </Badge>
@@ -566,19 +596,19 @@ function getTypeBadgeClass(type: TerminType) {
switch (type) { switch (type) {
case TerminType.KITA_FEST: case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG: 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.SCHLIESSTAG:
case TerminType.TEAMTAG: 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_INTERN:
case TerminType.GEBURTSTAG_EXTERN: 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.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG: case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE: case TerminType.ELTERNCAFE:
return "border-transparent bg-purple-500 text-white"; return "border-transparent bg-[var(--accent-soft)] text-[var(--accent-deep)]";
default: 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..."} placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."}
required required
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-[var(--ink-muted)]">
Markdown ist aktiv. HTML wird nicht interpretiert. Markdown ist aktiv. HTML wird nicht interpretiert.
</p> </p>
</div> </div>
{content.trim() && ( {content.trim() && (
<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">
<p className="mb-3 text-xs font-semibold uppercase text-muted-foreground"> <p className="mb-3 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Vorschau Vorschau
</p> </p>
<MarkdownContent content={content} /> <MarkdownContent content={content} />
@@ -83,7 +83,7 @@ export function NewsForm() {
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="attachments">Anhänge</Label> <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 <Input
id="attachments" id="attachments"
name="attachments" name="attachments"
@@ -91,14 +91,14 @@ export function NewsForm() {
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
multiple multiple
/> />
<p className="mt-2 flex items-center gap-1 text-xs text-muted-foreground"> <p className="mt-2 flex items-center gap-1 text-xs text-[var(--ink-muted)]">
<UploadCloud className="h-3.5 w-3.5" /> <UploadCloud className="h-3.5 w-3.5 [stroke-width:1.7]" />
PDF, JPG und PNG bis 10 MB pro Datei. PDF, JPG und PNG bis 10 MB pro Datei.
</p> </p>
</div> </div>
</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"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
id="sendEmail" id="sendEmail"
@@ -130,9 +130,9 @@ export function NewsForm() {
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{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 Veröffentlichen
</Button> </Button>
+38 -19
View File
@@ -1,6 +1,6 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; 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 { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils"; import { requireRole } from "@/lib/auth-utils";
@@ -49,26 +49,37 @@ export default async function AdminNewsPage() {
}); });
return ( return (
<div className="px-8 py-8"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="mb-8"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<Badge className="mb-3 bg-emerald-100 text-emerald-800 hover:bg-emerald-100"> <div>
Offizielle Kita-Kommunikation <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
</Badge> KOMMUNIKATION · SCHWARZES BRETT
<h1 className="flex items-center gap-3 text-2xl font-semibold tracking-tight"> </p>
<Megaphone className="h-6 w-6 text-emerald-700" /> <h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Digitales Schwarzes Brett Meldungen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
veröffentlichen
</em>
</h1> </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 Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
werden geschützt gespeichert und nur für eingeloggte Nutzer derselben werden geschützt gespeichert und nur für eingeloggte Nutzer
Kita ausgeliefert. derselben Kita ausgeliefert.
</p> </p>
</div> </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]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<Card> <Card>
<CardHeader> <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> <CardDescription>
Markdown wird sicher gerendert; PDF, JPG und PNG können als Markdown wird sicher gerendert; PDF, JPG und PNG können als
geschützte Anhänge hinzugefügt werden. geschützte Anhänge hinzugefügt werden.
@@ -88,27 +99,35 @@ export default async function AdminNewsPage() {
</CardHeader> </CardHeader>
<CardContent className="grid gap-3"> <CardContent className="grid gap-3">
{announcements.length === 0 ? ( {announcements.length === 0 ? (
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground"> <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">
Noch keine Ankündigungen veröffentlicht. <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>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Veröffentlichte Meldungen erscheinen hier.
</p>
</div>
) : ( ) : (
announcements.map((announcement) => ( announcements.map((announcement) => (
<div <div
key={announcement.id} 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"> <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} {announcement.title}
</p> </p>
{announcement.attachments.length > 0 && ( {announcement.attachments.length > 0 && (
<Badge variant="secondary" className="shrink-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} {announcement.attachments.length}
</Badge> </Badge>
)} )}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-[var(--ink-muted)]">
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", { {format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
locale: de, 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 { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import { Button } from "@/components/ui/button";
Card, import { Card, CardContent } from "@/components/ui/card";
CardContent, import { Input } from "@/components/ui/input";
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export const metadata = { title: "Adressbuch · Kita-Planer" }; 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 ( return (
<div className="flex h-full flex-col gap-6 p-6"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="flex flex-col gap-1"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<h1 className="text-2xl font-bold tracking-tight">Adressbuch</h1> <div>
<p className="max-w-2xl text-sm text-muted-foreground"> <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 Haushalte und Kontaktdaten, die explizit für das interne
Kita-Adressbuch freigegeben wurden. Kita-Adressbuch freigegeben wurden.
</p> </p>
</div> </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 ? ( {families.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-background p-10 text-center"> <Card className="border-dashed">
<Contact className="mb-4 h-10 w-10 text-muted-foreground" /> <CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<h3 className="text-lg font-semibold">Keine freigegebenen Kontakte</h3> <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<p className="mt-2 max-w-sm text-sm text-muted-foreground"> <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, Sobald mindestens ein Elternteil eines Haushalts zustimmt,
erscheint die Familie hier. erscheint die Familie hier.
</p> </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> </CardContent>
</Card> </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> </div>
</section>
))}
</Card>
</div>
)} )}
</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"; "use client";
import { useState, useTransition } from "react"; 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 { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -18,6 +18,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Table, Table,
TableBody, TableBody,
@@ -92,11 +93,15 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
return ( return (
<div className="flex flex-col gap-4"> <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}> <Dialog open={openCreate} onOpenChange={setOpenCreate}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="gap-2"> <Button className="gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4 [stroke-width:1.7]" />
ErzieherIn anlegen ErzieherIn anlegen
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@@ -131,39 +136,67 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
</Dialog> </Dialog>
</div> </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> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Vorname</TableHead> <TableHead className="pl-6">Vorname</TableHead>
<TableHead>Nachname</TableHead> <TableHead>Nachname</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead> <TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{educators.length === 0 ? ( {educators.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground h-24"> <TableCell colSpan={4} className="h-32 text-center">
Keine Einträge gefunden. <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> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
educators.map((ed) => ( educators.map((ed) => (
<TableRow key={ed.id}> <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>{ed.lastName}</TableCell>
<TableCell> <TableCell>
<Badge variant={ed.active ? "success" : "secondary"}> <Badge variant={ed.active ? "success" : "secondary"}>
{ed.active ? "Aktiv" : "Inaktiv"} {ed.active ? "Aktiv" : "Inaktiv"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="pr-6 text-right">
<Button variant="ghost" size="icon" onClick={() => setEditingEducator(ed)}> <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>
<Button variant="ghost" size="icon" className="text-destructive hover:text-destructive" onClick={() => handleDelete(ed.id)} disabled={isPending}> <Button
<Trash2 className="h-4 w-4" /> 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> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -171,7 +204,8 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </CardContent>
</Card>
{/* Edit Dialog */} {/* Edit Dialog */}
<Dialog open={!!editingEducator} onOpenChange={(open) => !open && setEditingEducator(null)}> <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" }], orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
}); });
const activeCount = educators.filter((educator) => educator.active).length;
return ( return (
<div className="flex h-full flex-col gap-6 p-6"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="flex items-start justify-between gap-4"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div> <div>
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight"> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<GraduationCap className="h-6 w-6 text-primary" /> VERWALTUNG · TEAM
ErzieherInnen-Verwaltung </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> </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 Stammdaten des Kita-Personals für interne Planung und spätere
Notdienst-Alarmierung. Notdienst-Alarmierung.
</p> </p>
</div> </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> </div>
</header>
<ErzieherList educators={educators} /> <ErzieherList educators={educators} />
</div> </div>
@@ -54,7 +54,7 @@ export function AddFamilyDialog() {
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm"> <Button size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="h-4 w-4" />
Familie hinzufügen Familie hinzufügen
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@@ -82,7 +82,9 @@ export function AddFamilyDialog() {
{/* ── Elternteile ────────────────────────────────────────── */} {/* ── Elternteile ────────────────────────────────────────── */}
<fieldset className="space-y-4"> <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"> <div className="grid grid-cols-2 gap-3">
<FormField <FormField
@@ -112,8 +114,13 @@ export function AddFamilyDialog() {
</fieldset> </fieldset>
<fieldset className="space-y-4"> <fieldset className="space-y-4">
<legend className="text-sm font-semibold"> <legend>
Elternteil 2 <span className="font-normal text-muted-foreground">(optional)</span> <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> </legend>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -149,7 +156,7 @@ export function AddFamilyDialog() {
{/* ── Kinder ─────────────────────────────────────────────── */} {/* ── Kinder ─────────────────────────────────────────────── */}
<fieldset className="space-y-3"> <fieldset className="space-y-3">
<div className="flex items-center justify-between"> <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}) Kinder ({childCount})
</legend> </legend>
{childCount < 10 && ( {childCount < 10 && (
@@ -168,7 +175,7 @@ export function AddFamilyDialog() {
{Array.from({ length: childCount }).map((_, i) => ( {Array.from({ length: childCount }).map((_, i) => (
<div <div
key={i} 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 && ( {childCount > 1 && (
<button <button
@@ -178,13 +185,13 @@ export function AddFamilyDialog() {
// Felder nach Index benannt sind und der letzte entfernt wird. // Felder nach Index benannt sind und der letzte entfernt wird.
setChildCount((c) => c - 1); 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`} aria-label={`Kind ${i + 1} entfernen`}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </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} Kind {i + 1}
</p> </p>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -203,7 +210,7 @@ export function AddFamilyDialog() {
))} ))}
{state.errors?.children?.[0] && ( {state.errors?.children?.[0] && (
<p className="text-xs text-destructive"> <p className="text-xs text-[var(--danger)]">
{state.errors.children[0]} {state.errors.children[0]}
</p> </p>
)} )}
@@ -211,7 +218,7 @@ export function AddFamilyDialog() {
{/* Globaler Fehler */} {/* Globaler Fehler */}
{state.errors?._form?.[0] && ( {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]} {state.errors._form[0]}
</p> </p>
)} )}
@@ -219,7 +226,7 @@ export function AddFamilyDialog() {
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="ghost"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
disabled={pending} disabled={pending}
> >
@@ -268,7 +275,7 @@ function FormField({
required={required} required={required}
aria-invalid={!!error} aria-invalid={!!error}
/> />
{error && <p className="text-xs text-destructive">{error}</p>} {error && <p className="text-xs text-[var(--danger)]">{error}</p>}
</div> </div>
); );
} }
+23 -13
View File
@@ -107,10 +107,10 @@ export function DutyManager({
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1"> <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> <span className="hidden sm:inline">Ämter</span>
{userAssignments.length > 0 && ( {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} {userAssignments.length}
</Badge> </Badge>
)} )}
@@ -127,16 +127,20 @@ export function DutyManager({
<div className="flex flex-col gap-6 py-4"> <div className="flex flex-col gap-6 py-4">
{/* Current Assignments */} {/* Current Assignments */}
<div className="flex flex-col gap-2"> <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 ? ( {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"> <div className="flex flex-wrap gap-2">
{userAssignments.map((assignment) => ( {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} {assignment.duty.name}
<button <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)} onClick={() => handleRemove(assignment.id)}
disabled={isPending} disabled={isPending}
> >
@@ -149,17 +153,21 @@ export function DutyManager({
</div> </div>
{/* Assign new duty */} {/* Assign new duty */}
<div className="flex flex-col gap-2 border-t pt-4"> <div className="flex flex-col gap-2 border-t border-[var(--hairline-soft)] pt-4">
<h4 className="text-sm font-semibold">Vorhandenes Amt zuweisen</h4> <h4 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Vorhandenes Amt zuweisen
</h4>
{unassignedDuties.length === 0 ? ( {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"> <div className="flex flex-wrap gap-2">
{unassignedDuties.map((duty) => ( {unassignedDuties.map((duty) => (
<Badge <Badge
key={duty.id} key={duty.id}
variant="outline" 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)} onClick={() => handleAssign(duty.id)}
> >
<Plus className="h-3 w-3" /> <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 */} {/* Only show delete if no one is assigned to it across the entire DB */}
{duty.assignments.length === 0 && ( {duty.assignments.length === 0 && (
<button <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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteDuty(duty.id); handleDeleteDuty(duty.id);
@@ -186,8 +194,10 @@ export function DutyManager({
</div> </div>
{/* Create new duty */} {/* Create new duty */}
<div className="flex flex-col gap-2 border-t pt-4"> <div className="flex flex-col gap-2 border-t border-[var(--hairline-soft)] pt-4">
<h4 className="text-sm font-semibold">Neues Amt anlegen</h4> <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"> <form action={handleCreateDuty} className="flex gap-2">
<Input name="name" placeholder="z.B. Wäschedienst" className="h-8" required /> <Input name="name" placeholder="z.B. Wäschedienst" className="h-8" required />
<Button size="sm" className="h-8" disabled={isPending}> <Button size="sm" className="h-8" disabled={isPending}>
@@ -163,7 +163,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
<Dialog onOpenChange={(open) => !open && resetState()}> <Dialog onOpenChange={(open) => !open && resetState()}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1"> <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> <span className="hidden sm:inline">Details</span>
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@@ -189,11 +189,13 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
<fieldset className="grid gap-3"> <fieldset className="grid gap-3">
<div className="flex items-center justify-between 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 && ( {parents.length < 2 && (
<Button <Button
type="button" type="button"
variant="outline" variant="pill"
size="sm" size="sm"
className="h-8 gap-1" className="h-8 gap-1"
onClick={addParent} onClick={addParent}
@@ -206,9 +208,12 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
</div> </div>
{parents.map((parent, index) => ( {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"> <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} Elternteil {index + 1}
</p> </p>
{parent.kind === "new" && ( {parent.kind === "new" && (
@@ -216,7 +221,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" 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)} onClick={() => removeNewParent(parent)}
disabled={isPending} disabled={isPending}
> >
@@ -254,10 +259,12 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
<fieldset className="grid gap-3"> <fieldset className="grid gap-3">
<div className="flex items-center justify-between 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 <Button
type="button" type="button"
variant="outline" variant="pill"
size="sm" size="sm"
className="h-8 gap-1" className="h-8 gap-1"
onClick={addChild} onClick={addChild}
@@ -269,21 +276,24 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
</div> </div>
{children.length === 0 ? ( {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. Keine Kinder verknüpft.
</p> </p>
) : ( ) : (
children.map((child, index) => ( 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"> <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} Kind {index + 1}
</p> </p>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" 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)} onClick={() => removeChild(child)}
disabled={isPending} disabled={isPending}
> >
+105 -39
View File
@@ -1,8 +1,12 @@
import { UserRole } from "@prisma/client"; import { UserRole } from "@prisma/client";
import { Mail, Search, SlidersHorizontal, Users } from "lucide-react";
import { requireRole } from "@/lib/auth-utils"; import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge"; 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 { import {
Table, Table,
TableBody, TableBody,
@@ -85,36 +89,76 @@ export default async function FamiliesPage() {
0, 0,
); );
const canManageDuties = session.user.role === UserRole.ADMIN; const canManageDuties = session.user.role === UserRole.ADMIN;
const pendingInviteCount = families.reduce(
(acc, family) =>
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
0,
);
return ( return (
<div className="px-8 py-8"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
{/* Header */} <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div className="mb-6 flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight"> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
Familienverwaltung 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> </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)]">
{families.length === 0 Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort
? "Noch keine Familien angelegt." pflegen.
: `${families.length} ${families.length === 1 ? "Familie" : "Familien"} · ${childCount} Kinder`}
</p> </p>
</div> </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 /> <AddFamilyDialog />
</div> </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 ? ( {families.length === 0 ? (
<EmptyState /> <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> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Familie</TableHead> <TableHead className="pl-6">Familie</TableHead>
<TableHead>Elternteile</TableHead> <TableHead>Elternteile</TableHead>
<TableHead>Kinder</TableHead> <TableHead>Kinder</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead> <TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -125,18 +169,34 @@ export default async function FamiliesPage() {
return ( return (
<TableRow key={family.id}> <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} {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>
<TableCell> <TableCell>
<div className="space-y-1.5"> <div className="space-y-2">
{family.users.map((parent) => ( {family.users.map((parent) => (
<div key={parent.id}> <div key={parent.id} className="min-w-0">
<div className="font-medium"> <div className="font-medium text-[var(--ink)]">
{parent.firstName} {parent.lastName} {parent.firstName} {parent.lastName}
</div> </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} {parent.email}
</span>
</div> </div>
</div> </div>
))} ))}
@@ -144,13 +204,15 @@ export default async function FamiliesPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
{family.children.length === 0 ? ( {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) => ( {family.children.map((child) => (
<span <span
key={child.id} 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} {child.firstName} {child.lastName}
</span> </span>
@@ -163,7 +225,7 @@ export default async function FamiliesPage() {
{hasPendingInvite ? "Einladung offen" : "Aktiv"} {hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="pr-6 text-right">
<div className="flex flex-wrap justify-end gap-1"> <div className="flex flex-wrap justify-end gap-1">
<EditFamilyDialog <EditFamilyDialog
family={{ family={{
@@ -198,7 +260,8 @@ export default async function FamiliesPage() {
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div> </CardContent>
</Card>
)} )}
</div> </div>
); );
@@ -206,28 +269,31 @@ export default async function FamiliesPage() {
function EmptyState() { function EmptyState() {
return ( return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-background px-6 py-16 text-center"> <Card className="border-dashed">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted"> <div className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<svg <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
className="h-6 w-6 text-muted-foreground" <Users className="h-6 w-6 [stroke-width:1.7]" />
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>
</div> </div>
<h3 className="mb-1 font-semibold">Noch keine Familien</h3> <h3 className="mb-1 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
<p className="mb-4 max-w-sm text-sm text-muted-foreground"> 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 Lege die erste Familie an und lade das Elternteil per Link ein, sein
Passwort zu setzen. Passwort zu setzen.
</p> </p>
<AddFamilyDialog /> <AddFamilyDialog />
</div> </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"; "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 { TerminType } from "@prisma/client";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -20,18 +21,77 @@ import { Label } from "@/components/ui/label";
import { createTerminAdmin } from "../actions"; import { createTerminAdmin } from "../actions";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
export function AdminTerminModal() { type AdminTerminFormState = {
const [open, setOpen] = useState(false); title: string;
const [isPending, startTransition] = useTransition(); 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 = { const data = {
title: formData.get("title") as string, title: formState.title,
description: formData.get("description") as string, description: formState.description,
type: formData.get("type") as TerminType, type: formState.type,
startDate: new Date(formData.get("startDate") as string).toISOString(), startDate: new Date(formState.startDate).toISOString(),
endDate: new Date(formData.get("endDate") as string).toISOString(), endDate: new Date(formState.endDate).toISOString(),
allDay: formData.get("allDay") === "on", allDay: formState.allDay,
}; };
startTransition(async () => { startTransition(async () => {
@@ -40,6 +100,7 @@ export function AdminTerminModal() {
toast.error(res.error); toast.error(res.error);
} else { } else {
toast.success("Termin wurde direkt angelegt!"); toast.success("Termin wurde direkt angelegt!");
setFormState(initialFormState);
setOpen(false); setOpen(false);
} }
}); });
@@ -48,13 +109,13 @@ export function AdminTerminModal() {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="gap-2 bg-primary"> <Button variant={triggerVariant} className={triggerClassName}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Termin direkt anlegen {triggerLabel}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<form action={handleAction}> <form onSubmit={handleSubmit}>
<DialogHeader> <DialogHeader>
<DialogTitle>Termin anlegen (Admin)</DialogTitle> <DialogTitle>Termin anlegen (Admin)</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -65,12 +126,25 @@ export function AdminTerminModal() {
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="title">Titel</Label> <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>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="description">Beschreibung (Optional)</Label> <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>
<div className="grid gap-2"> <div className="grid gap-2">
@@ -78,7 +152,9 @@ export function AdminTerminModal() {
<select <select
id="type" id="type"
name="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 required
> >
<option value={TerminType.KITA_FEST}>Kita-Fest</option> <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 grid-cols-2 gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="startDate">Start</Label> <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>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="endDate">Ende</Label> <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> </div>
<div className="flex items-center gap-2 mt-2"> <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"> <Label htmlFor="allDay" className="font-normal cursor-pointer">
Ganztägiger Termin Ganztägiger Termin
</Label> </Label>
@@ -126,3 +225,10 @@ export function AdminTerminModal() {
</Dialog> </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 ( return (
<div className="flex flex-col gap-3 rounded-md bg-muted/50 p-3"> <div className="flex flex-col gap-3 rounded-lg bg-[var(--surface-2)] p-3">
<div className="flex items-center gap-2 font-medium text-sm mb-1"> <div className="mb-1 flex items-center gap-2 text-sm font-medium text-[var(--ink)]">
<Utensils className="h-4 w-4 text-muted-foreground" /> <Utensils className="h-4 w-4 text-[var(--ink-muted)] [stroke-width:1.7]" />
Mitbring-Liste Mitbring-Liste
</div> </div>
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto"> <div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
{items.length === 0 ? ( {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) => ( items.map((item) => (
<div <div
key={item.id} 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"> <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} {item.user.firstName} {item.user.lastName}
</span> </span>
<span>{item.content}</span> <span>{item.content}</span>
@@ -80,11 +82,11 @@ export function MitbringselList({
<Button <Button
variant="ghost" variant="ghost"
size="icon" 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)} onClick={() => handleDelete(item.id)}
disabled={isPending} disabled={isPending}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button> </Button>
)} )}
</div> </div>
@@ -112,7 +114,7 @@ export function MitbringselList({
onClick={handleAdd} onClick={handleAdd}
disabled={!content.trim() || isPending} 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 Hinzufügen
</Button> </Button>
</div> </div>
@@ -3,17 +3,20 @@
import { useTransition } from "react"; import { useTransition } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; 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 { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { approveTermin, rejectTermin } from "../actions"; import { approveTermin, rejectTermin } from "../actions";
export type PendingTerminDto = { export type PendingTerminDto = {
id: string; id: string;
title: string; title: string;
startDate: Date; startDate: Date;
createdAt: Date;
ageInDays: number;
allDay: boolean; allDay: boolean;
createdBy: { firstName: string; lastName: string } | null; createdBy: { firstName: string; lastName: string } | null;
}; };
@@ -21,22 +24,28 @@ export type PendingTerminDto = {
export function PendingAnfragen({ termine }: { termine: PendingTerminDto[] }) { export function PendingAnfragen({ termine }: { termine: PendingTerminDto[] }) {
if (termine.length === 0) { if (termine.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50"> <Card>
<CalendarIcon className="h-10 w-10 text-muted-foreground mb-4" /> <CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center p-8 text-center animate-in fade-in-50">
<h3 className="text-lg font-semibold">Keine ausstehenden Anfragen</h3> <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<p className="text-sm text-muted-foreground"> <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. Es gibt aktuell keine Terminanfragen, die bestätigt werden müssen.
</p> </p>
</div> </CardContent>
</Card>
); );
} }
return ( return (
<div className="flex flex-col gap-4"> <Card className="overflow-hidden p-0">
{termine.map((termin) => ( {termine.map((termin) => (
<PendingTerminCard key={termin.id} termin={termin} /> <PendingTerminCard key={termin.id} termin={termin} />
))} ))}
</div> </Card>
); );
} }
@@ -67,40 +76,57 @@ function PendingTerminCard({ termin }: { termin: PendingTerminDto }) {
}; };
return ( return (
<Card> <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)]">
<CardContent className="flex items-center justify-between p-4 sm:p-6"> <div className="flex h-14 w-14 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)]">
<div className="flex flex-col gap-1"> <span className="text-[22px] font-medium leading-none tracking-[-0.02em] text-[var(--ink)] tabular-nums">
<div className="font-semibold">{termin.title}</div> {format(termin.startDate, "d", { locale: de })}
<div className="text-sm text-muted-foreground flex items-center gap-1"> </span>
<Clock className="h-3.5 w-3.5" /> <span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.08em]">
{format(termin.startDate, "PP", { locale: de })} {format(termin.startDate, "EEE", { locale: de })}
{!termin.allDay && `${format(termin.startDate, "p", { locale: de })}`} </span>
</div>
<div className="text-sm font-medium text-primary mt-1">
Angefragt von: {termin.createdBy?.firstName} {termin.createdBy?.lastName}
</div>
</div> </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 <Button
variant="outline" variant="ghost"
className="text-destructive hover:bg-destructive hover:text-destructive-foreground border-destructive/20" size="sm"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={handleReject} onClick={handleReject}
disabled={isPending} disabled={isPending}
> >
<X className="h-4 w-4 sm:mr-2" /> <X className="h-4 w-4 [stroke-width:1.7]" />
<span className="hidden sm:inline">Ablehnen</span> Ablehnen
</Button> </Button>
<Button <Button
className="bg-emerald-600 hover:bg-emerald-700 text-white" size="sm"
onClick={handleApprove} onClick={handleApprove}
disabled={isPending} disabled={isPending}
> >
<Check className="h-4 w-4 sm:mr-2" /> <Check className="h-4 w-4 [stroke-width:1.7]" />
<span className="hidden sm:inline">Freigeben</span> Genehmigen
</Button> </Button>
</div> </div>
</CardContent> </div>
</Card>
); );
} }
@@ -3,17 +3,18 @@
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client"; import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; 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 { 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 { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -22,6 +23,8 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { AdminTerminModal } from "./admin-termin-modal";
import { TerminRequestModal } from "./termin-request-modal";
export type TerminListItemDto = { export type TerminListItemDto = {
kind: "termin"; kind: "termin";
@@ -59,7 +62,6 @@ export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto;
export function TerminList({ export function TerminList({
termine, termine,
userId,
isAdmin, isAdmin,
}: { }: {
termine: CalendarListItemDto[]; termine: CalendarListItemDto[];
@@ -68,169 +70,130 @@ export function TerminList({
}) { }) {
if (termine.length === 0) { if (termine.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50"> <Card>
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center"> <CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<Calendar className="h-10 w-10 text-muted-foreground mb-4" /> <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<h3 className="mt-4 text-lg font-semibold">Keine Termine</h3> <Calendar className="h-6 w-6 [stroke-width:1.7]" />
<p className="mb-4 mt-2 text-sm text-muted-foreground"> </div>
Es stehen aktuell keine Termine an. <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> </p>
<div className="mt-5">
{isAdmin ? (
<AdminTerminModal triggerLabel="+ Termin anlegen" triggerClassName="" />
) : (
<TerminRequestModal
triggerLabel="+ Termin anlegen"
triggerVariant="default"
triggerClassName=""
/>
)}
</div> </div>
</div> </CardContent>
</Card>
); );
} }
const groups = groupByMonth(termine);
return ( return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="space-y-8">
{termine.map((termin) => ( {groups.map((group) => (
termin.kind === "duty" ? ( <section key={group.key}>
<DutyCard key={`duty-${termin.id}`} duty={termin} /> <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 <TerminRow key={`termin-${item.id}`} termin={item} />
key={`termin-${termin.id}`} ),
termin={termin} )}
userId={userId} </div>
isAdmin={isAdmin} </Card>
/> </section>
)
))} ))}
</div> </div>
); );
} }
function DutyCard({ duty }: { duty: DutyCalendarItemDto }) { function DutyRow({ duty }: { duty: DutyCalendarItemDto }) {
const color = "var(--good)";
return ( return (
<Card className="flex flex-col overflow-hidden border-emerald-200 bg-emerald-50/60"> <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)]">
<CardHeader className="pb-3"> <div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<div className="mb-2 flex items-start justify-between gap-2"> <DateTile date={duty.startDate} />
<Badge className="border-transparent bg-emerald-600 text-white hover:bg-emerald-700"> <div className="min-w-0">
Elterndienst <h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
</Badge>
</div>
<CardTitle className="line-clamp-2 leading-tight">
{duty.title} {duty.title}
</CardTitle> </h3>
<CardDescription className="mt-1 flex items-center gap-1 text-sm"> <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" /> <Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(duty.startDate, "dd.MM.", { locale: de })} -{" "} {format(duty.startDate, "dd.MM.", { locale: de })} -{" "}
{format(duty.endDate, "dd.MM.yyyy", { locale: de })} {format(duty.endDate, "dd.MM.yyyy", { locale: de })}
</CardDescription> <WashingMachine className="ml-1 h-3.5 w-3.5 [stroke-width:1.7]" />
</CardHeader> {duty.familyName}
<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>
</p> </p>
</div> </div>
</CardContent> <div className="flex items-center gap-2">
</Card> <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({ function TerminRow({ termin }: { termin: TerminListItemDto }) {
termin, const color = getTypeColor(termin.type);
userId,
isAdmin,
}: {
termin: TerminListItemDto;
userId: string;
isAdmin: boolean;
}) {
const [isPending, startTransition] = useTransition();
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 ( return (
<Dialog> <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> <DialogTrigger asChild>
<Button variant="outline" size="sm" className="mt-4"> <Button variant="ghost" size="sm">
Details Details
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<Button variant="ghost" size="icon" aria-label="Mehr Optionen">
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</div>
</div>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{termin.title}</DialogTitle> <DialogTitle>{termin.title}</DialogTitle>
@@ -246,24 +209,22 @@ function TerminDetailDialog({ termin }: { termin: TerminListItemDto }) {
</DialogHeader> </DialogHeader>
<div className="grid gap-4"> <div className="grid gap-4">
<Badge variant={getBadgeVariant(termin.type)} className={getBadgeColor(termin.type)}> <CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
{getTypeLabel(termin.type)}
</Badge>
{termin.description ? ( {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} {termin.description}
</p> </p>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm italic text-[var(--ink-faint)]">
Keine Beschreibung hinterlegt. Keine Beschreibung hinterlegt.
</p> </p>
)} )}
{termin.participantInfo && termin.showParticipantInfo && ( {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"> <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 Wichtiger Hinweis für Teilnehmer
</div> </div>
<p className="whitespace-pre-wrap">{termin.participantInfo}</p> <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" { function DateTile({ date }: { date: Date }) {
switch (type) { return (
case TerminType.KITA_FEST: <div className="flex h-14 w-14 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)]">
case TerminType.MITMACH_TAG: <span className="text-[22px] font-medium leading-none tracking-[-0.02em] text-[var(--ink)] tabular-nums">
return "default"; {format(date, "d", { locale: de })}
case TerminType.SCHLIESSTAG: </span>
case TerminType.TEAMTAG: <span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.08em]">
return "destructive"; {format(date, "EEE", { locale: de })}
case TerminType.GEBURTSTAG_INTERN: </span>
case TerminType.GEBURTSTAG_EXTERN: </div>
case TerminType.ELTERNABEND: );
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "secondary";
default:
return "outline";
}
} }
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) { switch (type) {
case TerminType.KITA_FEST: case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG: case TerminType.MITMACH_TAG:
return "bg-amber-500 hover:bg-amber-600 text-white border-transparent"; return "var(--good)";
case TerminType.SCHLIESSTAG: case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG: 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_INTERN:
case TerminType.GEBURTSTAG_EXTERN: 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.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG: case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE: case TerminType.ELTERNCAFE:
return "bg-purple-500 hover:bg-purple-600 text-white border-transparent"; return "oklch(0.50 0.10 280)";
default: 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: case TerminType.SCHLIESSTAG:
return "Schließtag"; return "Schließtag";
case TerminType.TEAMTAG: case TerminType.TEAMTAG:
return "Teamtag (Kita geschlossen)"; return "Teamtag";
case TerminType.ELTERNABEND: case TerminType.ELTERNABEND:
return "Elternabend"; return "Elternabend";
case TerminType.MITGLIEDERVERSAMMLUNG: case TerminType.MITGLIEDERVERSAMMLUNG:
@@ -331,9 +327,9 @@ function getTypeLabel(type: TerminType): string {
case TerminType.ELTERNCAFE: case TerminType.ELTERNCAFE:
return "Elterncafe"; return "Elterncafe";
case TerminType.GEBURTSTAG_INTERN: case TerminType.GEBURTSTAG_INTERN:
return "Geburtstag (Intern)"; return "Geburtstag";
case TerminType.GEBURTSTAG_EXTERN: case TerminType.GEBURTSTAG_EXTERN:
return "Raumanfrage (Extern)"; return "Raumanfrage";
default: default:
return "Sonstiges"; return "Sonstiges";
} }
@@ -1,6 +1,7 @@
"use client"; "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 { TerminType } from "@prisma/client";
import { CalendarPlus } from "lucide-react"; import { CalendarPlus } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -19,17 +20,74 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { createTerminRequest } from "../actions"; import { createTerminRequest } from "../actions";
export function TerminRequestModal() { type TerminRequestFormState = {
const [open, setOpen] = useState(false); title: string;
const [isPending, startTransition] = useTransition(); 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 = { const data = {
title: formData.get("title") as string, title: formState.title,
type: formData.get("type") as TerminType, type: formState.type,
startDate: new Date(formData.get("startDate") as string).toISOString(), startDate: new Date(formState.startDate).toISOString(),
endDate: new Date(formData.get("endDate") as string).toISOString(), endDate: new Date(formState.endDate).toISOString(),
allDay: formData.get("allDay") === "on", allDay: formState.allDay,
}; };
startTransition(async () => { startTransition(async () => {
@@ -38,6 +96,7 @@ export function TerminRequestModal() {
toast.error(res.error); toast.error(res.error);
} else { } else {
toast.success("Terminanfrage wurde erfolgreich gestellt!"); toast.success("Terminanfrage wurde erfolgreich gestellt!");
setFormState(initialFormState);
setOpen(false); setOpen(false);
} }
}); });
@@ -46,13 +105,13 @@ export function TerminRequestModal() {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="gap-2"> <Button variant={triggerVariant} className={triggerClassName}>
<CalendarPlus className="h-4 w-4" /> <CalendarPlus className="h-4 w-4 [stroke-width:1.7]" />
Termin anfragen {triggerLabel}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<form action={handleAction}> <form onSubmit={handleSubmit}>
<DialogHeader> <DialogHeader>
<DialogTitle>Termin anfragen</DialogTitle> <DialogTitle>Termin anfragen</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -64,7 +123,14 @@ export function TerminRequestModal() {
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="title">Titel</Label> <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>
<div className="grid gap-2"> <div className="grid gap-2">
@@ -72,7 +138,9 @@ export function TerminRequestModal() {
<select <select
id="type" id="type"
name="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 required
> >
<option value={TerminType.KITA_FEST}>Kita-Fest</option> <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 grid-cols-2 gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="startDate">Start</Label> <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>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="endDate">Ende</Label> <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> </div>
<div className="flex items-center gap-2 mt-2"> <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"> <Label htmlFor="allDay" className="font-normal cursor-pointer">
Ganztägiger Termin Ganztägiger Termin
</Label> </Label>
@@ -120,3 +211,10 @@ export function TerminRequestModal() {
</Dialog> </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>) { function buildTerminData(data: z.infer<typeof terminSchema>) {
const startDate = new Date(data.startDate); const startDate = new Date(data.startDate);
const endDate = new Date(data.endDate); 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) { if (endDate < startDate) {
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." }; return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
+225 -43
View File
@@ -2,11 +2,14 @@ import {
DutyAssignmentStatus, DutyAssignmentStatus,
EventParticipationStatus, EventParticipationStatus,
TerminStatus, TerminStatus,
TerminType,
UserRole, UserRole,
} from "@prisma/client"; } from "@prisma/client";
import { HelpCircle, ListFilter, Search } from "lucide-react";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Input } from "@/components/ui/input";
import { AdminTerminModal } from "./_components/admin-termin-modal"; import { AdminTerminModal } from "./_components/admin-termin-modal";
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen"; import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
import { TerminList, type CalendarListItemDto } from "./_components/termin-list"; import { TerminList, type CalendarListItemDto } from "./_components/termin-list";
@@ -17,7 +20,7 @@ export const metadata = { title: "Terminkalender · Kita-Planer" };
export default async function KalenderPage({ export default async function KalenderPage({
searchParams, searchParams,
}: { }: {
searchParams: { tab?: string }; searchParams: Promise<{ tab?: string }>;
}) { }) {
const session = await requireKitaSession(); const session = await requireKitaSession();
const isAdmin = const isAdmin =
@@ -25,7 +28,9 @@ export default async function KalenderPage({
session.user.role === UserRole.KOORDINATOR; session.user.role === UserRole.KOORDINATOR;
const familyIdForParticipation = session.user.familyId ?? "__no_family__"; 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({ const confirmedTermineRows = await prisma.termin.findMany({
where: { where: {
@@ -122,7 +127,7 @@ export default async function KalenderPage({
where: { where: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
status: DutyAssignmentStatus.PLANNED, status: DutyAssignmentStatus.PLANNED,
endDate: { gte: new Date() }, endDate: { gte: now },
}, },
select: { select: {
id: true, id: true,
@@ -177,7 +182,7 @@ export default async function KalenderPage({
let allPendingTermine: PendingTerminDto[] = []; let allPendingTermine: PendingTerminDto[] = [];
if (isAdmin) { if (isAdmin) {
allPendingTermine = await prisma.termin.findMany({ const pendingRows = await prisma.termin.findMany({
where: { where: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
status: TerminStatus.PENDING, status: TerminStatus.PENDING,
@@ -186,6 +191,8 @@ export default async function KalenderPage({
id: true, id: true,
title: true, title: true,
startDate: true, startDate: true,
createdAt: true,
type: true,
allDay: true, allDay: true,
createdBy: { createdBy: {
select: { select: {
@@ -196,67 +203,74 @@ export default async function KalenderPage({
}, },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
allPendingTermine = pendingRows.map((termin) => ({
...termin,
ageInDays: Math.floor(
(now.getTime() - termin.createdAt.getTime()) / (1000 * 60 * 60 * 24),
),
}));
} }
return ( return (
<div className="flex h-full flex-col gap-6 p-6"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="flex items-center justify-between"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight">Terminkalender</h1> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<p className="text-muted-foreground"> TERMINKALENDER
Alle anstehenden Termine, Feste und Schließtage im Überblick. </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> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex shrink-0 flex-wrap items-center gap-2">
<TerminRequestModal /> <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 />} {isAdmin && <AdminTerminModal />}
</div> </div>
</div> </header>
{isAdmin ? ( {isAdmin ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-5">
<div className="flex gap-4 border-b"> <TabBar
<a currentTab={currentTab}
href="/dashboard/kalender?tab=übersicht" overviewCount={allUserTermine.length}
className={`pb-2 text-sm font-medium transition-colors hover:text-primary ${ pendingCount={allPendingTermine.length}
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="pt-2"> <div>
{currentTab === "übersicht" ? ( {currentTab === "übersicht" ? (
<TerminList <CalendarOverview
termine={allUserTermine} termine={allUserTermine}
userId={session.user.id} userId={session.user.id}
isAdmin={isAdmin} isAdmin={isAdmin}
/> />
) : ( ) : currentTab === "anfragen" ? (
<PendingAnfragen termine={allPendingTermine} /> <PendingAnfragen termine={allPendingTermine} />
) : (
<CalendarOverview
termine={[]}
userId={session.user.id}
isAdmin={isAdmin}
/>
)} )}
</div> </div>
</div> </div>
) : ( ) : (
<TerminList <CalendarOverview
termine={allUserTermine} termine={allUserTermine}
userId={session.user.id} userId={session.user.id}
isAdmin={isAdmin} isAdmin={isAdmin}
@@ -265,3 +279,171 @@ export default async function KalenderPage({
</div> </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 type { Metadata } from "next";
import { Baby, LogOut } from "lucide-react"; import { LogOut } from "lucide-react";
import { signOut } from "@/auth"; import { signOut } from "@/auth";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { SidebarNav } from "@/components/dashboard/sidebar-nav"; import { SidebarNav } from "@/components/dashboard/sidebar-nav";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -34,40 +33,45 @@ export default async function DashboardLayout({
where: { id: session.user.kitaId }, where: { id: session.user.kitaId },
select: { name: true }, select: { name: true },
}); });
const displayName = session.user.name ?? session.user.email ?? "Kita-Planer";
return ( 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 ───────────────────────────────────────────────────── */} {/* ── 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 */} {/* Logo / Kita-Name */}
<div className="flex h-16 items-center gap-2.5 border-b px-5"> <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-md bg-primary text-primary-foreground"> <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">
<Baby className="h-4 w-4" /> {kita.name.slice(0, 1).toUpperCase()}
</div> </div>
<div className="min-w-0"> <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} {kita.name}
</p> </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>
</div> </div>
{/* Navigation */} {/* Navigation */}
<div className="flex-1 overflow-y-auto py-4"> <div className="flex-1 overflow-y-auto py-6">
<SidebarNav role={session.user.role} /> <SidebarNav role={session.user.role} />
</div> </div>
{/* Footer: User-Info + Abmelden */} {/* Footer: User-Info + Abmelden */}
<div className="border-t p-3"> <div className="border-t border-[var(--hairline-soft)] p-4">
<Separator className="mb-3" /> <div className="mb-3 flex items-center gap-2.5 px-1">
<div className="mb-2 px-3"> <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)]">
<p className="truncate text-xs font-medium"> {getInitials(displayName)}
{session.user.name ?? session.user.email} </div>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--ink)]">
{displayName}
</p> </p>
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-[11px] font-semibold uppercase text-[var(--ink-muted)]">
{session.user.role} {session.user.role}
</p> </p>
</div> </div>
</div>
<form <form
action={async () => { action={async () => {
"use server"; "use server";
@@ -78,7 +82,7 @@ export default async function DashboardLayout({
type="submit" type="submit"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="w-full justify-start gap-2 text-muted-foreground" className="w-full justify-start"
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
Abmelden Abmelden
@@ -88,9 +92,18 @@ export default async function DashboardLayout({
</aside> </aside>
{/* ── Main Content ──────────────────────────────────────────────── */} {/* ── Main Content ──────────────────────────────────────────────── */}
<main className="flex-1 overflow-y-auto"> <main className="min-w-0 overflow-y-auto">
{children} {children}
</main> </main>
</div> </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 ( 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"> <CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<CardTitle className="flex items-center gap-2 text-lg"> <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 Schwarzes Brett
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
@@ -81,26 +81,26 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
key={item.id} key={item.id}
type="button" type="button"
onClick={() => openAnnouncement(item)} 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="mb-3 flex items-start justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-[var(--ink-muted)]">
<Bell className="h-3.5 w-3.5" /> <Bell className="h-3.5 w-3.5 [stroke-width:1.7]" />
{item.createdAt} {item.createdAt}
</div> </div>
{isUnread && ( {isUnread && (
<Badge className="bg-emerald-600 text-white">NEU</Badge> <Badge variant="success">NEU</Badge>
)} )}
</div> </div>
<h3 className="line-clamp-2 font-semibold leading-snug"> <h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
{item.title} {item.title}
</h3> </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} {item.content}
</p> </p>
{item.attachments.length > 0 && ( {item.attachments.length > 0 && (
<p className="mt-3 flex items-center gap-1 text-xs font-medium text-emerald-700"> <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" /> <FileText className="h-3.5 w-3.5 [stroke-width:1.7]" />
{item.attachments.length} Anhang {item.attachments.length} Anhang
{item.attachments.length === 1 ? "" : "e"} {item.attachments.length === 1 ? "" : "e"}
</p> </p>
@@ -122,7 +122,7 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
</DialogDescription> </DialogDescription>
</DialogHeader> </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} /> <MarkdownContent content={selected.content} />
</div> </div>
@@ -138,7 +138,7 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
className="justify-start" className="justify-start"
> >
<a href={attachment.fileUrl} target="_blank" rel="noreferrer"> <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} {attachment.fileName}
</a> </a>
</Button> </Button>
@@ -1,58 +1,29 @@
"use client"; "use client";
import { useState } from "react"; import {
import { ArrowRight, CalendarHeart } from "lucide-react"; NotdienstForm,
type NotdienstCalendarItem,
import { Button } from "@/components/ui/button"; } from "./notdienst-form";
import { NotdienstForm } from "./notdienst-form";
type NotdienstEntryProps = { type NotdienstEntryProps = {
hasAnyAvailability: boolean;
targetYear: number; targetYear: number;
targetMonth: number; targetMonth: number;
isLocked: boolean; isLocked: boolean;
requiredDaysTotal: number; requiredDaysTotal: number;
initialSelectedDates: string[]; initialSelectedDates: string[];
childrenIds: string[]; childrenIds: string[];
calendarItems: NotdienstCalendarItem[];
}; };
export function NotdienstEntry({ export function NotdienstEntry({
hasAnyAvailability,
targetYear, targetYear,
targetMonth, targetMonth,
isLocked, isLocked,
requiredDaysTotal, requiredDaysTotal,
initialSelectedDates, initialSelectedDates,
childrenIds, childrenIds,
calendarItems,
}: NotdienstEntryProps) { }: 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 ( return (
<NotdienstForm <NotdienstForm
targetYear={targetYear} targetYear={targetYear}
@@ -61,6 +32,7 @@ export function NotdienstEntry({
requiredDaysTotal={requiredDaysTotal} requiredDaysTotal={requiredDaysTotal}
initialSelectedDates={initialSelectedDates} initialSelectedDates={initialSelectedDates}
childrenIds={childrenIds} childrenIds={childrenIds}
calendarItems={calendarItems}
/> />
); );
} }
+550 -99
View File
@@ -1,23 +1,83 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Info, Loader2, CheckCircle2 } from "lucide-react"; import {
import { format } from "date-fns"; 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 { de } from "date-fns/locale";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatDateKey, getWorkingDaysOfMonth } from "@/lib/date-utils"; import { formatDateKey, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { Button } from "@/components/ui/button";
import { saveNotdienstAvailabilities } from "./actions"; import { saveNotdienstAvailabilities } from "./actions";
// ===================================================================== export const notdienstEventCategories = {
// NotdienstForm · Client Component closure: {
// --------------------------------------------------------------------- label: "Schließtag",
// Erlaubt das Auswählen von N Tagen aus der Liste der Werktage des dot: "oklch(0.62 0.10 35)",
// Zielmonats. Speichert als Array von Datum-Strings. 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 { interface NotdienstFormProps {
targetYear: number; targetYear: number;
@@ -26,6 +86,7 @@ interface NotdienstFormProps {
requiredDaysTotal: number; requiredDaysTotal: number;
initialSelectedDates: string[]; initialSelectedDates: string[];
childrenIds: string[]; childrenIds: string[];
calendarItems: NotdienstCalendarItem[];
} }
export function NotdienstForm({ export function NotdienstForm({
@@ -35,6 +96,7 @@ export function NotdienstForm({
requiredDaysTotal, requiredDaysTotal,
initialSelectedDates, initialSelectedDates,
childrenIds, childrenIds,
calendarItems,
}: NotdienstFormProps) { }: NotdienstFormProps) {
const router = useRouter(); const router = useRouter();
// Lokaler State für die Auswahl. Ein Datum liegt als stabiler yyyy-MM-dd-Schlüssel vor. // 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 [isPending, startTransition] = useTransition();
const closureDateKeys = useMemo(
() => getClosureDateKeys(calendarItems),
[calendarItems],
);
const selectableDateKeys = useMemo(() => {
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth); 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 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; if (isLocked || isPending) return;
setSelectedDates(nextDates);
const newSet = new Set(selectedDates);
if (newSet.has(dateStr)) {
newSet.delete(dateStr);
} else {
newSet.add(dateStr);
}
setSelectedDates(newSet);
} }
function handleSave() { function handleSave() {
if (isLocked) return; if (isLocked) return;
if (!isFulfilled) { if (hasMinimumRequirement && !isFulfilled) {
toast.error( toast.error(
`Bitte wähle noch ${requiredDaysTotal - selectedCount} weitere Termine aus.`, `Bitte wähle noch ${requiredDaysTotal - selectedCount} weitere Termine aus.`,
); );
@@ -88,101 +159,481 @@ export function NotdienstForm({
} }
return ( 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> <div>
<h3 className="font-medium"> {isLocked ? (
{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)]">
? "Planung abgeschlossen" <Info className="mt-0.5 h-4 w-4 shrink-0" />
: "Deine ausgewählten Termine"} <p>
</h3> Die Frist für diesen Monat ist abgelaufen. Änderungen sind nicht
<p className="text-sm text-muted-foreground"> mehr möglich.
{isLocked
? "Die Frist für diesen Monat ist abgelaufen. Änderungen sind nicht mehr möglich."
: `Du hast ${selectedCount} von ${requiredDaysTotal} benötigten Tagen markiert.`}
</p> </p>
</div> </div>
</div>
<Button
onClick={handleSave}
disabled={isLocked || isPending || !isFulfilled}
className="shrink-0"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null} ) : null}
{isLocked ? "Gesperrt" : "Speichern"}
</Button>
</div>
{/* Datumsliste */} <div className="overflow-x-auto">
<div className="rounded-lg border bg-card"> <NotdienstCalendar
<div className="p-4 border-b bg-muted/30"> month={monthStart}
<p className="text-sm font-medium">Werktage im {format(new Date(targetYear, targetMonth - 1, 1), "MMMM", { locale: de })}</p> events={calendarItems}
selected={selectedDates}
requiredDays={requiredDaysTotal}
disabled={isLocked || isPending}
selectableDateKeys={selectableDateKeys}
onChange={handleChange}
onSave={handleSave}
isSaving={isPending}
/>
</div> </div>
<div className="divide-y"> <p className="sr-only" aria-live="polite">
{workingDays.map((day) => { {hasMinimumRequirement
const dateStr = formatDateKey(day); ? `Du hast ${selectedCount} von ${requiredDaysTotal} benötigten Tagen markiert.`
const isSelected = selectedDates.has(dateStr); : `${selectedLabel}. Für diese Kita ist aktuell keine Mindestanzahl hinterlegt.`}
const isToday = day.getTime() === new Date().setHours(0, 0, 0, 0); </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 ( return (
<button <section
key={dateStr} 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)]"
type="button" style={{
disabled={isLocked || isPending} backgroundImage:
onClick={() => toggleDate(dateStr)} "radial-gradient(oklch(0.85 0.02 75 / 0.10) 1px, transparent 1px)",
className={cn( backgroundSize: "16px 16px",
"flex w-full items-center justify-between px-4 py-3 text-left transition-colors", }}
!isLocked && !isPending && "hover:bg-muted/50",
isSelected && "bg-primary/5 hover:bg-primary/10",
(isLocked || isPending) && "cursor-not-allowed opacity-70",
)}
> >
<div className="flex items-center gap-3"> <header className="flex items-start justify-between gap-8">
{/* Custom Checkbox Indicator */}
<div
className={cn(
"flex h-5 w-5 items-center justify-center rounded border",
isSelected
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-background",
)}
>
{isSelected && <CheckCircle2 className="h-3.5 w-3.5" />}
</div>
<div> <div>
<span className={cn("font-medium", isSelected && "text-primary")}> <p className="text-[11px] font-semibold uppercase leading-none text-[var(--notdienst-accent)] [letter-spacing:0.18em]">
{format(day, "EEEE, dd. MMMM", { locale: de })} NOTDIENST · {format(visibleMonthStart, "MMMM yyyy", { locale: de })}
</span> </p>
{isToday && ( <h1 className="mt-2 text-[36px] font-normal leading-tight text-[var(--notdienst-ink)] [letter-spacing:-0.025em]">
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground"> Wann magst du{" "}
Heute <em className="font-normal italic text-[var(--notdienst-accent-deep)]">
</span> 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>
</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> </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> </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>
</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 Link from "next/link";
import { UserRole } from "@prisma/client"; import { TerminStatus, TerminType, UserRole } from "@prisma/client";
import { Info, Calendar, ArrowRight, ShieldAlert } from "lucide-react"; import { ArrowRight, ShieldAlert } from "lucide-react";
import { Manrope } from "next/font/google";
import { requireRole } from "@/lib/auth-utils"; import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -10,6 +11,11 @@ import { NotdienstEntry } from "./notdienst-entry";
export const metadata = { title: "Notdienst · Kita-Planer" }; export const metadata = { title: "Notdienst · Kita-Planer" };
const manrope = Manrope({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export default async function NotdienstPage() { export default async function NotdienstPage() {
// ELTERN können eintragen; Admins/Koordinatoren haben hier eigentlich // ELTERN können eintragen; Admins/Koordinatoren haben hier eigentlich
// andere Ansichten (Plan-Generierung), aber wir lassen sie zur Demonstration // andere Ansichten (Plan-Generierung), aber wir lassen sie zur Demonstration
@@ -44,7 +50,7 @@ export default async function NotdienstPage() {
: []; : [];
const targetData = getTargetMonthData(); 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 targetMonthStart = new Date(Date.UTC(targetYear, targetMonth - 1, 1));
const targetMonthEnd = new Date(Date.UTC(targetYear, targetMonth, 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); const childIds = children.map((child) => child.id);
// Bisherige Einträge für den Zielmonat laden, haushaltsweit über die Kinder. // Bisherige Einträge für den Zielmonat laden, haushaltsweit über die Kinder.
const [availabilities, availabilityCountForCurrentOrNextMonth] = const [availabilities, termine] =
children.length > 0 children.length > 0
? await Promise.all([ ? await Promise.all([
prisma.notdienstAvailability.findMany({ prisma.notdienstAvailability.findMany({
@@ -66,74 +72,88 @@ export default async function NotdienstPage() {
}, },
select: { date: true }, select: { date: true },
}), }),
prisma.notdienstAvailability.count({ prisma.termin.findMany({
where: { where: {
kitaId, kitaId,
childId: { in: childIds }, status: TerminStatus.CONFIRMED,
date: { startDate: { lt: targetMonthEnd },
gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1), endDate: { gte: targetMonthStart },
lt: new Date(new Date().getFullYear(), new Date().getMonth() + 2, 1),
}, },
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)); const selectedDates = availabilities.map((a) => formatDateKey(a.date));
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-8"> <div className={`${manrope.className} mx-auto max-w-7xl 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>
{children.length === 0 ? ( {children.length === 0 ? (
<NotdienstLockout /> <NotdienstLockout />
) : ( ) : (
<NotdienstEntry <NotdienstEntry
hasAnyAvailability={availabilityCountForCurrentOrNextMonth > 0}
targetYear={targetYear} targetYear={targetYear}
targetMonth={targetMonth} targetMonth={targetMonth}
isLocked={isLocked} isLocked={isLocked}
requiredDaysTotal={requiredDaysTotal} requiredDaysTotal={requiredDaysTotal}
initialSelectedDates={selectedDates} initialSelectedDates={selectedDates}
childrenIds={childIds} 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> </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() { function NotdienstLockout() {
return ( 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 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"> <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" /> <ShieldAlert className="h-10 w-10 [stroke-width:1.7]" />
</div> </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 Erst Kinder hinterlegen
</h2> </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 Bitte lege zuerst deine Kinder im Profil an, bevor du Notdienste
übernimmst. übernimmst.
</p> </p>
+125 -18
View File
@@ -1,9 +1,10 @@
import { UserRole } from "@prisma/client"; import { TerminType, UserRole } from "@prisma/client";
import { Settings2 } from "lucide-react"; import { ChevronLeft, ChevronRight, SlidersHorizontal } from "lucide-react";
import { requireRole } from "@/lib/auth-utils"; import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils"; import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { Button } from "@/components/ui/button";
import { PlanView } from "./plan-view"; import { PlanView } from "./plan-view";
export const metadata = { title: "Plan-Generierung · Kita-Planer" }; export const metadata = { title: "Plan-Generierung · Kita-Planer" };
@@ -14,8 +15,12 @@ export default async function PlanungsZentralePage() {
const { targetYear, targetMonth, monthName } = getTargetMonthData(); const { targetYear, targetMonth, monthName } = getTargetMonthData();
// Aktuellen Plan suchen const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
const plan = await prisma.notdienstPlan.findUnique({ 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: { where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth }, kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
}, },
@@ -30,10 +35,51 @@ export default async function PlanungsZentralePage() {
}, },
}, },
}, },
}); }),
prisma.family.findMany({
// Alle Werktage where: {
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth); 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. // 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", // 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 // Wir mappen die Assignments auf die Werktage
const daysWithAssignments = workingDays.map((day) => { const daysWithAssignments = workingDays.map((day) => {
const assignment = plan?.assignments.find( const assignment = plan?.assignments.find(
@@ -84,29 +164,56 @@ export default async function PlanungsZentralePage() {
}); });
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-8"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center"> <header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2"> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<Settings2 className="h-6 w-6 text-primary" /> NOTDIENST · PLANUNG
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> </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. Koordiniere und generiere den Notdienst-Plan.
</p> </p>
</div> </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"> <div className="flex shrink-0 items-center gap-2">
Zielmonat: {monthName} <div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
</span> <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> </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> </div>
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Einstellungen
</Button>
</div>
</header>
<PlanView <PlanView
planId={plan?.id} planId={plan?.id}
status={plan?.status} status={plan?.status}
days={daysWithAssignments} days={daysWithAssignments}
allChildren={allChildren} allChildren={allChildren}
familyStatuses={familyStatuses}
requiredDays={requiredDays}
respondedFamilies={respondedFamilies}
openFamilies={openFamilies}
totalFamilies={familyStatuses.length}
totalAvailabilitySlots={totalAvailabilitySlots}
coverageFactor={coverageFactor}
closureCount={closureCount}
monthName={monthName}
/> />
</div> </div>
); );
+422 -64
View File
@@ -1,14 +1,23 @@
"use client"; "use client";
import { useTransition } from "react"; import { useState, useTransition } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { NotdienstPlanStatus } from "@prisma/client"; 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 { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { import {
generatePlanAction, generatePlanAction,
publishPlanAction, publishPlanAction,
@@ -19,7 +28,7 @@ interface PlanViewProps {
planId?: string; planId?: string;
status?: NotdienstPlanStatus; status?: NotdienstPlanStatus;
days: { days: {
date: string; // ISO date: string;
assignmentId: string | null; assignmentId: string | null;
childId: string | null; childId: string | null;
}[]; }[];
@@ -28,10 +37,45 @@ interface PlanViewProps {
name: string; name: string;
parentName: 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 [isPending, startTransition] = useTransition();
const [activeTab, setActiveTab] = useState<"preview" | "manual">("preview");
const responsePercent =
totalFamilies > 0 ? Math.round((respondedFamilies / totalFamilies) * 100) : 0;
const handleGenerate = () => { const handleGenerate = () => {
startTransition(async () => { 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) { if (!planId) {
return ( return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-card p-12 text-center"> <div className="space-y-5">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary"> <div className="grid grid-cols-[minmax(0,2fr)_minmax(300px,1fr)] gap-5 max-[1050px]:grid-cols-1">
<Wand2 className="h-6 w-6" /> <Card className="p-0">
</div> <div className="border-b border-[var(--hairline-soft)] px-6 py-5">
<h3 className="mb-1 text-lg font-semibold">Noch kein Plan vorhanden</h3> <div className="flex items-start justify-between gap-4">
<p className="mb-6 max-w-sm text-sm text-muted-foreground"> <div>
Generiere den Plan basierend auf den Verfügbarkeiten der Eltern <h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
automatisch. 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> </p>
<Button onClick={handleGenerate} disabled={isPending}> </div>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} <span className="text-sm font-semibold tabular-nums text-[var(--ink-soft)]">
Plan automatisch generieren {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> </Button>
</div> </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"; const isDraft = status === "DRAFT";
return ( return (
<div className="space-y-6"> <div className="space-y-5">
<div className="flex items-center justify-between rounded-lg border bg-card p-4"> <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"> <div className="flex items-center gap-3">
<Badge variant={isDraft ? "warning" : "success"}> <Badge variant={isDraft ? "warning" : "success"}>
{isDraft ? "Entwurf" : "Veröffentlicht"} {isDraft ? "Entwurf" : "Veröffentlicht"}
</Badge> </Badge>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-[var(--ink-soft)]">
{isDraft {isDraft
? "Du kannst die Liste jetzt manuell anpassen." ? "Du kannst die Liste jetzt manuell anpassen."
: "Dieser Plan ist verbindlich."} : "Dieser Plan ist verbindlich."}
</span> </span>
</div> </div>
</Card>
{isDraft && ( {activeTab === "preview" ? (
<Button onClick={handlePublish} disabled={isPending}> <PlanPreviewTable days={days} allChildren={allChildren} />
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} ) : (
<CheckSquare className="mr-2 h-4 w-4" /> <ManualAssignmentTable
Plan veröffentlichen days={days}
</Button> allChildren={allChildren}
isDraft={isDraft}
isPending={isPending}
onAssignmentChange={handleAssignmentChange}
/>
)} )}
</div> </div>
);
}
<div className="rounded-lg border bg-card"> function FamilyStatusPill({ selectedDays }: { selectedDays: number }) {
<div className="grid grid-cols-[1fr_2fr] border-b bg-muted/30 p-4 text-sm font-medium"> if (selectedDays === 0) {
<div>Datum</div> return (
<div>Eingeteilte Familie</div> <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>
<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) => { {days.map((day) => {
const dateObj = new Date(day.date); const dateObj = new Date(day.date);
const isUnbesetzt = !day.childId; const assignedChild = allChildren.find(
(child) => child.id === day.childId,
);
return ( return (
<div <div
key={day.date} 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 })} {format(dateObj, "EE, dd.MM.", { locale: de })}
</div> </div>
<div> <div>
{isDraft ? ( {isDraft ? (
<select <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"} value={day.childId || "empty"}
disabled={isPending} disabled={isPending}
onChange={(e) => onChange={(event) =>
handleAssignmentChange(day.date, e.target.value) onAssignmentChange(day.date, event.target.value)
} }
> >
<option value="empty" className="text-destructive"> <option value="empty">-- Unbesetzt --</option>
-- Unbesetzt -- {allChildren.map((child) => (
</option> <option key={child.id} value={child.id}>
{allChildren.map((c) => ( {child.parentName} ({child.name})
<option key={c.id} value={c.id}>
{c.parentName} ({c.name})
</option> </option>
))} ))}
</select> </select>
) : assignedChild ? (
<span className="text-sm text-[var(--ink)]">
{assignedChild.parentName}{" "}
<span className="text-[var(--ink-muted)]">
({assignedChild.name})
</span>
</span>
) : ( ) : (
<div> <span className="text-sm font-medium text-[var(--danger)]">
{isUnbesetzt ? (
<span className="text-sm font-medium text-destructive">
Unbesetzt! Unbesetzt!
</span> </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> </div>
</div> </Card>
</div>
); );
} }
+166 -338
View File
@@ -1,83 +1,83 @@
import Link from "next/link"; import Link from "next/link";
import type { ComponentType } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle, ClipboardList } from "lucide-react"; import {
import { DutyAssignmentStatus, UserRole, NotdienstPlanStatus, TerminStatus } from "@prisma/client"; CalendarCheck2,
CalendarClock,
ChevronRight,
Clock3,
ListTodo,
Users,
type LucideProps,
} from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { getTargetMonthData } from "@/lib/date-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { import { Stat } from "@/components/ui/stat";
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { OnboardingDialog } from "@/components/OnboardingDialog"; import { OnboardingDialog } from "@/components/OnboardingDialog";
import { AlertButton } from "./alert-button"; import { AbsenceCard, UpcomingAbsencesCard } from "./absence-card";
import { AbsenceCard } from "./absence-card";
import { NewsTicker } from "./news-ticker";
import { RsvpActionCard } from "./rsvp-action-card";
export const metadata = { title: "Übersicht · Kita-Planer" }; export const metadata = { title: "Übersicht · Kita-Planer" };
export default async function DashboardPage() { export default async function DashboardPage() {
const session = await requireKitaSession(); const session = await requireKitaSession();
const now = new Date(); 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({ const kita = await prisma.kita.findUniqueOrThrow({
where: { id: session.user.kitaId }, where: { id: session.user.kitaId },
select: { select: {
name: true, name: true,
notdienstModuleEnabled: true, notdienstModuleEnabled: true,
notdienstMinPerChildPerMonth: true,
terminModuleEnabled: true, terminModuleEnabled: true,
adressbuchModuleEnabled: true, adressbuchModuleEnabled: true,
}, },
}); });
const today = new Date();
today.setHours(0, 0, 0, 0);
// Schnelle Statistiken und persönliche Dienste für die Übersicht.
const [ const [
familyCount, familyCount,
childCount, childCount,
upcomingDuties, todayAbsenceCount,
notdienstAvailabilityCount,
onboardingUser, onboardingUser,
absenceChildren, absenceChildren,
upcomingAbsences, upcomingAbsences,
latestAnnouncements,
] = await Promise.all([ ] = await Promise.all([
prisma.family.count({ prisma.family.count({
where: { kitaId: session.user.kitaId }, where: { kitaId: session.user.kitaId },
}), }),
prisma.child.count({ prisma.child.count({
where: { kitaId: session.user.kitaId }, where: { kitaId: session.user.kitaId, active: true },
}), }),
session.user.familyId prisma.absence.count({
? prisma.dutyAssignment.findMany({
where: { where: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
familyId: session.user.familyId, startDate: { lte: today },
status: DutyAssignmentStatus.PLANNED,
endDate: { gte: today }, endDate: { gte: today },
}, },
select: { }),
id: true, prisma.notdienstAvailability.count({
startDate: true, where: {
endDate: true, kitaId: session.user.kitaId,
dutyType: { date: {
select: { gte: targetMonthStart,
name: true, lt: targetMonthEnd,
}, },
}, },
}, }),
orderBy: { startDate: "asc" },
take: 5,
})
: Promise.resolve([]),
prisma.user.findFirstOrThrow({ prisma.user.findFirstOrThrow({
where: { where: {
id: session.user.id, id: session.user.id,
@@ -142,101 +142,25 @@ export default async function DashboardPage() {
take: 6, take: 6,
}) })
: Promise.resolve([]), : 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 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 ( 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 <OnboardingDialog
user={{ user={{
phone: onboardingUser.phone, phone: onboardingUser.phone,
@@ -253,44 +177,33 @@ export default async function DashboardPage() {
} }
/> />
<div className="mb-8"> <header className="mb-9 flex items-start justify-between gap-8">
<h1 className="text-2xl font-semibold tracking-tight"> <div>
Willkommen, {session.user.name?.split(" ")[0] ?? "zusammen"}! <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> </h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Hier ist die Übersicht für <strong>{kita.name}</strong>. 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> </p>
</div> </div>
<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)]">
<RsvpActionCard events={rsvpActionEvents} /> <Clock3 className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(now, "EEE, d. MMM yyyy", { locale: de })}
<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> </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 <AbsenceCard
today={format(today, "yyyy-MM-dd")} today={format(today, "yyyy-MM-dd")}
childOptions={absenceChildren.map((child) => ({ childOptions={absenceChildren.map((child) => ({
@@ -306,196 +219,111 @@ export default async function DashboardPage() {
endDate: format(absence.endDate, "yyyy-MM-dd"), endDate: format(absence.endDate, "yyyy-MM-dd"),
}))} }))}
/> />
)} ) : null}
</div>
{upcomingDuties.length > 0 && ( <aside>
<Card className="mb-8 border-emerald-200 bg-emerald-50/50"> <UpcomingAbsencesCard
<CardHeader className="pb-3"> absences={upcomingAbsences.map((absence) => ({
<CardTitle className="flex items-center gap-2 text-lg"> id: absence.id,
<ClipboardList className="h-5 w-5 text-emerald-700" /> childName: `${absence.child.firstName} ${absence.child.lastName}`,
Deine anstehenden Dienste reason: absence.reason,
</CardTitle> note: absence.note,
<CardDescription> startDate: format(absence.startDate, "yyyy-MM-dd"),
Die nächsten Einteilungen für deinen Haushalt. endDate: format(absence.endDate, "yyyy-MM-dd"),
</CardDescription> }))}
</CardHeader> />
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3"> </aside>
{upcomingDuties.map((duty) => ( </div>
<div key={duty.id} className="rounded-md border bg-background p-3">
<p className="font-medium">{duty.dutyType.name}</p> <section className="mt-5 grid grid-cols-4 gap-3.5 max-[1100px]:grid-cols-2 max-[640px]:grid-cols-1">
<p className="mt-1 text-sm text-muted-foreground"> <Stat label="Familien" value={familyCount} hint="registrierte Familien" />
{format(duty.startDate, "dd.MM.", { locale: de })} bis{" "} <Stat label="Kinder" value={childCount} hint="in der Einrichtung" />
{format(duty.endDate, "dd.MM.yyyy", { locale: de })} <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> </p>
</div> <div className="mt-4 grid grid-cols-3 gap-3 max-[1100px]:grid-cols-1">
))} <QuickCard
</CardContent> href="/dashboard/families"
</Card> icon={Users}
)} title="Familien verwalten"
sub="Familien anlegen, Kinder zuordnen, Kontakte pflegen."
{/* 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" />}
/> />
<StatCard {kita.terminModuleEnabled ? (
label="Kinder" <QuickCard
value={childCount} href="/dashboard/kalender"
description="in der Einrichtung" icon={CalendarClock}
icon={<ShieldCheck className="h-5 w-5 text-muted-foreground" />} 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> </div>
</section>
{/* 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>
)}
</div> </div>
); );
} }
function StatCard({ function QuickCard({
label, href,
value, icon: Icon,
description, title,
icon, sub,
}: { }: {
label: string; href: string;
value: number; icon: ComponentType<LucideProps>;
description: string; title: string;
icon: React.ReactNode; sub: string;
}) { }) {
return ( return (
<Card> <Link
<CardHeader className="flex flex-row items-center justify-between pb-2"> href={href}
<CardTitle className="text-sm font-medium text-muted-foreground"> 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)]"
{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"
> >
{existingAlert.status === "CONFIRMED" <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
? "Einsatz bestätigt" <Icon className="h-4.5 w-4.5 [stroke-width:1.7]" />
: existingAlert.status === "PENDING"
? "Wartet auf Bestätigung"
: "Abgebrochen"}
</Badge>
) : (
<AlertButton assignmentId={assignment.id} />
)}
</div> </div>
</CardContent> <div className="min-w-0 flex-1">
</Card> <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 ( return (
<form action={handleSubmit} className="grid gap-4"> <form action={handleSubmit} className="grid gap-4">
<div className="grid gap-2"> <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 <Input
id="phone" id="phone"
name="phone" name="phone"
@@ -56,7 +61,12 @@ export function ContactForm({ contact }: ContactFormProps) {
</div> </div>
<div className="grid gap-2"> <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 <Input
id="street" id="street"
name="street" name="street"
@@ -65,9 +75,14 @@ export function ContactForm({ contact }: ContactFormProps) {
/> />
</div> </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"> <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 <Input
id="postalCode" id="postalCode"
name="postalCode" name="postalCode"
@@ -76,7 +91,12 @@ export function ContactForm({ contact }: ContactFormProps) {
/> />
</div> </div>
<div className="grid gap-2"> <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 <Input
id="city" id="city"
name="city" name="city"
@@ -86,7 +106,7 @@ export function ContactForm({ contact }: ContactFormProps) {
</div> </div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end pt-1">
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{isPending ? ( {isPending ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -23,7 +23,7 @@ export function DeleteAccountDialog() {
const [confirmText, setConfirmText] = useState(""); const [confirmText, setConfirmText] = useState("");
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const isConfirmed = confirmText === "LÖSCHEN"; const isConfirmed = confirmText === "DELETE";
const handleDelete = () => { const handleDelete = () => {
if (!isConfirmed) return; if (!isConfirmed) return;
@@ -44,18 +44,19 @@ export function DeleteAccountDialog() {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="destructive" className="w-full sm:w-auto"> <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 Account & Daten endgültig löschen
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="border-destructive/30"> <DialogContent className="border-[var(--danger)]">
<DialogHeader> <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" /> <AlertTriangle className="h-5 w-5" />
Gefahrenzone: Account löschen Gefahrenzone: Account löschen
</DialogTitle> </DialogTitle>
<DialogDescription className="text-base pt-2"> <DialogDescription className="pt-2 text-base">
Diese Aktion kann <strong className="text-foreground">nicht</strong> rückgängig gemacht werden. 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 Wenn du das letzte Elternteil im Haushalt bist, wird die gesamte
Familie inklusive Kinder gelöscht. Gibt es ein weiteres Elternteil, Familie inklusive Kinder gelöscht. Gibt es ein weiteres Elternteil,
bleibt der Haushalt für diese Person erhalten und nur dein Account bleibt der Haushalt für diese Person erhalten und nur dein Account
@@ -63,16 +64,17 @@ export function DeleteAccountDialog() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="bg-destructive/10 text-destructive-foreground p-3 text-sm rounded-md my-2 border border-destructive/20"> <div className="my-2 rounded-lg border border-[var(--danger)] bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]">
Bitte tippe das Wort <strong>LÖSCHEN</strong> in das Feld unten ein, um den Vorgang zu bestätigen. Bitte tippe <strong>DELETE</strong> in das Feld unten ein, um den
Vorgang zu bestätigen.
</div> </div>
<div className="grid gap-2 py-4"> <div className="grid gap-2 py-4">
<Input <Input
value={confirmText} value={confirmText}
onChange={(e) => setConfirmText(e.target.value)} onChange={(e) => setConfirmText(e.target.value)}
placeholder="LÖSCHEN" placeholder="DELETE"
className="border-destructive/50 focus-visible:ring-destructive" className="border-[var(--danger)] focus-visible:ring-[var(--focus-ring)]"
/> />
</div> </div>
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { type FormEvent, useState, useTransition } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react"; import { Pencil, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -16,6 +16,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { getChildBirthdateRange } from "@/lib/child-birthdate";
import { createMyChild, deleteMyChild, updateMyChild } from "../actions"; import { createMyChild, deleteMyChild, updateMyChild } from "../actions";
export type MyChild = { export type MyChild = {
@@ -25,6 +26,18 @@ export type MyChild = {
dateOfBirth: string | null; dateOfBirth: string | null;
}; };
type ChildPayload = {
firstName: string;
lastName: string;
dateOfBirth: string;
};
const emptyChildPayload: ChildPayload = {
firstName: "",
lastName: "",
dateOfBirth: "",
};
export function MyChildrenManager({ export function MyChildrenManager({
items, items,
canManage, canManage,
@@ -33,38 +46,40 @@ export function MyChildrenManager({
canManage: boolean; canManage: boolean;
}) { }) {
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createPayload, setCreatePayload] =
useState<ChildPayload>(emptyChildPayload);
const [editingChild, setEditingChild] = useState<MyChild | null>(null); const [editingChild, setEditingChild] = useState<MyChild | null>(null);
const [editPayload, setEditPayload] =
useState<ChildPayload>(emptyChildPayload);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
function payloadFromForm(formData: FormData) { function openEditor(child: MyChild) {
return { setEditingChild(child);
firstName: String(formData.get("firstName") ?? ""), setEditPayload(payloadFromChild(child));
lastName: String(formData.get("lastName") ?? ""),
dateOfBirth: String(formData.get("dateOfBirth") ?? ""),
};
} }
function handleCreate(formData: FormData) { function handleCreate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
startTransition(async () => { startTransition(async () => {
const result = await createMyChild(payloadFromForm(formData)); const result = await createMyChild(createPayload);
if (result.error) { if (result.error) {
toast.error(result.error); toast.error(result.error);
return; return;
} }
toast.success("Kind angelegt."); toast.success("Kind angelegt.");
setCreatePayload(emptyChildPayload);
setCreateOpen(false); setCreateOpen(false);
}); });
} }
function handleUpdate(formData: FormData) { function handleUpdate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!editingChild) return; if (!editingChild) return;
startTransition(async () => { startTransition(async () => {
const result = await updateMyChild( const result = await updateMyChild(editingChild.id, editPayload);
editingChild.id,
payloadFromForm(formData),
);
if (result.error) { if (result.error) {
toast.error(result.error); toast.error(result.error);
return; return;
@@ -95,31 +110,45 @@ export function MyChildrenManager({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{items.length === 0 ? ( {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"> <div className="flex flex-col gap-2">
{items.map((child) => ( {items.map((child) => (
<div <div
key={child.id} 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> <div className="flex min-w-0 items-center gap-3">
<p className="font-medium"> <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} {child.firstName} {child.lastName}
</p> </p>
{child.dateOfBirth && ( {child.dateOfBirth ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-[var(--ink-soft)]">
Geboren am {new Date(child.dateOfBirth).toLocaleDateString("de-DE")} Geboren am{" "}
{new Date(child.dateOfBirth).toLocaleDateString("de-DE")}
</p>
) : (
<p className="text-xs italic text-[var(--ink-faint)]">
Geburtsdatum fehlt
</p> </p>
)} )}
</div> </div>
</div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setEditingChild(child)} className="h-7 w-7"
onClick={() => openEditor(child)}
disabled={isPending || !canManage} disabled={isPending || !canManage}
aria-label="Kind bearbeiten"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
@@ -127,9 +156,10 @@ export function MyChildrenManager({
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" 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)} onClick={() => handleDelete(child)}
disabled={isPending || !canManage} disabled={isPending || !canManage}
aria-label="Kind löschen"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@@ -140,22 +170,32 @@ export function MyChildrenManager({
)} )}
{canManage ? ( {canManage ? (
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) setCreatePayload(emptyChildPayload);
}}
>
<DialogTrigger asChild> <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" /> <Plus className="h-4 w-4" />
Kind hinzufügen Kind hinzufügen
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<form action={handleCreate}> <form onSubmit={handleCreate}>
<DialogHeader> <DialogHeader>
<DialogTitle>Kind hinzufügen</DialogTitle> <DialogTitle>Kind hinzufügen</DialogTitle>
<DialogDescription> <DialogDescription>
Dieses Kind wird mit deinem Haushalt verknüpft. Dieses Kind wird mit deinem Haushalt verknüpft.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ChildFields /> <ChildFields
idPrefix="create-child"
value={createPayload}
onChange={setCreatePayload}
/>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
@@ -173,7 +213,7 @@ export function MyChildrenManager({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) : ( ) : (
<p className="text-xs text-muted-foreground"> <p className="text-xs italic text-[var(--ink-faint)]">
Dein Account ist noch keinem Haushalt zugeordnet. Dein Account ist noch keinem Haushalt zugeordnet.
</p> </p>
)} )}
@@ -183,11 +223,17 @@ export function MyChildrenManager({
onOpenChange={(open) => !open && setEditingChild(null)} onOpenChange={(open) => !open && setEditingChild(null)}
> >
<DialogContent> <DialogContent>
<form action={handleUpdate}> <form onSubmit={handleUpdate}>
<DialogHeader> <DialogHeader>
<DialogTitle>Kind bearbeiten</DialogTitle> <DialogTitle>Kind bearbeiten</DialogTitle>
</DialogHeader> </DialogHeader>
{editingChild && <ChildFields child={editingChild} />} {editingChild && (
<ChildFields
idPrefix="edit-child"
value={editPayload}
onChange={setEditPayload}
/>
)}
<DialogFooter> <DialogFooter>
<Button <Button
type="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 ( return (
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <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 <Input
id="child-firstName" id={`${idPrefix}-firstName`}
name="firstName" name="firstName"
defaultValue={child?.firstName ?? ""} value={value.firstName}
onChange={(event) =>
onChange({ ...value, firstName: event.target.value })
}
required required
/> />
</div> </div>
<div className="grid gap-2"> <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 <Input
id="child-lastName" id={`${idPrefix}-lastName`}
name="lastName" name="lastName"
defaultValue={child?.lastName ?? ""} value={value.lastName}
onChange={(event) =>
onChange({ ...value, lastName: event.target.value })
}
required required
/> />
</div> </div>
<div className="grid gap-2"> <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 <Input
id="child-dateOfBirth" id={`${idPrefix}-dateOfBirth`}
name="dateOfBirth" name="dateOfBirth"
type="date" type="date"
defaultValue={child?.dateOfBirth ?? ""} min={birthdateRange.min}
max={birthdateRange.max}
value={value.dateOfBirth}
onChange={(event) =>
onChange({ ...value, dateOfBirth: event.target.value })
}
/> />
</div> </div>
</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 { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { NotdienstPlanStatus } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import {
CHILD_MAX_AGE_YEARS,
getChildBirthdateRange,
parseDateInput,
} from "@/lib/child-birthdate";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche, // Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
@@ -23,8 +29,8 @@ const childSchema = z
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (!data.dateOfBirth) return; if (!data.dateOfBirth) return;
const parsed = new Date(`${data.dateOfBirth}T00:00:00`); const parsed = parseDateInput(data.dateOfBirth);
if (Number.isNaN(parsed.getTime())) { if (!parsed) {
ctx.addIssue({ ctx.addIssue({
path: ["dateOfBirth"], path: ["dateOfBirth"],
code: "custom", code: "custom",
@@ -32,22 +38,19 @@ const childSchema = z
}); });
return; return;
} }
// In der Zukunft geborene Kinder existieren nicht. const { min, max } = getChildBirthdateRange();
const today = new Date(); if (data.dateOfBirth > max) {
today.setHours(23, 59, 59, 999);
if (parsed > today) {
ctx.addIssue({ ctx.addIssue({
path: ["dateOfBirth"], path: ["dateOfBirth"],
code: "custom", code: "custom",
message: "Geburtsdatum darf nicht in der Zukunft liegen.", message: "Geburtsdatum darf nicht in der Zukunft liegen.",
}); });
} }
// Schutz vor offensichtlichen Tippfehlern (1899 statt 1989 o.ä.). if (data.dateOfBirth < min) {
if (parsed.getFullYear() < 1900) {
ctx.addIssue({ ctx.addIssue({
path: ["dateOfBirth"], path: ["dateOfBirth"],
code: "custom", 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(), 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, // Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
// damit der User nicht nur "Ungültige Eingabedaten" sieht. // damit der User nicht nur "Ungültige Eingabedaten" sieht.
function firstZodMessage( function firstZodMessage(
@@ -290,6 +286,27 @@ export async function deleteMyAccount() {
return { error: "Account wurde nicht gefunden." }; 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) { if (!user.familyId) {
await prisma.user.delete({ await prisma.user.delete({
where: { id: user.id }, 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 { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { import {
AtSign,
Baby, Baby,
CalendarHeart, CalendarHeart,
Home, Home,
Mail, Mail,
Phone, Phone,
Shield, Shield,
Trash2,
UserCircle, UserCircle,
} from "lucide-react"; } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { requireKitaSession } from "@/lib/auth-utils";
import { Badge } from "@/components/ui/badge"; 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 { DeleteAccountDialog } from "./_components/delete-account-dialog";
import { MyChildrenManager } from "./_components/my-children-manager"; import { MyChildrenManager } from "./_components/my-children-manager";
import { CreateFamilyDialog } from "./_components/create-family-dialog"; import { CreateFamilyDialog } from "./_components/create-family-dialog";
@@ -24,7 +33,12 @@ export const metadata = { title: "Mein Profil · Kita-Planer" };
export default async function ProfilPage() { export default async function ProfilPage() {
const session = await requireKitaSession(); 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 }, where: { id: session.user.id },
select: { select: {
id: true, id: true,
@@ -63,94 +77,94 @@ export default async function ProfilPage() {
}, },
}, },
}, },
}); }),
]);
const fullName = `${user.firstName} ${user.lastName}`.trim();
return ( return (
<div className="flex h-full flex-col gap-6 p-6 max-w-4xl mx-auto w-full"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<div> <header className="mb-9">
<h1 className="text-2xl font-bold tracking-tight">Mein Profil</h1> <p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
<p className="text-muted-foreground"> 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. Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen.
</p> </p>
</div> </header>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid items-start gap-5 lg:grid-cols-2">
{/* Persönliche Daten */} <Card className="self-stretch">
<Card> <CardHeader className="pb-5">
<CardHeader> <div className="flex items-start gap-3">
<CardTitle className="flex items-center gap-2"> <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-5 w-5" /> <UserCircle className="h-4 w-4 [stroke-width:1.7]" />
Persönliche Daten </div>
</CardTitle> <div>
<CardTitle>Persönliche Daten</CardTitle>
<CardDescription className="mt-2">
Read-only Stammdaten deines Accounts.
</CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent>
<div> <DefList>
<div className="text-sm text-muted-foreground">Name</div> <DefItem icon={UserCircle} label="Name" value={fullName} />
<div className="font-medium text-lg"> <DefItem icon={Mail} label="E-Mail" value={user.email} />
{user.firstName} {user.lastName} <DefItem
</div> icon={Phone}
</div> label="Telefon"
<div> value={user.phone ? user.phone : <EmptyDefValue />}
<div className="text-sm text-muted-foreground flex items-center gap-1"> />
<Mail className="h-3.5 w-3.5" /> E-Mail <DefItem icon={Shield} label="Rolle" value={<RoleBadge role={user.role} />} />
</div> <DefItem
<div className="font-medium">{user.email}</div> icon={Home}
</div> label="Haushalt"
<div> value={
<div className="text-sm text-muted-foreground flex items-center gap-1"> user.family ? (
<Phone className="h-3.5 w-3.5" /> Telefon user.family.name
</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>
) : ( ) : (
<div className="mt-2 space-y-3 rounded-md border border-dashed p-3"> <div className="grid gap-3 rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm font-normal leading-5 text-[var(--ink-soft)]">
Dein Account ist noch keinem Haushalt zugeordnet. Lege hier Dein Account ist noch keinem Haushalt zugeordnet.
deinen eigenen Haushalt an, wenn du selbst Elternteil in der
Kita bist.
</p> </p>
<CreateFamilyDialog <CreateFamilyDialog
defaultFamilyName={`Familie ${user.lastName}`} defaultFamilyName={`Familie ${user.lastName}`}
/> />
</div> </div>
)} )
</div> }
<div className="pt-2 border-t mt-2"> />
<div className="text-sm text-muted-foreground flex items-center gap-1"> <DefItem
<CalendarHeart className="h-3.5 w-3.5" /> Mitglied seit icon={CalendarHeart}
</div> label="Mitglied seit"
<div className="text-sm"> value={format(user.createdAt, "PPP", { locale: de })}
{format(user.createdAt, "PPP", { locale: de })} />
</div> </DefList>
</div>
</CardContent> </CardContent>
</Card> </Card>
<div className="flex flex-col gap-6"> <div className="grid gap-4">
<Card> <Card>
<CardHeader> <CardHeader className="pb-5">
<CardTitle className="flex items-center gap-2"> <div className="flex items-start gap-3">
<Phone className="h-5 w-5" /> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
Kontaktdaten <AtSign className="h-4 w-4 [stroke-width:1.7]" />
</CardTitle> </div>
<CardDescription> <div>
Diese Angaben helfen bei Notdienst, Adressbuch und Abstimmung in <CardTitle>Kontaktdaten</CardTitle>
der Kita. <CardDescription className="mt-2">
Für Notdienst, Adressbuch und Abstimmung in der Kita.
</CardDescription> </CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ContactForm <ContactForm
@@ -164,13 +178,19 @@ export default async function ProfilPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Kinder */}
<Card> <Card>
<CardHeader> <CardHeader className="pb-5">
<CardTitle className="flex items-center gap-2"> <div className="flex items-start gap-3">
<Baby className="h-5 w-5" /> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
Meine Kinder <Baby className="h-4 w-4 [stroke-width:1.7]" />
</CardTitle> </div>
<div>
<CardTitle>Meine Kinder</CardTitle>
<CardDescription className="mt-2">
Kinder deines Haushalts verwalten.
</CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<MyChildrenManager <MyChildrenManager
@@ -187,23 +207,53 @@ export default async function ProfilPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Ämter */}
<Card> <Card>
<CardHeader> <CardHeader className="pb-5">
<CardTitle className="flex items-center gap-2"> <div className="flex items-start gap-3">
<Shield className="h-5 w-5" /> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
Meine Ämter & Dienste <Shield className="h-4 w-4 [stroke-width:1.7]" />
</CardTitle> </div>
<div>
<CardTitle>Meine Ämter & Dienste</CardTitle>
<CardDescription className="mt-2">
Feste Aufgaben und laufende Zuständigkeiten.
</CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{user.dutyAssignments.length === 0 ? ( {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) => ( {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} {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> </div>
)} )}
@@ -212,18 +262,24 @@ export default async function ProfilPage() {
</div> </div>
</div> </div>
<Card className="border-destructive/20 mt-8"> <Card className="mt-5 bg-[var(--surface-2)]">
<CardHeader> <CardContent className="flex items-center justify-between gap-6 pt-[22px] max-[760px]:flex-col max-[760px]:items-start">
<CardDescription> <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 Hier kannst du dein Profil und je nach Haushaltssituation auch die
zugehörigen Familiendaten DSGVO-konform löschen. zugehörigen Familiendaten DSGVO-konform löschen. Aktive
</CardDescription> Notdienst-Verpflichtungen blockieren die Löschung.
</CardHeader> </p>
<CardContent> </div>
<DeleteAccountDialog /> <DeleteAccountDialog />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }
+16 -13
View File
@@ -43,13 +43,13 @@ export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] })
if (events.length === 0) return null; if (events.length === 0) return null;
return ( 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"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-amber-950"> <CardTitle className="flex items-center gap-2 text-lg text-[var(--ink)]">
<AlertTriangle className="h-5 w-5 text-amber-700" /> <AlertTriangle className="h-5 w-5 text-[var(--warn)] [stroke-width:1.7]" />
Aktion erforderlich Aktion erforderlich
</CardTitle> </CardTitle>
<CardDescription className="text-amber-900/80"> <CardDescription className="text-[var(--ink-soft)]">
Für diese Termine fehlt noch mindestens eine Rückmeldung deiner Für diese Termine fehlt noch mindestens eine Rückmeldung deiner
Familie. Familie.
</CardDescription> </CardDescription>
@@ -58,11 +58,11 @@ export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] })
{events.map((event) => ( {events.map((event) => (
<div <div
key={event.id} 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> <div>
<p className="font-medium">{event.title}</p> <p className="font-medium text-[var(--ink)]">{event.title}</p>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-[var(--ink-soft)]">
Rückmeldung bis{" "} Rückmeldung bis{" "}
{format( {format(
new Date(event.rsvpDeadline ?? event.startDate), new Date(event.rsvpDeadline ?? event.startDate),
@@ -141,15 +141,18 @@ function RsvpDialog({ event }: { event: DashboardRsvpEventDto }) {
<div className="grid gap-3 py-2"> <div className="grid gap-3 py-2">
{event.children.map((child) => ( {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"> <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 && ( {child.status && (
<Badge variant="outline">bereits gemeldet</Badge> <Badge variant="outline">bereits gemeldet</Badge>
)} )}
</div> </div>
<div className="grid gap-2 sm:grid-cols-2"> <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 <input
type="radio" type="radio"
name={`rsvp-${child.id}`} 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 Nimmt teil
</label> </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 <input
type="radio" type="radio"
name={`rsvp-${child.id}`} 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 Nimmt nicht teil
</label> </label>
</div> </div>
+49 -66
View File
@@ -1,86 +1,69 @@
@import "tailwindcss"; @import "tailwindcss";
@import "./styles/tokens.css";
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--bg);
--color-foreground: var(--foreground); --color-foreground: var(--ink);
--color-card: var(--card); --color-card: var(--surface);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--ink);
--color-popover: var(--popover); --color-popover: var(--surface);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--ink);
--color-primary: var(--primary); --color-primary: var(--accent-deep);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: oklch(0.98 0.005 45);
--color-secondary: var(--secondary); --color-secondary: var(--surface-2);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--ink);
--color-muted: var(--muted); --color-muted: var(--surface-week);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--ink-muted);
--color-accent: var(--accent); --color-accent: var(--accent-soft);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-deep);
--color-destructive: var(--destructive); --color-destructive: var(--danger);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: oklch(0.98 0.005 45);
--color-border: var(--border); --color-border: var(--hairline);
--color-input: var(--input); --color-input: var(--hairline);
--color-ring: var(--ring); --color-ring: var(--accent);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-manrope);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-manrope);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
} }
:root { :root {
--background: oklch(1 0 0); --background: var(--bg);
--foreground: oklch(0.145 0 0); --foreground: var(--ink);
--card: oklch(1 0 0); --card: var(--surface);
--card-foreground: oklch(0.145 0 0); --card-foreground: var(--ink);
--popover: oklch(1 0 0); --popover: var(--surface);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: var(--ink);
--primary: oklch(0.205 0 0); --primary: var(--accent-deep);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.98 0.005 45);
--secondary: oklch(0.97 0 0); --secondary: var(--surface-2);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: var(--ink);
--muted: oklch(0.97 0 0); --muted: var(--surface-week);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: var(--ink-muted);
--accent: oklch(0.97 0 0); --accent-foreground: var(--accent-deep);
--accent-foreground: oklch(0.205 0 0); --destructive: var(--danger);
--destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.98 0.005 45);
--destructive-foreground: oklch(0.985 0 0); --border: var(--hairline);
--border: oklch(0.922 0 0); --input: var(--hairline);
--input: oklch(0.922 0 0); --ring: var(--accent);
--ring: oklch(0.708 0 0);
--radius: 0.625rem; --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 { @layer base {
* { * {
border-color: var(--border); border-color: var(--border);
} }
body { body {
background-color: var(--background); background-color: var(--bg);
color: var(--foreground); background-image: var(--paper-pattern);
background-size: 16px 16px;
color: var(--ink);
font-family: var(--font-sans), system-ui, sans-serif; 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 type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Manrope } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { UmamiAnalytics } from "@/components/analytics/umami-analytics"; import { UmamiAnalytics } from "@/components/analytics/umami-analytics";
const geistSans = Geist({ const manrope = Manrope({
variable: "--font-geist-sans", variable: "--font-manrope",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500", "600", "700"],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -56,7 +52,7 @@ export default function RootLayout({
return ( return (
<html <html
lang="de" lang="de"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${manrope.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
{children} {children}
+178 -7
View File
@@ -3,8 +3,10 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { import {
ArrowRight, ArrowRight,
Bell,
CheckCircle2, CheckCircle2,
ClipboardList, ClipboardList,
Clock,
DatabaseZap, DatabaseZap,
FileStack, FileStack,
Fingerprint, Fingerprint,
@@ -14,6 +16,7 @@ import {
MessageSquareText, MessageSquareText,
MessagesSquare, MessagesSquare,
PhoneCall, PhoneCall,
Search,
Route, Route,
ShieldCheck, ShieldCheck,
Table2, Table2,
@@ -72,7 +75,7 @@ const privacyPoints = [
{ {
icon: DatabaseZap, icon: DatabaseZap,
title: "Strikt isolierte Mandanten", 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, icon: Fingerprint,
@@ -81,7 +84,7 @@ const privacyPoints = [
}, },
{ {
icon: Trash2, 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.", 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, icon: MessagesSquare,
title: "WhatsApp für offizielle Infos", title: "WhatsApp für offizielle Infos",
text: "Ankündigungen, Anhänge und Lesestatus gehören in einen verbindlichen Kanal, nicht zwischen spontane Rückfragen.", 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, icon: Table2,
title: "Excel-Listen für Dienste", title: "Excel-Listen für Dienste",
text: "Verfügbarkeiten, Mindesttage und Dienstpläne werden strukturierter als in Tabellen mit Version 17-final-final.", 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, icon: FileStack,
title: "Verstreute PDFs und Mail-Anhänge", title: "Verstreute PDFs und Mail-Anhänge",
text: "Wichtige Dokumente hängen am passenden Kontext und bleiben geschützt abrufbar.", 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, icon: ClipboardList,
title: "Manuelle Kontaktlisten", title: "Manuelle Kontaktlisten",
text: "Familien, Kinder und freigegebene Kontaktdaten werden kontrolliert statt lose herumgereicht.", text: "Familien, Kinder und freigegebene Kontaktdaten werden kontrolliert statt lose herumgereicht.",
accent: "bg-sky-50 text-sky-700 ring-sky-100",
}, },
{ {
icon: PhoneCall, icon: PhoneCall,
title: "Telefonchaos bei Krankmeldungen", title: "Telefonchaos bei Krankmeldungen",
text: "Eltern melden Abwesenheiten digital; das Team sieht morgens schneller, wer fehlt.", 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 = [ const onboardingSteps = [
{ {
title: "Demo anfragen", title: onboardingTimeAnchors[0],
text: "Ihr beschreibt kurz eure aktuelle Organisation und die größten Reibungspunkte.", 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.", 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.", 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.", text: "Dienste, Kalender, Krankmeldungen, Adressbuch und Schwarzes Brett ersetzen nach und nach Nebenkanäle.",
}, },
]; ];
@@ -267,6 +282,27 @@ export default function LandingPage() {
</div> </div>
</section> </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"> <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 className="mb-10 grid gap-5 lg:grid-cols-[0.85fr_1.15fr] lg:items-end">
<div> <div>
@@ -293,7 +329,9 @@ export default function LandingPage() {
key={item.title} key={item.title}
className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm" 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" /> <Icon className="h-5 w-5" />
</div> </div>
<h3 className="font-semibold tracking-tight">{item.title}</h3> <h3 className="font-semibold tracking-tight">{item.title}</h3>
@@ -409,6 +447,9 @@ export default function LandingPage() {
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-0">
<FeaturePreview slug={feature.slug} />
</CardContent>
</Card> </Card>
); );
})} })}
@@ -593,3 +634,133 @@ export default function LandingPage() {
</div> </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"; "use client";
import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { import {
LayoutDashboard, LayoutDashboard,
@@ -15,24 +14,28 @@ import {
Megaphone, Megaphone,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { NavItem } from "@/components/ui/nav-item";
import { UserRole } from "@prisma/client"; import { UserRole } from "@prisma/client";
const navItems = [ const navItems = [
{ {
section: "Heute",
href: "/dashboard", href: "/dashboard",
label: "Übersicht", label: "Übersicht",
icon: LayoutDashboard, icon: LayoutDashboard,
exact: true, exact: true,
}, },
{ {
section: "Heute",
href: "/dashboard/notdienst", href: "/dashboard/notdienst",
label: "Notdienst (Eltern)", label: "Notdienst (Eltern)",
icon: CalendarCheck2, icon: CalendarCheck2,
exact: true, exact: true,
badge: "4",
}, },
{ {
section: "Heute",
href: "/dashboard/notdienst/plan", href: "/dashboard/notdienst/plan",
label: "Notdienst-Planung", label: "Notdienst-Planung",
icon: CalendarCheck2, icon: CalendarCheck2,
@@ -40,18 +43,21 @@ const navItems = [
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR], allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
}, },
{ {
section: "Heute",
href: "/dashboard/kalender", href: "/dashboard/kalender",
label: "Terminkalender", label: "Terminkalender",
icon: CalendarDays, icon: CalendarDays,
exact: false, exact: false,
}, },
{ {
section: "Heute",
href: "/dashboard/adressbuch", href: "/dashboard/adressbuch",
label: "Adressbuch", label: "Adressbuch",
icon: Contact, icon: Contact,
exact: false, exact: false,
}, },
{ {
section: "Verwaltung",
href: "/dashboard/families", href: "/dashboard/families",
label: "Familienverwaltung", label: "Familienverwaltung",
icon: Users, icon: Users,
@@ -59,6 +65,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN], allowedRoles: [UserRole.ADMIN],
}, },
{ {
section: "Verwaltung",
href: "/dashboard/admin/dienste", href: "/dashboard/admin/dienste",
label: "Dienstplan", label: "Dienstplan",
icon: ClipboardList, icon: ClipboardList,
@@ -66,6 +73,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN], allowedRoles: [UserRole.ADMIN],
}, },
{ {
section: "Verwaltung",
href: "/dashboard/admin/news", href: "/dashboard/admin/news",
label: "Schwarzes Brett", label: "Schwarzes Brett",
icon: Megaphone, icon: Megaphone,
@@ -73,6 +81,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN], allowedRoles: [UserRole.ADMIN],
}, },
{ {
section: "Verwaltung",
href: "/dashboard/admin/kalender", href: "/dashboard/admin/kalender",
label: "Event-Anmeldungen", label: "Event-Anmeldungen",
icon: CalendarCheck2, icon: CalendarCheck2,
@@ -80,6 +89,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR], allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
}, },
{ {
section: "Verwaltung",
href: "/dashboard/admin/abwesenheiten", href: "/dashboard/admin/abwesenheiten",
label: "Abwesenheiten", label: "Abwesenheiten",
icon: Stethoscope, icon: Stethoscope,
@@ -87,6 +97,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ERZIEHER], allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ERZIEHER],
}, },
{ {
section: "Verwaltung",
href: "/dashboard/erzieher", href: "/dashboard/erzieher",
label: "ErzieherInnen", label: "ErzieherInnen",
icon: GraduationCap, icon: GraduationCap,
@@ -94,6 +105,7 @@ const navItems = [
allowedRoles: [UserRole.ADMIN], allowedRoles: [UserRole.ADMIN],
}, },
{ {
section: "Verwaltung",
href: "/dashboard/profil", href: "/dashboard/profil",
label: "Mein Profil", label: "Mein Profil",
icon: UserCircle, icon: UserCircle,
@@ -103,32 +115,40 @@ const navItems = [
export function SidebarNav({ role }: { role: UserRole }) { export function SidebarNav({ role }: { role: UserRole }) {
const pathname = usePathname(); const pathname = usePathname();
const visibleItems = navItems.filter(
({ allowedRoles }) =>
!allowedRoles || (allowedRoles as UserRole[]).includes(role),
);
const sections = ["Heute", "Verwaltung"];
return ( return (
<nav className="flex flex-col gap-1 px-3"> <nav className="flex flex-col gap-8 px-5">
{navItems.map(({ href, label, icon: Icon, exact, allowedRoles }) => { {sections.map((section) => (
// Filter out items not allowed for the current role <div key={section}>
if (allowedRoles && !(allowedRoles as UserRole[]).includes(role)) { <p className="mb-3 px-2.5 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.18em]">
return null; {section}
} </p>
<div className="flex flex-col gap-1">
const isActive = exact ? pathname === href : pathname.startsWith(href); {visibleItems
.filter((item) => item.section === section)
.map(({ href, label, icon, exact, badge }) => {
const isActive = exact
? pathname === href
: pathname.startsWith(href);
return ( return (
<Link <NavItem
key={href} key={href}
href={href} href={href}
className={cn( label={label}
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors", icon={icon}
isActive active={isActive}
? "bg-primary text-primary-foreground" badge={badge}
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground", />
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
); );
})} })}
</div>
</div>
))}
</nav> </nav>
); );
} }
+7 -7
View File
@@ -4,21 +4,21 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const badgeVariants = cva( 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: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground", "border-transparent bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)]",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground", "border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]",
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground", "border-transparent bg-[var(--danger-soft)] text-[var(--danger)]",
outline: "text-foreground", outline: "border-[var(--hairline)] text-[var(--ink-soft)]",
success: 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: 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: { defaultVariants: {
+14 -10
View File
@@ -5,24 +5,28 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( 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: { variants: {
variant: { 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: 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: outline:
"border border-input bg-background 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: "hover:bg-accent hover:text-accent-foreground", ghost:
"rounded-lg border border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
destructive: destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90", "rounded-lg bg-[var(--danger)] text-[oklch(0.98_0.005_45)] hover:brightness-95",
link: "text-primary underline-offset-4 hover:underline", 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: { size: {
default: "h-10 px-4 py-2", default: "h-10 gap-2 px-[18px] py-2.5",
sm: "h-9 rounded-md px-3", sm: "h-9 gap-2 px-3",
lg: "h-11 rounded-md px-8", lg: "h-11 gap-2 px-8",
icon: "h-10 w-10", icon: "h-10 w-10",
}, },
}, },
+29 -11
View File
@@ -1,15 +1,33 @@
import * as React from "react"; import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const cardVariants = cva(
({ className, ...props }, ref) => ( "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 <div
ref={ref} ref={ref}
className={cn( className={cn(cardVariants({ variant, className }))}
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props} {...props}
/> />
), ),
@@ -18,7 +36,7 @@ Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ 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"; CardHeader.displayName = "CardHeader";
@@ -31,7 +49,7 @@ const CardTitle = React.forwardRef<HTMLElement, CardTitleProps>(
({ className, as: Comp = "div", ...props }, ref) => ( ({ className, as: Comp = "div", ...props }, ref) => (
<Comp <Comp
ref={ref as React.Ref<HTMLDivElement>} 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} {...props}
/> />
), ),
@@ -42,7 +60,7 @@ const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HT
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-[13.5px] text-[var(--ink-soft)]", className)}
{...props} {...props}
/> />
), ),
@@ -51,14 +69,14 @@ CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ 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"; CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ 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"; 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 <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -35,13 +35,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
> >
{children} {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" /> <X className="h-4 w-4" />
<span className="sr-only">Schließen</span> <span className="sr-only">Schließen</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
@@ -85,7 +85,7 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", "text-lg font-medium leading-none tracking-[-0.02em] text-[var(--ink)]",
className, className,
)} )}
{...props} {...props}
@@ -99,7 +99,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-[var(--ink-soft)]", className)}
{...props} {...props}
/> />
)); ));
+1 -1
View File
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
<input <input
type={type} type={type}
className={cn( 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, className,
)} )}
ref={ref} 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) => ( >(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
{...props}
className={cn( 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, 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} {children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
)); ));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
@@ -66,7 +70,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( 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" && 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", "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, className,
@@ -97,7 +101,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
+5 -4
View File
@@ -12,15 +12,16 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
position="bottom-center"
toastOptions={{ toastOptions={{
classNames: { classNames: {
toast: toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", "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-muted-foreground", description: "group-[.toast]:text-white/70",
actionButton: actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", "group-[.toast]:bg-[var(--accent)] group-[.toast]:text-white",
cancelButton: cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", "group-[.toast]:bg-white/10 group-[.toast]:text-white",
}, },
}} }}
{...props} {...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"> <div className="relative w-full overflow-auto">
<table <table
ref={ref} 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} {...props}
/> />
</div> </div>
@@ -20,7 +23,11 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ 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"; TableHeader.displayName = "TableHeader";
@@ -43,7 +50,7 @@ const TableRow = React.forwardRef<
<tr <tr
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -58,7 +65,7 @@ const TableHead = React.forwardRef<
<th <th
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -72,7 +79,10 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<td <td
ref={ref} 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} {...props}
/> />
)); ));
@@ -84,7 +94,7 @@ const TableCaption = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<caption <caption
ref={ref} ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)} className={cn("mt-4 text-sm text-[var(--ink-muted)]", className)}
{...props} {...props}
/> />
)); ));
+1 -1
View File
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
<textarea <textarea
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...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;
}