336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useMemo, useTransition } from "react";
|
|
import { AbsenceReason } from "@prisma/client";
|
|
import { Baby, CalendarX2, Check, ChevronRight, Loader2 } from "lucide-react";
|
|
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
|
|
import { toast } from "sonner";
|
|
|
|
import { reportAbsence } from "@/actions/absences";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
type ChildOption = {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
|
|
type AbsenceListItem = {
|
|
id: string;
|
|
childName: string;
|
|
reason: AbsenceReason;
|
|
note: string | null;
|
|
startDate: string;
|
|
endDate: string;
|
|
};
|
|
|
|
export function AbsenceCard({
|
|
childOptions,
|
|
today,
|
|
}: {
|
|
childOptions: ChildOption[];
|
|
absences: AbsenceListItem[];
|
|
today: string;
|
|
}) {
|
|
const [isPending, startTransition] = useTransition();
|
|
const defaultChildId = childOptions[0]?.id;
|
|
const hasChildren = childOptions.length > 0;
|
|
|
|
function handleSubmit(formData: FormData) {
|
|
startTransition(async () => {
|
|
const result = await reportAbsence({
|
|
childId: String(formData.get("childId") ?? ""),
|
|
startDate: String(formData.get("startDate") ?? ""),
|
|
endDate: String(formData.get("endDate") ?? ""),
|
|
reason: String(formData.get("reason") ?? ""),
|
|
note: String(formData.get("note") ?? ""),
|
|
});
|
|
|
|
if (result.error) {
|
|
toast.error(result.error);
|
|
return;
|
|
}
|
|
|
|
toast.success("Abwesenheit gemeldet. Die Kita ist informiert.");
|
|
});
|
|
}
|
|
|
|
return (
|
|
<Card variant="featured" className="min-w-0">
|
|
<CardHeader className="pb-5">
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
|
<CalendarX2 className="h-4 w-4 [stroke-width:1.7]" />
|
|
</div>
|
|
<div>
|
|
<CardTitle>Kind abmelden</CardTitle>
|
|
<CardDescription className="mt-2 max-w-xl">
|
|
Melde dein Kind für ganze Tage ab. Die Kita sieht die Meldung
|
|
sofort in der Tagesübersicht — keine zusätzliche E-Mail nötig.
|
|
</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent>
|
|
{hasChildren ? (
|
|
<form action={handleSubmit}>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<FieldLabel label="Welches Kind?">
|
|
<Select name="childId" defaultValue={defaultChildId} required>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Kind auswählen" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{childOptions.map((child) => (
|
|
<SelectItem key={child.id} value={child.id}>
|
|
{child.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</FieldLabel>
|
|
|
|
<FieldLabel label="Grund">
|
|
<Select
|
|
name="reason"
|
|
defaultValue={AbsenceReason.ILLNESS}
|
|
required
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Grund auswählen" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={AbsenceReason.ILLNESS}>Krank</SelectItem>
|
|
<SelectItem value={AbsenceReason.VACATION}>
|
|
Urlaub
|
|
</SelectItem>
|
|
<SelectItem value={AbsenceReason.OTHER}>
|
|
Sonstiges
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</FieldLabel>
|
|
|
|
<FieldLabel label="Von" htmlFor="absence-startDate">
|
|
<Input
|
|
id="absence-startDate"
|
|
name="startDate"
|
|
type="date"
|
|
defaultValue={today}
|
|
required
|
|
/>
|
|
</FieldLabel>
|
|
|
|
<FieldLabel label="Bis" htmlFor="absence-endDate">
|
|
<Input
|
|
id="absence-endDate"
|
|
name="endDate"
|
|
type="date"
|
|
defaultValue={today}
|
|
required
|
|
/>
|
|
</FieldLabel>
|
|
|
|
<div className="grid gap-2 md:col-span-2">
|
|
<Label
|
|
htmlFor="absence-note"
|
|
className="text-xs font-semibold text-[var(--ink-soft)]"
|
|
>
|
|
Bemerkung{" "}
|
|
<span className="font-medium text-[var(--ink-faint)]">
|
|
optional
|
|
</span>
|
|
</Label>
|
|
<Textarea
|
|
id="absence-note"
|
|
name="note"
|
|
rows={4}
|
|
placeholder="z. B. voraussichtlich wieder da am Freitag."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-7 flex items-center justify-between border-t border-[var(--hairline-soft)] pt-5">
|
|
<Button type="reset" variant="ghost">
|
|
Zurücksetzen
|
|
</Button>
|
|
<Button type="submit" disabled={isPending}>
|
|
{isPending ? <Loader2 className="animate-spin" /> : <Check />}
|
|
Abwesenheit melden
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<div className="flex min-h-72 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-center">
|
|
<Baby className="h-10 w-10 text-[var(--ink-muted)]" />
|
|
<p className="mt-4 font-medium text-[var(--ink)]">
|
|
Noch keine Kinder hinterlegt
|
|
</p>
|
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
|
Lege zuerst deine Kinder im Profil an, dann kannst du sie hier
|
|
abmelden.
|
|
</p>
|
|
<Button asChild className="mt-4" variant="ghost">
|
|
<Link href="/dashboard/profil">Zum Profil</Link>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function UpcomingAbsencesCard({
|
|
absences,
|
|
}: {
|
|
absences: AbsenceListItem[];
|
|
}) {
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<CardTitle>Anstehende Meldungen</CardTitle>
|
|
<CardDescription className="mt-2">
|
|
Aktuelle und kommende Abwesenheiten deiner Kinder.
|
|
</CardDescription>
|
|
</div>
|
|
<Badge variant="secondary">{absences.length}</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{absences.length === 0 ? (
|
|
<div className="flex min-h-36 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
|
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--good-soft)] text-[var(--good)]">
|
|
<Check className="h-4 w-4" />
|
|
</div>
|
|
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
|
|
Keine offenen Meldungen
|
|
</p>
|
|
<p className="mt-1 text-xs text-[var(--ink-soft)]">
|
|
Für die nächsten Tage ist alles ruhig.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-[var(--hairline-soft)]">
|
|
{absences.map((absence) => (
|
|
<AbsenceRow key={absence.id} absence={absence} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<Link
|
|
href="/dashboard/admin/abwesenheiten"
|
|
className="mt-4 inline-flex items-center gap-1 text-[13px] font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
|
>
|
|
Alle Abwesenheiten
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
</Link>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function FieldLabel({
|
|
label,
|
|
htmlFor,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
htmlFor?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="grid gap-2">
|
|
<Label
|
|
htmlFor={htmlFor}
|
|
className="text-xs font-semibold text-[var(--ink-soft)]"
|
|
>
|
|
{label}
|
|
</Label>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AbsenceRow({ absence }: { absence: AbsenceListItem }) {
|
|
const { day, month, detail } = useMemo(() => {
|
|
const start = new Date(`${absence.startDate}T00:00:00`);
|
|
const end = new Date(`${absence.endDate}T00:00:00`);
|
|
const range =
|
|
absence.startDate === absence.endDate
|
|
? "ganztägig"
|
|
: `${start.toLocaleDateString("de-DE", {
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
})}-${end.toLocaleDateString("de-DE", {
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
})}`;
|
|
|
|
return {
|
|
day: start.toLocaleDateString("de-DE", { day: "2-digit" }),
|
|
month: start
|
|
.toLocaleDateString("de-DE", { month: "short" })
|
|
.replace(".", ""),
|
|
detail: range,
|
|
};
|
|
}, [absence.endDate, absence.startDate]);
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 py-3">
|
|
<div className="flex h-11 w-11 shrink-0 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] leading-none">
|
|
<span className="text-base font-medium text-[var(--ink)] [font-variant-numeric:tabular-nums]">
|
|
{day}
|
|
</span>
|
|
<span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)]">
|
|
{month}
|
|
</span>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-semibold text-[var(--ink)]">
|
|
{absence.childName}
|
|
</p>
|
|
<p className="mt-1 flex items-center gap-1.5 text-xs text-[var(--ink-soft)]">
|
|
<span
|
|
className="h-1.5 w-1.5 rounded-full"
|
|
style={{ backgroundColor: reasonColor(absence.reason) }}
|
|
/>
|
|
{reasonLabel(absence.reason)} · {detail}
|
|
</p>
|
|
</div>
|
|
<Badge variant="destructive">Bestätigt</Badge>
|
|
</div>
|
|
{absence.note ? (
|
|
<p className="mt-1 truncate text-xs text-[var(--ink-muted)]">
|
|
{absence.note}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|