continued the kita-planer

This commit is contained in:
t.indorf
2026-05-08 14:32:14 +02:00
parent b686e714ff
commit 7aff691803
85 changed files with 9434 additions and 588 deletions
+284
View File
@@ -0,0 +1,284 @@
import Link from "next/link";
import { addWeeks, format, getISOWeek, startOfDay, startOfWeek } from "date-fns";
import { de } from "date-fns/locale";
import { CalendarRange, ClipboardList, Plus, Trash2 } from "lucide-react";
import { UserRole } from "@prisma/client";
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 { Textarea } from "@/components/ui/textarea";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { createDutyType, deleteDutyType } from "./actions";
import {
DutyPlanner,
type DutyPlanAssignmentDto,
type DutyPlanDutyTypeDto,
type DutyPlanFamilyDto,
type DutyPlanWeekDto,
} from "./duty-planner";
export const metadata = { title: "Dienstplan · Kita-Planer" };
const DEFAULT_WEEK_COUNT = 12;
const MAX_WEEK_COUNT = 104;
export default async function DienstePage({
searchParams,
}: {
searchParams: { start?: string; weeks?: string };
}) {
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
if (!session.user.kitaId) {
throw new Error("Kein Mandant zugeordnet.");
}
const rangeStart = getRangeStart(searchParams.start);
const weekCount = getWeekCount(searchParams.weeks);
const weeks = buildWeeks(rangeStart, weekCount);
const rangeEnd = startOfDay(new Date(weeks[weeks.length - 1].endDate));
const [dutyTypes, families, assignments] = await Promise.all([
prisma.dutyType.findMany({
where: { kitaId: session.user.kitaId },
select: {
id: true,
name: true,
description: true,
},
orderBy: { name: "asc" },
}),
prisma.family.findMany({
where: { kitaId: session.user.kitaId },
select: {
id: true,
name: true,
},
orderBy: { name: "asc" },
}),
prisma.dutyAssignment.findMany({
where: {
kitaId: session.user.kitaId,
startDate: {
gte: rangeStart,
lte: rangeEnd,
},
},
select: {
id: true,
dutyTypeId: true,
familyId: true,
startDate: true,
},
}),
]);
const dutyTypeDtos: DutyPlanDutyTypeDto[] = dutyTypes;
const familyDtos: DutyPlanFamilyDto[] = families;
const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map((assignment) => ({
id: assignment.id,
dutyTypeId: assignment.dutyTypeId,
familyId: assignment.familyId,
startDate: toDateKey(assignment.startDate),
}));
return (
<div className="space-y-6 p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<ClipboardList className="h-4 w-4" />
Admin
</div>
<h1 className="mt-2 text-2xl font-bold tracking-tight">
Dienstplan-Manager
</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Plane Dienste pro Kalenderwoche und Haushalt. Jede Änderung wird
direkt gespeichert.
</p>
</div>
<Card className="w-full lg:max-w-xl">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<CalendarRange className="h-4 w-4" />
Zeitraum
</CardTitle>
<CardDescription>
Du kannst beliebig weit voraus planen, jeweils in handlichen
Ausschnitten.
</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="grid gap-1.5">
<Label htmlFor="start">Startwoche</Label>
<Input
id="start"
name="start"
type="date"
defaultValue={toDateInputValue(rangeStart)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="weeks">Wochen</Label>
<Input
id="weeks"
name="weeks"
type="number"
min={1}
max={MAX_WEEK_COUNT}
defaultValue={weekCount}
/>
</div>
<Button type="submit">Anzeigen</Button>
<Button asChild type="button" variant="outline">
<Link
href={`/dashboard/admin/dienste?start=${toDateInputValue(
rangeStart,
)}&weeks=${Math.min(weekCount + 12, MAX_WEEK_COUNT)}`}
>
Weitere Wochen laden
</Link>
</Button>
</form>
</CardContent>
</Card>
</div>
<div className="grid gap-6 xl:grid-cols-[340px_1fr]">
<Card>
<CardHeader>
<CardTitle className="text-base">Diensttypen</CardTitle>
<CardDescription>
Beispiele sind Wäschedienst, Einkauf oder Gartendienst.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<form action={createDutyType} className="space-y-3">
<div className="grid gap-1.5">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" placeholder="Wäschedienst" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="description">Beschreibung</Label>
<Textarea
id="description"
name="description"
placeholder="Kurz beschreiben, was zu tun ist."
rows={3}
/>
</div>
<Button type="submit" size="sm">
<Plus className="h-4 w-4" />
Diensttyp anlegen
</Button>
</form>
<div className="space-y-2">
{dutyTypes.length === 0 ? (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
Noch keine Diensttypen angelegt.
</p>
) : (
dutyTypes.map((dutyType) => (
<div
key={dutyType.id}
className="flex items-start justify-between gap-3 rounded-md border p-3"
>
<div className="min-w-0">
<p className="font-medium">{dutyType.name}</p>
{dutyType.description && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{dutyType.description}
</p>
)}
</div>
<form action={deleteDutyType}>
<input
type="hidden"
name="dutyTypeId"
value={dutyType.id}
/>
<Button
type="submit"
variant="ghost"
size="icon"
aria-label={`${dutyType.name} löschen`}
>
<Trash2 className="h-4 w-4" />
</Button>
</form>
</div>
))
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Planung</CardTitle>
<CardDescription>
{weeks[0].label} bis {weeks[weeks.length - 1].label}
</CardDescription>
</CardHeader>
<CardContent>
<DutyPlanner
weeks={weeks}
dutyTypes={dutyTypeDtos}
families={familyDtos}
assignments={assignmentDtos}
/>
</CardContent>
</Card>
</div>
</div>
);
}
function getRangeStart(value: string | undefined) {
const parsed = value ? new Date(value) : new Date();
const safeDate = Number.isNaN(parsed.getTime()) ? new Date() : parsed;
return startOfDay(startOfWeek(safeDate, { weekStartsOn: 1 }));
}
function getWeekCount(value: string | undefined) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return DEFAULT_WEEK_COUNT;
return Math.min(Math.max(Math.trunc(parsed), 1), MAX_WEEK_COUNT);
}
function buildWeeks(start: Date, count: number): DutyPlanWeekDto[] {
return Array.from({ length: count }, (_, index) => {
const weekStart = addWeeks(start, index);
const weekEnd = addWeeks(start, index);
weekEnd.setDate(weekStart.getDate() + 6);
return {
key: toDateKey(weekStart),
label: `KW ${getISOWeek(weekStart)}: ${format(weekStart, "dd.MM.", {
locale: de,
})} - ${format(weekEnd, "dd.MM.", { locale: de })}`,
startDate: toDateKey(weekStart),
endDate: toDateKey(weekEnd),
};
});
}
function toDateKey(date: Date) {
return format(date, "yyyy-MM-dd");
}
function toDateInputValue(date: Date) {
return format(date, "yyyy-MM-dd");
}