Apply linen design system across app
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
@@ -1,164 +1,327 @@
|
||||
import Link from "next/link";
|
||||
import { addDays, format } from "date-fns";
|
||||
import { addDays, differenceInCalendarDays, format, isWeekend } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { ArrowLeft, ArrowRight, CalendarDays, Stethoscope } from "lucide-react";
|
||||
import { AbsenceReason } from "@prisma/client";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CalendarCheck2,
|
||||
CalendarDays,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Stethoscope,
|
||||
} from "lucide-react";
|
||||
import { AbsenceReason, TerminStatus, TerminType, UserRole } from "@prisma/client";
|
||||
|
||||
import { getDailyAbsences } from "@/actions/absences";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
AbsenceTable,
|
||||
reasonColor,
|
||||
reasonLabel,
|
||||
type AbsenceTableItem,
|
||||
} from "./absence-table";
|
||||
|
||||
export const metadata = { title: "Abwesenheiten · Kita-Planer" };
|
||||
|
||||
export default async function AdminAbwesenheitenPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { date?: string };
|
||||
searchParams: Promise<{ date?: string }>;
|
||||
}) {
|
||||
const selectedDate = parseDateParam(searchParams.date);
|
||||
const session = await requireRole([
|
||||
UserRole.ADMIN,
|
||||
UserRole.KOORDINATOR,
|
||||
UserRole.ERZIEHER,
|
||||
]);
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const kitaId = session.user.kitaId!;
|
||||
const today = startOfLocalDay(new Date());
|
||||
const selectedDate = parseDateParam(resolvedSearchParams.date, today);
|
||||
const dateKey = format(selectedDate, "yyyy-MM-dd");
|
||||
const result = await getDailyAbsences({ date: dateKey });
|
||||
const absences = result.absences ?? [];
|
||||
const childCount = await prisma.child.count({
|
||||
where: { kitaId, active: true },
|
||||
});
|
||||
const upcomingAbsenceCount = await prisma.absence.count({
|
||||
where: {
|
||||
kitaId,
|
||||
startDate: { gt: selectedDate },
|
||||
},
|
||||
});
|
||||
const closure = await prisma.termin.findFirst({
|
||||
where: {
|
||||
kitaId,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
type: { in: [TerminType.SCHLIESSTAG, TerminType.TEAMTAG] },
|
||||
startDate: { lte: selectedDate },
|
||||
endDate: { gte: selectedDate },
|
||||
},
|
||||
select: { title: true, type: true },
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
const isToday = dateKey === format(today, "yyyy-MM-dd");
|
||||
const isTooFarFuture = differenceInCalendarDays(selectedDate, today) > 30;
|
||||
const isClosedDay = isWeekend(selectedDate) || !!closure;
|
||||
const tableItems: AbsenceTableItem[] = absences.map((absence) => ({
|
||||
id: absence.id,
|
||||
childName: absence.childName,
|
||||
familyName: absence.familyName,
|
||||
reason: absence.reason,
|
||||
note: absence.note,
|
||||
startDate: absence.startDate,
|
||||
endDate: absence.endDate,
|
||||
}));
|
||||
const countsByReason = countByReason(tableItems);
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-slate-50 px-4 py-6 sm:px-8">
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="flex flex-col gap-4 rounded-lg border bg-background p-5 shadow-sm lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Stethoscope className="h-4 w-4" />
|
||||
Tagesübersicht
|
||||
</div>
|
||||
<h1 className="mt-2 text-3xl font-bold tracking-tight">
|
||||
Heute fehlen ({absences.length} Kinder)
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{format(selectedDate, "EEEE, dd. MMMM yyyy", { locale: de })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={hrefForDate(addDays(selectedDate, -1))}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Zurück
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/dashboard/admin/abwesenheiten">Heute</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={hrefForDate(addDays(selectedDate, 1))}>
|
||||
Vor
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<form className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
name="date"
|
||||
defaultValue={dateKey}
|
||||
className="h-9 w-40"
|
||||
/>
|
||||
<Button type="submit" size="sm">
|
||||
Anzeigen
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7 flex items-end justify-between gap-8 max-[1100px]:flex-col max-[1100px]:items-start">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||
TAGESÜBERSICHT
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Heute fehlen{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
{absences.length} Kinder
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
{format(selectedDate, "EEEE, d. MMMM yyyy", { locale: de })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
<Stethoscope className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||
{childCount - absences.length} anwesend
|
||||
</span>
|
||||
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
|
||||
<SegmentLink href={hrefForDate(addDays(selectedDate, -1))}>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Zurück
|
||||
</SegmentLink>
|
||||
<SegmentLink href={hrefForDate(today)} active={isToday}>
|
||||
Heute
|
||||
</SegmentLink>
|
||||
<SegmentLink href={hrefForDate(addDays(selectedDate, 1))}>
|
||||
Vor
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</SegmentLink>
|
||||
</div>
|
||||
<form className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
name="date"
|
||||
defaultValue={dateKey}
|
||||
className="w-[140px]"
|
||||
/>
|
||||
<Button type="submit">Anzeigen</Button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<FilterChip label="Alle Gruppen" />
|
||||
<FilterChip label="Alle Gründe" />
|
||||
</div>
|
||||
{tableItems.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-[var(--ink-soft)]">
|
||||
<ReasonInlineStat
|
||||
reason={AbsenceReason.ILLNESS}
|
||||
count={countsByReason[AbsenceReason.ILLNESS] ?? 0}
|
||||
/>
|
||||
<ReasonInlineStat
|
||||
reason={AbsenceReason.OTHER}
|
||||
count={countsByReason[AbsenceReason.OTHER] ?? 0}
|
||||
/>
|
||||
<ReasonInlineStat
|
||||
reason={AbsenceReason.VACATION}
|
||||
count={countsByReason[AbsenceReason.VACATION] ?? 0}
|
||||
/>
|
||||
<span>
|
||||
letzte Aktualisierung {format(new Date(), "HH:mm", { locale: de })}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{result.error ? (
|
||||
<Card className="border-destructive/30">
|
||||
<CardContent className="p-6 text-sm text-destructive">
|
||||
<Card className="border-[var(--danger)] bg-[var(--danger-soft)]">
|
||||
<CardContent className="pt-[22px] text-sm text-[var(--danger)]">
|
||||
{result.error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : absences.length === 0 ? (
|
||||
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-background p-8 text-center">
|
||||
<div className="mx-auto max-w-md">
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||
<CalendarDays className="h-10 w-10" />
|
||||
) : tableItems.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardTitle>
|
||||
{tableItems.length} von {childCount} Kindern abwesend ·{" "}
|
||||
{upcomingAbsenceCount} anstehend
|
||||
</CardTitle>
|
||||
<Badge variant="secondary">{dateKey}</Badge>
|
||||
</div>
|
||||
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
|
||||
Keine Abwesenheiten
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Für diesen Tag sind aktuell keine Kinder abgemeldet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AbsenceTable absences={tableItems} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isTooFarFuture ? (
|
||||
<EmptyState
|
||||
tone="accent"
|
||||
title="Noch nicht im Vorschauzeitraum"
|
||||
description="Abwesenheiten werden erst 30 Tage im Voraus angezeigt."
|
||||
/>
|
||||
) : isClosedDay ? (
|
||||
<EmptyState
|
||||
tone="accent"
|
||||
title={`Kita geschlossen am ${format(selectedDate, "d. MMMM", {
|
||||
locale: de,
|
||||
})}`}
|
||||
description={
|
||||
closure
|
||||
? closure.title
|
||||
: "Wochenende · für diesen Tag werden keine Anwesenheiten erwartet."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
<CardDescription className="mt-1">
|
||||
{absence.familyName}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<ReasonBadge reason={absence.reason} />
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 px-5 pb-5 sm:grid-cols-[180px_1fr]">
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Zeitraum</p>
|
||||
<p className="text-muted-foreground">
|
||||
{formatDateRange(absence.startDate, absence.endDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Notiz</p>
|
||||
<p className="text-muted-foreground">
|
||||
{absence.note || "Keine Bemerkung hinterlegt."}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<EmptyState
|
||||
tone="good"
|
||||
title="Keine Abwesenheiten"
|
||||
description="Für diesen Tag sind aktuell keine Kinder abgemeldet."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseDateParam(value: string | undefined) {
|
||||
if (!value) return new Date();
|
||||
function SegmentLink({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={[
|
||||
"inline-flex h-10 items-center gap-2 border-r border-[var(--hairline)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition last:border-r-0 hover:bg-[var(--surface-2)] hover:text-[var(--ink)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]",
|
||||
active ? "bg-[var(--accent-soft)] text-[var(--accent-deep)]" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({ label }: { label: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-transparent px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--accent-soft)] hover:text-[var(--accent-deep)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
>
|
||||
{label}
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasonInlineStat({
|
||||
reason,
|
||||
count,
|
||||
}: {
|
||||
reason: AbsenceReason;
|
||||
count: number;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: reasonColor(reason) }}
|
||||
/>
|
||||
{reasonLabel(reason)} {count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
tone,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
tone: "good" | "accent";
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
const isGood = tone === "good";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-8 py-12 text-center">
|
||||
<div
|
||||
className={[
|
||||
"flex h-12 w-12 items-center justify-center rounded-2xl",
|
||||
isGood
|
||||
? "bg-[var(--good-soft)] text-[var(--good)]"
|
||||
: "bg-[var(--accent-soft)] text-[var(--accent-deep)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{isGood ? (
|
||||
<CalendarCheck2 className="h-6 w-6 [stroke-width:1.7]" />
|
||||
) : (
|
||||
<CalendarDays className="h-6 w-6 [stroke-width:1.7]" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="mt-5 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-2 max-w-md text-sm text-[var(--ink-soft)]">
|
||||
{description}
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/admin/abwesenheiten"
|
||||
className="mt-5 inline-flex items-center gap-1 text-sm font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
>
|
||||
Wochenübersicht öffnen
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function parseDateParam(value: string | undefined, fallback: Date) {
|
||||
if (!value) return fallback;
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
||||
}
|
||||
|
||||
function hrefForDate(date: Date) {
|
||||
return `/dashboard/admin/abwesenheiten?date=${format(date, "yyyy-MM-dd")}`;
|
||||
}
|
||||
|
||||
function formatDateRange(startDate: string, endDate: string) {
|
||||
const start = new Date(`${startDate}T00:00:00`);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const startLabel = format(start, "dd.MM.", { locale: de });
|
||||
const endLabel = format(end, "dd.MM.yyyy", { locale: de });
|
||||
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
|
||||
function startOfLocalDay(date: Date) {
|
||||
const nextDate = new Date(date);
|
||||
nextDate.setHours(0, 0, 0, 0);
|
||||
return nextDate;
|
||||
}
|
||||
|
||||
function ReasonBadge({ reason }: { reason: AbsenceReason }) {
|
||||
if (reason === AbsenceReason.ILLNESS) {
|
||||
return <Badge variant="destructive">Krank</Badge>;
|
||||
}
|
||||
|
||||
if (reason === AbsenceReason.VACATION) {
|
||||
return <Badge variant="warning">Urlaub</Badge>;
|
||||
}
|
||||
|
||||
return <Badge variant="outline">Sonstiges</Badge>;
|
||||
function countByReason(items: AbsenceTableItem[]) {
|
||||
return items.reduce<Partial<Record<AbsenceReason, number>>>((acc, item) => {
|
||||
acc[item.reason] = (acc[item.reason] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
@@ -116,9 +116,11 @@ export function DutyPlanner({
|
||||
|
||||
if (dutyTypes.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed p-8 text-center">
|
||||
<p className="text-sm font-medium">Noch keine Diensttypen</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-8 text-center">
|
||||
<p className="text-sm font-medium text-[var(--ink)]">
|
||||
Noch keine Diensttypen
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Lege zuerst einen Diensttyp an, danach erscheint hier das Planungsgrid.
|
||||
</p>
|
||||
</div>
|
||||
@@ -136,29 +138,29 @@ export function DutyPlanner({
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||||
) : (
|
||||
<BellRing className="h-4 w-4" />
|
||||
<BellRing className="h-4 w-4 [stroke-width:1.7]" />
|
||||
)}
|
||||
Reminder diese Woche senden
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[760px] border-collapse text-sm">
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--hairline)]">
|
||||
<table className="w-full min-w-[760px] border-collapse text-[13.5px] text-[var(--ink)]">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="sticky left-0 z-10 w-56 bg-muted/95 px-4 py-3 text-left font-medium">
|
||||
<tr className="border-b border-[var(--hairline-soft)] bg-[var(--surface-2)]">
|
||||
<th className="sticky left-0 z-10 w-56 bg-[var(--surface-2)] px-4 py-3 text-left text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Woche
|
||||
</th>
|
||||
{dutyTypes.map((dutyType) => (
|
||||
<th
|
||||
key={dutyType.id}
|
||||
className="min-w-56 px-4 py-3 text-left font-medium"
|
||||
className="min-w-56 px-4 py-3 text-left font-medium text-[var(--ink)]"
|
||||
>
|
||||
<div>{dutyType.name}</div>
|
||||
{dutyType.description && (
|
||||
<div className="mt-1 line-clamp-1 text-xs font-normal text-muted-foreground">
|
||||
<div className="mt-1 line-clamp-1 text-xs font-normal text-[var(--ink-muted)]">
|
||||
{dutyType.description}
|
||||
</div>
|
||||
)}
|
||||
@@ -168,8 +170,11 @@ export function DutyPlanner({
|
||||
</thead>
|
||||
<tbody>
|
||||
{weeks.map((week) => (
|
||||
<tr key={week.key} className="border-b last:border-b-0">
|
||||
<td className="sticky left-0 z-10 bg-background px-4 py-3 font-medium">
|
||||
<tr
|
||||
key={week.key}
|
||||
className="border-b border-[var(--hairline-soft)] last:border-b-0 hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-[var(--surface)] px-4 py-3 font-medium">
|
||||
{week.label}
|
||||
</td>
|
||||
{dutyTypes.map((dutyType) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { addWeeks, format, getISOWeek, startOfDay, startOfWeek } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { CalendarRange, ClipboardList, Plus, Trash2 } from "lucide-react";
|
||||
import { CalendarRange, Plus, Trash2 } from "lucide-react";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -34,15 +34,16 @@ const MAX_WEEK_COUNT = 104;
|
||||
export default async function DienstePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { start?: string; weeks?: string };
|
||||
searchParams: Promise<{ start?: string; weeks?: string }>;
|
||||
}) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
|
||||
if (!session.user.kitaId) {
|
||||
throw new Error("Kein Mandant zugeordnet.");
|
||||
}
|
||||
|
||||
const rangeStart = getRangeStart(searchParams.start);
|
||||
const weekCount = getWeekCount(searchParams.weeks);
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const rangeStart = getRangeStart(resolvedSearchParams.start);
|
||||
const weekCount = getWeekCount(resolvedSearchParams.weeks);
|
||||
const weeks = buildWeeks(rangeStart, weekCount);
|
||||
const rangeEnd = startOfDay(new Date(weeks[weeks.length - 1].endDate));
|
||||
|
||||
@@ -83,77 +84,88 @@ export default async function DienstePage({
|
||||
|
||||
const dutyTypeDtos: DutyPlanDutyTypeDto[] = dutyTypes;
|
||||
const familyDtos: DutyPlanFamilyDto[] = families;
|
||||
const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map((assignment) => ({
|
||||
id: assignment.id,
|
||||
dutyTypeId: assignment.dutyTypeId,
|
||||
familyId: assignment.familyId,
|
||||
startDate: toDateKey(assignment.startDate),
|
||||
}));
|
||||
const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map(
|
||||
(assignment) => ({
|
||||
id: assignment.id,
|
||||
dutyTypeId: assignment.dutyTypeId,
|
||||
familyId: assignment.familyId,
|
||||
startDate: toDateKey(assignment.startDate),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
Admin
|
||||
</div>
|
||||
<h1 className="mt-2 text-2xl font-bold tracking-tight">
|
||||
Dienstplan-Manager
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||
ADMIN · DIENSTPLAN
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Dienste{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
planen
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Plane Dienste pro Kalenderwoche und Haushalt. Jede Änderung wird
|
||||
direkt gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
{dutyTypes.length} Diensttypen
|
||||
</span>
|
||||
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
{families.length} Familien
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="w-full lg:max-w-xl">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarRange className="h-4 w-4" />
|
||||
Zeitraum
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Du kannst beliebig weit voraus planen, jeweils in handlichen
|
||||
Ausschnitten.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="start">Startwoche</Label>
|
||||
<Input
|
||||
id="start"
|
||||
name="start"
|
||||
type="date"
|
||||
defaultValue={toDateInputValue(rangeStart)}
|
||||
/>
|
||||
<Card className="mb-5">
|
||||
<CardContent className="pt-[22px]">
|
||||
<form className="flex flex-wrap items-end gap-3">
|
||||
<div className="mr-auto min-w-64">
|
||||
<div className="flex items-center gap-2 text-base font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
<CalendarRange className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||
Zeitraum
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="weeks">Wochen</Label>
|
||||
<Input
|
||||
id="weeks"
|
||||
name="weeks"
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_WEEK_COUNT}
|
||||
defaultValue={weekCount}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Anzeigen</Button>
|
||||
<Button asChild type="button" variant="outline">
|
||||
<Link
|
||||
href={`/dashboard/admin/dienste?start=${toDateInputValue(
|
||||
rangeStart,
|
||||
)}&weeks=${Math.min(weekCount + 12, MAX_WEEK_COUNT)}`}
|
||||
>
|
||||
Weitere Wochen laden
|
||||
</Link>
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||
{weeks[0].label} bis {weeks[weeks.length - 1].label}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="start">Startwoche</Label>
|
||||
<Input
|
||||
id="start"
|
||||
name="start"
|
||||
type="date"
|
||||
defaultValue={toDateInputValue(rangeStart)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid w-28 gap-1.5">
|
||||
<Label htmlFor="weeks">Wochen</Label>
|
||||
<Input
|
||||
id="weeks"
|
||||
name="weeks"
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_WEEK_COUNT}
|
||||
defaultValue={weekCount}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Anzeigen</Button>
|
||||
<Button asChild type="button" variant="ghost">
|
||||
<Link
|
||||
href={`/dashboard/admin/dienste?start=${toDateInputValue(
|
||||
rangeStart,
|
||||
)}&weeks=${Math.min(weekCount + 12, MAX_WEEK_COUNT)}`}
|
||||
>
|
||||
Weitere Wochen laden
|
||||
</Link>
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[340px_1fr]">
|
||||
<Card>
|
||||
@@ -186,19 +198,19 @@ export default async function DienstePage({
|
||||
|
||||
<div className="space-y-2">
|
||||
{dutyTypes.length === 0 ? (
|
||||
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
||||
<p className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3 text-sm italic text-[var(--ink-faint)]">
|
||||
Noch keine Diensttypen angelegt.
|
||||
</p>
|
||||
) : (
|
||||
dutyTypes.map((dutyType) => (
|
||||
<div
|
||||
key={dutyType.id}
|
||||
className="flex items-start justify-between gap-3 rounded-md border p-3"
|
||||
className="flex items-start justify-between gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{dutyType.name}</p>
|
||||
<p className="font-medium text-[var(--ink)]">{dutyType.name}</p>
|
||||
{dutyType.description && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
<p className="mt-1 line-clamp-2 text-xs text-[var(--ink-soft)]">
|
||||
{dutyType.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -213,9 +225,10 @@ export default async function DienstePage({
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
aria-label={`${dutyType.name} löschen`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -85,29 +85,59 @@ export function AdminCalendarManager({
|
||||
termine: AdminTerminDto[];
|
||||
childRows: ChildDto[];
|
||||
}) {
|
||||
const rsvpCount = termine.filter((termin) => termin.requiresRsvp).length;
|
||||
const pendingCount = termine.filter(
|
||||
(termin) => termin.status === TerminStatus.PENDING,
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-6 p-6">
|
||||
<div className="flex flex-col justify-between gap-4 lg:flex-row lg:items-center">
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Event-Anmeldungen & Ausflüge
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||
ADMIN · EVENT-ANMELDUNGEN
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Termine{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
koordinieren
|
||||
</em>
|
||||
</h1>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind
|
||||
verfolgen.
|
||||
</p>
|
||||
</div>
|
||||
<TerminFormDialog mode="create" />
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
{termine.length} Termine
|
||||
</span>
|
||||
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
{rsvpCount} RSVP
|
||||
</span>
|
||||
{pendingCount > 0 && (
|
||||
<span className="inline-flex h-9 items-center rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-3 text-[13px] font-semibold text-[var(--warn)]">
|
||||
{pendingCount} ausstehend
|
||||
</span>
|
||||
)}
|
||||
<TerminFormDialog mode="create" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{termine.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-10 text-center">
|
||||
<CalendarPlus className="mx-auto h-10 w-10 text-muted-foreground" />
|
||||
<h2 className="mt-4 text-lg font-semibold">Noch keine Termine</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<Card className="border-dashed">
|
||||
<div className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<CalendarPlus className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<h2 className="mt-4 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
Noch keine Termine
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
|
||||
</p>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{termine.map((termin) => (
|
||||
@@ -134,7 +164,7 @@ function AdminTerminCard({
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b bg-muted/30">
|
||||
<CardHeader className="border-b border-[var(--hairline-soft)] bg-[var(--surface-2)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
@@ -149,8 +179,8 @@ function AdminTerminCard({
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-lg">{termin.title}</CardTitle>
|
||||
<CardDescription className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<CardDescription className="mt-1 flex flex-wrap items-center gap-2 text-[var(--ink-soft)]">
|
||||
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{format(new Date(termin.startDate), "dd.MM.yyyy", {
|
||||
locale: de,
|
||||
})}
|
||||
@@ -176,20 +206,20 @@ function AdminTerminCard({
|
||||
|
||||
<CardContent className="grid gap-5 pt-5">
|
||||
{termin.description && (
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
<p className="whitespace-pre-wrap text-sm text-[var(--ink-soft)]">
|
||||
{termin.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{termin.requiresRsvp ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex flex-col gap-3 rounded-md border bg-amber-50/70 p-4 text-amber-950 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-[var(--ink)] sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
|
||||
{stats.pending} Ausstehend
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-amber-900/80">
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Frist:{" "}
|
||||
{termin.rsvpDeadline
|
||||
? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", {
|
||||
@@ -198,11 +228,11 @@ function AdminTerminCard({
|
||||
: "bis Terminbeginn"}
|
||||
</p>
|
||||
</div>
|
||||
<Users className="h-5 w-5 shrink-0" />
|
||||
<Users className="h-5 w-5 shrink-0 text-[var(--warn)] [stroke-width:1.7]" />
|
||||
</div>
|
||||
|
||||
{termin.participantInfo && (
|
||||
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-4 text-sm text-yellow-950">
|
||||
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
|
||||
<span className="font-medium">Hinweis für Teilnehmer: </span>
|
||||
{termin.participantInfo}
|
||||
</div>
|
||||
@@ -233,7 +263,7 @@ function AdminTerminCard({
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||
<div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm italic text-[var(--ink-faint)]">
|
||||
Für diesen Termin ist keine Teilnahme-Abfrage aktiv.
|
||||
</div>
|
||||
)}
|
||||
@@ -304,11 +334,11 @@ function TerminFormDialog({
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={mode === "edit" ? "outline" : "default"} size="sm">
|
||||
<Button variant={mode === "edit" ? "pill" : "default"} size="sm">
|
||||
{mode === "edit" ? (
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
|
||||
) : (
|
||||
<CalendarPlus className="h-4 w-4" />
|
||||
<CalendarPlus className="h-4 w-4 [stroke-width:1.7]" />
|
||||
)}
|
||||
{mode === "edit" ? "Bearbeiten" : "Termin anlegen"}
|
||||
</Button>
|
||||
@@ -351,7 +381,7 @@ function TerminFormDialog({
|
||||
id={`${mode}-type`}
|
||||
name="type"
|
||||
defaultValue={termin?.type ?? TerminType.KITA_FEST}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
required
|
||||
>
|
||||
{Object.values(TerminType).map((type) => (
|
||||
@@ -389,13 +419,13 @@ function TerminFormDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-md border p-4">
|
||||
<div className="grid gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allDay"
|
||||
defaultChecked={termin?.allDay ?? false}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
|
||||
/>
|
||||
Ganztägiger Termin
|
||||
</label>
|
||||
@@ -404,7 +434,7 @@ function TerminFormDialog({
|
||||
type="checkbox"
|
||||
name="mitbringselListEnabled"
|
||||
defaultChecked={termin?.mitbringselListEnabled ?? false}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
|
||||
/>
|
||||
Mitbring-Liste aktiv
|
||||
</label>
|
||||
@@ -414,14 +444,14 @@ function TerminFormDialog({
|
||||
name="requiresRsvp"
|
||||
checked={requiresRsvp}
|
||||
onChange={(event) => setRequiresRsvp(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
|
||||
/>
|
||||
Teilnahme-Abfrage (RSVP) aktivieren
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{requiresRsvp && (
|
||||
<div className="grid gap-4 rounded-md border border-amber-200 bg-amber-50/60 p-4">
|
||||
<div className="grid gap-4 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${mode}-rsvpDeadline`}>
|
||||
Rückmeldefrist
|
||||
@@ -495,7 +525,7 @@ function RsvpStatusBadge({
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 text-muted-foreground">
|
||||
<Badge variant="outline" className="gap-1 text-[var(--ink-muted)]">
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
Ausstehend
|
||||
</Badge>
|
||||
@@ -566,19 +596,19 @@ function getTypeBadgeClass(type: TerminType) {
|
||||
switch (type) {
|
||||
case TerminType.KITA_FEST:
|
||||
case TerminType.MITMACH_TAG:
|
||||
return "border-transparent bg-amber-500 text-white";
|
||||
return "border-transparent bg-[var(--warn-soft)] text-[var(--warn)]";
|
||||
case TerminType.SCHLIESSTAG:
|
||||
case TerminType.TEAMTAG:
|
||||
return "border-transparent bg-rose-500 text-white";
|
||||
return "border-transparent bg-[var(--danger-soft)] text-[var(--danger)]";
|
||||
case TerminType.GEBURTSTAG_INTERN:
|
||||
case TerminType.GEBURTSTAG_EXTERN:
|
||||
return "border-transparent bg-blue-500 text-white";
|
||||
return "border-transparent bg-[oklch(0.94_0.04_280)] text-[oklch(0.48_0.08_280)]";
|
||||
case TerminType.ELTERNABEND:
|
||||
case TerminType.MITGLIEDERVERSAMMLUNG:
|
||||
case TerminType.ELTERNCAFE:
|
||||
return "border-transparent bg-purple-500 text-white";
|
||||
return "border-transparent bg-[var(--accent-soft)] text-[var(--accent-deep)]";
|
||||
default:
|
||||
return "border-slate-300 bg-slate-100 text-slate-800";
|
||||
return "border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,14 +67,14 @@ export function NewsForm() {
|
||||
placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
Markdown ist aktiv. HTML wird nicht interpretiert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{content.trim() && (
|
||||
<div className="rounded-md border bg-muted/30 p-4 text-sm">
|
||||
<p className="mb-3 text-xs font-semibold uppercase text-muted-foreground">
|
||||
<div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm">
|
||||
<p className="mb-3 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Vorschau
|
||||
</p>
|
||||
<MarkdownContent content={content} />
|
||||
@@ -83,7 +83,7 @@ export function NewsForm() {
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="attachments">Anhänge</Label>
|
||||
<div className="rounded-md border border-dashed bg-background p-4">
|
||||
<div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-4">
|
||||
<Input
|
||||
id="attachments"
|
||||
name="attachments"
|
||||
@@ -91,14 +91,14 @@ export function NewsForm() {
|
||||
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
|
||||
multiple
|
||||
/>
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<UploadCloud className="h-3.5 w-3.5" />
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-[var(--ink-muted)]">
|
||||
<UploadCloud className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
PDF, JPG und PNG bis 10 MB pro Datei.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="sendEmail"
|
||||
@@ -130,9 +130,9 @@ export function NewsForm() {
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
<Send className="h-4 w-4 [stroke-width:1.7]" />
|
||||
)}
|
||||
Veröffentlichen
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { FileText, Megaphone } from "lucide-react";
|
||||
import { FileText, Megaphone, Send } from "lucide-react";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
@@ -49,26 +49,37 @@ export default async function AdminNewsPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<Badge className="mb-3 bg-emerald-100 text-emerald-800 hover:bg-emerald-100">
|
||||
Offizielle Kita-Kommunikation
|
||||
</Badge>
|
||||
<h1 className="flex items-center gap-3 text-2xl font-semibold tracking-tight">
|
||||
<Megaphone className="h-6 w-6 text-emerald-700" />
|
||||
Digitales Schwarzes Brett
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
|
||||
werden geschützt gespeichert und nur für eingeloggte Nutzer derselben
|
||||
Kita ausgeliefert.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||
KOMMUNIKATION · SCHWARZES BRETT
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Meldungen{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
veröffentlichen
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
|
||||
werden geschützt gespeichert und nur für eingeloggte Nutzer
|
||||
derselben Kita ausgeliefert.
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
|
||||
<Megaphone className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||
{announcements.length} zuletzt
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Neue Ankündigung</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||
Neue Ankündigung
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Markdown wird sicher gerendert; PDF, JPG und PNG können als
|
||||
geschützte Anhänge hinzugefügt werden.
|
||||
@@ -88,27 +99,35 @@ export default async function AdminNewsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
{announcements.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||
Noch keine Ankündigungen veröffentlicht.
|
||||
</p>
|
||||
<div className="flex max-h-[280px] min-h-[220px] flex-col items-center justify-center rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Megaphone className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[var(--ink)]">
|
||||
Noch keine Ankündigungen
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Veröffentlichte Meldungen erscheinen hier.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
announcements.map((announcement) => (
|
||||
<div
|
||||
key={announcement.id}
|
||||
className="rounded-md border bg-background p-3"
|
||||
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<p className="font-medium leading-snug">
|
||||
<p className="font-medium leading-snug text-[var(--ink)]">
|
||||
{announcement.title}
|
||||
</p>
|
||||
{announcement.attachments.length > 0 && (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
<FileText className="h-3 w-3" />
|
||||
<FileText className="h-3 w-3 [stroke-width:1.7]" />
|
||||
{announcement.attachments.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
|
||||
locale: de,
|
||||
})}{" "}
|
||||
|
||||
Reference in New Issue
Block a user