Apply linen design system across app

This commit is contained in:
t.indorf
2026-05-20 22:47:52 +02:00
parent 99ff978116
commit 1c5d2f10ac
56 changed files with 4698 additions and 2159 deletions
+194 -366
View File
@@ -1,83 +1,83 @@
import Link from "next/link";
import type { ComponentType } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle, ClipboardList } from "lucide-react";
import { DutyAssignmentStatus, UserRole, NotdienstPlanStatus, TerminStatus } from "@prisma/client";
import {
CalendarCheck2,
CalendarClock,
ChevronRight,
Clock3,
ListTodo,
Users,
type LucideProps,
} from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils";
import { getTargetMonthData } from "@/lib/date-utils";
import { prisma } from "@/lib/prisma";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Stat } from "@/components/ui/stat";
import { OnboardingDialog } from "@/components/OnboardingDialog";
import { AlertButton } from "./alert-button";
import { AbsenceCard } from "./absence-card";
import { NewsTicker } from "./news-ticker";
import { RsvpActionCard } from "./rsvp-action-card";
import { AbsenceCard, UpcomingAbsencesCard } from "./absence-card";
export const metadata = { title: "Übersicht · Kita-Planer" };
export default async function DashboardPage() {
const session = await requireKitaSession();
const now = new Date();
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const targetData = getTargetMonthData();
const targetMonthStart = new Date(
Date.UTC(targetData.targetYear, targetData.targetMonth - 1, 1),
);
const targetMonthEnd = new Date(
Date.UTC(targetData.targetYear, targetData.targetMonth, 1),
);
const kita = await prisma.kita.findUniqueOrThrow({
where: { id: session.user.kitaId },
select: {
name: true,
notdienstModuleEnabled: true,
notdienstMinPerChildPerMonth: true,
terminModuleEnabled: true,
adressbuchModuleEnabled: true,
},
});
const today = new Date();
today.setHours(0, 0, 0, 0);
// Schnelle Statistiken und persönliche Dienste für die Übersicht.
const [
familyCount,
childCount,
upcomingDuties,
todayAbsenceCount,
notdienstAvailabilityCount,
onboardingUser,
absenceChildren,
upcomingAbsences,
latestAnnouncements,
] = await Promise.all([
prisma.family.count({
where: { kitaId: session.user.kitaId },
}),
prisma.child.count({
where: { kitaId: session.user.kitaId },
where: { kitaId: session.user.kitaId, active: true },
}),
prisma.absence.count({
where: {
kitaId: session.user.kitaId,
startDate: { lte: today },
endDate: { gte: today },
},
}),
prisma.notdienstAvailability.count({
where: {
kitaId: session.user.kitaId,
date: {
gte: targetMonthStart,
lt: targetMonthEnd,
},
},
}),
session.user.familyId
? prisma.dutyAssignment.findMany({
where: {
kitaId: session.user.kitaId,
familyId: session.user.familyId,
status: DutyAssignmentStatus.PLANNED,
endDate: { gte: today },
},
select: {
id: true,
startDate: true,
endDate: true,
dutyType: {
select: {
name: true,
},
},
},
orderBy: { startDate: "asc" },
take: 5,
})
: Promise.resolve([]),
prisma.user.findFirstOrThrow({
where: {
id: session.user.id,
@@ -142,101 +142,25 @@ export default async function DashboardPage() {
take: 6,
})
: Promise.resolve([]),
prisma.announcement.findMany({
where: { kitaId: session.user.kitaId },
select: {
id: true,
title: true,
content: true,
createdAt: true,
author: {
select: {
firstName: true,
lastName: true,
},
},
attachments: {
select: {
id: true,
fileName: true,
fileUrl: true,
fileType: true,
},
},
reads: {
where: { userId: session.user.id },
select: { id: true },
take: 1,
},
},
orderBy: { createdAt: "desc" },
take: 3,
}),
]);
const familyChildIds = absenceChildren.map((child) => child.id);
const openRsvpTermine =
session.user.familyId && familyChildIds.length > 0
? await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.CONFIRMED,
requiresRsvp: true,
OR: [
{ rsvpDeadline: { gt: now } },
{ rsvpDeadline: null, startDate: { gt: now } },
],
},
select: {
id: true,
title: true,
startDate: true,
rsvpDeadline: true,
participations: {
where: {
childId: { in: familyChildIds },
},
select: {
childId: true,
status: true,
},
},
},
orderBy: { startDate: "asc" },
take: 5,
})
: [];
const rsvpActionEvents = openRsvpTermine
.map((termin) => {
const statusByChildId = new Map(
termin.participations.map((participation) => [
participation.childId,
participation.status,
]),
);
return {
id: termin.id,
title: termin.title,
startDate: termin.startDate.toISOString(),
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
children: absenceChildren.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
status: statusByChildId.get(child.id) ?? null,
})),
};
})
.filter((termin) => termin.children.some((child) => !child.status));
const isAdmin =
session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.SUPERADMIN;
const canReportAbsences = session.user.role !== UserRole.ERZIEHER;
const requiredNotdienstDays = Math.max(
childCount * kita.notdienstMinPerChildPerMonth,
1,
);
const openNotdienstDays = Math.max(
requiredNotdienstDays - notdienstAvailabilityCount,
0,
);
const todayPresentCount = Math.max(childCount - todayAbsenceCount, 0);
const firstName =
session.user.name?.split(" ")[0] ??
session.user.email?.split("@")[0] ??
"zusammen";
return (
<div className="px-8 py-8">
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<OnboardingDialog
user={{
phone: onboardingUser.phone,
@@ -253,249 +177,153 @@ export default async function DashboardPage() {
}
/>
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">
Willkommen, {session.user.name?.split(" ")[0] ?? "zusammen"}!
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Hier ist die Übersicht für <strong>{kita.name}</strong>.
</p>
</div>
<RsvpActionCard events={rsvpActionEvents} />
<NewsTicker
items={latestAnnouncements.map((announcement) => ({
id: announcement.id,
title: announcement.title,
content: announcement.content,
createdAt: format(announcement.createdAt, "dd.MM.yyyy", {
locale: de,
}),
authorName: `${announcement.author.firstName} ${announcement.author.lastName}`,
isUnread: announcement.reads.length === 0,
attachments: announcement.attachments.map((attachment) => ({
id: attachment.id,
fileName: attachment.fileName,
fileUrl: attachment.fileUrl,
fileType: attachment.fileType,
})),
}))}
/>
{/* Heutiger Notdienst (Nur für Admins/Koordinatoren) */}
{isAdmin && (
<div className="mb-8">
<TodaysNotdienstCard kitaId={session.user.kitaId!} />
<header className="mb-9 flex items-start justify-between gap-8">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
ÜBERSICHT · {kita.name}
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Willkommen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
zurück
</em>
, {firstName}.
</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Heute, {format(now, "EEEE, d. MMMM yyyy", { locale: de })}. Hier
siehst du auf einen Blick, was bei {kita.name} ansteht und kannst
dein Kind schnell für Abwesenheiten melden.
</p>
</div>
)}
<div className="mt-9 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 py-2 text-[13px] font-semibold text-[var(--ink-soft)]">
<Clock3 className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(now, "EEE, d. MMM yyyy", { locale: de })}
</div>
</header>
{canReportAbsences && (
<AbsenceCard
today={format(today, "yyyy-MM-dd")}
childOptions={absenceChildren.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
}))}
absences={upcomingAbsences.map((absence) => ({
id: absence.id,
childName: `${absence.child.firstName} ${absence.child.lastName}`,
reason: absence.reason,
note: absence.note,
startDate: format(absence.startDate, "yyyy-MM-dd"),
endDate: format(absence.endDate, "yyyy-MM-dd"),
}))}
/>
)}
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
<div className="min-w-0">
{canReportAbsences ? (
<AbsenceCard
today={format(today, "yyyy-MM-dd")}
childOptions={absenceChildren.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
}))}
absences={upcomingAbsences.map((absence) => ({
id: absence.id,
childName: `${absence.child.firstName} ${absence.child.lastName}`,
reason: absence.reason,
note: absence.note,
startDate: format(absence.startDate, "yyyy-MM-dd"),
endDate: format(absence.endDate, "yyyy-MM-dd"),
}))}
/>
) : null}
</div>
{upcomingDuties.length > 0 && (
<Card className="mb-8 border-emerald-200 bg-emerald-50/50">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ClipboardList className="h-5 w-5 text-emerald-700" />
Deine anstehenden Dienste
</CardTitle>
<CardDescription>
Die nächsten Einteilungen für deinen Haushalt.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{upcomingDuties.map((duty) => (
<div key={duty.id} className="rounded-md border bg-background p-3">
<p className="font-medium">{duty.dutyType.name}</p>
<p className="mt-1 text-sm text-muted-foreground">
{format(duty.startDate, "dd.MM.", { locale: de })} bis{" "}
{format(duty.endDate, "dd.MM.yyyy", { locale: de })}
</p>
</div>
))}
</CardContent>
</Card>
)}
{/* Statistik-Kacheln */}
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
label="Familien"
value={familyCount}
description="registrierte Familien"
icon={<Users className="h-5 w-5 text-muted-foreground" />}
/>
<StatCard
label="Kinder"
value={childCount}
description="in der Einrichtung"
icon={<ShieldCheck className="h-5 w-5 text-muted-foreground" />}
/>
<aside>
<UpcomingAbsencesCard
absences={upcomingAbsences.map((absence) => ({
id: absence.id,
childName: `${absence.child.firstName} ${absence.child.lastName}`,
reason: absence.reason,
note: absence.note,
startDate: format(absence.startDate, "yyyy-MM-dd"),
endDate: format(absence.endDate, "yyyy-MM-dd"),
}))}
/>
</aside>
</div>
{/* Schnell-Aktionen für Admins */}
{isAdmin && (
<Card>
<CardHeader>
<CardTitle className="text-base">Schnellstart</CardTitle>
<CardDescription>
Die nächsten Schritte, um deine Kita einzurichten.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3">
<Button asChild size="sm">
<Link href="/dashboard/families">
<Users className="mr-2 h-4 w-4" />
Familien verwalten
</Link>
</Button>
{kita.terminModuleEnabled && (
<Button asChild size="sm" variant="outline">
<Link href="/dashboard/kalender">
<CalendarCheck2 className="mr-2 h-4 w-4" />
Terminkalender
</Link>
</Button>
)}
</CardContent>
</Card>
)}
<section className="mt-5 grid grid-cols-4 gap-3.5 max-[1100px]:grid-cols-2 max-[640px]:grid-cols-1">
<Stat label="Familien" value={familyCount} hint="registrierte Familien" />
<Stat label="Kinder" value={childCount} hint="in der Einrichtung" />
<Stat
label={`Notdienst ${targetData.monthName}`}
value={notdienstAvailabilityCount}
suffix={`/ ${requiredNotdienstDays}`}
hint={`Tage abgedeckt · ${openNotdienstDays} offen`}
/>
<Stat
label="Heute anwesend"
value={todayPresentCount}
hint="Kinder in der Kita"
/>
</section>
<section className="mt-7">
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Schnellstart
</h2>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Häufige Aktionen für Vorstand und Koordination.
</p>
<div className="mt-4 grid grid-cols-3 gap-3 max-[1100px]:grid-cols-1">
<QuickCard
href="/dashboard/families"
icon={Users}
title="Familien verwalten"
sub="Familien anlegen, Kinder zuordnen, Kontakte pflegen."
/>
{kita.terminModuleEnabled ? (
<QuickCard
href="/dashboard/kalender"
icon={CalendarClock}
title="Terminkalender"
sub="Schließtage, Elternabende und Feste planen."
/>
) : null}
{kita.notdienstModuleEnabled ? (
<QuickCard
href="/dashboard/notdienst/plan"
icon={ListTodo}
title="Notdienst-Planung"
sub="Verfügbarkeiten überblicken und Dienste zuweisen."
/>
) : null}
{kita.adressbuchModuleEnabled ? (
<QuickCard
href="/dashboard/adressbuch"
icon={CalendarCheck2}
title="Adressbuch"
sub="Kontakte der Kita-Gemeinschaft schnell finden."
/>
) : null}
</div>
</section>
</div>
);
}
function StatCard({
label,
value,
description,
icon,
function QuickCard({
href,
icon: Icon,
title,
sub,
}: {
label: string;
value: number;
description: string;
icon: React.ReactNode;
href: string;
icon: ComponentType<LucideProps>;
title: string;
sub: string;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{label}
</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{value}</div>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
const today = new Date();
today.setHours(0, 0, 0, 0);
// Wir suchen das Assignment für heute, aber nur wenn der Plan PUBLISHED ist
const assignment = await prisma.notdienstAssignment.findFirst({
where: {
kitaId,
date: today,
plan: { status: NotdienstPlanStatus.PUBLISHED },
},
select: {
id: true,
child: {
select: {
firstName: true,
lastName: true,
family: {
select: {
name: true,
users: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
},
alerts: {
select: {
status: true,
},
},
},
});
if (!assignment) return null;
const parentNames = assignment.child.family.users
.map((parent) => `${parent.firstName} ${parent.lastName}`)
.join(", ");
const existingAlert = assignment.alerts[0]; // Kann PENDING, CONFIRMED oder CANCELLED sein
return (
<Card className="border-primary/20 bg-primary/5">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-primary" />
Heutiger Notdienst
</CardTitle>
<CardDescription>
Laut dem veröffentlichten Plan ist heute folgende Familie eingeteilt:
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium text-base">
{parentNames || assignment.child.family.name}
</p>
<p className="text-sm text-muted-foreground">
Kind: {assignment.child.firstName} {assignment.child.lastName}
</p>
</div>
<div className="flex items-center gap-3">
{existingAlert ? (
<Badge
variant={
existingAlert.status === "CONFIRMED"
? "success"
: existingAlert.status === "PENDING"
? "warning"
: "destructive"
}
className="px-3 py-1 text-sm"
>
{existingAlert.status === "CONFIRMED"
? "Einsatz bestätigt"
: existingAlert.status === "PENDING"
? "Wartet auf Bestätigung"
: "Abgebrochen"}
</Badge>
) : (
<AlertButton assignmentId={assignment.id} />
)}
</div>
</CardContent>
</Card>
<Link
href={href}
className="group flex min-h-[86px] items-center gap-4 rounded-[14px] border border-[var(--hairline)] bg-[var(--surface)] p-4 transition hover:border-[var(--accent-mid)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Icon className="h-4.5 w-4.5 [stroke-width:1.7]" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-[var(--ink)]">
{title}
</p>
<p className="mt-1 line-clamp-2 text-[13px] leading-5 text-[var(--ink-soft)]">
{sub}
</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--ink-faint)] transition group-hover:translate-x-1 group-hover:text-[var(--accent-deep)]" />
</Link>
);
}