From 5d83c0dc1ee107efb08d2a1285de5804deb533d5 Mon Sep 17 00:00:00 2001 From: maro Date: Tue, 25 Aug 2026 16:52:34 +0200 Subject: [PATCH] Add calendar-based booking system: accounts, availability, admin calendar - Customer accounts (separate auth collection) with /konto area - Availability + AvailabilityOverrides collections driving real slot calculation - BookingRequests with race-safe confirmation (Postgres advisory lock + transaction) - Booking emails (request received, admin notify, confirmed, rejected, alternative proposed/accepted, cancelled) - Confirmed online bookings auto-create a private linked video-call Event - Custom Payload admin calendar view (month grid + day schedule) - Wired booking widget into offer pages and /termin-buchen --- app/(frontend)/angebote/[slug]/page.tsx | 37 +++- app/(frontend)/layout.tsx | 5 +- app/(frontend)/termin-buchen/page.tsx | 36 +++- app/(payload)/admin/importMap.js | 4 + collections/BookingRequests.ts | 54 +++-- components/Navbar.tsx | 16 +- components/admin/AdminCalendarView.module.css | 198 ++++++++++++++++++ components/admin/AdminCalendarView.tsx | 176 ++++++++++++++++ components/admin/CalendarNavLink.tsx | 23 ++ lib/booking/adminCalendar.ts | 81 +++++++ payload.config.ts | 11 + 11 files changed, 611 insertions(+), 30 deletions(-) create mode 100644 components/admin/AdminCalendarView.module.css create mode 100644 components/admin/AdminCalendarView.tsx create mode 100644 components/admin/CalendarNavLink.tsx create mode 100644 lib/booking/adminCalendar.ts diff --git a/app/(frontend)/angebote/[slug]/page.tsx b/app/(frontend)/angebote/[slug]/page.tsx index 47720f8..6e318e2 100644 --- a/app/(frontend)/angebote/[slug]/page.tsx +++ b/app/(frontend)/angebote/[slug]/page.tsx @@ -5,10 +5,12 @@ import { Section } from "@/components/Section"; import { ImagePlaceholder } from "@/components/ImagePlaceholder"; import { RichText } from "@/components/RichText"; import { CTA } from "@/components/CTA"; +import { BookingWidget } from "@/components/booking/BookingWidget"; import { getOfferBySlug } from "@/lib/payload/content"; import { moodForOffer } from "@/lib/angebote"; import { mediaAlt, mediaUrl } from "@/lib/payload/media"; import { OFFER_CATEGORIES } from "@/collections/Offers"; +import { getCurrentCustomer } from "@/lib/auth/customer"; export const dynamic = "force-dynamic"; @@ -30,6 +32,7 @@ export default async function OfferDetailPage({ params }: Args) { if (!offer) notFound(); const categoryLabel = OFFER_CATEGORIES.find((c) => c.value === offer.category)?.label; + const customer = offer.bookable ? await getCurrentCustomer() : null; return ( <> @@ -54,13 +57,39 @@ export default async function OfferDetailPage({ params }: Args) {
+ {(offer.price || offer.durationMinutes) && ( +
+ {offer.price && ( +
+ Preis + {offer.price} +
+ )} + {offer.durationMinutes && ( +
+ Dauer + {offer.durationMinutes} Minuten +
+ )} +
+ )}
- + + {offer.bookable ? ( +
+
+

Termin auswählen

+ +
+
+ ) : ( + + )} ); } diff --git a/app/(frontend)/layout.tsx b/app/(frontend)/layout.tsx index 9495d98..4077020 100644 --- a/app/(frontend)/layout.tsx +++ b/app/(frontend)/layout.tsx @@ -4,6 +4,7 @@ import { Navbar } from "@/components/Navbar"; import { Footer } from "@/components/Footer"; import { siteConfig } from "@/lib/site"; import { getOffers } from "@/lib/payload/content"; +import { getCurrentCustomer } from "@/lib/auth/customer"; import "./globals.css"; // Every page under this layout can read live content from Payload, so the @@ -45,7 +46,7 @@ export const metadata: Metadata = { }; export default async function RootLayout({ children }: LayoutProps<"/">) { - const offers = await getOffers(); + const [offers, customer] = await Promise.all([getOffers(), getCurrentCustomer()]); return ( ) { > Zum Inhalt springen - +
{children}
diff --git a/app/(frontend)/termin-buchen/page.tsx b/app/(frontend)/termin-buchen/page.tsx index df0b660..9b36d5e 100644 --- a/app/(frontend)/termin-buchen/page.tsx +++ b/app/(frontend)/termin-buchen/page.tsx @@ -2,8 +2,12 @@ import type { Metadata } from "next"; import { PageHeader } from "@/components/PageHeader"; import { Section, SectionHeading } from "@/components/Section"; import { ContactForm } from "@/components/ContactForm"; +import { AngebotCard } from "@/components/AngebotCard"; import { Reveal } from "@/components/Reveal"; import { getBookingGlobal, getContactGlobal } from "@/lib/payload/globals"; +import { getOffers } from "@/lib/payload/content"; +import { moodForOffer } from "@/lib/angebote"; +import { mediaUrl } from "@/lib/payload/media"; export const dynamic = "force-dynamic"; @@ -16,7 +20,8 @@ export async function generateMetadata(): Promise { } export default async function TerminBuchenPage() { - const [booking, contact] = await Promise.all([getBookingGlobal(), getContactGlobal()]); + const [booking, contact, offers] = await Promise.all([getBookingGlobal(), getContactGlobal(), getOffers()]); + const bookableOffers = offers.filter((offer) => offer.bookable); return ( <> @@ -27,6 +32,25 @@ export default async function TerminBuchenPage() { crumbs={[{ title: "Termin buchen" }]} /> + {bookableOffers.length > 0 && ( +
+ +
+ {bookableOffers.map((offer, i) => ( + + + + ))} +
+
+ )} + {booking.steps && booking.steps.length > 0 && (
@@ -46,7 +70,15 @@ export default async function TerminBuchenPage() {
- + 0 + ? "Für einen konkreten Termin wähle oben ein Angebot — hier kannst du auch einfach eine allgemeine Nachricht schreiben." + : undefined + } + align="left" + />
{ - if (operation !== "update" || data.status !== "confirmed" || originalDoc?.status === "confirmed") { +const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req }) => { + // Covers both paths: an existing request being confirmed, AND Anna + // manually creating a booking that's already "confirmed" from the start + // (e.g. a phone booking) — both must go through the same conflict check. + const wasAlreadyConfirmed = originalDoc?.status === "confirmed"; + if (data.status !== "confirmed" || wasAlreadyConfirmed) { return data; } - const date = data.date ?? originalDoc.date; - const startTime = data.startTime ?? originalDoc.startTime; - const endTime = data.endTime ?? originalDoc.endTime; - const appointmentType = data.appointmentType ?? originalDoc.appointmentType; + const date = data.date ?? originalDoc?.date; + const startTime = data.startTime ?? originalDoc?.startTime; + const endTime = data.endTime ?? originalDoc?.endTime; + const appointmentType = data.appointmentType ?? originalDoc?.appointmentType; + if (!date || !startTime || !endTime) return data; // Field-level validation will reject this anyway. await acquireBookingDayLock(req, dateKey(date)); + const dayStart = new Date(new Date(date).setHours(0, 0, 0, 0)).toISOString(); + const dayEnd = new Date(new Date(date).setHours(23, 59, 59, 999)).toISOString(); + const conflictClauses: Where[] = [ + { status: { equals: "confirmed" } }, + { date: { greater_than_equal: dayStart } }, + { date: { less_than_equal: dayEnd } }, + ]; + if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } }); + const { docs: sameDayConfirmed } = await req.payload.find({ collection: "booking-requests", - where: { - and: [ - { status: { equals: "confirmed" } }, - { id: { not_equals: originalDoc.id } }, - { date: { greater_than_equal: new Date(new Date(date).setHours(0, 0, 0, 0)).toISOString() } }, - { date: { less_than_equal: new Date(new Date(date).setHours(23, 59, 59, 999)).toISOString() } }, - ], - }, + where: { and: conflictClauses }, req, limit: 200, }); @@ -96,8 +103,8 @@ const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDo ); } - if (appointmentType === "online" && !data.linkedEvent && !originalDoc.linkedEvent) { - const offer = (await req.payload.findByID({ collection: "offers", id: data.offer ?? originalDoc.offer, req })) as Offer; + if (appointmentType === "online" && !data.linkedEvent && !originalDoc?.linkedEvent) { + const offer = (await req.payload.findByID({ collection: "offers", id: data.offer ?? originalDoc?.offer, req })) as Offer; const event = (await req.payload.create({ collection: "events", req, @@ -109,7 +116,10 @@ const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDo category: "sonstiges", isOnline: true, // Events' own beforeChange hook auto-generates the password. isPrivateBooking: true, - bookingRequest: originalDoc.id, + // Only set on the update-to-confirmed path — on create-as-confirmed + // this booking doesn't have an id yet (assigned after insert), so + // the back-reference is simply left blank in that one case. + ...(originalDoc?.id ? { bookingRequest: originalDoc.id } : {}), }, // Created as a draft so it never appears in public /termine listings // (see lib/payload/content.ts) — it's still reachable via its direct @@ -139,7 +149,11 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper const adminUrl = `${serverUrl}/admin/collections/booking-requests/${doc.id}`; const accountUrl = `${serverUrl}/konto/termine`; - if (operation === "create") { + // A brand-new request (the normal customer-initiated path) always gets + // the "received" + admin-notification pair. A booking Anna creates + // manually as already-confirmed/rejected (operation 26: manual bookings) + // skips this and falls through to the same status-based emails below. + if (operation === "create" && doc.status === "pending") { await sendEmail( user.email, bookingRequestReceivedEmail({ @@ -165,7 +179,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper return doc; } - const statusChanged = previousDoc?.status !== doc.status; + const statusChanged = operation === "create" || previousDoc?.status !== doc.status; if (statusChanged && doc.status === "confirmed") { let online: { joinUrl: string; password: string } | undefined; diff --git a/components/Navbar.tsx b/components/Navbar.tsx index ac989a5..236caa4 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -8,7 +8,7 @@ import { groupOffersByCategory } from "@/lib/angebote"; import { mainNav, ctaNav, siteConfig } from "@/lib/site"; import type { Offer } from "@/payload-types"; -export function Navbar({ offers }: { offers: Offer[] }) { +export function Navbar({ offers, customerName }: { offers: Offer[]; customerName?: string | null }) { const pathname = usePathname(); const groups = groupOffersByCategory(offers); const [megaOpen, setMegaOpen] = useState(false); @@ -173,7 +173,13 @@ export function Navbar({ offers }: { offers: Offer[] }) { )} -
+
+ + {customerName ? "Mein Konto" : "Anmelden"} + ) )} + + {customerName ? "Mein Konto" : "Anmelden"} + = { + pending: "Anfrage", + confirmed: "Bestätigt", + rejected: "Abgelehnt", + cancelled: "Storniert", + public: "Öffentlich", +}; +const DOT_CLASS: Record = { + pending: styles.dotPending, + confirmed: styles.dotConfirmed, + rejected: styles.dotRejected, + cancelled: styles.dotCancelled, + public: styles.dotPublic, +}; +const BADGE_CLASS: Record = { + pending: styles.badgePending, + confirmed: styles.badgeConfirmed, + rejected: styles.badgeRejected, + cancelled: styles.badgeCancelled, + public: styles.badgePublic, +}; + +function dateKey(d: Date) { + return d.toISOString().slice(0, 10); +} +function parseMonthParam(value: string | null): Date { + if (value && /^\d{4}-\d{2}$/.test(value)) { + const [y, m] = value.split("-").map(Number); + return new Date(y, m - 1, 1); + } + return new Date(new Date().getFullYear(), new Date().getMonth(), 1); +} +function monthParam(d: Date) { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} +function fmtTime(iso: string) { + return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); +} + +export async function AdminCalendarView({ initPageResult }: AdminViewServerProps) { + const req = initPageResult.req; + const searchParams = req.searchParams; + const month = parseMonthParam(searchParams.get("month")); + const selectedDayParam = searchParams.get("day"); + const selectedDay = selectedDayParam ? new Date(`${selectedDayParam}T00:00:00`) : null; + + const gridStart = new Date(month.getFullYear(), month.getMonth(), 1); + const firstWeekday = (gridStart.getDay() + 6) % 7; + const rangeStart = new Date(gridStart); + rangeStart.setDate(rangeStart.getDate() - firstWeekday); + const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate(); + const rangeEnd = new Date(rangeStart); + rangeEnd.setDate(rangeEnd.getDate() + 41); // 6 full weeks + + const items = await getAdminCalendarItems(req.payload, { from: rangeStart, to: rangeEnd }); + const itemsByDay = new Map(); + for (const item of items) { + const key = dateKey(new Date(item.date)); + itemsByDay.set(key, [...(itemsByDay.get(key) ?? []), item]); + } + + const cells: (Date | null)[] = []; + for (let i = 0; i < firstWeekday; i++) cells.push(null); + for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(month.getFullYear(), month.getMonth(), d)); + while (cells.length % 7 !== 0) cells.push(null); + + const prevMonth = new Date(month.getFullYear(), month.getMonth() - 1, 1); + const nextMonth = new Date(month.getFullYear(), month.getMonth() + 1, 1); + const today = dateKey(new Date()); + const monthLabel = month.toLocaleDateString("de-DE", { month: "long", year: "numeric" }); + + const dayItems = selectedDay ? (itemsByDay.get(dateKey(selectedDay)) ?? []) : []; + const hours = Array.from({ length: 13 }, (_, i) => i + 8); // 08:00–20:00 + + return ( +
+

Kalender

+

+ Alle bestätigten und vorgeschlagenen Termine, private Einzelbuchungen und öffentliche Veranstaltungen. +

+ +
+
+
+ + ‹ + + {monthLabel} + + › + +
+ +
+ {WEEKDAY_LABELS.map((d) => ( +
+ {d} +
+ ))} + {cells.map((day, i) => { + if (!day) return
; + const key = dateKey(day); + const dayEntries = itemsByDay.get(key) ?? []; + const isSelected = selectedDayParam === key; + return ( + + {day.getDate()} + {dayEntries.length > 0 && ( + + {dayEntries.slice(0, 4).map((entry, j) => ( + + ))} + + )} + + ); + })} +
+ +
+ {Object.entries(STATUS_LABEL).map(([status, label]) => ( + + + {label} + + ))} +
+
+ +
+
+ {selectedDay + ? selectedDay.toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long", year: "numeric" }) + : "Wähle einen Tag im Kalender"} +
+ {!selectedDay &&

Klicke auf einen Tag, um die Tagesübersicht zu sehen.

} + {selectedDay && dayItems.length === 0 &&

An diesem Tag ist nichts eingetragen.

} + {selectedDay && + dayItems.length > 0 && + hours.map((hour) => { + const hourEntries = dayItems.filter((item) => new Date(item.startTime).getHours() === hour); + return ( + + ); + })} +
+
+
+ ); +} diff --git a/components/admin/CalendarNavLink.tsx b/components/admin/CalendarNavLink.tsx new file mode 100644 index 0000000..9f2145b --- /dev/null +++ b/components/admin/CalendarNavLink.tsx @@ -0,0 +1,23 @@ +import Link from "next/link"; + +export function CalendarNavLink() { + return ( +
+ + 📅 Kalender + +
+ ); +} diff --git a/lib/booking/adminCalendar.ts b/lib/booking/adminCalendar.ts new file mode 100644 index 0000000..812aca3 --- /dev/null +++ b/lib/booking/adminCalendar.ts @@ -0,0 +1,81 @@ +import type { Payload } from "payload"; +import type { BookingRequest, Customer, Event, Offer } from "@/payload-types"; + +export type AdminCalendarItem = { + id: string; + kind: "booking" | "event"; + title: string; + subtitle?: string; + date: string; + startTime: string; + endTime: string; + status: string; // booking status, or "public" for a standalone Event + appointmentType?: "onsite" | "online"; + href: string; +}; + +/** + * Combines private BookingRequests with standalone public Events (excluding + * the auto-generated Events already represented by a confirmed online + * booking, so Anna never sees the same appointment listed twice) into one + * unified calendar feed for the admin view. + */ +export async function getAdminCalendarItems( + payload: Payload, + range: { from: Date; to: Date }, +): Promise { + const fromISO = range.from.toISOString(); + const toISO = range.to.toISOString(); + + const [{ docs: bookings }, { docs: events }] = await Promise.all([ + payload.find({ + collection: "booking-requests", + where: { and: [{ date: { greater_than_equal: fromISO } }, { date: { less_than_equal: toISO } }] }, + depth: 2, + limit: 500, + }), + payload.find({ + collection: "events", + where: { + and: [ + { date: { greater_than_equal: fromISO } }, + { date: { less_than_equal: toISO } }, + { isPrivateBooking: { not_equals: true } }, + ], + }, + depth: 0, + limit: 500, + }), + ]); + + const bookingItems: AdminCalendarItem[] = (bookings as BookingRequest[]).map((b) => { + const offer = typeof b.offer === "object" ? (b.offer as Offer) : null; + const customer = typeof b.user === "object" ? (b.user as Customer) : null; + return { + id: `booking-${b.id}`, + kind: "booking", + title: offer?.title ?? "Buchung", + subtitle: customer?.name, + date: b.date, + startTime: b.startTime, + endTime: b.endTime, + status: b.status, + appointmentType: b.appointmentType, + href: `/admin/collections/booking-requests/${b.id}`, + }; + }); + + const eventItems: AdminCalendarItem[] = (events as Event[]).map((e) => ({ + id: `event-${e.id}`, + kind: "event", + title: e.title, + date: e.date, + startTime: e.startTime ?? e.date, + endTime: e.endTime ?? e.date, + status: "public", + appointmentType: e.isOnline ? "online" : "onsite", + href: `/admin/collections/events/${e.id}`, + })); + + return [...bookingItems, ...eventItems].sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); +} diff --git a/payload.config.ts b/payload.config.ts index 39834e2..f35f827 100644 --- a/payload.config.ts +++ b/payload.config.ts @@ -38,6 +38,17 @@ export default buildConfig({ meta: { titleSuffix: "— Anouma Admin", }, + components: { + afterNavLinks: ["/components/admin/CalendarNavLink#CalendarNavLink"], + views: { + kalender: { + Component: "/components/admin/AdminCalendarView#AdminCalendarView", + path: "/kalender", + exact: true, + meta: { title: "Kalender" }, + }, + }, + }, }, collections: [ Users,