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
This commit is contained in:
2026-08-25 16:52:34 +02:00
parent 45261a0461
commit 5d83c0dc1e
11 changed files with 611 additions and 30 deletions
+176
View File
@@ -0,0 +1,176 @@
import type { AdminViewServerProps } from "payload";
import { getAdminCalendarItems, type AdminCalendarItem } from "@/lib/booking/adminCalendar";
import styles from "./AdminCalendarView.module.css";
const WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
const STATUS_LABEL: Record<string, string> = {
pending: "Anfrage",
confirmed: "Bestätigt",
rejected: "Abgelehnt",
cancelled: "Storniert",
public: "Öffentlich",
};
const DOT_CLASS: Record<string, string> = {
pending: styles.dotPending,
confirmed: styles.dotConfirmed,
rejected: styles.dotRejected,
cancelled: styles.dotCancelled,
public: styles.dotPublic,
};
const BADGE_CLASS: Record<string, string> = {
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<string, AdminCalendarItem[]>();
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:0020:00
return (
<div className={styles.page}>
<h1 className={styles.title}>Kalender</h1>
<p className={styles.subtitle}>
Alle bestätigten und vorgeschlagenen Termine, private Einzelbuchungen und öffentliche Veranstaltungen.
</p>
<div className={styles.layout}>
<div>
<div className={styles.monthNav}>
<a href={`?month=${monthParam(prevMonth)}`} aria-label="Vorheriger Monat">
</a>
<span className={styles.monthLabel}>{monthLabel}</span>
<a href={`?month=${monthParam(nextMonth)}`} aria-label="Nächster Monat">
</a>
</div>
<div className={styles.grid}>
{WEEKDAY_LABELS.map((d) => (
<div key={d} className={styles.weekdayLabel}>
{d}
</div>
))}
{cells.map((day, i) => {
if (!day) return <div key={i} className={`${styles.dayCell} ${styles.dayCellEmpty}`} />;
const key = dateKey(day);
const dayEntries = itemsByDay.get(key) ?? [];
const isSelected = selectedDayParam === key;
return (
<a
key={i}
href={`?month=${monthParam(month)}&day=${key}`}
className={`${styles.dayCell} ${isSelected ? styles.dayCellSelected : ""} ${key === today ? styles.dayCellToday : ""}`}
>
<span>{day.getDate()}</span>
{dayEntries.length > 0 && (
<span className={styles.dots}>
{dayEntries.slice(0, 4).map((entry, j) => (
<span key={j} className={`${styles.dot} ${DOT_CLASS[entry.status] ?? ""}`} />
))}
</span>
)}
</a>
);
})}
</div>
<div className={styles.legend}>
{Object.entries(STATUS_LABEL).map(([status, label]) => (
<span key={status} className={styles.legendItem}>
<span className={`${styles.dot} ${DOT_CLASS[status]}`} />
{label}
</span>
))}
</div>
</div>
<div className={styles.schedule}>
<div className={styles.scheduleHeader}>
{selectedDay
? selectedDay.toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long", year: "numeric" })
: "Wähle einen Tag im Kalender"}
</div>
{!selectedDay && <p className={styles.empty}>Klicke auf einen Tag, um die Tagesübersicht zu sehen.</p>}
{selectedDay && dayItems.length === 0 && <p className={styles.empty}>An diesem Tag ist nichts eingetragen.</p>}
{selectedDay &&
dayItems.length > 0 &&
hours.map((hour) => {
const hourEntries = dayItems.filter((item) => new Date(item.startTime).getHours() === hour);
return (
<div key={hour} className={styles.hourRow}>
<div className={styles.hourLabel}>{String(hour).padStart(2, "0")}:00</div>
<div className={styles.hourContent}>
{hourEntries.map((entry) => (
<a key={entry.id} href={entry.href} className={styles.entry}>
<span className={`${styles.badge} ${BADGE_CLASS[entry.status] ?? ""}`}>
{STATUS_LABEL[entry.status] ?? entry.status}
</span>
<strong>{entry.title}</strong>
<span>
{fmtTime(entry.startTime)}{fmtTime(entry.endTime)}
</span>
{entry.subtitle && <span>· {entry.subtitle}</span>}
{entry.appointmentType && <span>· {entry.appointmentType === "online" ? "Online" : "Vor Ort"}</span>}
</a>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}