Pool-Buchung auf m³-Kapazität umstellen

- Neuer 4-Schritt-Wizard: Wasser → Termin → Daten → Fertig
- Kapazität basiert auf m³/Tag statt Anzahl Buchungen
- Brunnen-Buchungen belasten keine m³-Kapazität
- Ortswasserleitung: m³ werden gegen Tageslimit geprüft
- BookingCalendar zeigt m³-basierte Verfügbarkeit
- Neues Setting max_m3_per_day (default 150)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael
2026-03-03 20:10:33 +01:00
parent 26ef0ec05f
commit ccc6a9b4ec
5 changed files with 287 additions and 165 deletions

View File

@@ -76,28 +76,37 @@ export async function POST(request: NextRequest) {
const supabase = createServiceClient();
// Max pro Tag laden
// Kapazitätsprüfung nur für Ortswasserleitung
if (wasserquelle === 'ortswasserleitung') {
// Max m³ pro Tag laden
const { data: setting } = await supabase
.from('settings')
.select('value')
.eq('key', 'max_pools_per_day')
.eq('key', 'max_m3_per_day')
.single();
const maxPerDay = setting ? parseInt(setting.value) : 5;
const maxM3PerDay = setting ? parseInt(setting.value) : 150;
// Aktuelle Buchungen für den Tag zählen
const { count } = await supabase
// Summe der m³ aller aktiven Ortswasserleitung-Buchungen des Tages
const { data: dayBookings } = await supabase
.from('buchungen')
.select('*', { count: 'exact', head: true })
.select('wassermenge_m3')
.eq('wunschdatum', wunschdatum)
.eq('status', 'aktiv');
.eq('status', 'aktiv')
.eq('wasserquelle', 'ortswasserleitung');
if ((count || 0) >= maxPerDay) {
const usedM3 = (dayBookings || []).reduce(
(sum: number, b: { wassermenge_m3: number | null }) => sum + (b.wassermenge_m3 || 0),
0
);
if (usedM3 + (wassermenge_m3 || 0) > maxM3PerDay) {
return NextResponse.json(
{ error: 'Dieser Tag ist bereits ausgebucht. Bitte wählen Sie einen anderen Termin.' },
{ error: `An diesem Tag sind nur noch ${maxM3PerDay - usedM3} m³ verfügbar. Bitte wählen Sie einen anderen Termin.` },
{ status: 409 }
);
}
}
// Buchung speichern
const { data: buchung, error: insertError } = await supabase

View File

@@ -11,37 +11,44 @@ export async function GET(request: NextRequest) {
const supabase = createServiceClient();
// Buchungen pro Tag zählen
// Buchungen mit Wasserquelle und Menge laden
const { data: buchungen } = await supabase
.from('buchungen')
.select('wunschdatum')
.select('wunschdatum, wasserquelle, wassermenge_m3')
.eq('status', 'aktiv')
.gte('wunschdatum', saisonStart)
.lte('wunschdatum', saisonEnde);
// Gruppieren
const countMap: Record<string, number> = {};
buchungen?.forEach((b: { wunschdatum: string }) => {
countMap[b.wunschdatum] = (countMap[b.wunschdatum] || 0) + 1;
// Pro Tag: Anzahl Buchungen + Summe m³ (nur Ortswasserleitung)
const dayMap: Record<string, { anzahl: number; summe_m3: number }> = {};
buchungen?.forEach((b: { wunschdatum: string; wasserquelle: string; wassermenge_m3: number | null }) => {
if (!dayMap[b.wunschdatum]) {
dayMap[b.wunschdatum] = { anzahl: 0, summe_m3: 0 };
}
dayMap[b.wunschdatum].anzahl += 1;
if (b.wasserquelle === 'ortswasserleitung' && b.wassermenge_m3) {
dayMap[b.wunschdatum].summe_m3 += b.wassermenge_m3;
}
});
const verfuegbarkeit = Object.entries(countMap).map(([datum, anzahl_buchungen]) => ({
const verfuegbarkeit = Object.entries(dayMap).map(([datum, info]) => ({
datum,
anzahl_buchungen,
anzahl_buchungen: info.anzahl,
summe_m3: info.summe_m3,
}));
// Max pro Tag laden
// Max pro Tag laden
const { data: setting } = await supabase
.from('settings')
.select('value')
.eq('key', 'max_pools_per_day')
.eq('key', 'max_m3_per_day')
.single();
const maxPerDay = setting ? parseInt(setting.value) : 5;
const maxM3PerDay = setting ? parseInt(setting.value) : 150;
return NextResponse.json({
verfuegbarkeit,
max_per_day: maxPerDay,
max_m3_per_day: maxM3PerDay,
});
} catch (err) {
console.error('Verfügbarkeit Error:', err);

View File

@@ -24,18 +24,18 @@ declare global {
}
}
type WizardStep = 'termin' | 'daten' | 'fertig';
type WizardStep = 'wasser' | 'termin' | 'daten' | 'fertig';
export default function PoolBuchungPage() {
const [step, setStep] = useState<WizardStep>('termin');
const [step, setStep] = useState<WizardStep>('wasser');
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [wasserquelle, setWasserquelle] = useState<'' | 'brunnen' | 'ortswasserleitung'>('');
const [wassermenge, setWassermenge] = useState('');
const [formData, setFormData] = useState({
name: '',
strasse: '',
telefon: '',
email: '',
wasserquelle: '' as '' | 'brunnen' | 'ortswasserleitung',
wassermenge_m3: '',
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
@@ -59,16 +59,16 @@ export default function PoolBuchungPage() {
}
}, []);
// Render Turnstile when Step 2 becomes visible and script is loaded
// Render Turnstile when Step 3 (daten) becomes visible and script is loaded
useEffect(() => {
if (step === 'daten' && turnstileReady) {
// Small delay to ensure the container ref is mounted
const timer = setTimeout(renderTurnstile, 100);
return () => clearTimeout(timer);
}
}, [step, turnstileReady, renderTurnstile]);
const steps = [
{ label: 'Wasser', done: step === 'termin' || step === 'daten' || step === 'fertig', active: step === 'wasser' },
{ label: 'Termin', done: step === 'daten' || step === 'fertig', active: step === 'termin' },
{ label: 'Daten', done: step === 'fertig', active: step === 'daten' },
{ label: 'Fertig', done: false, active: step === 'fertig' },
@@ -81,7 +81,6 @@ export default function PoolBuchungPage() {
setFieldErrors((prev) => ({ ...prev, [name]: '' }));
};
// Inline validation on blur
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const { name, value } = e.target;
const errs: Record<string, string> = {};
@@ -91,13 +90,26 @@ export default function PoolBuchungPage() {
if (!value.trim()) errs.email = 'E-Mail ist erforderlich';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) errs.email = 'Ungültige E-Mail-Adresse';
}
if (name === 'wassermenge_m3' && formData.wasserquelle === 'ortswasserleitung' && !value) {
errs.wassermenge_m3 = 'Wassermenge ist erforderlich';
}
setFieldErrors((prev) => ({ ...prev, ...errs }));
};
const goToStep2 = () => {
// Step 1 → Step 2
const goToTermin = () => {
if (!wasserquelle) {
setError('Bitte wählen Sie die Wasserquelle.');
return;
}
if (wasserquelle === 'ortswasserleitung' && !wassermenge) {
setError('Bitte geben Sie die Wassermenge an.');
return;
}
setError('');
setStep('termin');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
// Step 2 → Step 3
const goToDaten = () => {
if (!selectedDate) {
setError('Bitte wählen Sie einen Wunschtermin.');
return;
@@ -115,14 +127,6 @@ export default function PoolBuchungPage() {
setError('Bitte füllen Sie alle Pflichtfelder aus.');
return;
}
if (!formData.wasserquelle) {
setError('Bitte wählen Sie die Wasserquelle.');
return;
}
if (formData.wasserquelle === 'ortswasserleitung' && !formData.wassermenge_m3) {
setError('Bitte geben Sie die Wassermenge an.');
return;
}
if (!captchaToken) {
setError('Bitte bestätigen Sie das CAPTCHA.');
return;
@@ -135,7 +139,8 @@ export default function PoolBuchungPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
wassermenge_m3: formData.wassermenge_m3 ? parseFloat(formData.wassermenge_m3) : null,
wasserquelle,
wassermenge_m3: wassermenge ? parseFloat(wassermenge) : null,
wunschdatum: selectedDate,
captchaToken,
}),
@@ -157,11 +162,13 @@ export default function PoolBuchungPage() {
};
const resetForm = () => {
setFormData({ name: '', strasse: '', telefon: '', email: '', wasserquelle: '', wassermenge_m3: '' });
setFormData({ name: '', strasse: '', telefon: '', email: '' });
setWasserquelle('');
setWassermenge('');
setSelectedDate(null);
setCaptchaToken('');
setFieldErrors({});
setStep('termin');
setStep('wasser');
setShowConfirmation(false);
turnstileWidgetId.current = null;
};
@@ -172,6 +179,8 @@ export default function PoolBuchungPage() {
})
: '';
const wasserquelleLabel = wasserquelle === 'brunnen' ? 'Eigener Brunnen' : 'Ortswasserleitung';
return (
<div className="min-h-screen flex flex-col bg-bg">
<Script
@@ -186,9 +195,115 @@ export default function PoolBuchungPage() {
<ProgressBar steps={steps} />
</div>
{/* ── Step 1: Terminwahl ── */}
{/* ── Step 1: Wasserquelle & Menge ── */}
{step === 'wasser' && (
<div className="animate-fade-in">
<div className="mb-5">
<h2 className="text-xl font-bold text-primary">Wasserquelle wählen</h2>
<p className="text-text-muted text-sm mt-1">
Woher kommt das Wasser für die Pool-Befüllung?
</p>
</div>
<div className="space-y-4">
{/* Wasserquelle Toggle */}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => { setWasserquelle('brunnen'); setWassermenge(''); setError(''); }}
className={`py-5 px-3 rounded-xl border-2 text-sm font-medium transition-all active:scale-[0.97] ${
wasserquelle === 'brunnen'
? 'border-accent bg-accent/5 text-accent'
: 'border-border text-text-muted hover:border-border'
}`}
>
<span className="block text-2xl mb-2">&#x1F4A7;</span>
Eigener Brunnen
</button>
<button
type="button"
onClick={() => { setWasserquelle('ortswasserleitung'); setError(''); }}
className={`py-5 px-3 rounded-xl border-2 text-sm font-medium transition-all active:scale-[0.97] ${
wasserquelle === 'ortswasserleitung'
? 'border-accent bg-accent/5 text-accent'
: 'border-border text-text-muted hover:border-border'
}`}
>
<span className="block text-2xl mb-2">&#x1F6B0;</span>
Ortswasser&shy;leitung
</button>
</div>
{/* Brunnen-Hinweis */}
{wasserquelle === 'brunnen' && (
<div className="p-3 bg-success/5 border border-success/20 rounded-xl text-sm text-success animate-fade-in">
Bei Verwendung eines eigenen Brunnens sind alle Termine verfügbar.
</div>
)}
{/* Wassermenge bei Ortswasserleitung */}
{wasserquelle === 'ortswasserleitung' && (
<div className="animate-fade-in">
<label htmlFor="wassermenge_m3" className="block text-sm font-medium mb-1.5">
Geschätzte Wassermenge (m³) <span className="text-danger">*</span>
</label>
<input
id="wassermenge_m3"
type="number"
inputMode="decimal"
value={wassermenge}
onChange={(e) => { setWassermenge(e.target.value); setError(''); }}
min="5"
step="0.5"
className="w-full border border-border rounded-xl px-4 py-3 text-base max-w-[200px]"
placeholder="z.B. 25"
/>
<p className="text-xs text-text-muted mt-1.5">
Die Kapazität ist pro Tag begrenzt. Anhand Ihrer Menge wird die Verfügbarkeit berechnet.
</p>
</div>
)}
</div>
{error && (
<div className="mt-4 p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm">
{error}
</div>
)}
<div className="sticky-bottom bg-bg pt-4 pb-2 mt-4">
<button
onClick={goToTermin}
disabled={!wasserquelle}
className="w-full bg-primary text-white py-3.5 rounded-xl font-semibold text-base hover:bg-primary-light active:scale-[0.98] transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
Weiter zur Terminwahl
</button>
</div>
</div>
)}
{/* ── Step 2: Terminwahl ── */}
{step === 'termin' && (
<div className="animate-fade-in">
{/* Wasserquelle reminder */}
<div className="flex items-center justify-between bg-white rounded-xl border border-border p-3 mb-5">
<div className="flex items-center gap-2 text-sm">
<span>{wasserquelle === 'brunnen' ? '\u{1F4A7}' : '\u{1F6B0}'}</span>
<span className="font-medium text-primary">
{wasserquelleLabel}
{wassermenge ? `${wassermenge}` : ''}
</span>
</div>
<button
type="button"
onClick={() => { setStep('wasser'); setError(''); setSelectedDate(null); }}
className="text-xs text-accent font-medium hover:underline"
>
Ändern
</button>
</div>
<div className="mb-5">
<h2 className="text-xl font-bold text-primary">Wunschtermin wählen</h2>
<p className="text-text-muted text-sm mt-1">
@@ -199,6 +314,8 @@ export default function PoolBuchungPage() {
<BookingCalendar
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
requestedM3={wassermenge ? parseFloat(wassermenge) : null}
wasserquelle={wasserquelle as 'brunnen' | 'ortswasserleitung'}
/>
{selectedDate && (
@@ -214,24 +331,47 @@ export default function PoolBuchungPage() {
</div>
)}
{/* Sticky Next Button */}
<div className="sticky-bottom bg-bg pt-4 pb-2 mt-4">
<div className="sticky-bottom bg-bg pt-4 pb-2 mt-4 space-y-2">
<button
onClick={goToStep2}
onClick={goToDaten}
disabled={!selectedDate}
className="w-full bg-primary text-white py-3.5 rounded-xl font-semibold text-base hover:bg-primary-light active:scale-[0.98] transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
Weiter
</button>
<button
type="button"
onClick={() => { setStep('wasser'); setError(''); }}
className="w-full py-2.5 text-sm text-text-muted font-medium hover:text-primary transition-colors"
>
Zurück zur Wasserquelle
</button>
</div>
</div>
)}
{/* ── Step 2: Persönliche Daten ── */}
{/* ── Step 3: Persönliche Daten ── */}
{step === 'daten' && (
<form onSubmit={handleSubmit} className="animate-fade-in">
{/* Selected date reminder */}
<div className="flex items-center justify-between bg-white rounded-xl border border-border p-3 mb-5">
{/* Summary reminder */}
<div className="bg-white rounded-xl border border-border p-3 mb-5 space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<span>{wasserquelle === 'brunnen' ? '\u{1F4A7}' : '\u{1F6B0}'}</span>
<span className="font-medium text-primary">
{wasserquelleLabel}
{wassermenge ? `${wassermenge}` : ''}
</span>
</div>
<button
type="button"
onClick={() => { setStep('wasser'); setError(''); }}
className="text-xs text-accent font-medium hover:underline"
>
Ändern
</button>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<svg className="w-4 h-4 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
@@ -246,6 +386,7 @@ export default function PoolBuchungPage() {
Ändern
</button>
</div>
</div>
<div className="mb-5">
<h2 className="text-xl font-bold text-primary">Ihre Daten</h2>
@@ -331,63 +472,6 @@ export default function PoolBuchungPage() {
{fieldErrors.email && <p className="text-danger text-xs mt-1">{fieldErrors.email}</p>}
</div>
{/* Wasserquelle — Toggle Buttons */}
<div>
<label className="block text-sm font-medium mb-2">
Wasserquelle <span className="text-danger">*</span>
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => { setFormData(p => ({ ...p, wasserquelle: 'brunnen', wassermenge_m3: '' })); setError(''); }}
className={`py-3.5 px-3 rounded-xl border-2 text-sm font-medium transition-all active:scale-[0.97] ${
formData.wasserquelle === 'brunnen'
? 'border-accent bg-accent/5 text-accent'
: 'border-border text-text-muted hover:border-border'
}`}
>
<span className="block text-lg mb-1">&#x1F4A7;</span>
Eigener Brunnen
</button>
<button
type="button"
onClick={() => { setFormData(p => ({ ...p, wasserquelle: 'ortswasserleitung' })); setError(''); }}
className={`py-3.5 px-3 rounded-xl border-2 text-sm font-medium transition-all active:scale-[0.97] ${
formData.wasserquelle === 'ortswasserleitung'
? 'border-accent bg-accent/5 text-accent'
: 'border-border text-text-muted hover:border-border'
}`}
>
<span className="block text-lg mb-1">&#x1F6B0;</span>
Ortswasser&shy;leitung
</button>
</div>
</div>
{/* Wassermenge — conditional */}
{formData.wasserquelle === 'ortswasserleitung' && (
<div className="animate-fade-in">
<label htmlFor="wassermenge_m3" className="block text-sm font-medium mb-1.5">
Geschätzte Wassermenge (m³) <span className="text-danger">*</span>
</label>
<input
id="wassermenge_m3"
type="number"
name="wassermenge_m3"
inputMode="decimal"
value={formData.wassermenge_m3}
onChange={handleChange}
onBlur={handleBlur}
min="5"
step="0.5"
required
className={`w-full border rounded-xl px-4 py-3 text-base max-w-[200px] transition-colors ${fieldErrors.wassermenge_m3 ? 'border-danger' : 'border-border'}`}
placeholder="z.B. 25"
/>
{fieldErrors.wassermenge_m3 && <p className="text-danger text-xs mt-1">{fieldErrors.wassermenge_m3}</p>}
</div>
)}
{/* CAPTCHA */}
<div className="flex justify-center pt-2">
<div ref={turnstileContainerRef} />
@@ -441,8 +525,8 @@ export default function PoolBuchungPage() {
onClose={resetForm}
details={[
{ label: 'Termin', value: formattedDate },
{ label: 'Wasserquelle', value: formData.wasserquelle === 'brunnen' ? 'Eigener Brunnen' : 'Ortswasserleitung' },
...(formData.wassermenge_m3 ? [{ label: 'Menge', value: `${formData.wassermenge_m3}` }] : []),
{ label: 'Wasserquelle', value: wasserquelleLabel },
...(wassermenge ? [{ label: 'Menge', value: `${wassermenge}` }] : []),
]}
calendarEvent={selectedDate ? {
title: 'Pool-Befüllung',

View File

@@ -6,6 +6,8 @@ import { Verfuegbarkeit } from '@/types';
interface BookingCalendarProps {
onDateSelect: (date: string | null) => void;
selectedDate: string | null;
requestedM3: number | null;
wasserquelle: 'brunnen' | 'ortswasserleitung';
}
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
@@ -25,7 +27,7 @@ function formatDate(d: Date): string {
return d.toISOString().split('T')[0];
}
export default function BookingCalendar({ onDateSelect, selectedDate }: BookingCalendarProps) {
export default function BookingCalendar({ onDateSelect, selectedDate, requestedM3, wasserquelle }: BookingCalendarProps) {
const today = new Date();
const currentYear = today.getFullYear();
const saison = getSaisonRange(currentYear);
@@ -33,10 +35,12 @@ export default function BookingCalendar({ onDateSelect, selectedDate }: BookingC
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 [auslastungM3, setAuslastungM3] = useState<Record<string, number>>({});
const [maxM3PerDay, setMaxM3PerDay] = useState(150);
const [loading, setLoading] = useState(true);
const isBrunnen = wasserquelle === 'brunnen';
const loadVerfuegbarkeit = useCallback(async () => {
try {
const res = await fetch(`/api/pool/verfuegbarkeit?year=${currentYear}`);
@@ -44,12 +48,12 @@ export default function BookingCalendar({ onDateSelect, selectedDate }: BookingC
if (data.verfuegbarkeit) {
const map: Record<string, number> = {};
data.verfuegbarkeit.forEach((v: Verfuegbarkeit) => {
map[v.datum] = v.anzahl_buchungen;
map[v.datum] = v.summe_m3;
});
setAuslastung(map);
setAuslastungM3(map);
}
if (data.max_per_day) {
setMaxPerDay(data.max_per_day);
if (data.max_m3_per_day) {
setMaxM3PerDay(data.max_m3_per_day);
}
} catch {
// silent
@@ -77,20 +81,6 @@ export default function BookingCalendar({ onDateSelect, selectedDate }: BookingC
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);
@@ -98,20 +88,43 @@ export default function BookingCalendar({ onDateSelect, selectedDate }: BookingC
tomorrow.setHours(0, 0, 0, 0);
if (day < tomorrow) return 'disabled';
// Brunnen: immer verfügbar (belastet keine m³-Kapazität)
if (isBrunnen) return 'available';
// Ortswasserleitung: m³-Kapazität prüfen
const dateStr = formatDate(day);
const count = auslastung[dateStr] || 0;
if (count >= maxPerDay) return 'full';
if (count >= maxPerDay - 2) return 'partial';
const usedM3 = auslastungM3[dateStr] || 0;
const freeM3 = maxM3PerDay - usedM3;
if (requestedM3 && freeM3 < requestedM3) return 'full';
if (freeM3 <= maxM3PerDay * 0.3) return 'partial';
return 'available';
}
// Count available days this month
let availableDays = 0;
for (let d = 1; d <= daysInMonth; d++) {
const day = new Date(viewYear, viewMonth, d);
const status = getDayStatus(day);
if (status === 'available' || status === 'partial') availableDays++;
}
// Build availability header text
const headerText = (() => {
if (isBrunnen) {
return <><span className="font-semibold text-success">{availableDays}</span> {availableDays === 1 ? 'Tag' : 'Tage'} verfügbar im {MONTH_NAMES[viewMonth]}</>;
}
// For Ortswasserleitung, show m³ info
return <><span className="font-semibold text-success">{availableDays}</span> {availableDays === 1 ? 'Tag' : 'Tage'} verfügbar im {MONTH_NAMES[viewMonth]} (max. {maxM3PerDay} m³/Tag)</>;
})();
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]}
{headerText}
</p>
</div>
)}
@@ -168,8 +181,12 @@ export default function BookingCalendar({ onDateSelect, selectedDate }: BookingC
const status = getDayStatus(day);
const dateStr = formatDate(day);
const isSelected = selectedDate === dateStr;
const count = auslastung[dateStr] || 0;
const freeSlots = maxPerDay - count;
const usedM3 = auslastungM3[dateStr] || 0;
const freeM3 = maxM3PerDay - usedM3;
const ariaLabel = isBrunnen
? `${day.getDate()}. ${MONTH_NAMES[viewMonth]}, verfügbar`
: `${day.getDate()}. ${MONTH_NAMES[viewMonth]}, ${status === 'full' ? 'nicht genug Kapazität' : freeM3 + ' m³ frei'}`;
return (
<button
@@ -180,7 +197,7 @@ export default function BookingCalendar({ onDateSelect, selectedDate }: BookingC
}}
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'}`}
aria-label={ariaLabel}
>
{day.getDate()}
{/* Small dot indicator for partial */}
@@ -199,14 +216,18 @@ export default function BookingCalendar({ onDateSelect, selectedDate }: BookingC
<span className="w-2.5 h-2.5 rounded-sm bg-success/20 border border-success/30" />
Frei
</div>
{!isBrunnen && (
<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
Knapp
</div>
)}
{!isBrunnen && (
<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
Nicht verfügbar
</div>
)}
</div>
</div>
</div>

View File

@@ -39,6 +39,7 @@ export interface Setting {
export interface Verfuegbarkeit {
datum: string;
anzahl_buchungen: number;
summe_m3: number;
}
export interface BuchungFormData {