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

View File

@@ -11,37 +11,44 @@ export async function GET(request: NextRequest) {
const supabase = createServiceClient(); const supabase = createServiceClient();
// Buchungen pro Tag zählen // Buchungen mit Wasserquelle und Menge laden
const { data: buchungen } = await supabase const { data: buchungen } = await supabase
.from('buchungen') .from('buchungen')
.select('wunschdatum') .select('wunschdatum, wasserquelle, wassermenge_m3')
.eq('status', 'aktiv') .eq('status', 'aktiv')
.gte('wunschdatum', saisonStart) .gte('wunschdatum', saisonStart)
.lte('wunschdatum', saisonEnde); .lte('wunschdatum', saisonEnde);
// Gruppieren // Pro Tag: Anzahl Buchungen + Summe m³ (nur Ortswasserleitung)
const countMap: Record<string, number> = {}; const dayMap: Record<string, { anzahl: number; summe_m3: number }> = {};
buchungen?.forEach((b: { wunschdatum: string }) => { buchungen?.forEach((b: { wunschdatum: string; wasserquelle: string; wassermenge_m3: number | null }) => {
countMap[b.wunschdatum] = (countMap[b.wunschdatum] || 0) + 1; 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, 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 const { data: setting } = await supabase
.from('settings') .from('settings')
.select('value') .select('value')
.eq('key', 'max_pools_per_day') .eq('key', 'max_m3_per_day')
.single(); .single();
const maxPerDay = setting ? parseInt(setting.value) : 5; const maxM3PerDay = setting ? parseInt(setting.value) : 150;
return NextResponse.json({ return NextResponse.json({
verfuegbarkeit, verfuegbarkeit,
max_per_day: maxPerDay, max_m3_per_day: maxM3PerDay,
}); });
} catch (err) { } catch (err) {
console.error('Verfügbarkeit Error:', 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() { export default function PoolBuchungPage() {
const [step, setStep] = useState<WizardStep>('termin'); const [step, setStep] = useState<WizardStep>('wasser');
const [selectedDate, setSelectedDate] = useState<string | null>(null); const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [wasserquelle, setWasserquelle] = useState<'' | 'brunnen' | 'ortswasserleitung'>('');
const [wassermenge, setWassermenge] = useState('');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
strasse: '', strasse: '',
telefon: '', telefon: '',
email: '', email: '',
wasserquelle: '' as '' | 'brunnen' | 'ortswasserleitung',
wassermenge_m3: '',
}); });
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(''); 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(() => { useEffect(() => {
if (step === 'daten' && turnstileReady) { if (step === 'daten' && turnstileReady) {
// Small delay to ensure the container ref is mounted
const timer = setTimeout(renderTurnstile, 100); const timer = setTimeout(renderTurnstile, 100);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [step, turnstileReady, renderTurnstile]); }, [step, turnstileReady, renderTurnstile]);
const steps = [ 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: 'Termin', done: step === 'daten' || step === 'fertig', active: step === 'termin' },
{ label: 'Daten', done: step === 'fertig', active: step === 'daten' }, { label: 'Daten', done: step === 'fertig', active: step === 'daten' },
{ label: 'Fertig', done: false, active: step === 'fertig' }, { label: 'Fertig', done: false, active: step === 'fertig' },
@@ -81,7 +81,6 @@ export default function PoolBuchungPage() {
setFieldErrors((prev) => ({ ...prev, [name]: '' })); setFieldErrors((prev) => ({ ...prev, [name]: '' }));
}; };
// Inline validation on blur
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => { const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
const errs: Record<string, string> = {}; const errs: Record<string, string> = {};
@@ -91,13 +90,26 @@ export default function PoolBuchungPage() {
if (!value.trim()) errs.email = 'E-Mail ist erforderlich'; if (!value.trim()) errs.email = 'E-Mail ist erforderlich';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) errs.email = 'Ungültige E-Mail-Adresse'; 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 })); 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) { if (!selectedDate) {
setError('Bitte wählen Sie einen Wunschtermin.'); setError('Bitte wählen Sie einen Wunschtermin.');
return; return;
@@ -115,14 +127,6 @@ export default function PoolBuchungPage() {
setError('Bitte füllen Sie alle Pflichtfelder aus.'); setError('Bitte füllen Sie alle Pflichtfelder aus.');
return; 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) { if (!captchaToken) {
setError('Bitte bestätigen Sie das CAPTCHA.'); setError('Bitte bestätigen Sie das CAPTCHA.');
return; return;
@@ -135,7 +139,8 @@ export default function PoolBuchungPage() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
...formData, ...formData,
wassermenge_m3: formData.wassermenge_m3 ? parseFloat(formData.wassermenge_m3) : null, wasserquelle,
wassermenge_m3: wassermenge ? parseFloat(wassermenge) : null,
wunschdatum: selectedDate, wunschdatum: selectedDate,
captchaToken, captchaToken,
}), }),
@@ -157,11 +162,13 @@ export default function PoolBuchungPage() {
}; };
const resetForm = () => { const resetForm = () => {
setFormData({ name: '', strasse: '', telefon: '', email: '', wasserquelle: '', wassermenge_m3: '' }); setFormData({ name: '', strasse: '', telefon: '', email: '' });
setWasserquelle('');
setWassermenge('');
setSelectedDate(null); setSelectedDate(null);
setCaptchaToken(''); setCaptchaToken('');
setFieldErrors({}); setFieldErrors({});
setStep('termin'); setStep('wasser');
setShowConfirmation(false); setShowConfirmation(false);
turnstileWidgetId.current = null; turnstileWidgetId.current = null;
}; };
@@ -172,6 +179,8 @@ export default function PoolBuchungPage() {
}) })
: ''; : '';
const wasserquelleLabel = wasserquelle === 'brunnen' ? 'Eigener Brunnen' : 'Ortswasserleitung';
return ( return (
<div className="min-h-screen flex flex-col bg-bg"> <div className="min-h-screen flex flex-col bg-bg">
<Script <Script
@@ -186,9 +195,115 @@ export default function PoolBuchungPage() {
<ProgressBar steps={steps} /> <ProgressBar steps={steps} />
</div> </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' && ( {step === 'termin' && (
<div className="animate-fade-in"> <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"> <div className="mb-5">
<h2 className="text-xl font-bold text-primary">Wunschtermin wählen</h2> <h2 className="text-xl font-bold text-primary">Wunschtermin wählen</h2>
<p className="text-text-muted text-sm mt-1"> <p className="text-text-muted text-sm mt-1">
@@ -199,6 +314,8 @@ export default function PoolBuchungPage() {
<BookingCalendar <BookingCalendar
selectedDate={selectedDate} selectedDate={selectedDate}
onDateSelect={setSelectedDate} onDateSelect={setSelectedDate}
requestedM3={wassermenge ? parseFloat(wassermenge) : null}
wasserquelle={wasserquelle as 'brunnen' | 'ortswasserleitung'}
/> />
{selectedDate && ( {selectedDate && (
@@ -214,37 +331,61 @@ export default function PoolBuchungPage() {
</div> </div>
)} )}
{/* Sticky Next Button */} <div className="sticky-bottom bg-bg pt-4 pb-2 mt-4 space-y-2">
<div className="sticky-bottom bg-bg pt-4 pb-2 mt-4">
<button <button
onClick={goToStep2} onClick={goToDaten}
disabled={!selectedDate} 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" 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 Weiter
</button> </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>
</div> </div>
)} )}
{/* ── Step 2: Persönliche Daten ── */} {/* ── Step 3: Persönliche Daten ── */}
{step === 'daten' && ( {step === 'daten' && (
<form onSubmit={handleSubmit} className="animate-fade-in"> <form onSubmit={handleSubmit} className="animate-fade-in">
{/* Selected date reminder */} {/* Summary reminder */}
<div className="flex items-center justify-between bg-white rounded-xl border border-border p-3 mb-5"> <div className="bg-white rounded-xl border border-border p-3 mb-5 space-y-1.5">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center justify-between">
<svg className="w-4 h-4 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div className="flex items-center gap-2 text-sm">
<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" /> <span>{wasserquelle === 'brunnen' ? '\u{1F4A7}' : '\u{1F6B0}'}</span>
</svg> <span className="font-medium text-primary">
<span className="font-medium text-primary">{formattedDate}</span> {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" />
</svg>
<span className="font-medium text-primary">{formattedDate}</span>
</div>
<button
type="button"
onClick={() => { setStep('termin'); setError(''); }}
className="text-xs text-accent font-medium hover:underline"
>
Ändern
</button>
</div> </div>
<button
type="button"
onClick={() => { setStep('termin'); setError(''); }}
className="text-xs text-accent font-medium hover:underline"
>
Ändern
</button>
</div> </div>
<div className="mb-5"> <div className="mb-5">
@@ -331,63 +472,6 @@ export default function PoolBuchungPage() {
{fieldErrors.email && <p className="text-danger text-xs mt-1">{fieldErrors.email}</p>} {fieldErrors.email && <p className="text-danger text-xs mt-1">{fieldErrors.email}</p>}
</div> </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 */} {/* CAPTCHA */}
<div className="flex justify-center pt-2"> <div className="flex justify-center pt-2">
<div ref={turnstileContainerRef} /> <div ref={turnstileContainerRef} />
@@ -441,8 +525,8 @@ export default function PoolBuchungPage() {
onClose={resetForm} onClose={resetForm}
details={[ details={[
{ label: 'Termin', value: formattedDate }, { label: 'Termin', value: formattedDate },
{ label: 'Wasserquelle', value: formData.wasserquelle === 'brunnen' ? 'Eigener Brunnen' : 'Ortswasserleitung' }, { label: 'Wasserquelle', value: wasserquelleLabel },
...(formData.wassermenge_m3 ? [{ label: 'Menge', value: `${formData.wassermenge_m3}` }] : []), ...(wassermenge ? [{ label: 'Menge', value: `${wassermenge}` }] : []),
]} ]}
calendarEvent={selectedDate ? { calendarEvent={selectedDate ? {
title: 'Pool-Befüllung', title: 'Pool-Befüllung',

View File

@@ -6,6 +6,8 @@ import { Verfuegbarkeit } from '@/types';
interface BookingCalendarProps { interface BookingCalendarProps {
onDateSelect: (date: string | null) => void; onDateSelect: (date: string | null) => void;
selectedDate: string | null; selectedDate: string | null;
requestedM3: number | null;
wasserquelle: 'brunnen' | 'ortswasserleitung';
} }
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
@@ -25,7 +27,7 @@ function formatDate(d: Date): string {
return d.toISOString().split('T')[0]; 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 today = new Date();
const currentYear = today.getFullYear(); const currentYear = today.getFullYear();
const saison = getSaisonRange(currentYear); 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 initialMonth = today > saison.start ? today.getMonth() : saison.start.getMonth();
const [viewMonth, setViewMonth] = useState(initialMonth); const [viewMonth, setViewMonth] = useState(initialMonth);
const [viewYear] = useState(currentYear); const [viewYear] = useState(currentYear);
const [auslastung, setAuslastung] = useState<Record<string, number>>({}); const [auslastungM3, setAuslastungM3] = useState<Record<string, number>>({});
const [maxPerDay, setMaxPerDay] = useState(5); const [maxM3PerDay, setMaxM3PerDay] = useState(150);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const isBrunnen = wasserquelle === 'brunnen';
const loadVerfuegbarkeit = useCallback(async () => { const loadVerfuegbarkeit = useCallback(async () => {
try { try {
const res = await fetch(`/api/pool/verfuegbarkeit?year=${currentYear}`); const res = await fetch(`/api/pool/verfuegbarkeit?year=${currentYear}`);
@@ -44,12 +48,12 @@ export default function BookingCalendar({ onDateSelect, selectedDate }: BookingC
if (data.verfuegbarkeit) { if (data.verfuegbarkeit) {
const map: Record<string, number> = {}; const map: Record<string, number> = {};
data.verfuegbarkeit.forEach((v: Verfuegbarkeit) => { 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) { if (data.max_m3_per_day) {
setMaxPerDay(data.max_per_day); setMaxM3PerDay(data.max_m3_per_day);
} }
} catch { } catch {
// silent // silent
@@ -77,20 +81,6 @@ export default function BookingCalendar({ onDateSelect, selectedDate }: BookingC
const canGoBack = viewMonth > saison.start.getMonth(); const canGoBack = viewMonth > saison.start.getMonth();
const canGoForward = viewMonth < saison.end.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' { function getDayStatus(day: Date): 'disabled' | 'full' | 'available' | 'partial' {
if (day < saison.start || day > saison.end) return 'disabled'; if (day < saison.start || day > saison.end) return 'disabled';
const tomorrow = new Date(today); const tomorrow = new Date(today);
@@ -98,20 +88,43 @@ export default function BookingCalendar({ onDateSelect, selectedDate }: BookingC
tomorrow.setHours(0, 0, 0, 0); tomorrow.setHours(0, 0, 0, 0);
if (day < tomorrow) return 'disabled'; 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 dateStr = formatDate(day);
const count = auslastung[dateStr] || 0; const usedM3 = auslastungM3[dateStr] || 0;
if (count >= maxPerDay) return 'full'; const freeM3 = maxM3PerDay - usedM3;
if (count >= maxPerDay - 2) return 'partial';
if (requestedM3 && freeM3 < requestedM3) return 'full';
if (freeM3 <= maxM3PerDay * 0.3) return 'partial';
return 'available'; 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 ( return (
<div className="bg-white rounded-2xl border border-border overflow-hidden"> <div className="bg-white rounded-2xl border border-border overflow-hidden">
{/* Sticky availability header */} {/* Sticky availability header */}
{!loading && ( {!loading && (
<div className="px-4 py-2.5 bg-bg border-b border-border/50"> <div className="px-4 py-2.5 bg-bg border-b border-border/50">
<p className="text-xs text-text-muted text-center"> <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> </p>
</div> </div>
)} )}
@@ -168,8 +181,12 @@ export default function BookingCalendar({ onDateSelect, selectedDate }: BookingC
const status = getDayStatus(day); const status = getDayStatus(day);
const dateStr = formatDate(day); const dateStr = formatDate(day);
const isSelected = selectedDate === dateStr; const isSelected = selectedDate === dateStr;
const count = auslastung[dateStr] || 0; const usedM3 = auslastungM3[dateStr] || 0;
const freeSlots = maxPerDay - count; 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 ( return (
<button <button
@@ -180,7 +197,7 @@ export default function BookingCalendar({ onDateSelect, selectedDate }: BookingC
}} }}
disabled={status === 'disabled' || status === 'full'} 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' : ''}`} 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()} {day.getDate()}
{/* Small dot indicator for partial */} {/* 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" /> <span className="w-2.5 h-2.5 rounded-sm bg-success/20 border border-success/30" />
Frei Frei
</div> </div>
<div className="flex items-center gap-1.5"> {!isBrunnen && (
<span className="w-2.5 h-2.5 rounded-sm bg-warning/20 border border-warning/30" /> <div className="flex items-center gap-1.5">
Fast voll <span className="w-2.5 h-2.5 rounded-sm bg-warning/20 border border-warning/30" />
</div> Knapp
<div className="flex items-center gap-1.5"> </div>
<span className="w-2.5 h-2.5 rounded-sm bg-danger/15 border border-danger/20" /> )}
Voll {!isBrunnen && (
</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" />
Nicht verfügbar
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

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