Add non-destructive dev seed on startup

This commit is contained in:
t.indorf
2026-05-06 22:31:07 +02:00
parent 9f452fccac
commit b686e714ff
77 changed files with 10862 additions and 87 deletions
+180
View File
@@ -0,0 +1,180 @@
"use server";
import { revalidatePath } from "next/cache";
import { NotdienstPlanStatus, UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
// =====================================================================
// /dashboard/notdienst/plan · Server Actions
// =====================================================================
export async function generatePlanAction() {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const { targetYear, targetMonth } = getTargetMonthData();
// Prüfen, ob schon ein Plan existiert
const existingPlan = await prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
});
if (existingPlan) {
return { error: "Ein Plan für diesen Monat existiert bereits." };
}
// 1. Alle Verfügbarkeiten der Eltern für diesen Monat holen
const availabilities = await prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: new Date(targetYear, targetMonth - 1, 1),
lt: new Date(targetYear, targetMonth, 1),
},
},
include: { child: true, user: true },
});
// 2. Werktage holen
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
// 3. Algorithmus: Wir verteilen die Slots
// Counter für "Gerechtigkeit": wie oft wurde diese Familie schon eingeteilt?
const familyUsageCount: Record<string, number> = {};
const assignmentsToCreate: { childId: string; date: Date }[] = [];
for (const day of workingDays) {
// Alle Verfügbarkeiten für diesen spezifischen Tag
const availForDay = availabilities.filter(
(a) => a.date.getTime() === day.getTime(),
);
if (availForDay.length === 0) {
// Niemand verfügbar -> Bleibt leer ("Unbesetzt")
continue;
}
// Finde die Familie, die am wenigsten oft dran war
// Zufall einbauen bei Gleichstand
const sorted = availForDay.sort((a, b) => {
const countA = familyUsageCount[a.userId] || 0;
const countB = familyUsageCount[b.userId] || 0;
if (countA === countB) {
return Math.random() - 0.5; // Zufallsmischung
}
return countA - countB;
});
const selected = sorted[0];
assignmentsToCreate.push({
childId: selected.childId,
date: day,
});
// Usage-Counter erhöhen
familyUsageCount[selected.userId] =
(familyUsageCount[selected.userId] || 0) + 1;
}
// 4. In der DB speichern
try {
await prisma.$transaction(async (tx) => {
const plan = await tx.notdienstPlan.create({
data: {
kitaId,
year: targetYear,
month: targetMonth,
status: NotdienstPlanStatus.DRAFT,
createdById: session.user.id,
},
});
if (assignmentsToCreate.length > 0) {
await tx.notdienstAssignment.createMany({
data: assignmentsToCreate.map((a) => ({
kitaId,
planId: plan.id,
childId: a.childId,
date: a.date,
})),
});
}
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error) {
console.error(error);
return { error: "Fehler bei der Generierung des Plans." };
}
}
export async function publishPlanAction(planId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
await prisma.notdienstPlan.update({
where: { id: planId, kitaId },
data: {
status: NotdienstPlanStatus.PUBLISHED,
publishedAt: new Date(),
},
});
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/notdienst");
return { success: true };
}
export async function updateAssignmentAction(
planId: string,
date: string,
newChildId: string | null,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const parsedDate = new Date(date);
try {
await prisma.$transaction(async (tx) => {
// Prüfen, ob Plan noch DRAFT ist
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
});
if (!plan || plan.status !== NotdienstPlanStatus.DRAFT) {
throw new Error("Plan kann nicht mehr bearbeitet werden.");
}
// Altes Assignment für diesen Tag löschen
await tx.notdienstAssignment.deleteMany({
where: { planId, date: parsedDate },
});
// Neues anlegen, falls ausgewählt
if (newChildId) {
await tx.notdienstAssignment.create({
data: {
kitaId,
planId,
childId: newChildId,
date: parsedDate,
},
});
}
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error: any) {
return { error: error.message || "Update fehlgeschlagen." };
}
}
+93
View File
@@ -0,0 +1,93 @@
import { UserRole } from "@prisma/client";
import { Settings2 } from "lucide-react";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { PlanView } from "./plan-view";
export const metadata = { title: "Plan-Generierung · Kita-Planer" };
export default async function PlanungsZentralePage() {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const { targetYear, targetMonth, monthName } = getTargetMonthData();
// Aktuellen Plan suchen
const plan = await prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
include: {
assignments: {
include: { child: { include: { parentLinks: { include: { user: true } } } } },
},
},
});
// Alle Werktage
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
// aber für MVP reichen alle aktiven Kinder.
const allChildrenRaw = await prisma.child.findMany({
where: { kitaId, active: true },
include: {
parentLinks: { include: { user: true } },
},
orderBy: { lastName: "asc" },
});
const allChildren = allChildrenRaw.map((c) => {
// Einfachheit halber nehmen wir den ersten verknüpften Elternteil zur Anzeige
const parent = c.parentLinks[0]?.user;
const parentName = parent ? `${parent.firstName} ${parent.lastName}` : "Unbekannt";
return {
id: c.id,
name: `${c.firstName} ${c.lastName}`,
parentName,
};
});
// Wir mappen die Assignments auf die Werktage
const daysWithAssignments = workingDays.map((day) => {
const assignment = plan?.assignments.find(
(a) => a.date.getTime() === day.getTime()
);
return {
date: day.toISOString(),
assignmentId: assignment?.id || null,
childId: assignment?.childId || null,
};
});
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-8">
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Settings2 className="h-6 w-6 text-primary" />
Notdienst-Planung
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Koordiniere und generiere den Notdienst-Plan.
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
Zielmonat: {monthName}
</span>
</div>
</div>
<PlanView
planId={plan?.id}
status={plan?.status}
days={daysWithAssignments}
allChildren={allChildren}
/>
</div>
);
}
@@ -0,0 +1,186 @@
"use client";
import { useState, useTransition } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { NotdienstPlanStatus } from "@prisma/client";
import { Loader2, Wand2, CheckSquare } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
generatePlanAction,
publishPlanAction,
updateAssignmentAction,
} from "./actions";
interface PlanViewProps {
planId?: string;
status?: NotdienstPlanStatus;
days: {
date: string; // ISO
assignmentId: string | null;
childId: string | null;
}[];
allChildren: {
id: string;
name: string;
parentName: string;
}[];
}
export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
const [isPending, startTransition] = useTransition();
const handleGenerate = () => {
startTransition(async () => {
const result = await generatePlanAction();
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Plan erfolgreich generiert.");
}
});
};
const handlePublish = () => {
if (!planId) return;
startTransition(async () => {
const result = await publishPlanAction(planId);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Plan veröffentlicht! Er ist nun fix.");
}
});
};
const handleAssignmentChange = (date: string, newChildId: string) => {
if (!planId || status !== "DRAFT") return;
const valueToSet = newChildId === "empty" ? null : newChildId;
startTransition(async () => {
const result = await updateAssignmentAction(planId, date, valueToSet);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Zuteilung aktualisiert.");
}
});
};
if (!planId) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-card p-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Wand2 className="h-6 w-6" />
</div>
<h3 className="mb-1 text-lg font-semibold">Noch kein Plan vorhanden</h3>
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
Generiere den Plan basierend auf den Verfügbarkeiten der Eltern
automatisch.
</p>
<Button onClick={handleGenerate} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Plan automatisch generieren
</Button>
</div>
);
}
const isDraft = status === "DRAFT";
return (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
<div className="flex items-center gap-3">
<Badge variant={isDraft ? "warning" : "success"}>
{isDraft ? "Entwurf" : "Veröffentlicht"}
</Badge>
<span className="text-sm text-muted-foreground">
{isDraft
? "Du kannst die Liste jetzt manuell anpassen."
: "Dieser Plan ist verbindlich."}
</span>
</div>
{isDraft && (
<Button onClick={handlePublish} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<CheckSquare className="mr-2 h-4 w-4" />
Plan veröffentlichen
</Button>
)}
</div>
<div className="rounded-lg border bg-card">
<div className="grid grid-cols-[1fr_2fr] border-b bg-muted/30 p-4 text-sm font-medium">
<div>Datum</div>
<div>Eingeteilte Familie</div>
</div>
<div className="divide-y">
{days.map((day) => {
const dateObj = new Date(day.date);
const isUnbesetzt = !day.childId;
return (
<div
key={day.date}
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-muted/10"
>
<div className="font-medium">
{format(dateObj, "EE, dd.MM.", { locale: de })}
</div>
<div>
{isDraft ? (
<select
className="w-full max-w-sm rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={day.childId || "empty"}
disabled={isPending}
onChange={(e) =>
handleAssignmentChange(day.date, e.target.value)
}
>
<option value="empty" className="text-destructive">
-- Unbesetzt --
</option>
{allChildren.map((c) => (
<option key={c.id} value={c.id}>
{c.parentName} ({c.name})
</option>
))}
</select>
) : (
<div>
{isUnbesetzt ? (
<span className="text-sm font-medium text-destructive">
Unbesetzt!
</span>
) : (
<span className="text-sm">
{
allChildren.find((c) => c.id === day.childId)
?.parentName
}{" "}
<span className="text-muted-foreground">
(
{
allChildren.find((c) => c.id === day.childId)
?.name
}
)
</span>
</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}