feat: add parent announcements and family or child event responses
This commit is contained in:
@@ -2,7 +2,12 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
|
||||
import {
|
||||
DutyAssignmentStatus,
|
||||
RsvpMode,
|
||||
TerminStatus,
|
||||
TerminType,
|
||||
} from "@prisma/client";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import {
|
||||
@@ -15,6 +20,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { RsvpForm } from "@/components/rsvp-form";
|
||||
import type { RsvpEventDto } from "@/lib/rsvp";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -44,6 +51,10 @@ export type TerminListItemDto = {
|
||||
allDay: boolean;
|
||||
mitbringselListEnabled: boolean;
|
||||
requiresRsvp: boolean;
|
||||
rsvpMode: RsvpMode;
|
||||
rsvpDeadline: Date | null;
|
||||
hasRsvpResponses: boolean;
|
||||
rsvpEvent?: RsvpEventDto;
|
||||
participantInfo: string | null;
|
||||
showParticipantInfo: boolean;
|
||||
mitbringselItems?: {
|
||||
@@ -88,7 +99,10 @@ export type NotdienstSwapOptionDto = {
|
||||
familyName: string;
|
||||
};
|
||||
|
||||
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto | NotdienstCalendarItemDto;
|
||||
export type CalendarListItemDto =
|
||||
| TerminListItemDto
|
||||
| DutyCalendarItemDto
|
||||
| NotdienstCalendarItemDto;
|
||||
|
||||
export function TerminList({
|
||||
termine,
|
||||
@@ -116,7 +130,10 @@ export function TerminList({
|
||||
</p>
|
||||
<div className="mt-5">
|
||||
{isAdmin ? (
|
||||
<AdminTerminModal triggerLabel="+ Termin anlegen" triggerClassName="" />
|
||||
<AdminTerminModal
|
||||
triggerLabel="+ Termin anlegen"
|
||||
triggerClassName=""
|
||||
/>
|
||||
) : (
|
||||
<TerminRequestModal
|
||||
triggerLabel="+ Termin anlegen"
|
||||
@@ -141,14 +158,19 @@ export function TerminList({
|
||||
{group.label}
|
||||
</h2>
|
||||
<span className="rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)] tabular-nums">
|
||||
{group.items.length} {group.items.length === 1 ? "Termin" : "Termine"}
|
||||
{group.items.length}{" "}
|
||||
{group.items.length === 1 ? "Termin" : "Termine"}
|
||||
</span>
|
||||
</div>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||
{group.items.map((item) =>
|
||||
item.kind === "duty" ? (
|
||||
<DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} />
|
||||
<DutyRow
|
||||
key={`duty-${item.id}`}
|
||||
duty={item}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
) : item.kind === "notdienst" ? (
|
||||
<NotdienstRow
|
||||
key={`notdienst-${item.id}`}
|
||||
@@ -157,7 +179,12 @@ export function TerminList({
|
||||
notdienstSwapOptions={notdienstSwapOptions}
|
||||
/>
|
||||
) : (
|
||||
<TerminRow key={`termin-${item.id}`} termin={item} userId={userId} isAdmin={isAdmin} />
|
||||
<TerminRow
|
||||
key={`termin-${item.id}`}
|
||||
termin={item}
|
||||
userId={userId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
@@ -168,13 +195,22 @@ export function TerminList({
|
||||
);
|
||||
}
|
||||
|
||||
function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolean }) {
|
||||
function DutyRow({
|
||||
duty,
|
||||
isAdmin,
|
||||
}: {
|
||||
duty: DutyCalendarItemDto;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const color = duty.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)";
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
|
||||
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
|
||||
<div
|
||||
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<DateTile date={duty.startDate} />
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
||||
@@ -207,7 +243,10 @@ function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolea
|
||||
</Button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
|
||||
<a
|
||||
href="/dashboard/admin/dienste"
|
||||
@@ -257,7 +296,10 @@ function TerminRow({
|
||||
<>
|
||||
<Dialog>
|
||||
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
|
||||
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
|
||||
<div
|
||||
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<DateTile date={termin.startDate} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -276,7 +318,9 @@ function TerminRow({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
|
||||
<CategoryPill color={color}>
|
||||
{getTypeLabel(termin.type)}
|
||||
</CategoryPill>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
Details
|
||||
@@ -297,7 +341,10 @@ function TerminRow({
|
||||
</Button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-1 w-48 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
|
||||
<AdminTerminModal
|
||||
existingTermin={termin}
|
||||
@@ -342,7 +389,9 @@ function TerminRow({
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="flex">
|
||||
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
|
||||
<CategoryPill color={color}>
|
||||
{getTypeLabel(termin.type)}
|
||||
</CategoryPill>
|
||||
</div>
|
||||
|
||||
{termin.description ? (
|
||||
@@ -355,6 +404,16 @@ function TerminRow({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{termin.rsvpEvent ? (
|
||||
<section className="rounded-lg border border-[var(--hairline)] p-4">
|
||||
<h3 className="mb-3 font-medium">Eure Rückmeldung</h3>
|
||||
<RsvpForm
|
||||
key={JSON.stringify(termin.rsvpEvent)}
|
||||
event={termin.rsvpEvent}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{termin.participantInfo && termin.showParticipantInfo && (
|
||||
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
|
||||
<div className="mb-1 flex items-center gap-2 font-medium">
|
||||
@@ -385,8 +444,9 @@ function TerminRow({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Möchtest du den Termin “{termin.title}” wirklich löschen? Alle
|
||||
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
|
||||
Möchtest du den Termin “{termin.title}” wirklich
|
||||
löschen? Alle zugehörigen Teilnahmen und Mitbringsel-Einträge
|
||||
gehen verloren.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -436,14 +496,20 @@ function CategoryPill({
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)]"
|
||||
style={{ color }}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function groupByMonth(items: CalendarListItemDto[]) {
|
||||
const groups = new Map<string, { key: string; label: string; items: CalendarListItemDto[] }>();
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ key: string; label: string; items: CalendarListItemDto[] }
|
||||
>();
|
||||
|
||||
items.forEach((item) => {
|
||||
const key = format(item.startDate, "yyyy-MM");
|
||||
@@ -559,7 +625,8 @@ export function NotdienstSwapDialog({
|
||||
<DialogTitle>Notdienst tauschen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Frage Familie {target.familyName}, ob ihr den Notdienst am{" "}
|
||||
{format(target.startDate, "dd.MM.yyyy", { locale: de })} tauschen könnt.
|
||||
{format(target.startDate, "dd.MM.yyyy", { locale: de })} tauschen
|
||||
könnt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -575,11 +642,14 @@ export function NotdienstSwapDialog({
|
||||
onChange={(event) => setOfferedAssignmentId(event.target.value)}
|
||||
>
|
||||
{selectableOptions.length === 0 ? (
|
||||
<option value="">Kein eigener zukünftiger Notdienst verfügbar</option>
|
||||
<option value="">
|
||||
Kein eigener zukünftiger Notdienst verfügbar
|
||||
</option>
|
||||
) : (
|
||||
selectableOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{format(option.date, "dd.MM.yyyy", { locale: de })} - {option.childName}
|
||||
{format(option.date, "dd.MM.yyyy", { locale: de })} -{" "}
|
||||
{option.childName}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
@@ -603,7 +673,11 @@ export function NotdienstSwapDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)} disabled={isPending}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
@@ -632,7 +706,10 @@ function NotdienstRow({
|
||||
|
||||
return (
|
||||
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
|
||||
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
|
||||
<div
|
||||
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<DateTile date={notdienst.startDate} />
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
||||
@@ -640,7 +717,8 @@ function NotdienstRow({
|
||||
</h3>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
|
||||
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{format(notdienst.startDate, "dd.MM.yyyy", { locale: de })} (ganztägig)
|
||||
{format(notdienst.startDate, "dd.MM.yyyy", { locale: de })}{" "}
|
||||
(ganztägig)
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ink-muted)]" />
|
||||
Kind: {notdienst.childName}
|
||||
@@ -666,7 +744,10 @@ function NotdienstRow({
|
||||
</Button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
|
||||
<a
|
||||
href={`/dashboard/notdienst/plan?year=${notdienst.planYear}&month=${notdienst.planMonth}`}
|
||||
@@ -684,7 +765,11 @@ function NotdienstRow({
|
||||
target={notdienst}
|
||||
options={notdienstSwapOptions}
|
||||
trigger={
|
||||
<Button size="sm" variant="outline" className="h-8 px-2.5 text-xs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2.5 text-xs"
|
||||
>
|
||||
Tausch anfragen
|
||||
</Button>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user