Files
anouma/components/admin/AdminCalendarView.tsx
T

203 lines
8.7 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 Link from "next/link";
import { getAdminCalendarItems, type AdminCalendarItem } from "@/lib/booking/adminCalendar";
import { berlinParts } from "@/lib/calendar/timezone";
import { CalendarFeedPanel } from "./CalendarFeedPanel";
import { AdminCallButton } from "./AdminCallButton";
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) {
const { year, month, day } = berlinParts(d);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
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", {
timeZone: "Europe/Berlin",
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");
// Noon UTC stays on the same Berlin calendar day even when the server runs
// in UTC and the selected date is around a DST transition.
const selectedDay = selectedDayParam ? new Date(`${selectedDayParam}T12:00:00Z`) : 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 });
// Local API (overrideAccess: true by default) so this can see
// calendarFeedTokenHash despite its field-level access being locked to
// "nobody" for every real API path - see collections/Users.ts.
const currentUser = req.user ? await req.payload.findByID({ collection: "users", id: req.user.id, req }) : null;
const calendarFeedConfigured = Boolean((currentUser as { calendarFeedTokenHash?: string | null } | null)?.calendarFeedTokenHash);
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 startHours = dayItems.length ? dayItems.map((item) => berlinParts(new Date(item.startTime)).hour) : [];
const firstHour = Math.min(8, ...startHours);
const lastHour = Math.max(20, ...startHours);
const hours = Array.from({ length: lastHour - firstHour + 1 }, (_, i) => i + firstHour);
return (
<div className={styles.page}>
<Link href="/admin" className="anouma-admin-back">Zur Übersicht</Link>
<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>
<CalendarFeedPanel initiallyConfigured={calendarFeedConfigured} />
</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) => berlinParts(new Date(item.startTime)).hour === 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) => (
<div key={entry.id} className={styles.entry}>
<a href={entry.href} className={styles.entryLink}>
<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>
{entry.callEventSlug && <AdminCallButton eventSlug={entry.callEventSlug} />}
</div>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}