Apply linen design system across app
This commit is contained in:
@@ -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;
|
||||
}, {});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user