continued the kita-planer
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import Link from "next/link";
|
||||
import { addDays, format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { ArrowLeft, ArrowRight, CalendarDays, Stethoscope } from "lucide-react";
|
||||
import { AbsenceReason } from "@prisma/client";
|
||||
|
||||
import { getDailyAbsences } 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";
|
||||
|
||||
export const metadata = { title: "Abwesenheiten · Kita-Planer" };
|
||||
|
||||
export default async function AdminAbwesenheitenPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { date?: string };
|
||||
}) {
|
||||
const selectedDate = parseDateParam(searchParams.date);
|
||||
const dateKey = format(selectedDate, "yyyy-MM-dd");
|
||||
const result = await getDailyAbsences({ date: dateKey });
|
||||
const absences = result.absences ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-slate-50 px-4 py-6 sm:px-8">
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="flex flex-col gap-4 rounded-lg border bg-background p-5 shadow-sm lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Stethoscope className="h-4 w-4" />
|
||||
Tagesübersicht
|
||||
</div>
|
||||
<h1 className="mt-2 text-3xl font-bold tracking-tight">
|
||||
Heute fehlen ({absences.length} Kinder)
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{format(selectedDate, "EEEE, dd. MMMM yyyy", { locale: de })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={hrefForDate(addDays(selectedDate, -1))}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Zurück
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/dashboard/admin/abwesenheiten">Heute</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={hrefForDate(addDays(selectedDate, 1))}>
|
||||
Vor
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<form className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
name="date"
|
||||
defaultValue={dateKey}
|
||||
className="h-9 w-40"
|
||||
/>
|
||||
<Button type="submit" size="sm">
|
||||
Anzeigen
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.error ? (
|
||||
<Card className="border-destructive/30">
|
||||
<CardContent className="p-6 text-sm text-destructive">
|
||||
{result.error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : absences.length === 0 ? (
|
||||
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-background p-8 text-center">
|
||||
<div className="mx-auto max-w-md">
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||
<CalendarDays className="h-10 w-10" />
|
||||
</div>
|
||||
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
|
||||
Keine Abwesenheiten
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Für diesen Tag sind aktuell keine Kinder abgemeldet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{absences.map((absence) => (
|
||||
<Card key={absence.id} className="overflow-hidden">
|
||||
<CardHeader className="flex flex-col gap-3 p-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">
|
||||
{absence.childName}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{absence.familyName}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<ReasonBadge reason={absence.reason} />
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 px-5 pb-5 sm:grid-cols-[180px_1fr]">
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Zeitraum</p>
|
||||
<p className="text-muted-foreground">
|
||||
{formatDateRange(absence.startDate, absence.endDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Notiz</p>
|
||||
<p className="text-muted-foreground">
|
||||
{absence.note || "Keine Bemerkung hinterlegt."}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseDateParam(value: string | undefined) {
|
||||
if (!value) return new Date();
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
}
|
||||
|
||||
function hrefForDate(date: Date) {
|
||||
return `/dashboard/admin/abwesenheiten?date=${format(date, "yyyy-MM-dd")}`;
|
||||
}
|
||||
|
||||
function formatDateRange(startDate: string, endDate: string) {
|
||||
const start = new Date(`${startDate}T00:00:00`);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const startLabel = format(start, "dd.MM.", { locale: de });
|
||||
const endLabel = format(end, "dd.MM.yyyy", { locale: de });
|
||||
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
|
||||
}
|
||||
|
||||
function ReasonBadge({ reason }: { reason: AbsenceReason }) {
|
||||
if (reason === AbsenceReason.ILLNESS) {
|
||||
return <Badge variant="destructive">Krank</Badge>;
|
||||
}
|
||||
|
||||
if (reason === AbsenceReason.VACATION) {
|
||||
return <Badge variant="warning">Urlaub</Badge>;
|
||||
}
|
||||
|
||||
return <Badge variant="outline">Sonstiges</Badge>;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
"use server";
|
||||
|
||||
import { createElement } from "react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { DutyAssignmentStatus, UserRole } from "@prisma/client";
|
||||
import { endOfWeek, format, startOfDay, startOfWeek } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { z } from "zod";
|
||||
|
||||
import { DutyReminderEmail } from "@/emails/DutyReminderEmail";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const dutyTypeSchema = z.object({
|
||||
name: z.string().trim().min(2).max(60),
|
||||
description: z.string().trim().max(240).optional(),
|
||||
});
|
||||
|
||||
const assignmentSchema = z.object({
|
||||
dutyTypeId: z.string().min(1),
|
||||
familyId: z.string().nullable(),
|
||||
startDate: z.string().min(1),
|
||||
endDate: z.string().min(1),
|
||||
});
|
||||
|
||||
async function requireDutyAdmin() {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
|
||||
if (!session.user.kitaId) {
|
||||
throw new Error("Kein Mandant zugeordnet.");
|
||||
}
|
||||
return { session, kitaId: session.user.kitaId };
|
||||
}
|
||||
|
||||
function parseDateOnly(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new Error("Ungueltiges Datum.");
|
||||
}
|
||||
return startOfDay(date);
|
||||
}
|
||||
|
||||
export async function createDutyType(formData: FormData) {
|
||||
const { kitaId } = await requireDutyAdmin();
|
||||
const parsed = dutyTypeSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
description: formData.get("description") || undefined,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.dutyType.create({
|
||||
data: {
|
||||
kitaId,
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
},
|
||||
});
|
||||
revalidatePath("/dashboard/admin/dienste");
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Erstellen des Diensttyps:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDutyType(formData: FormData) {
|
||||
const { kitaId } = await requireDutyAdmin();
|
||||
const dutyTypeId = String(formData.get("dutyTypeId") ?? "");
|
||||
|
||||
if (!dutyTypeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.dutyType.deleteMany({
|
||||
where: { id: dutyTypeId, kitaId },
|
||||
});
|
||||
revalidatePath("/dashboard/admin/dienste");
|
||||
revalidatePath("/dashboard/kalender");
|
||||
revalidatePath("/dashboard");
|
||||
}
|
||||
|
||||
export async function upsertDutyAssignment(rawPayload: unknown) {
|
||||
const { kitaId } = await requireDutyAdmin();
|
||||
const parsed = assignmentSchema.safeParse(rawPayload);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungueltige Eingabedaten." };
|
||||
}
|
||||
|
||||
const startDate = parseDateOnly(parsed.data.startDate);
|
||||
const endDate = parseDateOnly(parsed.data.endDate);
|
||||
|
||||
if (endDate < startDate) {
|
||||
return { error: "Das Enddatum liegt vor dem Startdatum." };
|
||||
}
|
||||
|
||||
const dutyType = await prisma.dutyType.findFirst({
|
||||
where: { id: parsed.data.dutyTypeId, kitaId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!dutyType) {
|
||||
return { error: "Diensttyp gehoert nicht zu dieser Kita." };
|
||||
}
|
||||
|
||||
if (!parsed.data.familyId) {
|
||||
await prisma.dutyAssignment.deleteMany({
|
||||
where: {
|
||||
kitaId,
|
||||
dutyTypeId: parsed.data.dutyTypeId,
|
||||
startDate,
|
||||
},
|
||||
});
|
||||
revalidatePath("/dashboard/admin/dienste");
|
||||
revalidatePath("/dashboard/kalender");
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const family = await prisma.family.findFirst({
|
||||
where: { id: parsed.data.familyId, kitaId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!family) {
|
||||
return { error: "Familie gehoert nicht zu dieser Kita." };
|
||||
}
|
||||
|
||||
await prisma.dutyAssignment.upsert({
|
||||
where: {
|
||||
kitaId_dutyTypeId_startDate: {
|
||||
kitaId,
|
||||
dutyTypeId: parsed.data.dutyTypeId,
|
||||
startDate,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
kitaId,
|
||||
dutyTypeId: parsed.data.dutyTypeId,
|
||||
familyId: parsed.data.familyId,
|
||||
startDate,
|
||||
endDate,
|
||||
status: DutyAssignmentStatus.PLANNED,
|
||||
},
|
||||
update: {
|
||||
familyId: parsed.data.familyId,
|
||||
endDate,
|
||||
status: DutyAssignmentStatus.PLANNED,
|
||||
reminderSentAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/admin/dienste");
|
||||
revalidatePath("/dashboard/kalender");
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function checkAndSendDutyReminders() {
|
||||
const { kitaId } = await requireDutyAdmin();
|
||||
const configError = getAppEmailConfigError();
|
||||
|
||||
if (configError) {
|
||||
return { error: configError };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
|
||||
const weekEnd = endOfWeek(now, { weekStartsOn: 1 });
|
||||
|
||||
const assignments = await prisma.dutyAssignment.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
status: DutyAssignmentStatus.PLANNED,
|
||||
reminderSentAt: null,
|
||||
startDate: {
|
||||
gte: startOfDay(weekStart),
|
||||
lte: startOfDay(weekEnd),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
dutyType: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: {
|
||||
emailVerifiedAt: { not: null },
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const assignment of assignments) {
|
||||
const recipients = assignment.family.users.map((user) => user.email);
|
||||
if (recipients.length === 0) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const weekLabel = `${format(assignment.startDate, "dd.MM.", {
|
||||
locale: de,
|
||||
})} - ${format(assignment.endDate, "dd.MM.yyyy", { locale: de })}`;
|
||||
|
||||
const result = await sendAppEmail({
|
||||
to: recipients,
|
||||
subject: `Erinnerung: ${assignment.dutyType.name} diese Woche`,
|
||||
react: createElement(DutyReminderEmail, {
|
||||
familyName: assignment.family.name,
|
||||
dutyName: assignment.dutyType.name,
|
||||
weekLabel,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
errors.push(`${assignment.family.name}: ${result.error}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.dutyAssignment.update({
|
||||
where: { id: assignment.id },
|
||||
data: { reminderSentAt: new Date() },
|
||||
});
|
||||
sent += recipients.length;
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard/admin/dienste");
|
||||
return { success: true, sent, skipped, errors };
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { BellRing, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
checkAndSendDutyReminders,
|
||||
upsertDutyAssignment,
|
||||
} from "./actions";
|
||||
|
||||
const UNASSIGNED = "__unassigned__";
|
||||
|
||||
export type DutyPlanWeekDto = {
|
||||
key: string;
|
||||
label: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
export type DutyPlanDutyTypeDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
};
|
||||
|
||||
export type DutyPlanFamilyDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type DutyPlanAssignmentDto = {
|
||||
id: string;
|
||||
dutyTypeId: string;
|
||||
familyId: string;
|
||||
startDate: string;
|
||||
};
|
||||
|
||||
export function DutyPlanner({
|
||||
weeks,
|
||||
dutyTypes,
|
||||
families,
|
||||
assignments,
|
||||
}: {
|
||||
weeks: DutyPlanWeekDto[];
|
||||
dutyTypes: DutyPlanDutyTypeDto[];
|
||||
families: DutyPlanFamilyDto[];
|
||||
assignments: DutyPlanAssignmentDto[];
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [pendingCell, setPendingCell] = useState<string | null>(null);
|
||||
|
||||
const assignmentByCell = new Map(
|
||||
assignments.map((assignment) => [
|
||||
`${assignment.startDate}:${assignment.dutyTypeId}`,
|
||||
assignment,
|
||||
]),
|
||||
);
|
||||
|
||||
function handleAssignmentChange(
|
||||
week: DutyPlanWeekDto,
|
||||
dutyTypeId: string,
|
||||
value: string,
|
||||
) {
|
||||
const cellKey = `${week.startDate}:${dutyTypeId}`;
|
||||
setPendingCell(cellKey);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await upsertDutyAssignment({
|
||||
dutyTypeId,
|
||||
familyId: value === UNASSIGNED ? null : value,
|
||||
startDate: week.startDate,
|
||||
endDate: week.endDate,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success(
|
||||
value === UNASSIGNED
|
||||
? "Zuweisung entfernt"
|
||||
: "Dienstplan aktualisiert",
|
||||
);
|
||||
}
|
||||
setPendingCell(null);
|
||||
});
|
||||
}
|
||||
|
||||
function sendReminders() {
|
||||
startTransition(async () => {
|
||||
const result = await checkAndSendDutyReminders();
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`Erinnerungen versendet: ${result.sent ?? 0}. Ohne Empfaenger: ${
|
||||
result.skipped ?? 0
|
||||
}.`,
|
||||
);
|
||||
|
||||
if (result.errors?.length) {
|
||||
toast.warning(`${result.errors.length} Erinnerung(en) fehlgeschlagen.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (dutyTypes.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed p-8 text-center">
|
||||
<p className="text-sm font-medium">Noch keine Diensttypen</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Lege zuerst einen Diensttyp an, danach erscheint hier das Planungsgrid.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={sendReminders}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<BellRing className="h-4 w-4" />
|
||||
)}
|
||||
Reminder diese Woche senden
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[760px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="sticky left-0 z-10 w-56 bg-muted/95 px-4 py-3 text-left font-medium">
|
||||
Woche
|
||||
</th>
|
||||
{dutyTypes.map((dutyType) => (
|
||||
<th
|
||||
key={dutyType.id}
|
||||
className="min-w-56 px-4 py-3 text-left font-medium"
|
||||
>
|
||||
<div>{dutyType.name}</div>
|
||||
{dutyType.description && (
|
||||
<div className="mt-1 line-clamp-1 text-xs font-normal text-muted-foreground">
|
||||
{dutyType.description}
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{weeks.map((week) => (
|
||||
<tr key={week.key} className="border-b last:border-b-0">
|
||||
<td className="sticky left-0 z-10 bg-background px-4 py-3 font-medium">
|
||||
{week.label}
|
||||
</td>
|
||||
{dutyTypes.map((dutyType) => {
|
||||
const cellKey = `${week.startDate}:${dutyType.id}`;
|
||||
const currentAssignment = assignmentByCell.get(cellKey);
|
||||
return (
|
||||
<td key={cellKey} className="px-4 py-3">
|
||||
<Select
|
||||
value={currentAssignment?.familyId ?? UNASSIGNED}
|
||||
onValueChange={(value) =>
|
||||
handleAssignmentChange(week, dutyType.id, value)
|
||||
}
|
||||
disabled={isPending && pendingCell === cellKey}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="Familie wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNASSIGNED}>
|
||||
Nicht zugewiesen
|
||||
</SelectItem>
|
||||
{families.map((family) => (
|
||||
<SelectItem key={family.id} value={family.id}>
|
||||
{family.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Loader2, Send, UploadCloud } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createAnnouncement } from "@/actions/announcements";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
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";
|
||||
import { MarkdownContent } from "@/components/markdown-content";
|
||||
|
||||
export function NewsForm() {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [sendEmail, setSendEmail] = useState(false);
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
function handleSubmit(formData: FormData) {
|
||||
startTransition(async () => {
|
||||
if (sendEmail) {
|
||||
formData.set("sendEmail", "on");
|
||||
}
|
||||
|
||||
const result = await createAnnouncement(formData);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.warning) {
|
||||
toast.warning(result.warning);
|
||||
} else {
|
||||
toast.success("Ankündigung veröffentlicht.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={handleSubmit} className="grid gap-5">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="news-title">Titel</Label>
|
||||
<Input
|
||||
id="news-title"
|
||||
name="title"
|
||||
placeholder="Sommerfest: Aufbau und Mitbringliste"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="news-content">Text</Label>
|
||||
<Textarea
|
||||
id="news-content"
|
||||
name="content"
|
||||
rows={10}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Markdown ist aktiv. HTML wird nicht interpretiert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{content.trim() && (
|
||||
<div className="rounded-md border bg-muted/30 p-4 text-sm">
|
||||
<p className="mb-3 text-xs font-semibold uppercase text-muted-foreground">
|
||||
Vorschau
|
||||
</p>
|
||||
<MarkdownContent content={content} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="attachments">Anhänge</Label>
|
||||
<div className="rounded-md border border-dashed bg-background p-4">
|
||||
<Input
|
||||
id="attachments"
|
||||
name="attachments"
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
|
||||
multiple
|
||||
/>
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<UploadCloud className="h-3.5 w-3.5" />
|
||||
PDF, JPG und PNG bis 10 MB pro Datei.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="sendEmail"
|
||||
checked={sendEmail}
|
||||
onCheckedChange={(checked) => setSendEmail(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="sendEmail" className="cursor-pointer">
|
||||
Auch als E-Mail versenden
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{sendEmail && (
|
||||
<div className="mt-4 grid gap-2">
|
||||
<Label>Empfänger</Label>
|
||||
<Select name="audience" defaultValue="ELTERN">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Empfänger auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ELTERN">Eltern</SelectItem>
|
||||
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
|
||||
<SelectItem value="ALLE">Alle</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
Veröffentlichen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { FileText, Megaphone } from "lucide-react";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { NewsForm } from "./news-form";
|
||||
|
||||
export const metadata = { title: "Schwarzes Brett · Kita-Planer" };
|
||||
|
||||
export default async function AdminNewsPage() {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
const kitaId = session.user.kitaId;
|
||||
|
||||
if (!kitaId) {
|
||||
throw new Error("Kein Mandant zugeordnet.");
|
||||
}
|
||||
|
||||
const announcements = await prisma.announcement.findMany({
|
||||
where: { kitaId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
attachments: {
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
},
|
||||
},
|
||||
author: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<Badge className="mb-3 bg-emerald-100 text-emerald-800 hover:bg-emerald-100">
|
||||
Offizielle Kita-Kommunikation
|
||||
</Badge>
|
||||
<h1 className="flex items-center gap-3 text-2xl font-semibold tracking-tight">
|
||||
<Megaphone className="h-6 w-6 text-emerald-700" />
|
||||
Digitales Schwarzes Brett
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
|
||||
werden geschützt gespeichert und nur für eingeloggte Nutzer derselben
|
||||
Kita ausgeliefert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Neue Ankündigung</CardTitle>
|
||||
<CardDescription>
|
||||
Markdown wird sicher gerendert; PDF, JPG und PNG können als
|
||||
geschützte Anhänge hinzugefügt werden.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<NewsForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Zuletzt veröffentlicht</CardTitle>
|
||||
<CardDescription>
|
||||
Die neuesten Meldungen auf dem Schwarzen Brett.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
{announcements.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||
Noch keine Ankündigungen veröffentlicht.
|
||||
</p>
|
||||
) : (
|
||||
announcements.map((announcement) => (
|
||||
<div
|
||||
key={announcement.id}
|
||||
className="rounded-md border bg-background p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<p className="font-medium leading-snug">
|
||||
{announcement.title}
|
||||
</p>
|
||||
{announcement.attachments.length > 0 && (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
<FileText className="h-3 w-3" />
|
||||
{announcement.attachments.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
|
||||
locale: de,
|
||||
})}{" "}
|
||||
· {announcement.author.firstName}{" "}
|
||||
{announcement.author.lastName}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user