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>
This commit is contained in:
Michael
2026-03-02 21:35:32 +01:00
parent 32411cb27f
commit 39eac91568
22 changed files with 2772 additions and 94 deletions

View File

@@ -0,0 +1,604 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import Header from '@/components/Header';
import { Buchung, Wasserzaehler, Setting } from '@/types';
import ConfirmationModal from '@/components/ConfirmationModal';
type Tab = 'pool' | 'wasserzaehler' | 'einstellungen';
export default function AdminDashboardPage() {
const router = useRouter();
const supabase = createClient();
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<Tab>('pool');
const [buchungen, setBuchungen] = useState<Buchung[]>([]);
const [zaehler, setZaehler] = useState<Wasserzaehler[]>([]);
const [settings, setSettings] = useState<Setting[]>([]);
const [filterDate, setFilterDate] = useState('');
const [filterStatus, setFilterStatus] = useState('alle');
// Manual booking
const [showManualBooking, setShowManualBooking] = useState(false);
const [manualForm, setManualForm] = useState({
name: '', strasse: '', telefon: '', email: '',
wasserquelle: 'brunnen' as 'brunnen' | 'ortswasserleitung',
wassermenge_m3: '',
wunschdatum: '',
});
const [manualError, setManualError] = useState('');
const [showConfirmation, setShowConfirmation] = useState(false);
const [confirmMsg, setConfirmMsg] = useState('');
useEffect(() => {
supabase.auth.getUser().then(({ data: { user } }) => {
if (!user) {
router.push('/admin/login');
} else {
setLoading(false);
}
});
}, [supabase, router]);
const loadBuchungen = useCallback(async () => {
let query = supabase.from('buchungen').select('*').order('wunschdatum', { ascending: true });
if (filterDate) query = query.eq('wunschdatum', filterDate);
if (filterStatus !== 'alle') query = query.eq('status', filterStatus);
const { data, error } = await query;
if (error) console.error('loadBuchungen error:', error);
setBuchungen(data || []);
}, [supabase, filterDate, filterStatus]);
const loadZaehler = useCallback(async () => {
const { data } = await supabase.from('wasserzaehler').select('*').order('erstellt_am', { ascending: false });
setZaehler(data || []);
}, [supabase]);
const loadSettings = useCallback(async () => {
const { data } = await supabase.from('settings').select('*').order('key');
setSettings(data || []);
}, [supabase]);
useEffect(() => {
if (!loading) {
loadBuchungen();
loadZaehler();
loadSettings();
}
}, [loading, loadBuchungen, loadZaehler, loadSettings]);
const handleLogout = async () => {
await supabase.auth.signOut();
router.push('/admin/login');
};
const storniereBuchung = async (id: string) => {
await supabase.from('buchungen').update({ status: 'storniert' }).eq('id', id);
loadBuchungen();
};
const exportCSV = (data: Record<string, unknown>[], filename: string) => {
if (!data.length) return;
const headers = Object.keys(data[0]);
const csv = [
headers.join(';'),
...data.map(row => headers.map(h => `"${row[h] ?? ''}"`).join(';'))
].join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${filename}_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const handleManualBooking = async (e: React.FormEvent) => {
e.preventDefault();
setManualError('');
if (!manualForm.name || !manualForm.strasse || !manualForm.telefon || !manualForm.wunschdatum) {
setManualError('Bitte alle Pflichtfelder ausfüllen.');
return;
}
try {
const res = await fetch('/api/pool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...manualForm,
wassermenge_m3: manualForm.wassermenge_m3 ? parseFloat(manualForm.wassermenge_m3) : null,
email: manualForm.email || 'telefon@gemeinde.at',
}),
});
const data = await res.json();
if (!res.ok) {
setManualError(data.error || 'Fehler beim Speichern.');
return;
}
setShowManualBooking(false);
setManualForm({
name: '', strasse: '', telefon: '', email: '',
wasserquelle: 'brunnen', wassermenge_m3: '', wunschdatum: '',
});
setConfirmMsg('Manuelle Buchung erfolgreich eingetragen!');
setShowConfirmation(true);
loadBuchungen();
} catch {
setManualError('Verbindungsfehler.');
}
};
const updateSetting = async (key: string, value: string) => {
await supabase.from('settings').update({ value, aktualisiert_am: new Date().toISOString() }).eq('key', key);
loadSettings();
};
// Today stats
const todayStr = new Date().toISOString().split('T')[0];
const todayStats = useMemo(() => {
const todayBuchungen = buchungen.filter(b => b.wunschdatum === todayStr && b.status === 'aktiv');
const todayM3 = todayBuchungen.reduce((sum, b) => sum + (b.wassermenge_m3 || 0), 0);
const totalAktiv = buchungen.filter(b => b.status === 'aktiv').length;
return { today: todayBuchungen.length, m3: todayM3, total: totalAktiv };
}, [buchungen, todayStr]);
if (loading) {
return (
<div className="min-h-screen flex flex-col bg-bg">
<Header />
<main className="flex-1 max-w-6xl mx-auto px-4 py-6 w-full">
<div className="grid grid-cols-3 gap-4 mb-6">
{[1, 2, 3].map(i => <div key={i} className="skeleton h-24 rounded-2xl" />)}
</div>
<div className="skeleton h-64 rounded-2xl" />
</main>
</div>
);
}
const filteredBuchungen = buchungen;
return (
<div className="min-h-screen flex flex-col bg-bg">
<Header />
{/* Admin Bar */}
<div className="bg-white border-b border-border">
<div className="max-w-6xl mx-auto px-4 py-2.5 flex items-center justify-between">
<span className="text-[11px] bg-primary/8 text-primary px-2 py-0.5 rounded-md font-semibold uppercase tracking-wider">
Admin
</span>
<button
onClick={handleLogout}
className="text-xs text-text-muted hover:text-danger transition-colors font-medium"
>
Abmelden
</button>
</div>
</div>
{/* Today Stats */}
<div className="bg-white border-b border-border">
<div className="max-w-6xl mx-auto px-4 py-4">
<div className="grid grid-cols-3 gap-3">
<div className="bg-accent/5 rounded-xl p-3 text-center">
<div className="text-2xl font-bold text-accent">{todayStats.today}</div>
<div className="text-[11px] text-text-muted font-medium mt-0.5">Heute</div>
</div>
<div className="bg-success/5 rounded-xl p-3 text-center">
<div className="text-2xl font-bold text-success">{todayStats.m3}</div>
<div className="text-[11px] text-text-muted font-medium mt-0.5">m³ heute</div>
</div>
<div className="bg-primary/5 rounded-xl p-3 text-center">
<div className="text-2xl font-bold text-primary">{todayStats.total}</div>
<div className="text-[11px] text-text-muted font-medium mt-0.5">Gesamt aktiv</div>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="bg-white border-b border-border">
<div className="max-w-6xl mx-auto px-4 flex gap-0">
{([
['pool', 'Pool-Buchungen'],
['wasserzaehler', 'Wasserzähler'],
['einstellungen', 'Einstellungen'],
] as [Tab, string][]).map(([key, label]) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === key
? 'border-accent text-accent'
: 'border-transparent text-text-muted hover:text-text'
}`}
>
{label}
</button>
))}
</div>
</div>
<main className="flex-1 max-w-6xl mx-auto px-4 py-5 w-full">
{/* Pool Tab */}
{activeTab === 'pool' && (
<div className="space-y-4">
{/* Actions */}
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => setShowManualBooking(true)}
className="bg-accent text-white px-4 py-2.5 rounded-xl text-sm font-semibold hover:bg-accent-light active:scale-[0.98] transition-all flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Manuell eintragen
</button>
<div className="flex-1" />
<input
type="date"
value={filterDate}
onChange={(e) => setFilterDate(e.target.value)}
className="border border-border rounded-xl px-3 py-2.5 text-sm"
aria-label="Nach Datum filtern"
/>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="border border-border rounded-xl px-3 py-2.5 text-sm"
aria-label="Nach Status filtern"
>
<option value="alle">Alle Status</option>
<option value="aktiv">Aktiv</option>
<option value="storniert">Storniert</option>
<option value="erledigt">Erledigt</option>
</select>
{filterDate && (
<button
onClick={() => setFilterDate('')}
className="text-xs text-text-muted hover:text-danger font-medium"
>
Zurücksetzen
</button>
)}
<button
onClick={() => exportCSV(filteredBuchungen as unknown as Record<string, unknown>[], 'pool_buchungen')}
className="border border-border rounded-xl px-3 py-2.5 text-sm font-medium hover:bg-bg transition-colors"
aria-label="CSV exportieren"
>
CSV Export
</button>
</div>
{/* Table */}
<div className="bg-white rounded-2xl border border-border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-bg/80 text-text-muted text-[11px] uppercase tracking-wider">
<tr>
<th className="px-4 py-3 text-left font-semibold">Datum</th>
<th className="px-4 py-3 text-left font-semibold">Name</th>
<th className="px-4 py-3 text-left font-semibold">Straße</th>
<th className="px-4 py-3 text-left font-semibold">Telefon</th>
<th className="px-4 py-3 text-left font-semibold">E-Mail</th>
<th className="px-4 py-3 text-left font-semibold">Quelle</th>
<th className="px-4 py-3 text-left font-semibold">m³</th>
<th className="px-4 py-3 text-left font-semibold">Status</th>
<th className="px-4 py-3 text-left font-semibold">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{filteredBuchungen.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-12 text-center text-text-muted">
Keine Buchungen gefunden.
</td>
</tr>
) : (
filteredBuchungen.map((b) => (
<tr key={b.id} className="hover:bg-bg/30 transition-colors">
<td className="px-4 py-3 font-medium whitespace-nowrap">
{new Date(b.wunschdatum + 'T00:00:00').toLocaleDateString('de-AT')}
</td>
<td className="px-4 py-3 font-medium">{b.name}</td>
<td className="px-4 py-3 text-text-muted">{b.strasse}</td>
<td className="px-4 py-3 whitespace-nowrap">
<a
href={`tel:${b.telefon}`}
className="text-accent hover:underline font-medium"
aria-label={`${b.name} anrufen`}
>
{b.telefon}
</a>
</td>
<td className="px-4 py-3 text-text-muted">{b.email}</td>
<td className="px-4 py-3 text-text-muted">
{b.wasserquelle === 'brunnen' ? 'Brunnen' : 'Leitung'}
</td>
<td className="px-4 py-3">{b.wassermenge_m3 || '-'}</td>
<td className="px-4 py-3">
<span className={`inline-flex px-2.5 py-0.5 rounded-full text-[11px] font-semibold ${
b.status === 'aktiv'
? 'bg-success/10 text-success'
: b.status === 'storniert'
? 'bg-danger/10 text-danger'
: 'bg-text-muted/10 text-text-muted'
}`}>
{b.status}
</span>
</td>
<td className="px-4 py-3">
{b.status === 'aktiv' && (
<button
onClick={() => storniereBuchung(b.id)}
className="text-danger text-xs font-medium hover:underline"
>
Stornieren
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<div className="text-xs text-text-muted">
{filteredBuchungen.length} Buchung(en)
</div>
</div>
)}
{/* Wasserzähler Tab */}
{activeTab === 'wasserzaehler' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-primary">Wasserzähler</h3>
<button
onClick={() => exportCSV(zaehler as unknown as Record<string, unknown>[], 'wasserzaehler')}
className="border border-border rounded-xl px-3 py-2.5 text-sm font-medium hover:bg-bg transition-colors"
>
CSV Export
</button>
</div>
<div className="bg-white rounded-2xl border border-border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-bg/80 text-text-muted text-[11px] uppercase tracking-wider">
<tr>
<th className="px-4 py-3 text-left font-semibold">Haushalt</th>
<th className="px-4 py-3 text-left font-semibold">Adresse</th>
<th className="px-4 py-3 text-left font-semibold">Zählernr.</th>
<th className="px-4 py-3 text-right font-semibold">Alter Stand</th>
<th className="px-4 py-3 text-right font-semibold">Neuer Stand</th>
<th className="px-4 py-3 text-right font-semibold">Verbrauch</th>
<th className="px-4 py-3 text-left font-semibold">Ablesedatum</th>
<th className="px-4 py-3 text-left font-semibold">Token</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{zaehler.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-text-muted">
Keine Wasserzähler gefunden.
</td>
</tr>
) : (
zaehler.map((z) => (
<tr key={z.id} className="hover:bg-bg/30 transition-colors">
<td className="px-4 py-3 font-medium">{z.haushalt_name}</td>
<td className="px-4 py-3 text-text-muted">{z.adresse}</td>
<td className="px-4 py-3 font-mono text-xs">{z.zaehlernummer}</td>
<td className="px-4 py-3 text-right">{z.alter_stand} m³</td>
<td className="px-4 py-3 text-right font-medium">
{z.neuer_stand !== null ? `${z.neuer_stand}` : '-'}
</td>
<td className="px-4 py-3 text-right">
{z.verbrauch !== null ? (
<span className="font-semibold text-accent">{z.verbrauch} m³</span>
) : '-'}
</td>
<td className="px-4 py-3">
{z.ablesedatum
? new Date(z.ablesedatum).toLocaleDateString('de-AT')
: '-'}
</td>
<td className="px-4 py-3">
<code className="text-xs bg-bg px-1.5 py-0.5 rounded select-all">
{z.access_token.slice(0, 8)}...
</code>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Einstellungen Tab */}
{activeTab === 'einstellungen' && (
<div className="space-y-5 max-w-xl">
<h3 className="text-lg font-bold text-primary">Einstellungen</h3>
<div className="bg-white rounded-2xl border border-border divide-y divide-border/50">
{settings.map((s) => (
<div key={s.key} className="p-4 flex items-center gap-4">
<div className="flex-1">
<div className="font-medium text-sm">{s.beschreibung || s.key}</div>
<div className="text-[11px] text-text-muted font-mono">{s.key}</div>
</div>
<input
type="text"
defaultValue={s.value}
onBlur={(e) => {
if (e.target.value !== s.value) {
updateSetting(s.key, e.target.value);
}
}}
className="border border-border rounded-xl px-3 py-2.5 text-sm w-48 text-right"
aria-label={s.beschreibung || s.key}
/>
</div>
))}
</div>
<p className="text-xs text-text-muted">
Änderungen werden beim Verlassen des Feldes gespeichert.
</p>
</div>
)}
</main>
{/* Manual Booking Modal */}
{showManualBooking && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-end sm:items-center justify-center z-50 p-0 sm:p-4 animate-fade-in">
<div className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl max-w-lg w-full p-6 pb-8 max-h-[90vh] overflow-y-auto animate-slide-up">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-primary">
Manuell eintragen
</h3>
<button
onClick={() => setShowManualBooking(false)}
className="w-8 h-8 rounded-full flex items-center justify-center hover:bg-bg transition-colors text-text-muted"
aria-label="Schließen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm text-text-muted mb-4">
Für telefonische Anmeldungen.
</p>
<form onSubmit={handleManualBooking} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">Name *</label>
<input
type="text"
value={manualForm.name}
onChange={(e) => setManualForm(f => ({ ...f, name: e.target.value }))}
required
className="w-full border border-border rounded-xl px-3 py-2.5 text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium mb-1">Straße *</label>
<input
type="text"
value={manualForm.strasse}
onChange={(e) => setManualForm(f => ({ ...f, strasse: e.target.value }))}
required
className="w-full border border-border rounded-xl px-3 py-2.5 text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium mb-1">Telefon *</label>
<input
type="tel"
inputMode="tel"
value={manualForm.telefon}
onChange={(e) => setManualForm(f => ({ ...f, telefon: e.target.value }))}
required
className="w-full border border-border rounded-xl px-3 py-2.5 text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium mb-1">E-Mail</label>
<input
type="email"
inputMode="email"
value={manualForm.email}
onChange={(e) => setManualForm(f => ({ ...f, email: e.target.value }))}
className="w-full border border-border rounded-xl px-3 py-2.5 text-sm"
placeholder="optional"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Wunschtermin *</label>
<input
type="date"
value={manualForm.wunschdatum}
onChange={(e) => setManualForm(f => ({ ...f, wunschdatum: e.target.value }))}
required
className="w-full border border-border rounded-xl px-3 py-2.5 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setManualForm(f => ({ ...f, wasserquelle: 'brunnen' }))}
className={`py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
manualForm.wasserquelle === 'brunnen'
? 'border-accent bg-accent/5 text-accent'
: 'border-border text-text-muted'
}`}
>
Brunnen
</button>
<button
type="button"
onClick={() => setManualForm(f => ({ ...f, wasserquelle: 'ortswasserleitung' }))}
className={`py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
manualForm.wasserquelle === 'ortswasserleitung'
? 'border-accent bg-accent/5 text-accent'
: 'border-border text-text-muted'
}`}
>
Leitung
</button>
</div>
{manualForm.wasserquelle === 'ortswasserleitung' && (
<div>
<label className="block text-xs font-medium mb-1">Wassermenge (m³)</label>
<input
type="number"
inputMode="decimal"
value={manualForm.wassermenge_m3}
onChange={(e) => setManualForm(f => ({ ...f, wassermenge_m3: e.target.value }))}
min="5"
step="0.5"
className="w-full border border-border rounded-xl px-3 py-2.5 text-sm"
/>
</div>
)}
{manualError && (
<div className="p-2.5 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm">
{manualError}
</div>
)}
<button
type="submit"
className="w-full bg-accent text-white py-3 rounded-xl font-semibold hover:bg-accent-light active:scale-[0.98] transition-all"
>
Eintragen
</button>
</form>
</div>
</div>
)}
{showConfirmation && (
<ConfirmationModal
title="Erfolgreich!"
message={confirmMsg}
onClose={() => setShowConfirmation(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import Header from '@/components/Header';
export default function AdminLoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
const supabase = createClient();
const { error: authError } = await supabase.auth.signInWithPassword({
email,
password,
});
if (authError) {
setError('Ungültige Anmeldedaten. Bitte versuchen Sie es erneut.');
setLoading(false);
return;
}
router.push('/admin/dashboard');
};
return (
<div className="min-h-screen flex flex-col bg-bg">
<Header back={{ href: '/', label: 'Zurück' }} />
<main className="flex-1 flex items-center justify-center px-4 py-12">
<div className="bg-white rounded-2xl border border-border shadow-sm p-8 max-w-sm w-full animate-slide-up">
<div className="text-center mb-6">
<div className="w-14 h-14 bg-primary/10 rounded-2xl flex items-center justify-center mx-auto mb-3">
<svg className="w-7 h-7 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<h2 className="text-xl font-bold text-primary">Mitarbeiter-Login</h2>
<p className="text-text-muted text-sm mt-1">
Zugang für Gemeindemitarbeiter
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1.5">E-Mail</label>
<input
id="email"
type="email"
inputMode="email"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(''); }}
autoComplete="email"
required
className="w-full border border-border rounded-xl px-4 py-3 text-base"
placeholder="mitarbeiter@gemeinde.at"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1.5">Passwort</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
autoComplete="current-password"
required
className="w-full border border-border rounded-xl px-4 py-3 text-base"
/>
</div>
{error && (
<div className="p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-primary text-white py-3.5 rounded-xl font-semibold hover:bg-primary-light active:scale-[0.98] transition-all disabled:opacity-50"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
Anmeldung...
</span>
) : (
'Anmelden'
)}
</button>
</form>
</div>
</main>
</div>
);
}

190
src/app/api/pool/route.ts Normal file
View File

@@ -0,0 +1,190 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServiceClient } from '@/lib/supabase/server';
import { Resend } from 'resend';
function getResend() {
return new Resend(process.env.RESEND_API_KEY);
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, strasse, telefon, email, wasserquelle, wassermenge_m3, wunschdatum, captchaToken } = body;
// CAPTCHA-Verifizierung
if (!captchaToken) {
return NextResponse.json(
{ error: 'CAPTCHA-Verifizierung fehlt.' },
{ status: 403 }
);
}
const turnstileResponse = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
secret: process.env.TURNSTILE_SECRET_KEY,
response: captchaToken,
}),
}
);
const turnstileResult = await turnstileResponse.json();
if (!turnstileResult.success) {
return NextResponse.json(
{ error: 'CAPTCHA-Verifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.' },
{ status: 403 }
);
}
// Validierung
if (!name || !strasse || !telefon || !email || !wasserquelle || !wunschdatum) {
return NextResponse.json(
{ error: 'Alle Pflichtfelder müssen ausgefüllt sein.' },
{ status: 400 }
);
}
if (wasserquelle === 'ortswasserleitung' && !wassermenge_m3) {
return NextResponse.json(
{ error: 'Bei Ortswasserleitung muss die Wassermenge angegeben werden.' },
{ status: 400 }
);
}
// Datum-Validierung (15. März bis 30. Juni)
const datum = new Date(wunschdatum);
const year = datum.getFullYear();
const saisonStart = new Date(year, 2, 15);
const saisonEnde = new Date(year, 5, 30);
if (datum < saisonStart || datum > saisonEnde) {
return NextResponse.json(
{ error: 'Buchungen sind nur vom 15. März bis 30. Juni möglich.' },
{ status: 400 }
);
}
const supabase = createServiceClient();
// Max pro Tag laden
const { data: setting } = await supabase
.from('settings')
.select('value')
.eq('key', 'max_pools_per_day')
.single();
const maxPerDay = setting ? parseInt(setting.value) : 5;
// Aktuelle Buchungen für den Tag zählen
const { count } = await supabase
.from('buchungen')
.select('*', { count: 'exact', head: true })
.eq('wunschdatum', wunschdatum)
.eq('status', 'aktiv');
if ((count || 0) >= maxPerDay) {
return NextResponse.json(
{ error: 'Dieser Tag ist bereits ausgebucht. Bitte wählen Sie einen anderen Termin.' },
{ status: 409 }
);
}
// Buchung speichern
const { data: buchung, error: insertError } = await supabase
.from('buchungen')
.insert({
name,
strasse,
telefon,
email,
wasserquelle,
wassermenge_m3: wassermenge_m3 || null,
wunschdatum,
status: 'aktiv',
erstellt_von: 'buerger',
})
.select()
.single();
if (insertError) {
console.error('Buchung Insert Error:', insertError);
return NextResponse.json(
{ error: 'Fehler beim Speichern der Buchung.' },
{ status: 500 }
);
}
// Bestätigungs-E-Mail senden
const datumFormatiert = new Date(wunschdatum + 'T00:00:00').toLocaleDateString('de-AT', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
try {
await getResend().emails.send({
from: 'Gemeindeamt Weißkirchen <noreply@resend.dev>',
to: email,
subject: `Ihre Anmeldung zur Pool-Befüllung — ${datumFormatiert}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #0d2b4e; color: white; padding: 20px; text-align: center;">
<h2 style="margin: 0;">Gemeindeamt Weißkirchen</h2>
<p style="margin: 5px 0 0 0; opacity: 0.7; font-size: 14px;">an der Traun</p>
</div>
<div style="padding: 30px; background: #f8fafc;">
<p>Sehr geehrte/r <strong>${name}</strong>,</p>
<p>Ihre Anmeldung zur Pool-Befüllung wurde erfolgreich übermittelt.</p>
<div style="background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px; margin: 20px 0;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #64748b; font-size: 14px;">Termin:</td>
<td style="padding: 8px 0; font-weight: bold; text-align: right;">${datumFormatiert}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #64748b; font-size: 14px;">Wasserquelle:</td>
<td style="padding: 8px 0; text-align: right;">${wasserquelle === 'brunnen' ? 'Eigener Brunnen' : 'Ortswasserleitung'}</td>
</tr>
${wassermenge_m3 ? `
<tr>
<td style="padding: 8px 0; color: #64748b; font-size: 14px;">Wassermenge:</td>
<td style="padding: 8px 0; text-align: right;">${wassermenge_m3} m³</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #64748b; font-size: 14px;">Adresse:</td>
<td style="padding: 8px 0; text-align: right;">${strasse}</td>
</tr>
</table>
</div>
<p style="font-size: 14px; color: #64748b;">
Bei Fragen wenden Sie sich bitte an:<br>
<a href="mailto:gemeinde@weisskirchen.ooe.gv.at">gemeinde@weisskirchen.ooe.gv.at</a><br>
Tel: +43 7243 50600
</p>
<p>Mit freundlichen Grüßen,<br><strong>Gemeindeamt Weißkirchen an der Traun</strong></p>
</div>
<div style="background-color: #091d35; color: rgba(255,255,255,0.5); padding: 15px; text-align: center; font-size: 12px;">
Gemeindeplatz 1, 4616 Weißkirchen an der Traun
</div>
</div>
`,
});
} catch (emailError) {
console.error('E-Mail Versand fehlgeschlagen:', emailError);
// Buchung wurde trotzdem gespeichert
}
return NextResponse.json({ success: true, buchung_id: buchung.id });
} catch (err) {
console.error('Pool API Error:', err);
return NextResponse.json(
{ error: 'Interner Serverfehler.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServiceClient } from '@/lib/supabase/server';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString());
const saisonStart = `${year}-03-15`;
const saisonEnde = `${year}-06-30`;
const supabase = createServiceClient();
// Buchungen pro Tag zählen
const { data: buchungen } = await supabase
.from('buchungen')
.select('wunschdatum')
.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;
});
const verfuegbarkeit = Object.entries(countMap).map(([datum, anzahl_buchungen]) => ({
datum,
anzahl_buchungen,
}));
// Max pro Tag laden
const { data: setting } = await supabase
.from('settings')
.select('value')
.eq('key', 'max_pools_per_day')
.single();
const maxPerDay = setting ? parseInt(setting.value) : 5;
return NextResponse.json({
verfuegbarkeit,
max_per_day: maxPerDay,
});
} catch (err) {
console.error('Verfügbarkeit Error:', err);
return NextResponse.json(
{ error: 'Interner Serverfehler.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServiceClient } from '@/lib/supabase/server';
// GET: Wasserzähler per Token laden
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const token = searchParams.get('token');
if (!token) {
return NextResponse.json(
{ error: 'Token fehlt.' },
{ status: 400 }
);
}
const supabase = createServiceClient();
const { data, error } = await supabase
.from('wasserzaehler')
.select('*')
.eq('access_token', token)
.single();
if (error || !data) {
return NextResponse.json(
{ error: 'Ungültiger Token.' },
{ status: 404 }
);
}
return NextResponse.json(data);
} catch (err) {
console.error('Wasserzähler GET Error:', err);
return NextResponse.json(
{ error: 'Interner Serverfehler.' },
{ status: 500 }
);
}
}
// POST: Neuen Zählerstand speichern
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { token, neuer_stand } = body;
if (!token || neuer_stand === undefined || neuer_stand === null) {
return NextResponse.json(
{ error: 'Token und neuer Zählerstand sind erforderlich.' },
{ status: 400 }
);
}
const supabase = createServiceClient();
// Aktuellen Zähler laden
const { data: zaehler, error: fetchError } = await supabase
.from('wasserzaehler')
.select('*')
.eq('access_token', token)
.single();
if (fetchError || !zaehler) {
return NextResponse.json(
{ error: 'Ungültiger Token.' },
{ status: 404 }
);
}
if (neuer_stand < zaehler.alter_stand) {
return NextResponse.json(
{ error: 'Der neue Stand kann nicht kleiner als der alte Stand sein.' },
{ status: 400 }
);
}
const verbrauch = neuer_stand - zaehler.alter_stand;
// Update
const { error: updateError } = await supabase
.from('wasserzaehler')
.update({
neuer_stand,
verbrauch,
ablesedatum: new Date().toISOString(),
})
.eq('access_token', token);
if (updateError) {
console.error('Wasserzähler Update Error:', updateError);
return NextResponse.json(
{ error: 'Fehler beim Speichern.' },
{ status: 500 }
);
}
return NextResponse.json({ success: true, verbrauch });
} catch (err) {
console.error('Wasserzähler POST Error:', err);
return NextResponse.json(
{ error: 'Interner Serverfehler.' },
{ status: 500 }
);
}
}

View File

@@ -1,26 +1,117 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--color-primary: #0d2b4e;
--color-primary-light: #1a4a7a;
--color-primary-dark: #091d35;
--color-accent: #2563eb;
--color-accent-light: #3b82f6;
--color-success: #16a34a;
--color-warning: #f59e0b;
--color-danger: #dc2626;
--color-bg: #f8fafc;
--color-bg-card: #ffffff;
--color-text: #1e293b;
--color-text-muted: #64748b;
--color-border: #e2e8f0;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background: var(--color-bg);
color: var(--color-text);
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Animated Checkmark ── */
@keyframes checkmark-circle {
0% { transform: scale(0); opacity: 0; }
50% { transform: scale(1.1); }
100% { transform: scale(1); opacity: 1; }
}
@keyframes checkmark-draw {
0% { stroke-dashoffset: 50; }
100% { stroke-dashoffset: 0; }
}
.animate-checkmark-circle {
animation: checkmark-circle 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
.animate-checkmark-draw {
stroke-dasharray: 50;
stroke-dashoffset: 50;
animation: checkmark-draw 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.3s forwards;
}
/* ── Skeleton Loading ── */
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
}
.skeleton {
background: linear-gradient(90deg, var(--color-border) 25%, #f1f5f9 50%, var(--color-border) 75%);
background-size: 200% 100%;
animation: skeleton-pulse 1.5s ease-in-out infinite;
border-radius: 8px;
}
/* ── Slide-up entrance ── */
@keyframes slide-up {
0% { transform: translateY(20px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
.animate-slide-up {
animation: slide-up 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
/* ── Fade-in ── */
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
.animate-fade-in {
animation: fade-in 0.3s ease forwards;
}
/* ── Calendar day styles ── */
.cal-day {
@apply w-11 h-11 rounded-xl flex items-center justify-center text-sm cursor-pointer transition-all;
min-width: 44px;
min-height: 44px;
}
.cal-day:hover:not(.cal-disabled):not(.cal-full) {
@apply bg-accent/15 text-accent;
}
.cal-day:active:not(.cal-disabled):not(.cal-full) {
@apply scale-95;
}
.cal-selected {
@apply bg-accent text-white font-bold shadow-md shadow-accent/25;
}
.cal-full {
@apply bg-danger/8 text-danger/60 cursor-not-allowed line-through;
}
.cal-disabled {
@apply text-text-muted/30 cursor-not-allowed;
}
.cal-available {
@apply bg-success/10 text-success font-semibold;
}
.cal-partial {
@apply bg-warning/10 text-warning font-semibold;
}
/* ── Form input focus ring ── */
input:focus, select:focus, textarea:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
border-color: var(--color-accent) !important;
}
/* ── Sticky submit helper ── */
.sticky-bottom {
position: sticky;
bottom: 0;
z-index: 10;
padding-bottom: env(safe-area-inset-bottom, 0px);
}

View File

@@ -1,20 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Gemeindeamt Weißkirchen an der Traun",
description: "Bürgerportal der Gemeinde Weißkirchen an der Traun - Pool-Befüllung & Wasserzähler",
};
export default function RootLayout({
@@ -23,10 +12,8 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<html lang="de">
<body className="antialiased min-h-screen">
{children}
</body>
</html>

View File

@@ -1,65 +1,98 @@
import Image from "next/image";
import Footer from '@/components/Footer';
import Link from 'next/link';
export default function Home() {
const now = new Date();
const year = now.getFullYear();
const saisonStart = new Date(year, 2, 15);
const saisonEnde = new Date(year, 5, 30);
const isSaison = now >= saisonStart && now <= saisonEnde;
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
<div className="min-h-screen flex flex-col bg-bg">
{/* Gemeinde-Header — prominent, eigenständig */}
<div className="bg-primary text-white">
<div className="max-w-lg mx-auto px-6 pt-10 pb-8 text-center">
{/* Wappen */}
<div className="w-20 h-20 bg-white/15 backdrop-blur-sm rounded-2xl flex items-center justify-center text-3xl font-bold mx-auto mb-4">
W
</div>
<h1 className="text-xl font-bold leading-tight">
Weißkirchen an der Traun
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<p className="text-white/50 text-sm mt-1">Bürgerportal</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
</div>
<main className="flex-1 max-w-lg mx-auto px-4 w-full -mt-4">
{/* Seasonal Banner */}
{isSaison && (
<div className="bg-accent/10 border border-accent/20 rounded-2xl px-4 py-3 mb-5 text-center">
<p className="text-sm text-accent font-medium">
Pool-Saison läuft bis 30. Juni {year}
</p>
</div>
)}
{/* Warum-Text */}
<p className="text-center text-text-muted text-sm mb-6 px-2">
Große Wasserentnahmen koordinieren, damit die Versorgung für alle gesichert bleibt.
</p>
{/* 2 große CTA Buttons */}
<div className="space-y-3">
<Link
href="/pool"
className="group flex items-center gap-4 bg-white rounded-2xl border border-border p-5 hover:border-accent/40 hover:shadow-lg active:scale-[0.98] transition-all"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<div className="w-14 h-14 bg-accent/10 rounded-xl flex items-center justify-center shrink-0 group-hover:bg-accent/15 transition-colors">
<span className="text-2xl" role="img" aria-label="Pool">&#x1F3CA;</span>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-base font-bold text-primary group-hover:text-accent transition-colors">
Pool-Befüllung anmelden
</h2>
<p className="text-text-muted text-xs mt-0.5">
Wunschtermin wählen, in 2 Min. erledigt
</p>
</div>
<svg className="w-5 h-5 text-text-muted/40 group-hover:text-accent group-hover:translate-x-0.5 transition-all shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
<Link
href="/wasserzaehler"
className="group flex items-center gap-4 bg-white rounded-2xl border border-border p-5 hover:border-accent/40 hover:shadow-lg active:scale-[0.98] transition-all"
>
Documentation
</a>
<div className="w-14 h-14 bg-accent/10 rounded-xl flex items-center justify-center shrink-0 group-hover:bg-accent/15 transition-colors">
<span className="text-2xl" role="img" aria-label="Wasserzähler">&#x1F4A7;</span>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-base font-bold text-primary group-hover:text-accent transition-colors">
Wasserzähler melden
</h2>
<p className="text-text-muted text-xs mt-0.5">
QR-Code scannen, Zählerstand eingeben
</p>
</div>
<svg className="w-5 h-5 text-text-muted/40 group-hover:text-accent group-hover:translate-x-0.5 transition-all shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
{/* Admin Login Link */}
<div className="mt-16 text-center pb-4">
<Link
href="/admin/login"
className="text-text-muted/50 text-xs hover:text-primary transition-colors"
>
Mitarbeiter-Zugang
</Link>
</div>
</main>
<Footer />
</div>
);
}

447
src/app/pool/page.tsx Normal file
View File

@@ -0,0 +1,447 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import Script from 'next/script';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import BookingCalendar from '@/components/BookingCalendar';
import ConfirmationModal from '@/components/ConfirmationModal';
import ProgressBar from '@/components/ProgressBar';
declare global {
interface Window {
turnstile?: {
render: (container: string | HTMLElement, options: {
sitekey: string;
callback: (token: string) => void;
'expired-callback': () => void;
'error-callback': () => void;
theme?: 'light' | 'dark' | 'auto';
language?: string;
}) => string;
reset: (widgetId: string) => void;
};
}
}
type WizardStep = 'termin' | 'daten' | 'fertig';
export default function PoolBuchungPage() {
const [step, setStep] = useState<WizardStep>('termin');
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [formData, setFormData] = useState({
name: '',
strasse: '',
telefon: '',
email: '',
wasserquelle: '' as '' | 'brunnen' | 'ortswasserleitung',
wassermenge_m3: '',
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [showConfirmation, setShowConfirmation] = useState(false);
const [captchaToken, setCaptchaToken] = useState('');
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const turnstileWidgetId = useRef<string | null>(null);
const turnstileContainerRef = useRef<HTMLDivElement>(null);
const renderTurnstile = useCallback(() => {
if (window.turnstile && turnstileContainerRef.current && !turnstileWidgetId.current) {
turnstileWidgetId.current = window.turnstile.render(turnstileContainerRef.current, {
sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || '',
callback: (token: string) => setCaptchaToken(token),
'expired-callback': () => setCaptchaToken(''),
'error-callback': () => setCaptchaToken(''),
theme: 'light',
language: 'de',
});
}
}, []);
const steps = [
{ 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' },
];
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
setError('');
setFieldErrors((prev) => ({ ...prev, [name]: '' }));
};
// Inline validation on blur
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const { name, value } = e.target;
const errs: Record<string, string> = {};
if (name === 'name' && !value.trim()) errs.name = 'Name ist erforderlich';
if (name === 'telefon' && !value.trim()) errs.telefon = 'Telefonnummer ist erforderlich';
if (name === 'email') {
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 = () => {
if (!selectedDate) {
setError('Bitte wählen Sie einen Wunschtermin.');
return;
}
setError('');
setStep('daten');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!formData.name || !formData.telefon || !formData.email) {
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;
}
setSubmitting(true);
try {
const res = await fetch('/api/pool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
wassermenge_m3: formData.wassermenge_m3 ? parseFloat(formData.wassermenge_m3) : null,
wunschdatum: selectedDate,
captchaToken,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Es ist ein Fehler aufgetreten.');
return;
}
setStep('fertig');
setShowConfirmation(true);
} catch {
setError('Verbindungsfehler. Bitte versuchen Sie es erneut.');
} finally {
setSubmitting(false);
}
};
const resetForm = () => {
setFormData({ name: '', strasse: '', telefon: '', email: '', wasserquelle: '', wassermenge_m3: '' });
setSelectedDate(null);
setCaptchaToken('');
setFieldErrors({});
setStep('termin');
setShowConfirmation(false);
if (window.turnstile && turnstileWidgetId.current) {
window.turnstile.reset(turnstileWidgetId.current);
}
};
const formattedDate = selectedDate
? new Date(selectedDate + 'T00:00:00').toLocaleDateString('de-AT', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
})
: '';
return (
<div className="min-h-screen flex flex-col bg-bg">
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
onReady={renderTurnstile}
/>
<Header back={{ href: '/', label: 'Zurück' }} />
<main className="flex-1 max-w-lg mx-auto px-4 py-5 w-full">
{/* Progress Bar */}
<div className="mb-6 px-2">
<ProgressBar steps={steps} />
</div>
{/* ── Step 1: Terminwahl ── */}
{step === 'termin' && (
<div className="animate-fade-in">
<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">
Saison: 15. März bis 30. Juni {new Date().getFullYear()}
</p>
</div>
<BookingCalendar
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
/>
{selectedDate && (
<div className="mt-4 p-4 bg-accent/5 border border-accent/20 rounded-xl text-sm animate-fade-in">
<span className="text-text-muted">Gewählt:</span>{' '}
<strong className="text-primary">{formattedDate}</strong>
</div>
)}
{error && (
<div className="mt-4 p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm">
{error}
</div>
)}
{/* Sticky Next Button */}
<div className="sticky-bottom bg-bg pt-4 pb-2 mt-4">
<button
onClick={goToStep2}
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>
</div>
</div>
)}
{/* ── Step 2: 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">
<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 className="mb-5">
<h2 className="text-xl font-bold text-primary">Ihre Daten</h2>
</div>
<div className="space-y-4">
{/* Name */}
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1.5">
Name <span className="text-danger">*</span>
</label>
<input
id="name"
type="text"
name="name"
value={formData.name}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="name"
required
className={`w-full border rounded-xl px-4 py-3 text-base transition-colors ${fieldErrors.name ? 'border-danger' : 'border-border'}`}
placeholder="Max Mustermann"
/>
{fieldErrors.name && <p className="text-danger text-xs mt-1">{fieldErrors.name}</p>}
</div>
{/* Adresse */}
<div>
<label htmlFor="strasse" className="block text-sm font-medium mb-1.5">
Straße & Hausnummer
</label>
<input
id="strasse"
type="text"
name="strasse"
value={formData.strasse}
onChange={handleChange}
autoComplete="street-address"
className="w-full border border-border rounded-xl px-4 py-3 text-base"
placeholder="Hauptstraße 12"
/>
</div>
{/* Telefon */}
<div>
<label htmlFor="telefon" className="block text-sm font-medium mb-1.5">
Telefon <span className="text-danger">*</span>
</label>
<input
id="telefon"
type="tel"
name="telefon"
inputMode="tel"
value={formData.telefon}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="tel"
required
className={`w-full border rounded-xl px-4 py-3 text-base transition-colors ${fieldErrors.telefon ? 'border-danger' : 'border-border'}`}
placeholder="+43 664 1234567"
/>
{fieldErrors.telefon && <p className="text-danger text-xs mt-1">{fieldErrors.telefon}</p>}
</div>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1.5">
E-Mail <span className="text-danger">*</span>
</label>
<input
id="email"
type="email"
name="email"
inputMode="email"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="email"
required
className={`w-full border rounded-xl px-4 py-3 text-base transition-colors ${fieldErrors.email ? 'border-danger' : 'border-border'}`}
placeholder="max@beispiel.at"
/>
{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} />
</div>
</div>
{/* Error */}
{error && (
<div className="mt-4 p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm">
{error}
</div>
)}
{/* Sticky Submit */}
<div className="sticky-bottom bg-bg pt-4 pb-2 mt-4 space-y-2">
<button
type="submit"
disabled={submitting || !captchaToken}
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"
>
{submitting ? (
<span className="flex items-center justify-center gap-2">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
Wird gesendet...
</span>
) : (
'Anmeldung absenden'
)}
</button>
<button
type="button"
onClick={() => { setStep('termin'); setError(''); }}
className="w-full py-2.5 text-sm text-text-muted font-medium hover:text-primary transition-colors"
>
Zurück zur Terminwahl
</button>
</div>
</form>
)}
</main>
<Footer />
{/* Success Modal */}
{showConfirmation && (
<ConfirmationModal
title="Anmeldung erfolgreich!"
message="Sie erhalten in Kürze eine Bestätigung per E-Mail."
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}` }] : []),
]}
calendarEvent={selectedDate ? {
title: 'Pool-Befüllung',
date: selectedDate,
} : undefined}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,274 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import ConfirmationModal from '@/components/ConfirmationModal';
import { Wasserzaehler } from '@/types';
export default function WasserzaehlerPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex flex-col bg-bg">
<Header back={{ href: '/', label: 'Zurück' }} />
<main className="flex-1 max-w-lg mx-auto px-4 py-6 w-full">
<div className="space-y-4">
<div className="skeleton h-8 w-48" />
<div className="skeleton h-4 w-64" />
<div className="skeleton h-40 w-full" />
<div className="skeleton h-32 w-full" />
</div>
</main>
<Footer />
</div>
}>
<WasserzaehlerContent />
</Suspense>
);
}
function WasserzaehlerContent() {
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [zaehler, setZaehler] = useState<Wasserzaehler | null>(null);
const [neuerStand, setNeuerStand] = useState('');
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [showConfirmation, setShowConfirmation] = useState(false);
const [tokenError, setTokenError] = useState(false);
useEffect(() => {
if (!token) {
setTokenError(true);
setLoading(false);
return;
}
async function loadZaehler() {
try {
const res = await fetch(`/api/wasserzaehler?token=${token}`);
if (!res.ok) {
setTokenError(true);
return;
}
const data = await res.json();
setZaehler(data);
} catch {
setTokenError(true);
} finally {
setLoading(false);
}
}
loadZaehler();
}, [token]);
const verbrauch = zaehler && neuerStand
? Math.max(0, parseFloat(neuerStand) - zaehler.alter_stand)
: null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!neuerStand || parseFloat(neuerStand) < 0) {
setError('Bitte geben Sie einen gültigen Zählerstand ein.');
return;
}
if (zaehler && parseFloat(neuerStand) < zaehler.alter_stand) {
setError('Der neue Stand kann nicht kleiner als der alte Stand sein.');
return;
}
setSubmitting(true);
try {
const res = await fetch('/api/wasserzaehler', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
neuer_stand: parseFloat(neuerStand),
}),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Fehler beim Speichern.');
return;
}
setShowConfirmation(true);
} catch {
setError('Verbindungsfehler. Bitte versuchen Sie es erneut.');
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div className="min-h-screen flex flex-col bg-bg">
<Header back={{ href: '/', label: 'Zurück' }} />
<main className="flex-1 max-w-lg mx-auto px-4 py-6 w-full">
<div className="space-y-4 animate-fade-in">
<div className="skeleton h-8 w-48" />
<div className="skeleton h-4 w-64" />
<div className="skeleton h-40 w-full" />
<div className="skeleton h-32 w-full" />
</div>
</main>
<Footer />
</div>
);
}
if (tokenError || !zaehler) {
return (
<div className="min-h-screen flex flex-col bg-bg">
<Header back={{ href: '/', label: 'Zurück' }} />
<main className="flex-1 flex items-center justify-center px-4">
<div className="bg-white rounded-2xl border border-border p-8 max-w-sm w-full text-center animate-slide-up">
<div className="w-16 h-16 bg-danger/10 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-danger" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-bold mb-2">Ungültiger Zugang</h3>
<p className="text-text-muted text-sm">
{!token
? 'Bitte nutzen Sie den QR-Code auf Ihrem Ableseblatt um diese Seite aufzurufen.'
: 'Der verwendete Link ist ungültig oder abgelaufen. Bitte kontaktieren Sie das Gemeindeamt.'}
</p>
</div>
</main>
<Footer />
</div>
);
}
return (
<div className="min-h-screen flex flex-col bg-bg">
<Header back={{ href: '/', label: 'Zurück' }} />
<main className="flex-1 max-w-lg mx-auto px-4 py-5 w-full">
<div className="mb-5">
<h2 className="text-xl font-bold text-primary">
Wasserzähler-Ablesung
</h2>
<p className="text-text-muted text-sm mt-1">
Geben Sie Ihren aktuellen Zählerstand ein.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Vorbefüllte Daten */}
<div className="bg-white rounded-2xl border border-border p-4 space-y-0">
<div className="flex justify-between py-2.5 border-b border-border/40">
<span className="text-sm text-text-muted">Name</span>
<span className="text-sm font-medium">{zaehler.haushalt_name}</span>
</div>
<div className="flex justify-between py-2.5 border-b border-border/40">
<span className="text-sm text-text-muted">Adresse</span>
<span className="text-sm font-medium">{zaehler.adresse}</span>
</div>
<div className="flex justify-between py-2.5 border-b border-border/40">
<span className="text-sm text-text-muted">Zählernr.</span>
<span className="text-sm font-semibold font-mono tracking-wide">{zaehler.zaehlernummer}</span>
</div>
<div className="flex justify-between py-2.5">
<span className="text-sm text-text-muted">Letzter Stand</span>
<span className="text-sm font-medium">{zaehler.alter_stand.toFixed(2)} m³</span>
</div>
</div>
{/* Neuer Stand — große Eingabe */}
<div className="bg-white rounded-2xl border border-border p-5">
<label htmlFor="neuerStand" className="block text-sm font-medium mb-3">
Neuer Zählerstand (m³)
</label>
<div className="relative">
<input
id="neuerStand"
type="number"
inputMode="decimal"
value={neuerStand}
onChange={(e) => {
setNeuerStand(e.target.value);
setError('');
}}
step="0.01"
min={zaehler.alter_stand}
required
className="w-full border border-border rounded-xl px-4 py-4 text-2xl font-mono font-bold text-center tracking-wider"
placeholder={zaehler.alter_stand.toFixed(2)}
aria-label="Neuer Zählerstand in Kubikmeter"
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-text-muted text-sm font-medium">m³</span>
</div>
{/* Live-Verbrauch */}
{verbrauch !== null && neuerStand && (
<div className="mt-4 p-4 bg-accent/5 border border-accent/15 rounded-xl animate-fade-in">
<div className="text-xs text-text-muted mb-1">Verbrauch seit letzter Ablesung</div>
<div className="text-3xl font-bold text-accent tracking-tight">
{verbrauch.toFixed(2)} m³
</div>
<div className="text-[11px] text-text-muted mt-1">
{zaehler.alter_stand.toFixed(2)} {parseFloat(neuerStand).toFixed(2)} m³
</div>
</div>
)}
</div>
{/* Error */}
{error && (
<div className="p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm">
{error}
</div>
)}
{/* Sticky Submit */}
<div className="sticky-bottom bg-bg pt-4 pb-2">
<button
type="submit"
disabled={submitting || !neuerStand}
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"
>
{submitting ? (
<span className="flex items-center justify-center gap-2">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
Wird gespeichert...
</span>
) : (
'Zählerstand speichern'
)}
</button>
</div>
</form>
</main>
<Footer />
{showConfirmation && (
<ConfirmationModal
title="Zählerstand gespeichert!"
message="Vielen Dank! Ihr Zählerstand wurde erfolgreich übermittelt."
onClose={() => {
setShowConfirmation(false);
setNeuerStand('');
}}
details={[
{ label: 'Zählernummer', value: zaehler.zaehlernummer },
{ label: 'Neuer Stand', value: `${parseFloat(neuerStand).toFixed(2)}` },
...(verbrauch !== null ? [{ label: 'Verbrauch', value: `${verbrauch.toFixed(2)}` }] : []),
]}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,214 @@
'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>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import { ReactNode } from 'react';
interface ConfirmationModalProps {
title: string;
message: string;
onClose: () => void;
details?: { label: string; value: string }[];
calendarEvent?: {
title: string;
date: string;
};
children?: ReactNode;
}
function generateCalendarUrl(title: string, dateStr: string): string {
const date = new Date(dateStr + 'T08:00:00');
const endDate = new Date(dateStr + 'T09:00:00');
const fmt = (d: Date) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
return `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(title)}&dates=${fmt(date)}/${fmt(endDate)}`;
}
export default function ConfirmationModal({ title, message, onClose, details, calendarEvent, children }: ConfirmationModalProps) {
return (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-end sm:items-center justify-center z-50 p-0 sm:p-4 animate-fade-in">
<div className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl max-w-md w-full p-6 pb-8 animate-slide-up">
{/* Animated Checkmark */}
<div className="w-20 h-20 bg-success/10 rounded-full flex items-center justify-center mx-auto mb-5 animate-checkmark-circle">
<svg className="w-10 h-10 text-success" fill="none" viewBox="0 0 24 24">
<path
className="animate-checkmark-draw"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 className="text-xl font-bold text-center mb-1">{title}</h3>
<p className="text-text-muted text-center text-sm mb-5">{message}</p>
{/* Detail Card */}
{details && details.length > 0 && (
<div className="bg-bg rounded-xl p-4 mb-5 space-y-2">
{details.map((d) => (
<div key={d.label} className="flex justify-between text-sm">
<span className="text-text-muted">{d.label}</span>
<span className="font-medium">{d.value}</span>
</div>
))}
</div>
)}
{children}
{/* Calendar Save Button */}
{calendarEvent && (
<a
href={generateCalendarUrl(calendarEvent.title, calendarEvent.date)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full py-3 mb-3 border border-border rounded-xl text-sm font-medium text-primary hover:bg-bg transition-colors"
>
<svg className="w-4 h-4" 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>
In Kalender speichern
</a>
)}
<button
onClick={onClose}
className="w-full bg-primary text-white py-3.5 rounded-xl font-semibold hover:bg-primary-light transition-colors"
>
Fertig
</button>
</div>
</div>
);
}

18
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,18 @@
export default function Footer() {
return (
<footer className="mt-auto border-t border-border/50 bg-white">
<div className="max-w-2xl mx-auto px-4 py-5 text-center space-y-1">
<p className="text-xs text-text-muted">
Gemeindeamt Weißkirchen an der Traun
</p>
<p className="text-[11px] text-text-muted/70">
<a href="tel:+4372435060" className="hover:text-primary">+43 7243 50600</a>
{' | '}
<a href="mailto:gemeinde@weisskirchen.ooe.gv.at" className="hover:text-primary">
gemeinde@weisskirchen.ooe.gv.at
</a>
</p>
</div>
</footer>
);
}

44
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,44 @@
import Link from 'next/link';
interface HeaderProps {
back?: { href: string; label: string };
}
export default function Header({ back }: HeaderProps) {
return (
<header className="bg-primary text-white">
<div className="max-w-2xl mx-auto px-4 py-3">
{back ? (
<div className="flex items-center gap-3">
<Link
href={back.href}
className="flex items-center gap-1 text-white/80 hover:text-white transition-colors -ml-1 py-1"
aria-label={back.label}
>
<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>
<span className="text-sm">{back.label}</span>
</Link>
</div>
) : (
<Link href="/" className="block">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/15 backdrop-blur-sm rounded-xl flex items-center justify-center text-lg font-bold shrink-0">
W
</div>
<div className="min-w-0">
<h1 className="text-base font-semibold leading-tight truncate">
Weißkirchen an der Traun
</h1>
<p className="text-white/50 text-[11px]">
Bürgerportal
</p>
</div>
</div>
</Link>
)}
</div>
</header>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
interface Step {
label: string;
done: boolean;
active: boolean;
}
interface ProgressBarProps {
steps: Step[];
}
export default function ProgressBar({ steps }: ProgressBarProps) {
return (
<div className="flex items-center gap-1 w-full" role="progressbar" aria-label="Fortschritt">
{steps.map((step, i) => (
<div key={step.label} className="flex items-center flex-1 last:flex-none">
{/* Step indicator */}
<div className="flex flex-col items-center">
<div
className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all ${
step.done
? 'bg-success text-white'
: step.active
? 'bg-accent text-white shadow-md shadow-accent/25'
: 'bg-border/60 text-text-muted/60'
}`}
>
{step.done ? (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
) : (
i + 1
)}
</div>
<span className={`text-[10px] mt-1 font-medium ${
step.done ? 'text-success' : step.active ? 'text-accent' : 'text-text-muted/50'
}`}>
{step.label}
</span>
</div>
{/* Connector line */}
{i < steps.length - 1 && (
<div className={`flex-1 h-0.5 mx-1.5 rounded-full transition-colors ${
step.done ? 'bg-success/40' : 'bg-border/60'
}`} />
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,8 @@
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}

View File

@@ -0,0 +1,35 @@
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function createServerSupabaseClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Ignore in Server Components
}
},
},
}
);
}
export function createServiceClient() {
const { createClient } = require('@supabase/supabase-js');
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
}

49
src/middleware.ts Normal file
View File

@@ -0,0 +1,49 @@
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
const { data: { user } } = await supabase.auth.getUser();
// Protect /admin/dashboard
if (request.nextUrl.pathname.startsWith('/admin/dashboard') && !user) {
const url = request.nextUrl.clone();
url.pathname = '/admin/login';
return NextResponse.redirect(url);
}
// If logged in and trying to access login, redirect to dashboard
if (request.nextUrl.pathname === '/admin/login' && user) {
const url = request.nextUrl.clone();
url.pathname = '/admin/dashboard';
return NextResponse.redirect(url);
}
return supabaseResponse;
}
export const config = {
matcher: ['/admin/:path*'],
};

49
src/types/index.ts Normal file
View File

@@ -0,0 +1,49 @@
export interface Buchung {
id: string;
name: string;
strasse: string;
telefon: string;
email: string;
wasserquelle: 'brunnen' | 'ortswasserleitung';
wassermenge_m3: number | null;
wunschdatum: string;
status: 'aktiv' | 'storniert' | 'erledigt';
notiz: string | null;
erstellt_am: string;
erstellt_von: 'buerger' | 'admin';
}
export interface Wasserzaehler {
id: string;
access_token: string;
haushalt_name: string;
adresse: string;
zaehlernummer: string;
alter_stand: number;
neuer_stand: number | null;
verbrauch: number | null;
ablesedatum: string | null;
erstellt_am: string;
}
export interface Setting {
key: string;
value: string;
beschreibung: string | null;
aktualisiert_am: string;
}
export interface Verfuegbarkeit {
datum: string;
anzahl_buchungen: number;
}
export interface BuchungFormData {
name: string;
strasse: string;
telefon: string;
email: string;
wasserquelle: 'brunnen' | 'ortswasserleitung';
wassermenge_m3: number | null;
wunschdatum: string;
}