feat: add parent announcements and family or child event responses
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import {
|
||||
EventParticipationStatus,
|
||||
RsvpMode,
|
||||
TerminStatus,
|
||||
TerminType,
|
||||
} from "@prisma/client";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { suggestedRsvpMode } from "@/lib/rsvp";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -48,10 +50,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
createTerminAdmin,
|
||||
updateTerminAdmin,
|
||||
} from "../../kalender/actions";
|
||||
import { createTerminAdmin, updateTerminAdmin } from "../../kalender/actions";
|
||||
|
||||
type AdminTerminDto = {
|
||||
id: string;
|
||||
@@ -64,6 +63,12 @@ type AdminTerminDto = {
|
||||
allDay: boolean;
|
||||
mitbringselListEnabled: boolean;
|
||||
requiresRsvp: boolean;
|
||||
rsvpMode: RsvpMode;
|
||||
familyParticipations: {
|
||||
familyId: string;
|
||||
status: EventParticipationStatus;
|
||||
adultCount: number;
|
||||
}[];
|
||||
rsvpDeadline: string | null;
|
||||
participantInfo: string | null;
|
||||
participations: {
|
||||
@@ -81,9 +86,11 @@ type ChildDto = {
|
||||
export function AdminCalendarManager({
|
||||
termine,
|
||||
childRows,
|
||||
familyRows,
|
||||
}: {
|
||||
termine: AdminTerminDto[];
|
||||
childRows: ChildDto[];
|
||||
familyRows: { id: string; name: string }[];
|
||||
}) {
|
||||
const rsvpCount = termine.filter((termin) => termin.requiresRsvp).length;
|
||||
const pendingCount = termine.filter(
|
||||
@@ -104,8 +111,8 @@ export function AdminCalendarManager({
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind
|
||||
verfolgen.
|
||||
Termine verwalten, RSVP aktivieren und den Rücklauf pro Familie oder
|
||||
Kind verfolgen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
@@ -134,7 +141,7 @@ export function AdminCalendarManager({
|
||||
Noch keine Termine
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
|
||||
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -145,6 +152,7 @@ export function AdminCalendarManager({
|
||||
key={termin.id}
|
||||
termin={termin}
|
||||
childRows={childRows}
|
||||
familyRows={familyRows}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -156,11 +164,25 @@ export function AdminCalendarManager({
|
||||
function AdminTerminCard({
|
||||
termin,
|
||||
childRows,
|
||||
familyRows,
|
||||
}: {
|
||||
termin: AdminTerminDto;
|
||||
childRows: ChildDto[];
|
||||
familyRows: { id: string; name: string }[];
|
||||
}) {
|
||||
const stats = useMemo(() => getStats(termin, childRows), [termin, childRows]);
|
||||
const isFamily = termin.rsvpMode === RsvpMode.FAMILY;
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
isFamily
|
||||
? familyRows.map((family) => ({
|
||||
id: family.id,
|
||||
name: family.name,
|
||||
familyName: family.name,
|
||||
}))
|
||||
: childRows,
|
||||
[isFamily, familyRows, childRows],
|
||||
);
|
||||
const stats = useMemo(() => getStats(termin, rows), [termin, rows]);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
@@ -175,7 +197,9 @@ function AdminTerminCard({
|
||||
{getStatusLabel(termin.status)}
|
||||
</Badge>
|
||||
{termin.requiresRsvp && (
|
||||
<Badge variant="warning">RSVP aktiv</Badge>
|
||||
<Badge variant="warning">
|
||||
Antwort pro {isFamily ? "Familie" : "Kind"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-lg">{termin.title}</CardTitle>
|
||||
@@ -217,14 +241,19 @@ function AdminTerminCard({
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
|
||||
{stats.pending} Ausstehend
|
||||
{stats.pending} Ausstehend{" "}
|
||||
{isFamily ? `· ${stats.adultCount} Erwachsene` : ""}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Frist:{" "}
|
||||
{termin.rsvpDeadline
|
||||
? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", {
|
||||
locale: de,
|
||||
})
|
||||
? format(
|
||||
new Date(termin.rsvpDeadline),
|
||||
"dd.MM.yyyy HH:mm",
|
||||
{
|
||||
locale: de,
|
||||
},
|
||||
)
|
||||
: "bis Terminbeginn"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -241,18 +270,24 @@ function AdminTerminCard({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kind</TableHead>
|
||||
<TableHead>Familie</TableHead>
|
||||
<TableHead>{isFamily ? "Familie" : "Kind"}</TableHead>
|
||||
<TableHead>{isFamily ? "Erwachsene" : "Familie"}</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{childRows.map((child) => {
|
||||
{rows.map((child) => {
|
||||
const status = stats.statusByChildId.get(child.id) ?? null;
|
||||
return (
|
||||
<TableRow key={child.id}>
|
||||
<TableCell className="font-medium">{child.name}</TableCell>
|
||||
<TableCell>{child.familyName}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{child.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isFamily
|
||||
? (stats.adultCounts.get(child.id) ?? "–")
|
||||
: child.familyName}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RsvpStatusBadge status={status} />
|
||||
</TableCell>
|
||||
@@ -283,6 +318,13 @@ function TerminFormDialog({
|
||||
const [requiresRsvp, setRequiresRsvp] = useState(
|
||||
termin?.requiresRsvp ?? false,
|
||||
);
|
||||
const [rsvpMode, setRsvpMode] = useState(
|
||||
termin?.rsvpMode ?? suggestedRsvpMode(TerminType.KITA_FEST),
|
||||
);
|
||||
const [modeTouched, setModeTouched] = useState(false);
|
||||
const modeLocked =
|
||||
!!termin &&
|
||||
termin.participations.length + termin.familyParticipations.length > 0;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleAction(formData: FormData) {
|
||||
@@ -300,6 +342,7 @@ function TerminFormDialog({
|
||||
allDay: formData.get("allDay") === "on",
|
||||
mitbringselListEnabled: formData.get("mitbringselListEnabled") === "on",
|
||||
requiresRsvp: hasRsvp,
|
||||
rsvpMode,
|
||||
rsvpDeadline:
|
||||
hasRsvp && deadlineRaw ? new Date(deadlineRaw).toISOString() : null,
|
||||
participantInfo: String(formData.get("participantInfo") ?? ""),
|
||||
@@ -330,6 +373,10 @@ function TerminFormDialog({
|
||||
setOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
setRequiresRsvp(termin?.requiresRsvp ?? false);
|
||||
setRsvpMode(
|
||||
termin?.rsvpMode ?? suggestedRsvpMode(TerminType.KITA_FEST),
|
||||
);
|
||||
setModeTouched(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -380,6 +427,12 @@ function TerminFormDialog({
|
||||
<select
|
||||
id={`${mode}-type`}
|
||||
name="type"
|
||||
onChange={(event) => {
|
||||
if (!termin && !modeLocked && !modeTouched)
|
||||
setRsvpMode(
|
||||
suggestedRsvpMode(event.target.value as TerminType),
|
||||
);
|
||||
}}
|
||||
defaultValue={termin?.type ?? TerminType.KITA_FEST}
|
||||
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
required
|
||||
@@ -453,9 +506,33 @@ function TerminFormDialog({
|
||||
{requiresRsvp && (
|
||||
<div className="grid gap-4 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${mode}-rsvpDeadline`}>
|
||||
Rückmeldefrist
|
||||
</Label>
|
||||
<Label htmlFor={`${mode}-rsvpMode`}>Antwortart</Label>
|
||||
<select
|
||||
id={`${mode}-rsvpMode`}
|
||||
value={rsvpMode}
|
||||
disabled={modeLocked}
|
||||
onChange={(e) => {
|
||||
setRsvpMode(e.target.value as RsvpMode);
|
||||
setModeTouched(true);
|
||||
}}
|
||||
className="h-10 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 text-sm disabled:opacity-60"
|
||||
>
|
||||
<option value="FAMILY">
|
||||
Pro Familie · mit Anzahl Erwachsener
|
||||
</option>
|
||||
<option value="CHILD">
|
||||
Pro Kind · einzelne Zu-/Absage
|
||||
</option>
|
||||
</select>
|
||||
{modeLocked ? (
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Die Antwortart ist gesperrt, da bereits Rückmeldungen
|
||||
vorliegen.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${mode}-rsvpDeadline`}>Rückmeldefrist</Label>
|
||||
<Input
|
||||
id={`${mode}-rsvpDeadline`}
|
||||
name="rsvpDeadline"
|
||||
@@ -532,28 +609,34 @@ function RsvpStatusBadge({
|
||||
);
|
||||
}
|
||||
|
||||
function getStats(termin: AdminTerminDto, children: ChildDto[]) {
|
||||
const statusByChildId = new Map(
|
||||
termin.participations.map((participation) => [
|
||||
participation.childId,
|
||||
participation.status,
|
||||
]),
|
||||
function getStats(termin: AdminTerminDto, rows: ChildDto[]) {
|
||||
const isFamily = termin.rsvpMode === RsvpMode.FAMILY;
|
||||
const statusByChildId = new Map<string, EventParticipationStatus>(
|
||||
isFamily
|
||||
? termin.familyParticipations.map((row) => [row.familyId, row.status])
|
||||
: termin.participations.map((row) => [row.childId, row.status]),
|
||||
);
|
||||
const adultCounts = new Map(
|
||||
termin.familyParticipations.map((row) => [row.familyId, row.adultCount]),
|
||||
);
|
||||
|
||||
let attending = 0;
|
||||
let notAttending = 0;
|
||||
|
||||
children.forEach((child) => {
|
||||
const status = statusByChildId.get(child.id);
|
||||
if (status === EventParticipationStatus.ATTENDING) attending += 1;
|
||||
let adultCount = 0;
|
||||
for (const row of rows) {
|
||||
const status = statusByChildId.get(row.id);
|
||||
if (status === EventParticipationStatus.ATTENDING) {
|
||||
attending += 1;
|
||||
adultCount += adultCounts.get(row.id) ?? 0;
|
||||
}
|
||||
if (status === EventParticipationStatus.NOT_ATTENDING) notAttending += 1;
|
||||
});
|
||||
|
||||
}
|
||||
return {
|
||||
attending,
|
||||
notAttending,
|
||||
pending: Math.max(children.length - attending - notAttending, 0),
|
||||
pending: rows.length - attending - notAttending,
|
||||
statusByChildId,
|
||||
adultCounts,
|
||||
adultCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function AdminKalenderPage() {
|
||||
return <div className="p-8">Kein Mandant zugeordnet.</div>;
|
||||
}
|
||||
|
||||
const [termine, children] = await Promise.all([
|
||||
const [termine, children, families] = await Promise.all([
|
||||
prisma.termin.findMany({
|
||||
where: { kitaId },
|
||||
select: {
|
||||
@@ -28,6 +28,10 @@ export default async function AdminKalenderPage() {
|
||||
allDay: true,
|
||||
mitbringselListEnabled: true,
|
||||
requiresRsvp: true,
|
||||
rsvpMode: true,
|
||||
familyParticipations: {
|
||||
select: { familyId: true, status: true, adultCount: true },
|
||||
},
|
||||
rsvpDeadline: true,
|
||||
participantInfo: true,
|
||||
participations: {
|
||||
@@ -60,6 +64,11 @@ export default async function AdminKalenderPage() {
|
||||
{ firstName: "asc" },
|
||||
],
|
||||
}),
|
||||
prisma.family.findMany({
|
||||
where: { kitaId },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -70,6 +79,7 @@ export default async function AdminKalenderPage() {
|
||||
endDate: termin.endDate.toISOString(),
|
||||
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
|
||||
}))}
|
||||
familyRows={families}
|
||||
childRows={children.map((child) => ({
|
||||
id: child.id,
|
||||
name: `${child.firstName} ${child.lastName}`,
|
||||
|
||||
@@ -64,7 +64,9 @@ export function NewsForm() {
|
||||
rows={10}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."}
|
||||
placeholder={
|
||||
"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."
|
||||
}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
@@ -110,21 +112,23 @@ export function NewsForm() {
|
||||
</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 className="mt-4 grid gap-2">
|
||||
<Label htmlFor="news-audience">Empfängergruppe</Label>
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Bestimmt, wer die Meldung und Anhänge in der App sehen darf und
|
||||
optional die E-Mail erhält.
|
||||
</p>
|
||||
<Select name="audience" defaultValue="ELTERN">
|
||||
<SelectTrigger id="news-audience">
|
||||
<SelectValue placeholder="Empfänger auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ELTERN">Eltern</SelectItem>
|
||||
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
|
||||
<SelectItem value="ALLE">Eltern und ErzieherInnen</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { announcementAudienceLabels } from "@/lib/announcement-access";
|
||||
import { NewsForm } from "./news-form";
|
||||
|
||||
export const metadata = { title: "Schwarzes Brett · Kita-Planer" };
|
||||
@@ -30,6 +31,7 @@ export default async function AdminNewsPage() {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
audience: true,
|
||||
createdAt: true,
|
||||
attachments: {
|
||||
select: {
|
||||
@@ -63,8 +65,8 @@ export default async function AdminNewsPage() {
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
|
||||
werden geschützt gespeichert und nur für eingeloggte Nutzer
|
||||
derselben Kita ausgeliefert.
|
||||
werden geschützt gespeichert und nur an die gewählte Empfängergruppe
|
||||
deiner Kita ausgeliefert.
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
|
||||
@@ -127,6 +129,9 @@ export default async function AdminNewsPage() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="mb-2">
|
||||
{announcementAudienceLabels[announcement.audience]}
|
||||
</Badge>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
|
||||
locale: de,
|
||||
|
||||
Reference in New Issue
Block a user