442 lines
17 KiB
TypeScript
442 lines
17 KiB
TypeScript
"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>
|
|
);
|
|
}
|