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:
@@ -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;
|
||||
|
||||
229
package-lock.json
generated
229
package-lock.json
generated
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
108
src/app/admin/login/page.tsx
Normal file
108
src/app/admin/login/page.tsx
Normal 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
190
src/app/api/pool/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
53
src/app/api/pool/verfuegbarkeit/route.ts
Normal file
53
src/app/api/pool/verfuegbarkeit/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
106
src/app/api/wasserzaehler/route.ts
Normal file
106
src/app/api/wasserzaehler/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
139
src/app/page.tsx
139
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 (
|
||||
<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">🏊</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">💧</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
447
src/app/pool/page.tsx
Normal 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">💧</span>
|
||||
Eigener Brunnen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setFormData(p => ({ ...p, wasserquelle: 'ortswasserleitung' })); setError(''); }}
|
||||
className={`py-3.5 px-3 rounded-xl border-2 text-sm font-medium transition-all active:scale-[0.97] ${
|
||||
formData.wasserquelle === 'ortswasserleitung'
|
||||
? 'border-accent bg-accent/5 text-accent'
|
||||
: 'border-border text-text-muted hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-lg mb-1">🚰</span>
|
||||
Ortswasser­leitung
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wassermenge — conditional */}
|
||||
{formData.wasserquelle === 'ortswasserleitung' && (
|
||||
<div className="animate-fade-in">
|
||||
<label htmlFor="wassermenge_m3" className="block text-sm font-medium mb-1.5">
|
||||
Geschätzte Wassermenge (m³) <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="wassermenge_m3"
|
||||
type="number"
|
||||
name="wassermenge_m3"
|
||||
inputMode="decimal"
|
||||
value={formData.wassermenge_m3}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
min="5"
|
||||
step="0.5"
|
||||
required
|
||||
className={`w-full border rounded-xl px-4 py-3 text-base max-w-[200px] transition-colors ${fieldErrors.wassermenge_m3 ? 'border-danger' : 'border-border'}`}
|
||||
placeholder="z.B. 25"
|
||||
/>
|
||||
{fieldErrors.wassermenge_m3 && <p className="text-danger text-xs mt-1">{fieldErrors.wassermenge_m3}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CAPTCHA */}
|
||||
<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} m³` }] : []),
|
||||
]}
|
||||
calendarEvent={selectedDate ? {
|
||||
title: 'Pool-Befüllung',
|
||||
date: selectedDate,
|
||||
} : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
274
src/app/wasserzaehler/page.tsx
Normal file
274
src/app/wasserzaehler/page.tsx
Normal 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)} m³` },
|
||||
...(verbrauch !== null ? [{ label: 'Verbrauch', value: `${verbrauch.toFixed(2)} m³` }] : []),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
src/components/BookingCalendar.tsx
Normal file
214
src/components/BookingCalendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
src/components/ConfirmationModal.tsx
Normal file
83
src/components/ConfirmationModal.tsx
Normal 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
18
src/components/Footer.tsx
Normal 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
44
src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/ProgressBar.tsx
Normal file
54
src/components/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
src/lib/supabase/client.ts
Normal file
8
src/lib/supabase/client.ts
Normal 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!
|
||||
);
|
||||
}
|
||||
35
src/lib/supabase/server.ts
Normal file
35
src/lib/supabase/server.ts
Normal 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
49
src/middleware.ts
Normal 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
49
src/types/index.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user