diff --git a/next.config.ts b/next.config.ts index e9ffa30..3ae9045 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,12 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + // Vercel-kompatibel + output: undefined, // standard für Vercel + // Disable image optimization für einfaches Deployment + images: { + unoptimized: true, + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 6d9209d..58c3f24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,12 @@ "name": "gemeindeportal", "version": "0.1.0", "dependencies": { + "@supabase/ssr": "^0.9.0", + "@supabase/supabase-js": "^2.98.0", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "resend": "^6.9.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -694,6 +697,104 @@ "node": ">= 10" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@supabase/auth-js": { + "version": "2.98.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.98.0.tgz", + "integrity": "sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.98.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.98.0.tgz", + "integrity": "sha512-N/xEyiNU5Org+d+PNCpv+TWniAXRzxIURxDYsS/m2I/sfAB/HcM9aM2Dmf5edj5oWb9GxID1OBaZ8NMmPXL+Lg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.98.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.98.0.tgz", + "integrity": "sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.98.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.98.0.tgz", + "integrity": "sha512-rOWt28uGyFipWOSd+n0WVMr9kUXiWaa7J4hvyLCIHjRFqWm1z9CaaKAoYyfYMC1Exn3WT8WePCgiVhlAtWC2yw==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.9.0.tgz", + "integrity": "sha512-UFY6otYV3yqCgV+AyHj80vNkTvbf1Gas2LW4dpbQ4ap6p6v3eB2oaDfcI99jsuJzwVBCFU4BJI+oDYyhNk1z0Q==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.97.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.98.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.98.0.tgz", + "integrity": "sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.98.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.98.0.tgz", + "integrity": "sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.98.0", + "@supabase/functions-js": "2.98.0", + "@supabase/postgrest-js": "2.98.0", + "@supabase/realtime-js": "2.98.0", + "@supabase/storage-js": "2.98.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -978,12 +1079,17 @@ "version": "20.19.35", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1004,6 +1110,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -1042,6 +1157,19 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1073,6 +1201,12 @@ "node": ">=10.13.0" } }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1080,6 +1214,15 @@ "dev": true, "license": "ISC" }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -1466,6 +1609,12 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/postal-mime": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -1516,6 +1665,27 @@ "react": "^19.2.3" } }, + "node_modules/resend": { + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.3.tgz", + "integrity": "sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.3", + "svix": "1.84.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1589,6 +1759,16 @@ "node": ">=0.10.0" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -1612,6 +1792,16 @@ } } }, + "node_modules/svix": { + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", + "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, "node_modules/tailwindcss": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", @@ -1657,8 +1847,41 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 85b1883..5e5b225 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,12 @@ "start": "next start" }, "dependencies": { + "@supabase/ssr": "^0.9.0", + "@supabase/supabase-js": "^2.98.0", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "resend": "^6.9.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..10ab0bc --- /dev/null +++ b/src/app/admin/dashboard/page.tsx @@ -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('pool'); + const [buchungen, setBuchungen] = useState([]); + const [zaehler, setZaehler] = useState([]); + const [settings, setSettings] = useState([]); + 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[], 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 ( +
+
+
+
+ {[1, 2, 3].map(i =>
)} +
+
+
+
+ ); + } + + const filteredBuchungen = buchungen; + + return ( +
+
+ + {/* Admin Bar */} +
+
+ + Admin + + +
+
+ + {/* Today Stats */} +
+
+
+
+
{todayStats.today}
+
Heute
+
+
+
{todayStats.m3}
+
m³ heute
+
+
+
{todayStats.total}
+
Gesamt aktiv
+
+
+
+
+ + {/* Tabs */} +
+
+ {([ + ['pool', 'Pool-Buchungen'], + ['wasserzaehler', 'Wasserzähler'], + ['einstellungen', 'Einstellungen'], + ] as [Tab, string][]).map(([key, label]) => ( + + ))} +
+
+ +
+ {/* Pool Tab */} + {activeTab === 'pool' && ( +
+ {/* Actions */} +
+ +
+ setFilterDate(e.target.value)} + className="border border-border rounded-xl px-3 py-2.5 text-sm" + aria-label="Nach Datum filtern" + /> + + {filterDate && ( + + )} + +
+ + {/* Table */} +
+
+ + + + + + + + + + + + + + + + {filteredBuchungen.length === 0 ? ( + + + + ) : ( + filteredBuchungen.map((b) => ( + + + + + + + + + + + + )) + )} + +
DatumNameStraßeTelefonE-MailQuelleStatusAktion
+ Keine Buchungen gefunden. +
+ {new Date(b.wunschdatum + 'T00:00:00').toLocaleDateString('de-AT')} + {b.name}{b.strasse} + + {b.telefon} + + {b.email} + {b.wasserquelle === 'brunnen' ? 'Brunnen' : 'Leitung'} + {b.wassermenge_m3 || '-'} + + {b.status} + + + {b.status === 'aktiv' && ( + + )} +
+
+
+
+ {filteredBuchungen.length} Buchung(en) +
+
+ )} + + {/* Wasserzähler Tab */} + {activeTab === 'wasserzaehler' && ( +
+
+

Wasserzähler

+ +
+
+
+ + + + + + + + + + + + + + + {zaehler.length === 0 ? ( + + + + ) : ( + zaehler.map((z) => ( + + + + + + + + + + + )) + )} + +
HaushaltAdresseZählernr.Alter StandNeuer StandVerbrauchAblesedatumToken
+ Keine Wasserzähler gefunden. +
{z.haushalt_name}{z.adresse}{z.zaehlernummer}{z.alter_stand} m³ + {z.neuer_stand !== null ? `${z.neuer_stand} m³` : '-'} + + {z.verbrauch !== null ? ( + {z.verbrauch} m³ + ) : '-'} + + {z.ablesedatum + ? new Date(z.ablesedatum).toLocaleDateString('de-AT') + : '-'} + + + {z.access_token.slice(0, 8)}... + +
+
+
+
+ )} + + {/* Einstellungen Tab */} + {activeTab === 'einstellungen' && ( +
+

Einstellungen

+
+ {settings.map((s) => ( +
+
+
{s.beschreibung || s.key}
+
{s.key}
+
+ { + 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} + /> +
+ ))} +
+

+ Änderungen werden beim Verlassen des Feldes gespeichert. +

+
+ )} +
+ + {/* Manual Booking Modal */} + {showManualBooking && ( +
+
+
+

+ Manuell eintragen +

+ +
+

+ Für telefonische Anmeldungen. +

+
+
+
+ + setManualForm(f => ({ ...f, name: e.target.value }))} + required + className="w-full border border-border rounded-xl px-3 py-2.5 text-sm" + /> +
+
+ + setManualForm(f => ({ ...f, strasse: e.target.value }))} + required + className="w-full border border-border rounded-xl px-3 py-2.5 text-sm" + /> +
+
+ + setManualForm(f => ({ ...f, telefon: e.target.value }))} + required + className="w-full border border-border rounded-xl px-3 py-2.5 text-sm" + /> +
+
+ + setManualForm(f => ({ ...f, email: e.target.value }))} + className="w-full border border-border rounded-xl px-3 py-2.5 text-sm" + placeholder="optional" + /> +
+
+ +
+ + setManualForm(f => ({ ...f, wunschdatum: e.target.value }))} + required + className="w-full border border-border rounded-xl px-3 py-2.5 text-sm" + /> +
+ +
+ + +
+ + {manualForm.wasserquelle === 'ortswasserleitung' && ( +
+ + 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" + /> +
+ )} + + {manualError && ( +
+ {manualError} +
+ )} + + +
+
+
+ )} + + {showConfirmation && ( + setShowConfirmation(false)} + /> + )} +
+ ); +} diff --git a/src/app/admin/login/page.tsx b/src/app/admin/login/page.tsx new file mode 100644 index 0000000..c37272b --- /dev/null +++ b/src/app/admin/login/page.tsx @@ -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 ( +
+
+
+
+
+
+ + + +
+

Mitarbeiter-Login

+

+ Zugang für Gemeindemitarbeiter +

+
+ +
+
+ + { 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" + /> +
+
+ + { setPassword(e.target.value); setError(''); }} + autoComplete="current-password" + required + className="w-full border border-border rounded-xl px-4 py-3 text-base" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+
+ ); +} diff --git a/src/app/api/pool/route.ts b/src/app/api/pool/route.ts new file mode 100644 index 0000000..9920df3 --- /dev/null +++ b/src/app/api/pool/route.ts @@ -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 ', + to: email, + subject: `Ihre Anmeldung zur Pool-Befüllung — ${datumFormatiert}`, + html: ` +
+
+

Gemeindeamt Weißkirchen

+

an der Traun

+
+
+

Sehr geehrte/r ${name},

+

Ihre Anmeldung zur Pool-Befüllung wurde erfolgreich übermittelt.

+
+ + + + + + + + + + ${wassermenge_m3 ? ` + + + + + ` : ''} + + + + +
Termin:${datumFormatiert}
Wasserquelle:${wasserquelle === 'brunnen' ? 'Eigener Brunnen' : 'Ortswasserleitung'}
Wassermenge:${wassermenge_m3} m³
Adresse:${strasse}
+
+

+ Bei Fragen wenden Sie sich bitte an:
+ gemeinde@weisskirchen.ooe.gv.at
+ Tel: +43 7243 50600 +

+

Mit freundlichen Grüßen,
Gemeindeamt Weißkirchen an der Traun

+
+
+ Gemeindeplatz 1, 4616 Weißkirchen an der Traun +
+
+ `, + }); + } 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 } + ); + } +} diff --git a/src/app/api/pool/verfuegbarkeit/route.ts b/src/app/api/pool/verfuegbarkeit/route.ts new file mode 100644 index 0000000..be15569 --- /dev/null +++ b/src/app/api/pool/verfuegbarkeit/route.ts @@ -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 = {}; + 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 } + ); + } +} diff --git a/src/app/api/wasserzaehler/route.ts b/src/app/api/wasserzaehler/route.ts new file mode 100644 index 0000000..3cb9304 --- /dev/null +++ b/src/app/api/wasserzaehler/route.ts @@ -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 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..cd1910b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..12f2fe1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - - + + {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index 295f8fd..65df66f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. +
+ {/* Gemeinde-Header — prominent, eigenständig */} +
+
+ {/* Wappen */} +
+ W +
+

+ Weißkirchen an der Traun

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

+

Bürgerportal

- + + {/* Admin Login Link */} +
+ + Mitarbeiter-Zugang +

+
); } diff --git a/src/app/pool/page.tsx b/src/app/pool/page.tsx new file mode 100644 index 0000000..1ace0b3 --- /dev/null +++ b/src/app/pool/page.tsx @@ -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('termin'); + const [selectedDate, setSelectedDate] = useState(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>({}); + const turnstileWidgetId = useRef(null); + const turnstileContainerRef = useRef(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) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + setError(''); + setFieldErrors((prev) => ({ ...prev, [name]: '' })); + }; + + // Inline validation on blur + const handleBlur = (e: React.FocusEvent) => { + const { name, value } = e.target; + const errs: Record = {}; + 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 ( +
+