298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
import Link from "next/link";
|
|
import { addWeeks, format, getISOWeek, startOfDay, startOfWeek } from "date-fns";
|
|
import { de } from "date-fns/locale";
|
|
import { CalendarRange, 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: Promise<{ start?: string; weeks?: string }>;
|
|
}) {
|
|
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
|
|
if (!session.user.kitaId) {
|
|
throw new Error("Kein Mandant zugeordnet.");
|
|
}
|
|
|
|
const resolvedSearchParams = await searchParams;
|
|
const rangeStart = getRangeStart(resolvedSearchParams.start);
|
|
const weekCount = getWeekCount(resolvedSearchParams.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="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-[900px]:flex-col max-[900px]:items-start">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
|
ADMIN · DIENSTPLAN
|
|
</p>
|
|
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
|
Dienste{" "}
|
|
<em className="font-normal italic text-[var(--accent-deep)]">
|
|
planen
|
|
</em>
|
|
</h1>
|
|
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
|
Plane Dienste pro Kalenderwoche und Haushalt. Jede Änderung wird
|
|
direkt gespeichert.
|
|
</p>
|
|
</div>
|
|
<div className="flex shrink-0 flex-wrap gap-2">
|
|
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
|
{dutyTypes.length} Diensttypen
|
|
</span>
|
|
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
|
{families.length} Familien
|
|
</span>
|
|
</div>
|
|
</header>
|
|
|
|
<Card className="mb-5">
|
|
<CardContent className="pt-[22px]">
|
|
<form className="flex flex-wrap items-end gap-3">
|
|
<div className="mr-auto min-w-64">
|
|
<div className="flex items-center gap-2 text-base font-medium tracking-[-0.02em] text-[var(--ink)]">
|
|
<CalendarRange className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
|
Zeitraum
|
|
</div>
|
|
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
|
{weeks[0].label} bis {weeks[weeks.length - 1].label}
|
|
</p>
|
|
</div>
|
|
<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 w-28 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="ghost">
|
|
<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 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-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3 text-sm italic text-[var(--ink-faint)]">
|
|
Noch keine Diensttypen angelegt.
|
|
</p>
|
|
) : (
|
|
dutyTypes.map((dutyType) => (
|
|
<div
|
|
key={dutyType.id}
|
|
className="flex items-start justify-between gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
|
|
>
|
|
<div className="min-w-0">
|
|
<p className="font-medium text-[var(--ink)]">{dutyType.name}</p>
|
|
{dutyType.description && (
|
|
<p className="mt-1 line-clamp-2 text-xs text-[var(--ink-soft)]">
|
|
{dutyType.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<form action={deleteDutyType}>
|
|
<input
|
|
type="hidden"
|
|
name="dutyTypeId"
|
|
value={dutyType.id}
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
|
aria-label={`${dutyType.name} löschen`}
|
|
>
|
|
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
|
|
</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");
|
|
}
|