feat(sprint7): Phase 2.5 — Club Event Calendar
- Flyway V14: club_events + event_rsvps tables with reminder_sent tracking - Enums: EventType, RsvpStatus, RecurrenceRule + extend AuditEventType/NotificationType - Entities: ClubEvent (extends AbstractTenantEntity), EventRsvp (unique event+member) - Repositories: ClubEventRepository, EventRsvpRepository with date-range and status queries - EventService: CRUD, RSVP with maxAttendees enforcement (409 if full), iCal RFC 5545 generation, recurring event virtual expansion, notifications on create/cancel, auto-post to Info Board - EventReminderScheduler: hourly check, 24h reminder to ACCEPTED/MAYBE attendees - EventController: admin CRUD (MANAGE_INFO_BOARD permission), portal upcoming events, RSVP endpoint, iCal download (text/calendar), attendee list - Frontend: events.ts service (React Query hooks matching apiClient pattern), admin calendar page (month grid with event dots, create dialog, event cards), portal events page (RSVP buttons, capacity display) - Navigation: added Kalender with Calendar icon - i18n: events.* keys in de.json and en.json - UI: added @radix-ui/react-switch + Switch component
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
getEventTypeColor,
|
||||
getEventTypeLabel,
|
||||
getIcalUrl,
|
||||
useCancelEventMutation,
|
||||
useCreateEventMutation,
|
||||
useEventsQuery,
|
||||
} from "@/services/events"
|
||||
import {
|
||||
addMonths,
|
||||
eachDayOfInterval,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
format,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
subMonths,
|
||||
} from "date-fns"
|
||||
import { de } from "date-fns/locale"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
Calendar,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
MapPin,
|
||||
Plus,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react"
|
||||
|
||||
import type { ClubEvent, EventType, RecurrenceRule } from "@/services/events"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
export default function CalendarPage() {
|
||||
const t = useTranslations("events")
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
|
||||
const monthStart = startOfMonth(currentMonth)
|
||||
const monthEnd = endOfMonth(currentMonth)
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 })
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 })
|
||||
|
||||
const from = monthStart.toISOString()
|
||||
const to = monthEnd.toISOString()
|
||||
|
||||
const { data: events = [] as ClubEvent[], isLoading } = useEventsQuery(
|
||||
from,
|
||||
to
|
||||
)
|
||||
const cancelEvent = useCancelEventMutation()
|
||||
|
||||
const days = eachDayOfInterval({ start: calendarStart, end: calendarEnd })
|
||||
const weekDays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||
|
||||
const getEventsForDay = (day: Date): ClubEvent[] =>
|
||||
events.filter((event: ClubEvent) => isSameDay(new Date(event.startAt), day))
|
||||
|
||||
const selectedDayEvents: ClubEvent[] = selectedDate
|
||||
? events.filter((event: ClubEvent) =>
|
||||
isSameDay(new Date(event.startAt), selectedDate)
|
||||
)
|
||||
: []
|
||||
|
||||
const upcomingEvents = events
|
||||
.filter((e: ClubEvent) => new Date(e.startAt) >= new Date())
|
||||
.sort(
|
||||
(a: ClubEvent, b: ClubEvent) =>
|
||||
new Date(a.startAt).getTime() - new Date(b.startAt).getTime()
|
||||
)
|
||||
.slice(0, 5)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("createEvent")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("createEvent")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CreateEventForm onSuccess={() => setShowCreateDialog(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Calendar Grid */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<CardTitle className="text-lg">
|
||||
{format(currentMonth, "MMMM yyyy", { locale: de })}
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Week day headers */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{weekDays.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-center text-xs font-medium text-muted-foreground py-2"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Day cells */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((day) => {
|
||||
const dayEvents = getEventsForDay(day)
|
||||
const isCurrentMonth = isSameMonth(day, currentMonth)
|
||||
const isSelected = selectedDate && isSameDay(day, selectedDate)
|
||||
const isToday = isSameDay(day, new Date())
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => setSelectedDate(day)}
|
||||
className={`
|
||||
relative flex flex-col items-center justify-start p-1 h-16 rounded-md text-sm transition-colors
|
||||
${!isCurrentMonth ? "text-muted-foreground/40" : ""}
|
||||
${isSelected ? "bg-primary/10 ring-1 ring-primary" : "hover:bg-muted"}
|
||||
${isToday ? "font-bold" : ""}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
isToday
|
||||
? "bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center text-xs"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{format(day, "d")}
|
||||
</span>
|
||||
{dayEvents.length > 0 && (
|
||||
<div className="flex gap-0.5 mt-1 flex-wrap justify-center">
|
||||
{dayEvents.slice(0, 3).map((event: ClubEvent) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={`w-1.5 h-1.5 rounded-full ${getEventTypeColor(event.eventType)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sidebar: Selected day events or upcoming */}
|
||||
<div className="space-y-4">
|
||||
{selectedDate ? (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">
|
||||
{format(selectedDate, "EEEE, d. MMMM", { locale: de })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedDayEvents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noEventsOnDay")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{selectedDayEvents.map((event: ClubEvent) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onCancel={() => cancelEvent.mutate(event.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{t("upcomingEvents")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Laden...</p>
|
||||
) : upcomingEvents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noUpcomingEvents")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{upcomingEvents.map((event: ClubEvent) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onCancel={() => cancelEvent.mutate(event.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventCard({
|
||||
event,
|
||||
onCancel,
|
||||
}: {
|
||||
event: ClubEvent
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const t = useTranslations("events")
|
||||
const accepted = event.attendeeCounts?.ACCEPTED ?? 0
|
||||
const maybe = event.attendeeCounts?.MAYBE ?? 0
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{getEventTypeLabel(event.eventType)}
|
||||
</Badge>
|
||||
{event.recurring && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
🔁
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-medium mt-1">{event.title}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>
|
||||
📅{" "}
|
||||
{format(new Date(event.startAt), "dd.MM.yyyy HH:mm", { locale: de })}
|
||||
</p>
|
||||
{event.location && (
|
||||
<p className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" /> {event.location}
|
||||
</p>
|
||||
)}
|
||||
<p className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" /> {accepted} Zusagen, {maybe} Vielleicht
|
||||
{event.maxAttendees && ` / max. ${event.maxAttendees}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 pt-1">
|
||||
<a href={getIcalUrl(event.id)} download className="inline-flex">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs">
|
||||
<Download className="h-3 w-3 mr-1" /> iCal
|
||||
</Button>
|
||||
</a>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-destructive"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" /> {t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateEventForm({ onSuccess }: { onSuccess: () => void }) {
|
||||
const t = useTranslations("events")
|
||||
const createEvent = useCreateEventMutation()
|
||||
const [recurring, setRecurring] = useState(false)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const formData = new FormData(e.currentTarget)
|
||||
|
||||
createEvent.mutate(
|
||||
{
|
||||
title: formData.get("title") as string,
|
||||
description: (formData.get("description") as string) || undefined,
|
||||
eventType: formData.get("eventType") as EventType,
|
||||
startAt: new Date(formData.get("startAt") as string).toISOString(),
|
||||
endAt: formData.get("endAt")
|
||||
? new Date(formData.get("endAt") as string).toISOString()
|
||||
: undefined,
|
||||
location: (formData.get("location") as string) || undefined,
|
||||
maxAttendees: formData.get("maxAttendees")
|
||||
? Number(formData.get("maxAttendees"))
|
||||
: undefined,
|
||||
recurring,
|
||||
recurrenceRule: recurring
|
||||
? (formData.get("recurrenceRule") as RecurrenceRule)
|
||||
: undefined,
|
||||
recurrenceEndDate:
|
||||
recurring && formData.get("recurrenceEndDate")
|
||||
? (formData.get("recurrenceEndDate") as string)
|
||||
: undefined,
|
||||
postToInfoBoard: true,
|
||||
},
|
||||
{ onSuccess }
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">{t("form.title")}</Label>
|
||||
<Input id="title" name="title" required maxLength={200} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="eventType">{t("form.type")}</Label>
|
||||
<Select name="eventType" defaultValue="MEETING">
|
||||
<option value="MEETING">Mitgliederversammlung</option>
|
||||
<option value="HARVEST_FESTIVAL">Erntefest</option>
|
||||
<option value="BOARD_MEETING">Vorstandssitzung</option>
|
||||
<option value="WORKSHOP">Workshop</option>
|
||||
<option value="OTHER">Sonstiges</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startAt">{t("form.start")}</Label>
|
||||
<Input id="startAt" name="startAt" type="datetime-local" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endAt">{t("form.end")}</Label>
|
||||
<Input id="endAt" name="endAt" type="datetime-local" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">{t("form.location")}</Label>
|
||||
<Input id="location" name="location" maxLength={300} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">{t("form.description")}</Label>
|
||||
<Textarea id="description" name="description" rows={3} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxAttendees">{t("form.maxAttendees")}</Label>
|
||||
<Input id="maxAttendees" name="maxAttendees" type="number" min={1} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id="recurring"
|
||||
checked={recurring}
|
||||
onCheckedChange={setRecurring}
|
||||
/>
|
||||
<Label htmlFor="recurring">{t("form.recurring")}</Label>
|
||||
</div>
|
||||
|
||||
{recurring && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="recurrenceRule">{t("form.recurrenceRule")}</Label>
|
||||
<Select name="recurrenceRule" defaultValue="WEEKLY">
|
||||
<option value="WEEKLY">Wöchentlich</option>
|
||||
<option value="BIWEEKLY">Alle 2 Wochen</option>
|
||||
<option value="MONTHLY">Monatlich</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="recurrenceEndDate">{t("form.recurrenceEnd")}</Label>
|
||||
<Input
|
||||
id="recurrenceEndDate"
|
||||
name="recurrenceEndDate"
|
||||
type="date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={createEvent.isPending}>
|
||||
{createEvent.isPending ? "Erstelle..." : t("createEvent")}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
getEventTypeLabel,
|
||||
getIcalUrl,
|
||||
usePortalRsvpMutation,
|
||||
useUpcomingEventsQuery,
|
||||
} from "@/services/events"
|
||||
import { format } from "date-fns"
|
||||
import { de } from "date-fns/locale"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
Calendar,
|
||||
Check,
|
||||
Download,
|
||||
HelpCircle,
|
||||
MapPin,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
|
||||
import type { ClubEvent, RsvpStatus } from "@/services/events"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
export default function PortalEventsPage() {
|
||||
const t = useTranslations("events")
|
||||
const { data: events = [], isLoading } = useUpcomingEventsQuery()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Calendar className="h-6 w-6" />
|
||||
{t("portalTitle")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{t("portalDescription")}</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">Laden...</p>
|
||||
) : events.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
{t("noUpcomingEvents")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{events.map((event: ClubEvent) => (
|
||||
<PortalEventCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PortalEventCard({ event }: { event: ClubEvent }) {
|
||||
const t = useTranslations("events")
|
||||
const rsvpMutation = usePortalRsvpMutation(event.id)
|
||||
const accepted = event.attendeeCounts?.ACCEPTED ?? 0
|
||||
const maybe = event.attendeeCounts?.MAYBE ?? 0
|
||||
const isFull = event.maxAttendees != null && accepted >= event.maxAttendees
|
||||
|
||||
const handleRsvp = (status: RsvpStatus) => {
|
||||
rsvpMutation.mutate(status)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<Badge variant="secondary" className="mb-2">
|
||||
{getEventTypeLabel(event.eventType)}
|
||||
</Badge>
|
||||
<CardTitle className="text-lg">{event.title}</CardTitle>
|
||||
</div>
|
||||
{isFull && <Badge variant="destructive">{t("full")}</Badge>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p>
|
||||
📅{" "}
|
||||
{format(new Date(event.startAt), "EEEE, d. MMMM yyyy · HH:mm", {
|
||||
locale: de,
|
||||
})}{" "}
|
||||
Uhr
|
||||
{event.endAt && ` – ${format(new Date(event.endAt), "HH:mm")} Uhr`}
|
||||
</p>
|
||||
{event.location && (
|
||||
<p className="flex items-center gap-1">
|
||||
<MapPin className="h-4 w-4" /> {event.location}
|
||||
</p>
|
||||
)}
|
||||
<p className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" /> {accepted} Zusagen, {maybe} Vielleicht
|
||||
{event.maxAttendees && ` (max. ${event.maxAttendees})`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{event.description && <p className="text-sm">{event.description}</p>}
|
||||
|
||||
{/* RSVP Buttons */}
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={event.myRsvpStatus === "ACCEPTED" ? "default" : "outline"}
|
||||
onClick={() => handleRsvp("ACCEPTED")}
|
||||
disabled={isFull && event.myRsvpStatus !== "ACCEPTED"}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" /> {t("rsvp.accept")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={event.myRsvpStatus === "MAYBE" ? "default" : "outline"}
|
||||
onClick={() => handleRsvp("MAYBE")}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4 mr-1" /> {t("rsvp.maybe")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={
|
||||
event.myRsvpStatus === "DECLINED" ? "destructive" : "outline"
|
||||
}
|
||||
onClick={() => handleRsvp("DECLINED")}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" /> {t("rsvp.decline")}
|
||||
</Button>
|
||||
<a href={getIcalUrl(event.id)} download>
|
||||
<Button size="sm" variant="ghost">
|
||||
<Download className="h-4 w-4 mr-1" /> iCal
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ComponentRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -39,6 +39,11 @@ export const navigationsData: NavigationType[] = [
|
||||
href: "/info-board",
|
||||
iconName: "Megaphone",
|
||||
},
|
||||
{
|
||||
title: "Kalender",
|
||||
href: "/calendar",
|
||||
iconName: "Calendar",
|
||||
},
|
||||
{
|
||||
title: "Personal",
|
||||
href: "/settings/staff",
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type EventType =
|
||||
| "MEETING"
|
||||
| "HARVEST_FESTIVAL"
|
||||
| "BOARD_MEETING"
|
||||
| "WORKSHOP"
|
||||
| "OTHER"
|
||||
export type RsvpStatus = "ACCEPTED" | "DECLINED" | "MAYBE"
|
||||
export type RecurrenceRule = "WEEKLY" | "BIWEEKLY" | "MONTHLY"
|
||||
|
||||
export interface ClubEvent {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
eventType: EventType
|
||||
startAt: string
|
||||
endAt: string | null
|
||||
location: string | null
|
||||
maxAttendees: number | null
|
||||
recurring: boolean
|
||||
recurrenceRule: RecurrenceRule | null
|
||||
recurrenceEndDate: string | null
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
attendeeCounts: Record<RsvpStatus, number>
|
||||
myRsvpStatus: RsvpStatus | null
|
||||
}
|
||||
|
||||
export interface CreateEventRequest {
|
||||
title: string
|
||||
description?: string
|
||||
eventType: EventType
|
||||
startAt: string
|
||||
endAt?: string
|
||||
location?: string
|
||||
maxAttendees?: number
|
||||
recurring: boolean
|
||||
recurrenceRule?: RecurrenceRule
|
||||
recurrenceEndDate?: string
|
||||
postToInfoBoard?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateEventRequest {
|
||||
title: string
|
||||
description?: string
|
||||
eventType: EventType
|
||||
startAt: string
|
||||
endAt?: string
|
||||
location?: string
|
||||
maxAttendees?: number
|
||||
recurring: boolean
|
||||
recurrenceRule?: RecurrenceRule
|
||||
recurrenceEndDate?: string
|
||||
}
|
||||
|
||||
export interface RsvpResponse {
|
||||
memberId: string
|
||||
memberName: string
|
||||
status: RsvpStatus
|
||||
respondedAt: string
|
||||
}
|
||||
|
||||
// --- Query Hooks ---
|
||||
|
||||
export function useEventsQuery(from: string, to: string) {
|
||||
return useQuery({
|
||||
queryKey: ["events", from, to],
|
||||
queryFn: () =>
|
||||
apiClient<ClubEvent[]>(`/events?from=${from}&to=${to}`),
|
||||
enabled: !!from && !!to,
|
||||
})
|
||||
}
|
||||
|
||||
export function useEventQuery(eventId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["events", eventId],
|
||||
queryFn: () => apiClient<ClubEvent>(`/events/${eventId}`),
|
||||
enabled: !!eventId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpcomingEventsQuery() {
|
||||
return useQuery({
|
||||
queryKey: ["events", "upcoming"],
|
||||
queryFn: () => apiClient<ClubEvent[]>("/portal/events"),
|
||||
})
|
||||
}
|
||||
|
||||
export function useEventAttendeesQuery(eventId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["events", eventId, "attendees"],
|
||||
queryFn: () => apiClient<RsvpResponse[]>(`/events/${eventId}/attendees`),
|
||||
enabled: !!eventId,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Mutation Hooks ---
|
||||
|
||||
export function useCreateEventMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateEventRequest) =>
|
||||
apiClient<ClubEvent>("/events", { method: "POST", body: data }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["events"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateEventMutation(eventId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdateEventRequest) =>
|
||||
apiClient<ClubEvent>(`/events/${eventId}`, { method: "PUT", body: data }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["events"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCancelEventMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (eventId: string) =>
|
||||
apiClient<void>(`/events/${eventId}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["events"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRsvpMutation(eventId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (status: RsvpStatus) =>
|
||||
apiClient<{ status: RsvpStatus; respondedAt: string }>(
|
||||
`/events/${eventId}/rsvp`,
|
||||
{ method: "POST", body: { status } }
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["events"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePortalRsvpMutation(eventId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (status: RsvpStatus) =>
|
||||
apiClient<{ status: RsvpStatus; respondedAt: string }>(
|
||||
`/portal/events/${eventId}/rsvp`,
|
||||
{ method: "POST", body: { status } }
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["events"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
export function getEventTypeLabel(type: EventType): string {
|
||||
const labels: Record<EventType, string> = {
|
||||
MEETING: "Mitgliederversammlung",
|
||||
HARVEST_FESTIVAL: "Erntefest",
|
||||
BOARD_MEETING: "Vorstandssitzung",
|
||||
WORKSHOP: "Workshop",
|
||||
OTHER: "Sonstiges",
|
||||
}
|
||||
return labels[type]
|
||||
}
|
||||
|
||||
export function getEventTypeColor(type: EventType): string {
|
||||
const colors: Record<EventType, string> = {
|
||||
MEETING: "bg-blue-500",
|
||||
HARVEST_FESTIVAL: "bg-green-500",
|
||||
BOARD_MEETING: "bg-purple-500",
|
||||
WORKSHOP: "bg-amber-500",
|
||||
OTHER: "bg-gray-500",
|
||||
}
|
||||
return colors[type]
|
||||
}
|
||||
|
||||
export function getRsvpStatusLabel(status: RsvpStatus): string {
|
||||
const labels: Record<RsvpStatus, string> = {
|
||||
ACCEPTED: "Zusage",
|
||||
DECLINED: "Absage",
|
||||
MAYBE: "Vielleicht",
|
||||
}
|
||||
return labels[status]
|
||||
}
|
||||
|
||||
export function getIcalUrl(eventId: string): string {
|
||||
return `/api/backend/events/${eventId}/ical`
|
||||
}
|
||||
Reference in New Issue
Block a user