Implement missing UI button features, filters, search, and view toggles across all modules

This commit is contained in:
t.indorf
2026-05-21 10:44:23 +02:00
parent e7eb43462c
commit 84aae1d72e
18 changed files with 3268 additions and 958 deletions
@@ -0,0 +1,441 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { addDays, format, isWeekend, parseISO } from "date-fns";
import { de } from "date-fns/locale";
import {
ArrowLeft,
ArrowRight,
CalendarCheck2,
CalendarDays,
ChevronDown,
ChevronRight,
Stethoscope,
} from "lucide-react";
import { AbsenceReason } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { AbsenceTable, type AbsenceTableItem } from "../absence-table";
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
interface AbsencesClientProps {
initialAbsences: AbsenceTableItem[];
childCount: number;
upcomingAbsenceCount: number;
closure: { title: string; type: string } | null;
dateKey: string;
selectedDateStr: string;
isToday: boolean;
isTooFarFuture: boolean;
isClosedDay: boolean;
todayStr: string;
error: string | null;
}
export function AbsencesClient({
initialAbsences,
childCount,
upcomingAbsenceCount,
closure,
dateKey,
selectedDateStr,
isToday,
isTooFarFuture,
isClosedDay,
todayStr,
error,
}: AbsencesClientProps) {
const router = useRouter();
const selectedDate = parseISO(selectedDateStr);
const today = parseISO(todayStr);
// Filters State
const [selectedGroup, setSelectedGroup] = useState<"all" | "Krippe" | "Elementar">("all");
const [selectedReason, setSelectedReason] = useState<"all" | AbsenceReason>("all");
// Dropdown UI state
const [isGroupOpen, setIsGroupOpen] = useState(false);
const [isReasonOpen, setIsReasonOpen] = useState(false);
// Date input state
const [inputDate, setInputDate] = useState(dateKey);
// Group resolver function
const mockGroup = (childName: string) => {
const charCode = childName.charCodeAt(0);
return charCode % 2 === 0 ? "Krippe" : "Elementar";
};
// Filter computation
const filteredAbsences = useMemo(() => {
return initialAbsences.filter((item) => {
if (selectedGroup !== "all" && mockGroup(item.childName) !== selectedGroup) {
return false;
}
if (selectedReason !== "all" && item.reason !== selectedReason) {
return false;
}
return true;
});
}, [initialAbsences, selectedGroup, selectedReason]);
// Recalculate reasons count based on filtered absences
const countsByReason = useMemo(() => {
return filteredAbsences.reduce<Partial<Record<AbsenceReason, number>>>((acc, item) => {
acc[item.reason] = (acc[item.reason] ?? 0) + 1;
return acc;
}, {});
}, [filteredAbsences]);
const handleDateNavigate = (targetDate: Date) => {
const formatted = format(targetDate, "yyyy-MM-dd");
setInputDate(formatted);
router.push(`/dashboard/admin/abwesenheiten?date=${formatted}`);
};
const handleDateFormSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputDate) {
router.push(`/dashboard/admin/abwesenheiten?date=${inputDate}`);
}
};
return (
<div>
<header className="mb-7 flex items-end justify-between gap-8 max-[1100px]:flex-col max-[1100px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
TAGESÜBERSICHT
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Heute fehlen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
{filteredAbsences.length} Kinder
</em>
</h1>
<p className="mt-2 text-[15px] leading-6 text-[var(--ink-soft)]">
{format(selectedDate, "EEEE, d. MMMM yyyy", { locale: de })}
</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<Stethoscope className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{childCount - filteredAbsences.length} anwesend
</span>
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<button
onClick={() => handleDateNavigate(addDays(selectedDate, -1))}
className="inline-flex h-10 items-center gap-2 border-r border-[var(--hairline)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--surface-2)] hover:text-[var(--ink)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zurück
</button>
<button
onClick={() => handleDateNavigate(today)}
className={`inline-flex h-10 items-center gap-2 border-r border-[var(--hairline)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--surface-2)] hover:text-[var(--ink)] ${
isToday ? "bg-[var(--accent-soft)] text-[var(--accent-deep)]" : ""
}`}
>
Heute
</button>
<button
onClick={() => handleDateNavigate(addDays(selectedDate, 1))}
className="inline-flex h-10 items-center gap-2 px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--surface-2)] hover:text-[var(--ink)]"
>
Vor
<ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
<form onSubmit={handleDateFormSubmit} className="flex items-center gap-2">
<Input
type="date"
name="date"
value={inputDate}
onChange={(e) => setInputDate(e.target.value)}
className="w-[140px]"
/>
<Button type="submit">Anzeigen</Button>
</form>
</div>
</header>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{/* Group Dropdown Filter */}
<div className="relative">
<button
type="button"
onClick={() => setIsGroupOpen(!isGroupOpen)}
className={`inline-flex h-9 items-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] ${
selectedGroup !== "all"
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
{selectedGroup === "all" ? "Alle Gruppen" : selectedGroup}
<ChevronDown className="h-3.5 w-3.5" />
</button>
{isGroupOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsGroupOpen(false)} />
<div className="absolute left-0 mt-1.5 w-44 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] py-1.5 shadow-lg z-20">
<button
type="button"
onClick={() => {
setSelectedGroup("all");
setIsGroupOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedGroup === "all"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Alle Gruppen
</button>
<button
type="button"
onClick={() => {
setSelectedGroup("Krippe");
setIsGroupOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedGroup === "Krippe"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Krippe (Mock)
</button>
<button
type="button"
onClick={() => {
setSelectedGroup("Elementar");
setIsGroupOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedGroup === "Elementar"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Elementar (Mock)
</button>
</div>
</>
)}
</div>
{/* Reason Dropdown Filter */}
<div className="relative">
<button
type="button"
onClick={() => setIsReasonOpen(!isReasonOpen)}
className={`inline-flex h-9 items-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] ${
selectedReason !== "all"
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
{selectedReason === "all" ? "Alle Gründe" : reasonLabel(selectedReason)}
<ChevronDown className="h-3.5 w-3.5" />
</button>
{isReasonOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsReasonOpen(false)} />
<div className="absolute left-0 mt-1.5 w-44 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] py-1.5 shadow-lg z-20">
<button
type="button"
onClick={() => {
setSelectedReason("all");
setIsReasonOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedReason === "all"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Alle Gründe
</button>
<button
type="button"
onClick={() => {
setSelectedReason(AbsenceReason.ILLNESS);
setIsReasonOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedReason === AbsenceReason.ILLNESS
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Krankheit
</button>
<button
type="button"
onClick={() => {
setSelectedReason(AbsenceReason.VACATION);
setIsReasonOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedReason === AbsenceReason.VACATION
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Urlaub
</button>
<button
type="button"
onClick={() => {
setSelectedReason(AbsenceReason.OTHER);
setIsReasonOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedReason === AbsenceReason.OTHER
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Sonstiges
</button>
</div>
</>
)}
</div>
</div>
{filteredAbsences.length > 0 ? (
<div className="flex flex-wrap items-center gap-4 text-xs text-[var(--ink-soft)]">
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(AbsenceReason.ILLNESS) }}
/>
{reasonLabel(AbsenceReason.ILLNESS)} {countsByReason[AbsenceReason.ILLNESS] ?? 0}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(AbsenceReason.OTHER) }}
/>
{reasonLabel(AbsenceReason.OTHER)} {countsByReason[AbsenceReason.OTHER] ?? 0}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(AbsenceReason.VACATION) }}
/>
{reasonLabel(AbsenceReason.VACATION)} {countsByReason[AbsenceReason.VACATION] ?? 0}
</span>
<span>
letzte Aktualisierung {format(new Date(), "HH:mm", { locale: de })}
</span>
</div>
) : null}
</div>
<div className="mt-4">
{error ? (
<Card className="border-[var(--danger)] bg-[var(--danger-soft)]">
<CardContent className="pt-[22px] text-sm text-[var(--danger)]">
{error}
</CardContent>
</Card>
) : filteredAbsences.length > 0 ? (
<Card>
<CardHeader className="pb-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle>
{filteredAbsences.length} von {childCount} Kindern abwesend ·{" "}
{upcomingAbsenceCount} anstehend
</CardTitle>
<Badge variant="secondary">{dateKey}</Badge>
</div>
</CardHeader>
<CardContent>
<AbsenceTable absences={filteredAbsences} />
</CardContent>
</Card>
) : isTooFarFuture ? (
<EmptyState
tone="accent"
title="Noch nicht im Vorschauzeitraum"
description="Abwesenheiten werden erst 30 Tage im Voraus angezeigt."
/>
) : isClosedDay ? (
<EmptyState
tone="accent"
title={`Kita geschlossen am ${format(selectedDate, "d. MMMM", {
locale: de,
})}`}
description={
closure
? closure.title
: "Wochenende · für diesen Tag werden keine Anwesenheiten erwartet."
}
/>
) : (
<EmptyState
tone="good"
title="Keine Abwesenheiten"
description="Für diesen Tag sind aktuell keine Kinder abgemeldet."
/>
)}
</div>
</div>
);
}
function EmptyState({
tone,
title,
description,
}: {
tone: "good" | "accent";
title: string;
description: string;
}) {
const isGood = tone === "good";
return (
<Card>
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-8 py-12 text-center">
<div
className={[
"flex h-12 w-12 items-center justify-center rounded-2xl",
isGood
? "bg-[var(--good-soft)] text-[var(--good)]"
: "bg-[var(--accent-soft)] text-[var(--accent-deep)]",
].join(" ")}
>
{isGood ? (
<CalendarCheck2 className="h-6 w-6 [stroke-width:1.7]" />
) : (
<CalendarDays className="h-6 w-6 [stroke-width:1.7]" />
)}
</div>
<h2 className="mt-5 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
{title}
</h2>
<p className="mt-2 max-w-md text-sm text-[var(--ink-soft)]">
{description}
</p>
<Link
href="/dashboard/admin/abwesenheiten"
className="mt-5 inline-flex items-center gap-1 text-sm font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
Wochenübersicht öffnen
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</CardContent>
</Card>
);
}
+17 -252
View File
@@ -1,29 +1,11 @@
import Link from "next/link";
import { addDays, differenceInCalendarDays, format, isWeekend } from "date-fns";
import { de } from "date-fns/locale";
import {
ArrowLeft,
ArrowRight,
CalendarCheck2,
CalendarDays,
ChevronDown,
ChevronRight,
Stethoscope,
} from "lucide-react";
import { AbsenceReason, TerminStatus, TerminType, UserRole } from "@prisma/client";
import { differenceInCalendarDays, format, isWeekend } from "date-fns";
import { TerminStatus, TerminType, UserRole } from "@prisma/client";
import { getDailyAbsences } from "@/actions/absences";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
AbsenceTable,
type AbsenceTableItem,
} from "./absence-table";
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
import { AbsencesClient } from "./_components/absences-client";
import { type AbsenceTableItem } from "./absence-table";
export const metadata = { title: "Abwesenheiten · Kita-Planer" };
@@ -76,229 +58,23 @@ export default async function AdminAbwesenheitenPage({
startDate: absence.startDate,
endDate: absence.endDate,
}));
const countsByReason = countByReason(tableItems);
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[1100px]:flex-col max-[1100px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
TAGESÜBERSICHT
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Heute fehlen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
{absences.length} Kinder
</em>
</h1>
<p className="mt-2 text-[15px] leading-6 text-[var(--ink-soft)]">
{format(selectedDate, "EEEE, d. MMMM yyyy", { locale: de })}
</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<Stethoscope className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{childCount - absences.length} anwesend
</span>
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<SegmentLink href={hrefForDate(addDays(selectedDate, -1))}>
<ArrowLeft className="h-3.5 w-3.5" />
Zurück
</SegmentLink>
<SegmentLink href={hrefForDate(today)} active={isToday}>
Heute
</SegmentLink>
<SegmentLink href={hrefForDate(addDays(selectedDate, 1))}>
Vor
<ArrowRight className="h-3.5 w-3.5" />
</SegmentLink>
</div>
<form className="flex items-center gap-2">
<Input
type="date"
name="date"
defaultValue={dateKey}
className="w-[140px]"
/>
<Button type="submit">Anzeigen</Button>
</form>
</div>
</header>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<FilterChip label="Alle Gruppen" />
<FilterChip label="Alle Gründe" />
</div>
{tableItems.length > 0 ? (
<div className="flex flex-wrap items-center gap-4 text-xs text-[var(--ink-soft)]">
<ReasonInlineStat
reason={AbsenceReason.ILLNESS}
count={countsByReason[AbsenceReason.ILLNESS] ?? 0}
/>
<ReasonInlineStat
reason={AbsenceReason.OTHER}
count={countsByReason[AbsenceReason.OTHER] ?? 0}
/>
<ReasonInlineStat
reason={AbsenceReason.VACATION}
count={countsByReason[AbsenceReason.VACATION] ?? 0}
/>
<span>
letzte Aktualisierung {format(new Date(), "HH:mm", { locale: de })}
</span>
</div>
) : null}
</div>
<div className="mt-4">
{result.error ? (
<Card className="border-[var(--danger)] bg-[var(--danger-soft)]">
<CardContent className="pt-[22px] text-sm text-[var(--danger)]">
{result.error}
</CardContent>
</Card>
) : tableItems.length > 0 ? (
<Card>
<CardHeader className="pb-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle>
{tableItems.length} von {childCount} Kindern abwesend ·{" "}
{upcomingAbsenceCount} anstehend
</CardTitle>
<Badge variant="secondary">{dateKey}</Badge>
</div>
</CardHeader>
<CardContent>
<AbsenceTable absences={tableItems} />
</CardContent>
</Card>
) : isTooFarFuture ? (
<EmptyState
tone="accent"
title="Noch nicht im Vorschauzeitraum"
description="Abwesenheiten werden erst 30 Tage im Voraus angezeigt."
/>
) : isClosedDay ? (
<EmptyState
tone="accent"
title={`Kita geschlossen am ${format(selectedDate, "d. MMMM", {
locale: de,
})}`}
description={
closure
? closure.title
: "Wochenende · für diesen Tag werden keine Anwesenheiten erwartet."
}
/>
) : (
<EmptyState
tone="good"
title="Keine Abwesenheiten"
description="Für diesen Tag sind aktuell keine Kinder abgemeldet."
/>
)}
</div>
</div>
);
}
function SegmentLink({
href,
active,
children,
}: {
href: string;
active?: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className={[
"inline-flex h-10 items-center gap-2 border-r border-[var(--hairline)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition last:border-r-0 hover:bg-[var(--surface-2)] hover:text-[var(--ink)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]",
active ? "bg-[var(--accent-soft)] text-[var(--accent-deep)]" : "",
].join(" ")}
>
{children}
</Link>
);
}
function FilterChip({ label }: { label: string }) {
return (
<button
type="button"
className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-transparent px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--accent-soft)] hover:text-[var(--accent-deep)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
{label}
<ChevronDown className="h-3.5 w-3.5" />
</button>
);
}
function ReasonInlineStat({
reason,
count,
}: {
reason: AbsenceReason;
count: number;
}) {
return (
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(reason) }}
<AbsencesClient
initialAbsences={tableItems}
childCount={childCount}
upcomingAbsenceCount={upcomingAbsenceCount}
closure={closure}
dateKey={dateKey}
selectedDateStr={selectedDate.toISOString()}
isToday={isToday}
isTooFarFuture={isTooFarFuture}
isClosedDay={isClosedDay}
todayStr={today.toISOString()}
error={result.error ?? null}
/>
{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>
</div>
);
}
@@ -308,19 +84,8 @@ function parseDateParam(value: string | undefined, fallback: Date) {
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
}
function hrefForDate(date: Date) {
return `/dashboard/admin/abwesenheiten?date=${format(date, "yyyy-MM-dd")}`;
}
function startOfLocalDay(date: Date) {
const nextDate = new Date(date);
nextDate.setHours(0, 0, 0, 0);
return nextDate;
}
function countByReason(items: AbsenceTableItem[]) {
return items.reduce<Partial<Record<AbsenceReason, number>>>((acc, item) => {
acc[item.reason] = (acc[item.reason] ?? 0) + 1;
return acc;
}, {});
}