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:
604
src/app/admin/dashboard/page.tsx
Normal file
604
src/app/admin/dashboard/page.tsx
Normal 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} m³` : '-'}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user