Files
anouma/components/admin/AdminCalendarView.tsx
T
maro 5d83c0dc1e 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
2026-08-25 16:52:34 +02:00

177 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}