Files
gemeindeportal/src/components/BookingCalendar.tsx
Michael 39eac91568 GemeindePortal: Full implementation with Apple HIG redesign
- Landing page with large CTAs and seasonal banner
- Multi-step Pool booking wizard with progress bar
- Animated confirmation modals with calendar save
- Wasserzähler flow with large number input and live consumption
- Admin dashboard with today-stats, CSV export, click-to-call
- BookingCalendar with skeleton loading and 44px touch targets
- Cloudflare Turnstile CAPTCHA on pool form
- Supabase auth, RLS, and API routes
- Inline form validation, sticky submit buttons
- Mobile-first responsive design throughout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:35:32 +01:00

215 lines
8.0 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback } from 'react';
import { Verfuegbarkeit } from '@/types';
interface BookingCalendarProps {
onDateSelect: (date: string | null) => void;
selectedDate: string | null;
}
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const MONTH_NAMES = [
'Jänner', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
function getSaisonRange(year: number) {
return {
start: new Date(year, 2, 15),
end: new Date(year, 5, 30),
};
}
function formatDate(d: Date): string {
return d.toISOString().split('T')[0];
}
export default function BookingCalendar({ onDateSelect, selectedDate }: BookingCalendarProps) {
const today = new Date();
const currentYear = today.getFullYear();
const saison = getSaisonRange(currentYear);
const initialMonth = today > saison.start ? today.getMonth() : saison.start.getMonth();
const [viewMonth, setViewMonth] = useState(initialMonth);
const [viewYear] = useState(currentYear);
const [auslastung, setAuslastung] = useState<Record<string, number>>({});
const [maxPerDay, setMaxPerDay] = useState(5);
const [loading, setLoading] = useState(true);
const loadVerfuegbarkeit = useCallback(async () => {
try {
const res = await fetch(`/api/pool/verfuegbarkeit?year=${currentYear}`);
const data = await res.json();
if (data.verfuegbarkeit) {
const map: Record<string, number> = {};
data.verfuegbarkeit.forEach((v: Verfuegbarkeit) => {
map[v.datum] = v.anzahl_buchungen;
});
setAuslastung(map);
}
if (data.max_per_day) {
setMaxPerDay(data.max_per_day);
}
} catch {
// silent
} finally {
setLoading(false);
}
}, [currentYear]);
useEffect(() => {
loadVerfuegbarkeit();
}, [loadVerfuegbarkeit]);
const firstDayOfMonth = new Date(viewYear, viewMonth, 1);
let startDow = firstDayOfMonth.getDay() - 1;
if (startDow < 0) startDow = 6;
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const days: (Date | null)[] = [];
for (let i = 0; i < startDow; i++) days.push(null);
for (let d = 1; d <= daysInMonth; d++) {
days.push(new Date(viewYear, viewMonth, d));
}
const canGoBack = viewMonth > saison.start.getMonth();
const canGoForward = viewMonth < saison.end.getMonth();
// Count available days this month
let availableDays = 0;
for (let d = 1; d <= daysInMonth; d++) {
const day = new Date(viewYear, viewMonth, d);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
if (day >= saison.start && day <= saison.end && day >= tomorrow) {
const dateStr = formatDate(day);
const count = auslastung[dateStr] || 0;
if (count < maxPerDay) availableDays++;
}
}
function getDayStatus(day: Date): 'disabled' | 'full' | 'available' | 'partial' {
if (day < saison.start || day > saison.end) return 'disabled';
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
if (day < tomorrow) return 'disabled';
const dateStr = formatDate(day);
const count = auslastung[dateStr] || 0;
if (count >= maxPerDay) return 'full';
if (count >= maxPerDay - 2) return 'partial';
return 'available';
}
return (
<div className="bg-white rounded-2xl border border-border overflow-hidden">
{/* Sticky availability header */}
{!loading && (
<div className="px-4 py-2.5 bg-bg border-b border-border/50">
<p className="text-xs text-text-muted text-center">
<span className="font-semibold text-success">{availableDays}</span> {availableDays === 1 ? 'Tag' : 'Tage'} verfügbar im {MONTH_NAMES[viewMonth]}
</p>
</div>
)}
<div className="p-4">
{/* Month navigation */}
<div className="flex items-center justify-between mb-4">
<button
onClick={() => canGoBack && setViewMonth(viewMonth - 1)}
disabled={!canGoBack}
className="w-10 h-10 rounded-xl flex items-center justify-center hover:bg-bg disabled:opacity-20 disabled:cursor-not-allowed transition-colors"
aria-label="Vorheriger Monat"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<h3 className="font-bold text-primary text-base">
{MONTH_NAMES[viewMonth]} {viewYear}
</h3>
<button
onClick={() => canGoForward && setViewMonth(viewMonth + 1)}
disabled={!canGoForward}
className="w-10 h-10 rounded-xl flex items-center justify-center hover:bg-bg disabled:opacity-20 disabled:cursor-not-allowed transition-colors"
aria-label="Nächster Monat"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/* Weekday headers */}
<div className="grid grid-cols-7 gap-1 mb-1">
{WEEKDAYS.map((d) => (
<div key={d} className="text-center text-[11px] font-semibold text-text-muted/60 py-1 uppercase">
{d}
</div>
))}
</div>
{/* Days grid */}
{loading ? (
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: 35 }).map((_, i) => (
<div key={i} className="skeleton w-11 h-11 mx-auto" />
))}
</div>
) : (
<div className="grid grid-cols-7 gap-1">
{days.map((day, i) => {
if (!day) return <div key={`empty-${i}`} className="w-11 h-11" />;
const status = getDayStatus(day);
const dateStr = formatDate(day);
const isSelected = selectedDate === dateStr;
const count = auslastung[dateStr] || 0;
const freeSlots = maxPerDay - count;
return (
<button
key={dateStr}
onClick={() => {
if (status === 'disabled' || status === 'full') return;
onDateSelect(isSelected ? null : dateStr);
}}
disabled={status === 'disabled' || status === 'full'}
className={`cal-day relative ${isSelected ? 'cal-selected' : ''} ${status === 'full' ? 'cal-full' : ''} ${status === 'disabled' ? 'cal-disabled' : ''} ${status === 'available' && !isSelected ? 'cal-available' : ''} ${status === 'partial' && !isSelected ? 'cal-partial' : ''}`}
aria-label={`${day.getDate()}. ${MONTH_NAMES[viewMonth]}, ${status === 'full' ? 'ausgebucht' : freeSlots + ' Plätze frei'}`}
>
{day.getDate()}
{/* Small dot indicator for partial */}
{status === 'partial' && !isSelected && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-warning" />
)}
</button>
);
})}
</div>
)}
{/* Legend */}
<div className="flex items-center justify-center gap-5 mt-4 pt-3 border-t border-border/50 text-[11px] text-text-muted">
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-sm bg-success/20 border border-success/30" />
Frei
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-sm bg-warning/20 border border-warning/30" />
Fast voll
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-sm bg-danger/15 border border-danger/20" />
Voll
</div>
</div>
</div>
</div>
);
}