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:
@@ -76,28 +76,37 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const supabase = createServiceClient();
|
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
|
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;
|
||||||
|
|
||||||
// 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(
|
||||||
|
(sum: number, b: { wassermenge_m3: number | null }) => sum + (b.wassermenge_m3 || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (usedM3 + (wassermenge_m3 || 0) > maxM3PerDay) {
|
||||||
return NextResponse.json(
|
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 }
|
{ status: 409 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Buchung speichern
|
// Buchung speichern
|
||||||
const { data: buchung, error: insertError } = await supabase
|
const { data: buchung, error: insertError } = await supabase
|
||||||
|
|||||||
@@ -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 m³ 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);
|
||||||
|
|||||||
@@ -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">💧</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">🚰</span>
|
||||||
|
Ortswasser­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} m³` : ''}
|
||||||
|
</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,24 +331,47 @@ 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 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} m³` : ''}
|
||||||
|
</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">
|
<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">
|
<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" />
|
<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
|
Ändern
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<h2 className="text-xl font-bold text-primary">Ihre Daten</h2>
|
<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>}
|
{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">💧</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">🚰</span>
|
|
||||||
Ortswasser­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} m³` }] : []),
|
...(wassermenge ? [{ label: 'Menge', value: `${wassermenge} m³` }] : []),
|
||||||
]}
|
]}
|
||||||
calendarEvent={selectedDate ? {
|
calendarEvent={selectedDate ? {
|
||||||
title: 'Pool-Befüllung',
|
title: 'Pool-Befüllung',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{!isBrunnen && (
|
||||||
<div className="flex items-center gap-1.5">
|
<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" />
|
<span className="w-2.5 h-2.5 rounded-sm bg-warning/20 border border-warning/30" />
|
||||||
Fast voll
|
Knapp
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{!isBrunnen && (
|
||||||
<div className="flex items-center gap-1.5">
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user