Wasserzähler QR-Code System + Serienbrief + Admin Import

- Wasserzähler-Stammdaten Import (Drag & Drop Excel/CSV)
- QR-Code Druckseite mit Browser-Vorschau
- QR-Code Excel Download (ExcelJS, eingebettete QR-PNGs)
- Serienbrief wie Vorlage wasserablesung.pdf
  - HTML-Vorschau (max 20) + PDF Download (PDFKit, 1000+ skalierbar)
  - Antwortkarte mit QR-Code, Briefmarke, Zählerdaten
- Bürgerseite: nur Kundennr./Zählernr./Stand (Datenschutz)
- Kundennummer + letzter_stand + letzte_ablesung zum Schema
- Bürgermeister: Patrick Krutzler
- CAPTCHA verify API Route

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael
2026-03-03 00:20:44 +01:00
parent 39eac91568
commit bb97c4b1fa
18 changed files with 4365 additions and 51 deletions

View File

@@ -359,20 +359,59 @@ export default function AdminDashboardPage() {
{/* 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 className="flex items-center justify-between gap-2 flex-wrap">
<div>
<h3 className="text-lg font-bold text-primary">Wasserzähler</h3>
{zaehler.length > 0 && (
<p className="text-[11px] text-text-muted">
Letzter Import: {new Date(
Math.max(...zaehler.map(z => new Date(z.erstellt_am).getTime()))
).toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
</p>
)}
</div>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => router.push('/admin/zaehler-import')}
className="bg-accent text-white px-3 py-2.5 rounded-xl text-sm font-semibold hover:bg-accent-light active:scale-[0.98] transition-all flex items-center gap-1.5"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Stammdaten importieren
</button>
<button
onClick={() => router.push('/admin/serienbrief')}
className="border border-border bg-white px-3 py-2.5 rounded-xl text-sm font-semibold hover:bg-bg active:scale-[0.98] transition-all flex items-center gap-1.5"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Serienbrief
</button>
<button
onClick={() => router.push('/admin/qrcodes')}
className="border border-border bg-white px-3 py-2.5 rounded-xl text-sm font-semibold hover:bg-bg active:scale-[0.98] transition-all flex items-center gap-1.5"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
QR-Codes
</button>
<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>
<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">Kundennr.</th>
<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>
@@ -386,13 +425,14 @@ export default function AdminDashboardPage() {
<tbody className="divide-y divide-border/50">
{zaehler.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-text-muted">
<td colSpan={9} 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-mono text-xs font-semibold">{z.kundennummer}</td>
<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>

View File

@@ -1,22 +1,92 @@
'use client';
import { useState } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Script from 'next/script';
import { createClient } from '@/lib/supabase/client';
import Header from '@/components/Header';
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;
};
}
}
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 [captchaToken, setCaptchaToken] = useState('');
const [turnstileReady, setTurnstileReady] = useState(false);
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',
});
}
}, []);
useEffect(() => {
if (turnstileReady) {
const timer = setTimeout(renderTurnstile, 100);
return () => clearTimeout(timer);
}
}, [turnstileReady, renderTurnstile]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!captchaToken) {
setError('Bitte bestätigen Sie das CAPTCHA.');
return;
}
setLoading(true);
// CAPTCHA serverseitig verifizieren
try {
const verifyRes = await fetch('/api/verify-captcha', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ captchaToken }),
});
if (!verifyRes.ok) {
setError('CAPTCHA-Verifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.');
setCaptchaToken('');
if (turnstileWidgetId.current && window.turnstile) {
window.turnstile.reset(turnstileWidgetId.current);
}
setLoading(false);
return;
}
} catch {
setError('Verbindungsfehler. Bitte versuchen Sie es erneut.');
setLoading(false);
return;
}
const supabase = createClient();
const { error: authError } = await supabase.auth.signInWithPassword({
email,
@@ -25,6 +95,10 @@ export default function AdminLoginPage() {
if (authError) {
setError('Ungültige Anmeldedaten. Bitte versuchen Sie es erneut.');
setCaptchaToken('');
if (turnstileWidgetId.current && window.turnstile) {
window.turnstile.reset(turnstileWidgetId.current);
}
setLoading(false);
return;
}
@@ -34,6 +108,11 @@ export default function AdminLoginPage() {
return (
<div className="min-h-screen flex flex-col bg-bg">
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
strategy="afterInteractive"
onReady={() => setTurnstileReady(true)}
/>
<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">
@@ -77,6 +156,9 @@ export default function AdminLoginPage() {
/>
</div>
{/* Turnstile CAPTCHA */}
<div ref={turnstileContainerRef} className="flex justify-center" />
{error && (
<div className="p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm">
{error}
@@ -85,7 +167,7 @@ export default function AdminLoginPage() {
<button
type="submit"
disabled={loading}
disabled={loading || !captchaToken}
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 ? (

View File

@@ -0,0 +1,230 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import Header from '@/components/Header';
import QRCode from 'qrcode';
import { Wasserzaehler } from '@/types';
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'https://gemeindeportal.vercel.app';
interface QRData {
zaehler: Wasserzaehler;
dataUrl: string;
}
export default function AdminQRCodesPage() {
const router = useRouter();
const supabase = createClient();
const [loading, setLoading] = useState(true);
const [qrItems, setQrItems] = useState<QRData[]>([]);
const [error, setError] = useState('');
const loadAndGenerate = useCallback(async () => {
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/admin/login');
return;
}
const { data, error: fetchError } = await supabase
.from('wasserzaehler')
.select('*')
.order('kundennummer', { ascending: true });
if (fetchError) {
setError('Fehler beim Laden der Wasserzähler.');
setLoading(false);
return;
}
const zaehlerList: Wasserzaehler[] = data || [];
const items: QRData[] = await Promise.all(
zaehlerList.map(async (z) => {
const url = `${BASE_URL}/wasserzaehler?token=${z.access_token}`;
const dataUrl = await QRCode.toDataURL(url, {
width: 300,
margin: 2,
errorCorrectionLevel: 'M',
});
return { zaehler: z, dataUrl };
})
);
setQrItems(items);
} catch {
setError('Unerwarteter Fehler.');
} finally {
setLoading(false);
}
}, [supabase, router]);
useEffect(() => {
loadAndGenerate();
}, [loadAndGenerate]);
if (loading) {
return (
<div className="min-h-screen flex flex-col bg-bg">
<Header back={{ href: '/admin/dashboard', label: 'Dashboard' }} />
<main className="flex-1 max-w-4xl mx-auto px-4 py-6 w-full">
<div className="space-y-4">
<div className="skeleton h-8 w-64" />
<div className="skeleton h-4 w-48" />
<div className="skeleton h-96 w-full" />
</div>
</main>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex flex-col bg-bg">
<Header back={{ href: '/admin/dashboard', label: 'Dashboard' }} />
<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">
<p className="text-danger">{error}</p>
</div>
</main>
</div>
);
}
return (
<div className="min-h-screen flex flex-col bg-bg">
{/* Screen-only header */}
<div className="print:hidden">
<Header back={{ href: '/admin/dashboard', label: 'Dashboard' }} />
{/* Admin Bar */}
<div className="bg-white border-b border-border">
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-primary">QR-Codes Wasserzähler</h2>
<p className="text-sm text-text-muted">
{qrItems.length} Zähler je 1 Seite pro Kunde beim Drucken
</p>
</div>
<div className="flex gap-2">
<a
href="/api/zaehler-qrcodes-excel"
className="border border-border bg-white px-4 py-2.5 rounded-xl text-sm font-semibold hover:bg-bg 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 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Excel Download
</a>
<button
onClick={() => window.print()}
className="bg-accent text-white px-5 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="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
Drucken (Strg+P)
</button>
</div>
</div>
</div>
</div>
{/* Preview on screen / Print pages */}
<main className="flex-1 max-w-4xl mx-auto px-4 py-5 w-full print:max-w-none print:p-0 print:m-0">
{qrItems.length === 0 ? (
<div className="bg-white rounded-2xl border border-border p-12 text-center print:hidden">
<p className="text-text-muted">Keine Wasserzähler gefunden. Bitte zuerst das Import-Script ausführen.</p>
<code className="block mt-2 text-xs bg-bg p-2 rounded-lg">
npx tsx scripts/import-zaehler.ts zaehlerablesen_2025.xlsx
</code>
</div>
) : (
<div className="space-y-6 print:space-y-0">
{qrItems.map((item, index) => (
<div
key={item.zaehler.id}
className="bg-white rounded-2xl border border-border p-8 print:rounded-none print:border-0 print:p-0 print:break-after-page"
>
<div className="flex flex-col items-center justify-center print:min-h-screen print:py-16">
{/* Gemeinde Header */}
<div className="text-center mb-8 print:mb-12">
<h1 className="text-xl font-bold text-primary print:text-2xl">
Gemeindeamt Weißkirchen an der Traun
</h1>
<p className="text-text-muted text-sm mt-1 print:text-base">
Wasserzähler-Ablesung {new Date().getFullYear()}
</p>
</div>
{/* QR Code */}
<div className="mb-6 print:mb-10">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.dataUrl}
alt={`QR-Code für Kundennr. ${item.zaehler.kundennummer}`}
className="w-48 h-48 print:w-64 print:h-64"
/>
</div>
{/* Info */}
<div className="text-center space-y-2 print:space-y-3">
<div>
<span className="text-xs text-text-muted uppercase tracking-wider print:text-sm">Kundennummer</span>
<div className="text-2xl font-bold font-mono tracking-wider print:text-3xl">
{item.zaehler.kundennummer}
</div>
</div>
<div>
<span className="text-xs text-text-muted uppercase tracking-wider print:text-sm">Zählernummer</span>
<div className="text-lg font-semibold font-mono print:text-xl">
{item.zaehler.zaehlernummer}
</div>
</div>
</div>
{/* Instructions */}
<div className="mt-8 p-4 bg-bg rounded-xl max-w-sm text-center print:mt-12 print:bg-gray-50 print:max-w-md print:p-6">
<p className="text-sm text-text-muted print:text-base">
Scannen Sie den QR-Code mit Ihrem Smartphone um Ihren aktuellen Zählerstand zu melden.
</p>
</div>
{/* Screen-only badge */}
<div className="mt-4 text-xs text-text-muted print:hidden">
Seite {index + 1} von {qrItems.length}
</div>
</div>
</div>
))}
</div>
)}
</main>
{/* Print Styles */}
<style jsx global>{`
@media print {
body {
margin: 0 !important;
padding: 0 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.print\\:hidden {
display: none !important;
}
.print\\:break-after-page {
break-after: page;
}
.print\\:min-h-screen {
min-height: 100vh;
}
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,676 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import Header from '@/components/Header';
import QRCode from 'qrcode';
import { Wasserzaehler } from '@/types';
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'https://gemeindeportal.vercel.app';
interface LetterData {
zaehler: Wasserzaehler;
qrDataUrl: string;
}
export default function AdminSerienbriefPage() {
const router = useRouter();
const supabase = createClient();
const [loading, setLoading] = useState(true);
const [letters, setLetters] = useState<LetterData[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [error, setError] = useState('');
const loadData = useCallback(async () => {
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/admin/login');
return;
}
const { data, error: fetchError } = await supabase
.from('wasserzaehler')
.select('*')
.order('kundennummer', { ascending: true });
if (fetchError) {
setError('Fehler beim Laden der Wasserzähler.');
setLoading(false);
return;
}
const zaehlerList: Wasserzaehler[] = data || [];
setTotalCount(zaehlerList.length);
// Browser preview limited to 20 — use PDF download for all
const previewList = zaehlerList.slice(0, 20);
const items: LetterData[] = await Promise.all(
previewList.map(async (z) => {
const url = `${BASE_URL}/wasserzaehler?token=${z.access_token}`;
const qrDataUrl = await QRCode.toDataURL(url, {
width: 300,
margin: 2,
errorCorrectionLevel: 'M',
});
return { zaehler: z, qrDataUrl };
})
);
setLetters(items);
} catch {
setError('Unerwarteter Fehler.');
} finally {
setLoading(false);
}
}, [supabase, router]);
useEffect(() => {
loadData();
}, [loadData]);
if (loading) {
return (
<div className="min-h-screen flex flex-col bg-bg">
<Header back={{ href: '/admin/dashboard', label: 'Dashboard' }} />
<main className="flex-1 max-w-4xl mx-auto px-4 py-6 w-full">
<div className="space-y-4">
<div className="skeleton h-8 w-64" />
<div className="skeleton h-4 w-48" />
<div className="skeleton h-[600px] w-full" />
</div>
</main>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex flex-col bg-bg">
<Header back={{ href: '/admin/dashboard', label: 'Dashboard' }} />
<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">
<p className="text-danger">{error}</p>
</div>
</main>
</div>
);
}
const today = new Date().toLocaleDateString('de-AT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
return (
<div className="min-h-screen flex flex-col bg-bg serienbrief-page">
{/* Screen-only header */}
<div className="no-print">
<Header back={{ href: '/admin/dashboard', label: 'Dashboard' }} />
<div className="bg-white border-b border-border">
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-primary">Serienbrief Wasserablesung</h2>
<p className="text-sm text-text-muted">
{totalCount} Briefe gesamt{totalCount > 20 ? ` (Vorschau: erste 20)` : ''} PDF Download fuer alle
</p>
</div>
<div className="flex gap-2">
<a
href="/api/zaehler-serienbrief-pdf"
className="bg-accent text-white px-5 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 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
PDF Download
</a>
<a
href="/api/zaehler-qrcodes-excel"
className="border border-border bg-white px-4 py-2.5 rounded-xl text-sm font-semibold hover:bg-bg 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 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Excel
</a>
<button
onClick={() => window.print()}
className="border border-border bg-white px-4 py-2.5 rounded-xl text-sm font-semibold hover:bg-bg 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="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
Drucken (Strg+P)
</button>
</div>
</div>
</div>
</div>
{/* Letter pages */}
<main className="flex-1 max-w-4xl mx-auto px-4 py-5 w-full print-main">
{letters.length === 0 ? (
<div className="bg-white rounded-2xl border border-border p-12 text-center no-print">
<p className="text-text-muted">Keine Wasserzähler gefunden.</p>
</div>
) : (
<div className="space-y-6 letter-container">
{letters.map((item, index) => (
<div
key={item.zaehler.id}
className="bg-white rounded-2xl border border-border letter-page"
>
<div className="letter-inner">
{/* ====== KOPFZEILE ====== */}
<div className="letter-header">
<div className="header-left">
<div className="gemeinde-name">Gemeinde Weißkirchen/Traun</div>
<div className="gemeinde-addr">Gemeindeplatz 1, 4616 Weißkirchen a. d. Traun</div>
<div className="gemeinde-uid">UID: ATU23479000</div>
</div>
<div className="header-right">
<div>Homepage: www.weisskirchen.at</div>
<div>E-Mail: linda.raml@weisskirchen.ooe.gv.at</div>
<div>Telefon: 07243/56155-15</div>
<div>Fax: 07243/56155-35</div>
</div>
</div>
<div className="header-line" />
{/* ====== ABSENDER + TITEL ====== */}
<div className="sender-title-row">
<div className="sender-block">
<div className="sender-line">
Absender: Gemeinde Weißkirchen/Traun, 4616 Weißkirchen a. d. Traun
</div>
<div className="recipient">
<div>{item.zaehler.haushalt_name}</div>
{item.zaehler.adresse && <div>{item.zaehler.adresse}</div>}
<div>4616 Weißkirchen an der Traun</div>
</div>
</div>
<div className="title-block">
<h2 className="letter-title">Wasserablesung</h2>
<table className="info-table">
<tbody>
<tr>
<td>Datum:</td>
<td>{today}</td>
</tr>
<tr>
<td>Kundennummer:</td>
<td>{item.zaehler.kundennummer || '—'}</td>
</tr>
<tr>
<td>(EDV-Nummer)</td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
{/* ====== BRIEFTEXT ====== */}
<div className="letter-body">
<p>Sehr geehrte Kundin, sehr geehrter Kunde!</p>
<p>
Die Gemeinde Weißkirchen/Traun ersucht Sie höflichst um Bekanntgabe des
Wasserzählerstandes Ihres unten genannten Objektes.
</p>
<p className="online-highlight">
<strong>NEU Zählerstand bequem online melden:</strong> Scannen Sie einfach den
QR-Code auf der Antwortkarte unten mit Ihrer Smartphone-Kamera. Sie werden
automatisch auf unsere Webseite weitergeleitet, auf der Sie Ihren aktuellen
Zählerstand in wenigen Sekunden eingeben können.
</p>
<p>
Alternativ können Sie den nachstehenden Abschnitt ausgefüllt bis spätestens
{' '}<strong>29.08.{new Date().getFullYear()}</strong> durch
</p>
<ul>
<li>persönliche Abgabe</li>
<li>den Postweg</li>
<li>mittels E-Mail linda.raml@weisskirchen.ooe.gv.at</li>
<li>oder in den Gemeindebriefkasten der Gemeinde Weißkirchen/Traun</li>
</ul>
<p>zu retournieren.</p>
<p>
Sollten Sie Fragen haben oder Ihnen die Ablesung Schwierigkeiten bereiten,
ersuchen wir um Ihren Anruf unter der im Kopf genannten Telefonnummer.
</p>
<div className="signature">
<p>Mit freundlichen Grüßen</p>
<p>Der Bürgermeister:</p>
<p className="sig-name">Patrick Krutzler</p>
</div>
</div>
{/* ====== TRENNLINIE ====== */}
<div className="cut-line">
<span>Hier abtrennen</span>
</div>
{/* ====== ANTWORTKARTE ====== */}
<div className="reply-card">
<div className="reply-content">
<div className="reply-left">
<div className="reply-row">
<span className="reply-label">Kundennummer:</span>
<span className="reply-value">{item.zaehler.kundennummer || '—'}</span>
</div>
<div className="reply-details">
<div className="reply-row">
<span className="reply-label">Objekt:</span>
<span className="reply-value-bold">{item.zaehler.adresse || '—'}</span>
</div>
<div className="reply-row">
<span className="reply-label">Name:</span>
<span className="reply-value-bold">{item.zaehler.haushalt_name}</span>
</div>
<div className="reply-row">
<span className="reply-label">Zählernummer:</span>
<span className="reply-value-bold">{item.zaehler.zaehlernummer}</span>
</div>
</div>
<div className="reply-stands">
<div className="reply-row">
<span className="reply-label">Zuletzt abgelesener Zählerstand:</span>
<span className="reply-value">{item.zaehler.alter_stand ?? '—'}</span>
<span className="reply-unit">m³</span>
</div>
</div>
{/* Neuer Zählerstand — Kästchen */}
<div className="new-stand-row">
<span className="reply-label-big">Neuer Zählerstand:</span>
<div className="stand-boxes">
{[...Array(7)].map((_, i) => (
<div key={i} className="stand-box" />
))}
</div>
<span className="reply-unit">m³</span>
</div>
<div className="new-stand-row">
<span className="reply-label-big">abgelesen am:</span>
<div className="date-line" />
</div>
<div className="reply-small">
Der (die) Unterfertigte bestätigt hiermit die Richtigkeit der Angaben.
</div>
<div className="new-stand-row">
<span className="reply-label">Datum/Unterschrift:</span>
<div className="date-line" />
</div>
<div className="reply-small" style={{ marginTop: '4px' }}>
Eventuelle Anmerkungen/TelNr. für Rückfragen:
</div>
</div>
<div className="reply-right">
<div className="postage-box">
Postgebühr<br />beim<br />Empfänger<br />einheben
</div>
<div className="reply-address">
<strong>Antwortkarte</strong><br />
Gemeinde Weißkirchen/Traun<br />
Gemeindeplatz 1<br />
4616 Weißkirchen a. d. Traun
</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.qrDataUrl}
alt={`QR-Code für ${item.zaehler.kundennummer}`}
className="qr-image"
/>
<div className="qr-hint">
<strong>Zählerstand online melden:</strong><br />
QR-Code mit Smartphone-Kamera scannen
</div>
</div>
</div>
</div>
{/* Screen-only indicator */}
<div className="page-indicator no-print">
Brief {index + 1} von {letters.length} {item.zaehler.haushalt_name}
</div>
</div>
</div>
))}
{totalCount > letters.length && (
<div className="bg-accent/5 border border-accent/20 rounded-2xl p-6 text-center no-print">
<p className="text-sm font-semibold text-accent mb-2">
Vorschau zeigt {letters.length} von {totalCount} Briefen
</p>
<p className="text-sm text-text-muted mb-3">
Nutzen Sie den PDF Download oben, um alle {totalCount} Briefe als druckfertige PDF-Datei herunterzuladen.
</p>
<a
href="/api/zaehler-serienbrief-pdf"
className="inline-flex items-center gap-2 bg-accent text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-accent-light active:scale-[0.98] transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Alle {totalCount} Briefe als PDF downloaden
</a>
</div>
)}
</div>
)}
</main>
<style jsx global>{`
/* ===== SCREEN STYLES ===== */
.letter-inner {
padding: 32px;
font-family: 'Times New Roman', Times, serif;
font-size: 13px;
line-height: 1.55;
color: #000;
}
.letter-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 4px;
}
.header-left .gemeinde-name {
font-weight: bold;
font-size: 15px;
}
.header-left .gemeinde-addr,
.header-left .gemeinde-uid {
font-size: 11px;
color: #333;
}
.header-right {
text-align: right;
font-size: 11px;
color: #333;
}
.header-line {
border-bottom: 1px solid #999;
margin-bottom: 20px;
}
.sender-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.sender-line {
font-size: 9px;
color: #555;
border-bottom: 1px solid #999;
padding-bottom: 2px;
margin-bottom: 12px;
max-width: 320px;
}
.recipient {
font-size: 14px;
font-weight: 500;
line-height: 1.6;
}
.title-block {
text-align: left;
}
.letter-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
}
.info-table {
font-size: 13px;
}
.info-table td {
padding: 2px 8px 2px 0;
}
.info-table td:first-child {
color: #333;
}
.letter-body {
font-size: 13px;
line-height: 1.65;
margin-bottom: 16px;
}
.letter-body p {
margin-bottom: 10px;
}
.letter-body ul {
margin: 4px 0 4px 20px;
list-style-type: disc;
}
.letter-body li {
margin-bottom: 2px;
}
.signature {
margin-top: 16px;
text-align: right;
}
.signature p {
margin-bottom: 2px;
}
.sig-name {
font-weight: bold;
}
.cut-line {
border-top: 2px dashed #999;
margin: 16px 0;
position: relative;
text-align: center;
}
.cut-line span {
background: white;
padding: 0 12px;
font-size: 9px;
color: #999;
letter-spacing: 3px;
position: relative;
top: -8px;
}
.reply-card {
font-size: 12px;
}
.reply-content {
display: flex;
gap: 20px;
}
.reply-left {
flex: 1;
}
.reply-right {
width: 170px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.reply-row {
margin-bottom: 4px;
}
.reply-label {
color: #333;
margin-right: 6px;
}
.reply-value {
font-weight: bold;
font-size: 14px;
}
.reply-value-bold {
font-weight: bold;
font-size: 13px;
}
.reply-unit {
margin-left: 4px;
font-size: 12px;
}
.reply-details {
margin: 8px 0;
}
.reply-stands {
margin: 8px 0 12px;
}
.reply-label-big {
font-size: 14px;
font-weight: bold;
margin-right: 8px;
}
.new-stand-row {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.stand-boxes {
display: flex;
gap: 2px;
}
.stand-box {
width: 24px;
height: 30px;
border: 1.5px solid #000;
}
.date-line {
flex: 1;
border-bottom: 1px solid #000;
margin-left: 8px;
min-width: 150px;
height: 22px;
}
.reply-small {
font-size: 10px;
color: #333;
margin-bottom: 4px;
}
.online-highlight {
border: 1.5px solid #000;
padding: 8px 12px;
background: #f8f8f8;
margin-bottom: 12px;
}
.qr-hint {
font-size: 10px;
font-weight: 600;
text-align: center;
line-height: 1.4;
color: #222;
margin-top: -2px;
margin-bottom: 4px;
}
.qr-image {
width: 110px;
height: 110px;
}
.postage-box {
border: 1px solid #000;
padding: 5px 10px;
font-size: 9px;
text-align: center;
line-height: 1.4;
}
.reply-address {
font-size: 11px;
text-align: center;
line-height: 1.5;
}
.page-indicator {
margin-top: 16px;
text-align: center;
font-size: 11px;
color: #999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* ===== PRINT STYLES ===== */
@media print {
@page {
size: A4;
margin: 0;
}
body {
margin: 0 !important;
padding: 0 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.no-print {
display: none !important;
}
.print-main {
max-width: none !important;
padding: 0 !important;
margin: 0 !important;
}
.letter-container {
display: block !important;
}
.letter-container > * + * {
margin-top: 0 !important;
}
.letter-page {
border: none !important;
border-radius: 0 !important;
break-after: page;
page-break-after: always;
}
.letter-inner {
padding: 1.5cm 2cm 1cm 2cm;
height: 100vh;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.letter-header {
margin-bottom: 6px;
}
.header-line {
margin-bottom: 16px;
}
.sender-title-row {
margin-bottom: 20px;
}
.letter-body {
flex: 1;
margin-bottom: 0;
}
.cut-line {
margin: 10px 0;
}
.qr-image {
width: 90px;
height: 90px;
}
.stand-box {
width: 20px;
height: 26px;
}
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,603 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import Header from '@/components/Header';
import * as XLSX from 'xlsx';
interface ParsedRow {
kundennummer: string;
zaehlernummer: string;
haushalt_name: string;
adresse: string;
letzter_stand: number | null;
letzte_ablesung: string | null;
error?: string;
}
interface ImportResult {
inserted: number;
updated: number;
errors: number;
error_details: string[];
}
function parseCSV(text: string): string[][] {
// Auto-detect delimiter
const firstLine = text.split('\n')[0] || '';
const semicolons = (firstLine.match(/;/g) || []).length;
const commas = (firstLine.match(/,/g) || []).length;
const delimiter = semicolons >= commas ? ';' : ',';
const rows: string[][] = [];
const lines = text.split(/\r?\n/);
for (const line of lines) {
if (!line.trim()) continue;
const cells: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (char === delimiter && !inQuotes) {
cells.push(current.trim());
current = '';
} else {
current += char;
}
}
cells.push(current.trim());
rows.push(cells);
}
return rows;
}
function parseDate(value: unknown): string | null {
if (!value) return null;
// Excel serial date number
if (typeof value === 'number') {
const date = new Date((value - 25569) * 86400 * 1000);
if (!isNaN(date.getTime())) {
return date.toISOString().split('T')[0];
}
return null;
}
const str = String(value).trim();
if (!str) return null;
// DD.MM.YYYY (Austrian format)
const dotMatch = str.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (dotMatch) {
return `${dotMatch[3]}-${dotMatch[2].padStart(2, '0')}-${dotMatch[1].padStart(2, '0')}`;
}
// YYYY-MM-DD
const isoMatch = str.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (isoMatch) {
return `${isoMatch[1]}-${isoMatch[2]}-${isoMatch[3]}`;
}
return null;
}
function parseNumber(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number') return value;
// Handle German number format: "1.234,56" → 1234.56
const str = String(value).trim().replace(/\s/g, '');
const germanNum = str.replace(/\./g, '').replace(',', '.');
const num = parseFloat(germanNum);
return isNaN(num) ? null : num;
}
function rowsFromExcel(file: File): Promise<ParsedRow[]> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: 'array' });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const raw: unknown[][] = XLSX.utils.sheet_to_json(sheet, { header: 1 });
// Skip header row
const dataRows = raw.slice(1).filter(r =>
Array.isArray(r) && r.some(cell => cell !== null && cell !== undefined && cell !== '')
);
const parsed: ParsedRow[] = dataRows.map(r => {
const row = r as unknown[];
const kundennummer = String(row[0] ?? '').trim();
const zaehlernummer = String(row[1] ?? '').trim();
const haushalt_name = String(row[2] ?? '').trim();
const adresse = String(row[3] ?? '').trim();
// Spalte E (index 4) = Ort → ignorieren
const letzter_stand = parseNumber(row[5]);
const letzte_ablesung = parseDate(row[6]);
let error: string | undefined;
if (!kundennummer) error = 'Kundennummer fehlt';
else if (!zaehlernummer) error = 'Zählernummer fehlt';
return { kundennummer, zaehlernummer, haushalt_name, adresse, letzter_stand, letzte_ablesung, error };
});
resolve(parsed);
} catch (err) {
reject(err);
}
};
reader.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.'));
reader.readAsArrayBuffer(file);
});
}
function rowsFromCSV(file: File): Promise<ParsedRow[]> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const text = e.target?.result as string;
const raw = parseCSV(text);
// Skip header row
const dataRows = raw.slice(1).filter(r => r.some(cell => cell !== ''));
const parsed: ParsedRow[] = dataRows.map(r => {
const kundennummer = (r[0] ?? '').trim();
const zaehlernummer = (r[1] ?? '').trim();
const haushalt_name = (r[2] ?? '').trim();
const adresse = (r[3] ?? '').trim();
// Spalte E (index 4) = Ort → ignorieren
const letzter_stand = parseNumber(r[5]);
const letzte_ablesung = parseDate(r[6]);
let error: string | undefined;
if (!kundennummer) error = 'Kundennummer fehlt';
else if (!zaehlernummer) error = 'Zählernummer fehlt';
return { kundennummer, zaehlernummer, haushalt_name, adresse, letzter_stand, letzte_ablesung, error };
});
resolve(parsed);
} catch (err) {
reject(err);
}
};
reader.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.'));
reader.readAsText(file, 'utf-8');
});
}
export default function ZaehlerImportPage() {
const router = useRouter();
const supabase = createClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState(true);
const [dragOver, setDragOver] = useState(false);
const [fileName, setFileName] = useState('');
const [rows, setRows] = useState<ParsedRow[]>([]);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<ImportResult | null>(null);
const [parseError, setParseError] = useState('');
useEffect(() => {
supabase.auth.getUser().then(({ data: { user } }) => {
if (!user) {
router.push('/admin/login');
} else {
setLoading(false);
}
});
}, [supabase, router]);
const processFile = useCallback(async (file: File) => {
setParseError('');
setResult(null);
setRows([]);
setFileName(file.name);
const ext = file.name.toLowerCase().split('.').pop();
if (ext !== 'xlsx' && ext !== 'xls' && ext !== 'csv') {
setParseError('Nur .xlsx, .xls oder .csv Dateien werden unterstützt.');
return;
}
try {
const parsed = ext === 'csv'
? await rowsFromCSV(file)
: await rowsFromExcel(file);
if (parsed.length === 0) {
setParseError('Die Datei enthält keine Daten (oder nur eine Kopfzeile).');
return;
}
if (parsed.length > 1000) {
setParseError(`Die Datei enthält ${parsed.length} Zeilen. Maximal 1000 sind erlaubt.`);
return;
}
setRows(parsed);
} catch {
setParseError('Die Datei konnte nicht gelesen werden. Bitte Format prüfen.');
}
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) processFile(file);
}, [processFile]);
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) processFile(file);
}, [processFile]);
const handleImport = async () => {
const validRows = rows.filter(r => !r.error);
if (validRows.length === 0) return;
setImporting(true);
setResult(null);
try {
const res = await fetch('/api/zaehler-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rows: validRows.map(({ error: _, ...rest }) => rest),
}),
});
const data = await res.json();
if (!res.ok) {
setResult({ inserted: 0, updated: 0, errors: 1, error_details: [data.error] });
return;
}
setResult(data);
} catch {
setResult({ inserted: 0, updated: 0, errors: 1, error_details: ['Verbindungsfehler.'] });
} finally {
setImporting(false);
}
};
const reset = () => {
setRows([]);
setFileName('');
setResult(null);
setParseError('');
if (fileInputRef.current) fileInputRef.current.value = '';
};
const validCount = rows.filter(r => !r.error).length;
const errorCount = rows.filter(r => r.error).length;
if (loading) {
return (
<div className="min-h-screen flex flex-col bg-bg">
<Header back={{ href: '/admin/dashboard', label: 'Dashboard' }} />
<main className="flex-1 max-w-4xl mx-auto px-4 py-6 w-full">
<div className="skeleton h-8 w-64 mb-4" />
<div className="skeleton h-48 w-full" />
</main>
</div>
);
}
return (
<div className="min-h-screen flex flex-col bg-bg">
<Header back={{ href: '/admin/dashboard', label: 'Dashboard' }} />
{/* Admin Bar */}
<div className="bg-white border-b border-border">
<div className="max-w-4xl mx-auto px-4 py-3">
<h2 className="text-lg font-bold text-primary">Stammdaten importieren</h2>
<p className="text-sm text-text-muted">
Excel- oder CSV-Datei mit Wasserzähler-Stammdaten hochladen
</p>
</div>
</div>
<main className="flex-1 max-w-4xl mx-auto px-4 py-5 w-full space-y-5">
{/* Drop Zone */}
{!result && (
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`
border-2 border-dashed rounded-2xl p-10 text-center cursor-pointer transition-all
${dragOver
? 'border-accent bg-accent/5 scale-[1.01]'
: rows.length > 0
? 'border-success/40 bg-success/5'
: 'border-border hover:border-accent/40 hover:bg-bg/50'
}
`}
>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileInput}
className="hidden"
/>
{rows.length > 0 ? (
<>
<div className="w-12 h-12 bg-success/10 rounded-full flex items-center justify-center mx-auto mb-3">
<svg className="w-6 h-6 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="font-medium text-sm">{fileName}</div>
<div className="text-text-muted text-xs mt-1">
{rows.length} Zeile(n) erkannt Klicken um andere Datei zu wählen
</div>
</>
) : (
<>
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-3">
<svg className="w-6 h-6 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<div className="font-medium text-sm">
Datei hierher ziehen oder klicken
</div>
<div className="text-text-muted text-xs mt-1">
.xlsx, .xls oder .csv (max. 1000 Zeilen)
</div>
</>
)}
</div>
)}
{/* Parse Error */}
{parseError && (
<div className="p-4 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm">
{parseError}
</div>
)}
{/* Spalten-Info */}
{!result && rows.length === 0 && !parseError && (
<div className="bg-white rounded-2xl border border-border p-5">
<h3 className="text-sm font-semibold mb-3">Erwartetes Format</h3>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="text-text-muted">
<tr>
<th className="text-left py-1.5 pr-4 font-medium">Spalte</th>
<th className="text-left py-1.5 pr-4 font-medium">Inhalt</th>
<th className="text-left py-1.5 font-medium">Pflicht</th>
</tr>
</thead>
<tbody className="text-sm">
{[
['A', 'Kundennummer', 'Ja'],
['B', 'Zählernummer', 'Ja'],
['C', 'Name', 'Nein'],
['D', 'Adresse', 'Nein'],
['E', 'Ort (wird ignoriert)', '—'],
['F', 'Letzter Stand (m³)', 'Nein'],
['G', 'Letzte Ablesung (Datum)', 'Nein'],
].map(([col, desc, req]) => (
<tr key={col} className="border-t border-border/30">
<td className="py-1.5 pr-4 font-mono font-semibold">{col}</td>
<td className="py-1.5 pr-4">{desc}</td>
<td className="py-1.5">
{req === 'Ja' ? (
<span className="text-danger font-medium">{req}</span>
) : (
<span className="text-text-muted">{req}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-text-muted mt-3">
Erste Zeile wird als Kopfzeile erkannt und übersprungen.
Bei vorhandener Kundennummer werden die Daten aktualisiert.
</p>
</div>
)}
{/* Preview Table */}
{rows.length > 0 && !result && (
<>
{/* Stats Bar */}
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm font-medium">
{rows.length} Zeile(n) erkannt
</span>
{validCount > 0 && (
<span className="inline-flex px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-success/10 text-success">
{validCount} gültig
</span>
)}
{errorCount > 0 && (
<span className="inline-flex px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-danger/10 text-danger">
{errorCount} fehlerhaft
</span>
)}
<div className="flex-1" />
<button
onClick={reset}
className="text-xs text-text-muted hover:text-danger font-medium"
>
Zurücksetzen
</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 w-8">#</th>
<th className="px-4 py-3 text-left font-semibold">Kundennr.</th>
<th className="px-4 py-3 text-left font-semibold">Zählernr.</th>
<th className="px-4 py-3 text-left font-semibold">Name</th>
<th className="px-4 py-3 text-left font-semibold">Adresse</th>
<th className="px-4 py-3 text-right font-semibold">Letzter Stand</th>
<th className="px-4 py-3 text-left font-semibold">Letzte Ablesung</th>
<th className="px-4 py-3 text-left font-semibold">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{rows.slice(0, 5).map((r, i) => (
<tr
key={i}
className={r.error ? 'bg-danger/5' : 'hover:bg-bg/30 transition-colors'}
>
<td className="px-4 py-3 text-text-muted text-xs">{i + 1}</td>
<td className="px-4 py-3 font-mono text-xs font-semibold">{r.kundennummer || '—'}</td>
<td className="px-4 py-3 font-mono text-xs">{r.zaehlernummer || '—'}</td>
<td className="px-4 py-3">{r.haushalt_name || '—'}</td>
<td className="px-4 py-3 text-text-muted">{r.adresse || '—'}</td>
<td className="px-4 py-3 text-right">
{r.letzter_stand !== null ? `${r.letzter_stand}` : '—'}
</td>
<td className="px-4 py-3">
{r.letzte_ablesung
? new Date(r.letzte_ablesung + 'T00:00:00').toLocaleDateString('de-AT')
: '—'}
</td>
<td className="px-4 py-3">
{r.error ? (
<span className="text-danger text-xs font-medium">{r.error}</span>
) : (
<span className="inline-flex px-2 py-0.5 rounded-full text-[10px] font-semibold bg-success/10 text-success">OK</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{rows.length > 5 && (
<div className="px-4 py-2.5 bg-bg/50 text-xs text-text-muted text-center border-t border-border/50">
und {rows.length - 5} weitere Zeile(n)
</div>
)}
</div>
{/* Import Button */}
<div className="flex items-center gap-3">
<button
onClick={handleImport}
disabled={importing || validCount === 0}
className="bg-accent text-white px-6 py-3 rounded-xl font-semibold hover:bg-accent-light active:scale-[0.98] transition-all disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
>
{importing ? (
<>
<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 importiert
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
{validCount} Einträge importieren
</>
)}
</button>
{errorCount > 0 && (
<span className="text-xs text-text-muted">
{errorCount} fehlerhafte Zeile(n) werden übersprungen
</span>
)}
</div>
</>
)}
{/* Result */}
{result && (
<div className="space-y-4 animate-fade-in">
<div className="bg-white rounded-2xl border border-border p-6">
{/* Animated Checkmark */}
<div className="w-16 h-16 bg-success/10 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-success" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-bold text-center mb-4">Import abgeschlossen</h3>
<div className="grid grid-cols-3 gap-3 mb-5">
<div className="bg-success/5 rounded-xl p-3 text-center">
<div className="text-2xl font-bold text-success">{result.inserted}</div>
<div className="text-[11px] text-text-muted font-medium mt-0.5">Neu importiert</div>
</div>
<div className="bg-accent/5 rounded-xl p-3 text-center">
<div className="text-2xl font-bold text-accent">{result.updated}</div>
<div className="text-[11px] text-text-muted font-medium mt-0.5">Aktualisiert</div>
</div>
<div className={`rounded-xl p-3 text-center ${result.errors > 0 ? 'bg-danger/5' : 'bg-bg'}`}>
<div className={`text-2xl font-bold ${result.errors > 0 ? 'text-danger' : 'text-text-muted'}`}>{result.errors}</div>
<div className="text-[11px] text-text-muted font-medium mt-0.5">Fehler</div>
</div>
</div>
{/* Error Details */}
{result.error_details.length > 0 && (
<div className="p-3 bg-danger/5 border border-danger/20 rounded-xl mb-5">
<div className="text-xs font-semibold text-danger mb-2">Fehlerdetails:</div>
<ul className="text-xs text-danger space-y-1">
{result.error_details.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
<div className="flex gap-3">
<button
onClick={reset}
className="flex-1 border border-border py-3 rounded-xl font-semibold text-sm hover:bg-bg transition-colors"
>
Neuer Import
</button>
<button
onClick={() => router.push('/admin/dashboard')}
className="flex-1 bg-primary text-white py-3 rounded-xl font-semibold text-sm hover:bg-primary-light transition-colors"
>
Zum Dashboard
</button>
</div>
</div>
</div>
)}
</main>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServiceClient } from '@/lib/supabase/server';
import { createServiceClient, createServerSupabaseClient } from '@/lib/supabase/server';
import { Resend } from 'resend';
function getResend() {
@@ -11,32 +11,39 @@ export async function POST(request: NextRequest) {
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 }
);
}
// Prüfen ob Admin eingeloggt ist (kein CAPTCHA nötig)
const authClient = await createServerSupabaseClient();
const { data: { user } } = await authClient.auth.getUser();
const isAdmin = !!user;
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,
}),
// CAPTCHA-Verifizierung nur für Bürger (nicht für Admins)
if (!isAdmin) {
if (!captchaToken) {
return NextResponse.json(
{ error: 'CAPTCHA-Verifizierung fehlt.' },
{ status: 403 }
);
}
);
const turnstileResult = await turnstileResponse.json();
if (!turnstileResult.success) {
return NextResponse.json(
{ error: 'CAPTCHA-Verifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.' },
{ 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
@@ -104,7 +111,7 @@ export async function POST(request: NextRequest) {
wassermenge_m3: wassermenge_m3 || null,
wunschdatum,
status: 'aktiv',
erstellt_von: 'buerger',
erstellt_von: isAdmin ? 'admin' : 'buerger',
})
.select()
.single();
@@ -127,7 +134,7 @@ export async function POST(request: NextRequest) {
try {
await getResend().emails.send({
from: 'Gemeindeamt Weißkirchen <noreply@resend.dev>',
from: 'Gemeindeamt Weißkirchen <gemeinde@datacrew.at>',
to: email,
subject: `Ihre Anmeldung zur Pool-Befüllung — ${datumFormatiert}`,
html: `

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const { captchaToken } = await request.json();
if (!captchaToken) {
return NextResponse.json(
{ error: 'CAPTCHA-Token fehlt.' },
{ status: 400 }
);
}
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 result = await turnstileResponse.json();
if (!result.success) {
return NextResponse.json(
{ error: 'CAPTCHA-Verifizierung fehlgeschlagen.' },
{ status: 403 }
);
}
return NextResponse.json({ success: true });
} catch {
return NextResponse.json(
{ error: 'Interner Serverfehler.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,140 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServiceClient, createServerSupabaseClient } from '@/lib/supabase/server';
import { randomUUID } from 'crypto';
interface ImportRow {
kundennummer: string;
zaehlernummer: string;
haushalt_name: string;
adresse: string;
letzter_stand: number | null;
letzte_ablesung: string | null;
}
export async function POST(request: NextRequest) {
try {
// Auth check
const authClient = await createServerSupabaseClient();
const { data: { user } } = await authClient.auth.getUser();
if (!user) {
return NextResponse.json(
{ error: 'Nicht authentifiziert.' },
{ status: 401 }
);
}
const body = await request.json();
const rows: ImportRow[] = body.rows;
if (!Array.isArray(rows) || rows.length === 0) {
return NextResponse.json(
{ error: 'Keine Daten zum Importieren.' },
{ status: 400 }
);
}
if (rows.length > 1000) {
return NextResponse.json(
{ error: 'Maximal 1000 Zeilen pro Import.' },
{ status: 400 }
);
}
const supabase = createServiceClient();
// Validate rows
const validRows: ImportRow[] = [];
const errorDetails: string[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row.kundennummer?.trim() || !row.zaehlernummer?.trim()) {
errorDetails.push(`Zeile ${i + 1}: Kundennummer und Zählernummer sind Pflicht.`);
continue;
}
validRows.push({
kundennummer: row.kundennummer.trim(),
zaehlernummer: row.zaehlernummer.trim(),
haushalt_name: row.haushalt_name?.trim() || '',
adresse: row.adresse?.trim() || '',
letzter_stand: row.letzter_stand,
letzte_ablesung: row.letzte_ablesung || null,
});
}
// Fetch existing records by kundennummer
const kundennummern = validRows.map(r => r.kundennummer);
const { data: existing } = await supabase
.from('wasserzaehler')
.select('kundennummer')
.in('kundennummer', kundennummern);
const existingSet = new Set((existing || []).map((e: { kundennummer: string }) => e.kundennummer));
let inserted = 0;
let updated = 0;
// Insert new rows
const newRows = validRows.filter(r => !existingSet.has(r.kundennummer));
if (newRows.length > 0) {
const insertData = newRows.map(r => ({
kundennummer: r.kundennummer,
zaehlernummer: r.zaehlernummer,
haushalt_name: r.haushalt_name,
adresse: r.adresse,
alter_stand: r.letzter_stand ?? 0,
letzter_stand: r.letzter_stand,
letzte_ablesung: r.letzte_ablesung,
access_token: randomUUID(),
}));
const { error: insertError } = await supabase
.from('wasserzaehler')
.insert(insertData);
if (insertError) {
errorDetails.push(`Insert-Fehler: ${insertError.message}`);
} else {
inserted = newRows.length;
}
}
// Update existing rows
const updateRows = validRows.filter(r => existingSet.has(r.kundennummer));
for (const r of updateRows) {
const { error: updateError } = await supabase
.from('wasserzaehler')
.update({
zaehlernummer: r.zaehlernummer,
haushalt_name: r.haushalt_name,
adresse: r.adresse,
alter_stand: r.letzter_stand ?? 0,
letzter_stand: r.letzter_stand,
letzte_ablesung: r.letzte_ablesung,
})
.eq('kundennummer', r.kundennummer);
if (updateError) {
errorDetails.push(`Update-Fehler für ${r.kundennummer}: ${updateError.message}`);
} else {
updated++;
}
}
const errors = errorDetails.length;
return NextResponse.json({
inserted,
updated,
errors,
error_details: errorDetails,
});
} catch (err) {
console.error('Zaehler-Import Error:', err);
return NextResponse.json(
{ error: 'Interner Serverfehler.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,223 @@
import { NextResponse } from 'next/server';
import { createServiceClient, createServerSupabaseClient } from '@/lib/supabase/server';
import QRCode from 'qrcode';
import ExcelJS from 'exceljs';
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'https://gemeindeportal.vercel.app';
export async function GET() {
try {
// Auth check
const authClient = await createServerSupabaseClient();
const { data: { user } } = await authClient.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Nicht authentifiziert.' }, { status: 401 });
}
const supabase = createServiceClient();
const { data: zaehler, error } = await supabase
.from('wasserzaehler')
.select('*')
.order('kundennummer', { ascending: true });
if (error || !zaehler || zaehler.length === 0) {
return NextResponse.json(
{ error: 'Keine Wasserzähler gefunden.' },
{ status: 404 }
);
}
// Create workbook
const workbook = new ExcelJS.Workbook();
workbook.creator = 'Gemeindeportal';
workbook.created = new Date();
// --- Sheet 1: Übersicht (Tabelle mit allen Kunden) ---
const listSheet = workbook.addWorksheet('Übersicht', {
pageSetup: {
paperSize: 9, // A4
orientation: 'landscape',
fitToPage: true,
fitToWidth: 1,
fitToHeight: 0,
},
});
listSheet.columns = [
{ header: 'Kundennummer', key: 'kundennummer', width: 18 },
{ header: 'Zählernummer', key: 'zaehlernummer', width: 18 },
{ header: 'Name', key: 'name', width: 28 },
{ header: 'Adresse', key: 'adresse', width: 30 },
{ header: 'Letzter Stand (m³)', key: 'stand', width: 18 },
{ header: 'QR-Code URL', key: 'url', width: 55 },
];
// Header styling
const headerRow = listSheet.getRow(1);
headerRow.font = { bold: true, size: 11 };
headerRow.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF0D2B4E' },
};
headerRow.font = { bold: true, size: 11, color: { argb: 'FFFFFFFF' } };
headerRow.alignment = { vertical: 'middle' };
headerRow.height = 28;
for (const z of zaehler) {
const url = `${BASE_URL}/wasserzaehler?token=${z.access_token}`;
listSheet.addRow({
kundennummer: z.kundennummer || '',
zaehlernummer: z.zaehlernummer,
name: z.haushalt_name,
adresse: z.adresse || '',
stand: z.alter_stand,
url,
});
}
// Auto-filter
listSheet.autoFilter = {
from: { row: 1, column: 1 },
to: { row: zaehler.length + 1, column: 6 },
};
// --- Sheet 2+: Ein Blatt pro Kunde mit QR-Code (Druckseiten) ---
for (let i = 0; i < zaehler.length; i++) {
const z = zaehler[i];
const url = `${BASE_URL}/wasserzaehler?token=${z.access_token}`;
// Generate QR code as PNG buffer
const qrBuffer = await QRCode.toBuffer(url, {
width: 400,
margin: 2,
errorCorrectionLevel: 'M',
type: 'png',
});
const sheetName = `${z.kundennummer || `Kunde ${i + 1}`}`.slice(0, 31);
const sheet = workbook.addWorksheet(sheetName, {
pageSetup: {
paperSize: 9, // A4
orientation: 'portrait',
horizontalCentered: true,
verticalCentered: true,
margins: {
left: 0.7,
right: 0.7,
top: 1.0,
bottom: 1.0,
header: 0.3,
footer: 0.3,
},
},
});
// Set column widths
sheet.getColumn(1).width = 5;
sheet.getColumn(2).width = 25;
sheet.getColumn(3).width = 35;
sheet.getColumn(4).width = 5;
// Title
sheet.mergeCells('B2:C2');
const titleCell = sheet.getCell('B2');
titleCell.value = 'Gemeindeamt Weißkirchen an der Traun';
titleCell.font = { bold: true, size: 16, color: { argb: 'FF0D2B4E' } };
titleCell.alignment = { horizontal: 'center', vertical: 'middle' };
sheet.getRow(2).height = 30;
// Subtitle
sheet.mergeCells('B3:C3');
const subtitleCell = sheet.getCell('B3');
subtitleCell.value = `Wasserzähler-Ablesung ${new Date().getFullYear()}`;
subtitleCell.font = { size: 12, color: { argb: 'FF666666' } };
subtitleCell.alignment = { horizontal: 'center', vertical: 'middle' };
sheet.getRow(3).height = 22;
// QR Code image — rows 5-20
const imageId = workbook.addImage({
buffer: qrBuffer as unknown as ExcelJS.Buffer,
extension: 'png',
});
sheet.addImage(imageId, {
tl: { col: 1.5, row: 5 },
ext: { width: 250, height: 250 },
});
// Space for QR code
for (let row = 5; row <= 20; row++) {
sheet.getRow(row).height = 18;
}
// Customer info below QR code
const infoStartRow = 22;
// Kundennummer
sheet.getCell(`B${infoStartRow}`).value = 'Kundennummer';
sheet.getCell(`B${infoStartRow}`).font = { size: 10, color: { argb: 'FF999999' } };
sheet.getCell(`B${infoStartRow}`).alignment = { horizontal: 'center' };
sheet.mergeCells(`B${infoStartRow}:C${infoStartRow}`);
sheet.getCell(`B${infoStartRow + 1}`).value = z.kundennummer || '—';
sheet.getCell(`B${infoStartRow + 1}`).font = { bold: true, size: 22 };
sheet.getCell(`B${infoStartRow + 1}`).alignment = { horizontal: 'center' };
sheet.mergeCells(`B${infoStartRow + 1}:C${infoStartRow + 1}`);
sheet.getRow(infoStartRow + 1).height = 32;
// Zählernummer
sheet.getCell(`B${infoStartRow + 3}`).value = 'Zählernummer';
sheet.getCell(`B${infoStartRow + 3}`).font = { size: 10, color: { argb: 'FF999999' } };
sheet.getCell(`B${infoStartRow + 3}`).alignment = { horizontal: 'center' };
sheet.mergeCells(`B${infoStartRow + 3}:C${infoStartRow + 3}`);
sheet.getCell(`B${infoStartRow + 4}`).value = z.zaehlernummer;
sheet.getCell(`B${infoStartRow + 4}`).font = { bold: true, size: 16 };
sheet.getCell(`B${infoStartRow + 4}`).alignment = { horizontal: 'center' };
sheet.mergeCells(`B${infoStartRow + 4}:C${infoStartRow + 4}`);
sheet.getRow(infoStartRow + 4).height = 26;
// Instructions
sheet.mergeCells(`B${infoStartRow + 7}:C${infoStartRow + 7}`);
const instrCell = sheet.getCell(`B${infoStartRow + 7}`);
instrCell.value = 'Scannen Sie den QR-Code mit Ihrem Smartphone';
instrCell.font = { size: 11, color: { argb: 'FF666666' } };
instrCell.alignment = { horizontal: 'center', wrapText: true };
sheet.mergeCells(`B${infoStartRow + 8}:C${infoStartRow + 8}`);
const instrCell2 = sheet.getCell(`B${infoStartRow + 8}`);
instrCell2.value = 'um Ihren aktuellen Zählerstand zu melden.';
instrCell2.font = { size: 11, color: { argb: 'FF666666' } };
instrCell2.alignment = { horizontal: 'center', wrapText: true };
// Border box around content
for (let row = 1; row <= infoStartRow + 9; row++) {
const r = sheet.getRow(row);
r.getCell(2).border = { left: { style: 'thin', color: { argb: 'FFE0E0E0' } } };
r.getCell(3).border = { right: { style: 'thin', color: { argb: 'FFE0E0E0' } } };
}
}
// Generate buffer
const buffer = await workbook.xlsx.writeBuffer();
const filename = `wasserzaehler_qrcodes_${new Date().toISOString().split('T')[0]}.xlsx`;
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="${filename}"`,
},
});
} catch (err) {
console.error('QR-Code Excel Error:', err);
return NextResponse.json(
{ error: 'Interner Serverfehler.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,369 @@
import { NextResponse } from 'next/server';
import { createServiceClient, createServerSupabaseClient } from '@/lib/supabase/server';
import QRCode from 'qrcode';
import PDFDocument from 'pdfkit';
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'https://gemeindeportal.vercel.app';
const MARGIN_LEFT = 56; // ~2cm
const MARGIN_RIGHT = 56;
const PAGE_WIDTH = 595.28; // A4
const PAGE_HEIGHT = 841.89;
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN_LEFT - MARGIN_RIGHT;
export async function GET() {
try {
// Auth check
const authClient = await createServerSupabaseClient();
const { data: { user } } = await authClient.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Nicht authentifiziert.' }, { status: 401 });
}
const supabase = createServiceClient();
const { data: zaehler, error } = await supabase
.from('wasserzaehler')
.select('*')
.order('kundennummer', { ascending: true });
if (error || !zaehler || zaehler.length === 0) {
return NextResponse.json({ error: 'Keine Wasserzähler gefunden.' }, { status: 404 });
}
// Create PDF with streaming
const doc = new PDFDocument({
size: 'A4',
margins: { top: 42, bottom: 28, left: MARGIN_LEFT, right: MARGIN_RIGHT },
bufferPages: false, // Stream mode — memory efficient
info: {
Title: `Wasserablesung Serienbriefe ${new Date().getFullYear()}`,
Author: 'Gemeinde Weißkirchen/Traun',
Creator: 'Gemeindeportal',
},
});
// Collect chunks into array for response
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
const today = new Date().toLocaleDateString('de-AT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
const year = new Date().getFullYear();
// Generate all pages
for (let i = 0; i < zaehler.length; i++) {
const z = zaehler[i];
if (i > 0) doc.addPage();
// Generate QR code as PNG buffer
const url = `${BASE_URL}/wasserzaehler?token=${z.access_token}`;
const qrBuffer = await QRCode.toBuffer(url, {
width: 200,
margin: 1,
errorCorrectionLevel: 'M',
type: 'png',
});
renderLetterPage(doc, z, qrBuffer, today, year);
}
doc.end();
// Wait for PDF to finish
await new Promise<void>((resolve) => doc.on('end', resolve));
const pdfBuffer = Buffer.concat(chunks);
const filename = `wasserablesung_serienbriefe_${new Date().toISOString().split('T')[0]}.pdf`;
return new NextResponse(pdfBuffer, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': String(pdfBuffer.length),
},
});
} catch (err) {
console.error('Serienbrief PDF Error:', err);
return NextResponse.json({ error: 'Interner Serverfehler.' }, { status: 500 });
}
}
interface ZaehlerRow {
kundennummer: string | null;
zaehlernummer: string;
haushalt_name: string;
adresse: string | null;
alter_stand: number | null;
access_token: string;
}
function renderLetterPage(
doc: PDFKit.PDFDocument,
z: ZaehlerRow,
qrBuffer: Buffer,
today: string,
year: number,
) {
let y = 42;
// ====== HEADER ======
doc.font('Helvetica-Bold').fontSize(11);
doc.text('Gemeinde Weisskirchen/Traun', MARGIN_LEFT, y);
y += 13;
doc.font('Helvetica').fontSize(8.5);
doc.text('Gemeindeplatz 1, 4616 Weisskirchen a. d. Traun', MARGIN_LEFT, y);
y += 11;
doc.text('UID: ATU23479000', MARGIN_LEFT, y);
// Right header
doc.fontSize(8.5);
const rightX = PAGE_WIDTH - MARGIN_RIGHT;
doc.text('Homepage: www.weisskirchen.at', MARGIN_LEFT, 42, { width: CONTENT_WIDTH, align: 'right' });
doc.text('E-Mail: linda.raml@weisskirchen.ooe.gv.at', MARGIN_LEFT, 55, { width: CONTENT_WIDTH, align: 'right' });
doc.text('Telefon: 07243/56155-15', MARGIN_LEFT, 68, { width: CONTENT_WIDTH, align: 'right' });
doc.text('Fax: 07243/56155-35', MARGIN_LEFT, 81, { width: CONTENT_WIDTH, align: 'right' });
// Header line
y = 92;
doc.moveTo(MARGIN_LEFT, y).lineTo(rightX, y).lineWidth(0.5).strokeColor('#999999').stroke();
y += 16;
// ====== SENDER LINE ======
doc.font('Helvetica').fontSize(7).fillColor('#555555');
doc.text('Absender: Gemeinde Weisskirchen/Traun, 4616 Weisskirchen a. d. Traun', MARGIN_LEFT, y);
doc.moveTo(MARGIN_LEFT, y + 10).lineTo(MARGIN_LEFT + 260, y + 10).lineWidth(0.3).strokeColor('#999999').stroke();
y += 22;
// ====== RECIPIENT ======
doc.font('Helvetica').fontSize(11).fillColor('#000000');
doc.text(z.haushalt_name, MARGIN_LEFT, y);
y += 14;
if (z.adresse) {
doc.text(z.adresse, MARGIN_LEFT, y);
y += 14;
}
doc.text('4616 Weisskirchen an der Traun', MARGIN_LEFT, y);
// ====== TITLE BLOCK (right side) ======
const titleX = 340;
const titleY = 108;
doc.font('Helvetica-Bold').fontSize(15).fillColor('#000000');
doc.text('Wasserablesung', titleX, titleY);
doc.font('Helvetica').fontSize(10);
const infoY = titleY + 26;
doc.text('Datum:', titleX, infoY);
doc.text(today, titleX + 100, infoY);
doc.text('Kundennummer:', titleX, infoY + 15);
doc.text(z.kundennummer || '—', titleX + 100, infoY + 15);
doc.fontSize(9).fillColor('#555555');
doc.text('(EDV-Nummer)', titleX, infoY + 28);
doc.fillColor('#000000');
// ====== LETTER BODY ======
y = 194;
doc.font('Helvetica').fontSize(10.5);
doc.text('Sehr geehrte Kundin, sehr geehrter Kunde!', MARGIN_LEFT, y, { width: CONTENT_WIDTH });
y += 22;
doc.text(
'Die Gemeinde Weisskirchen/Traun ersucht Sie hoeflichst um Bekanntgabe des ' +
'Wasserzaehlerstandes Ihres unten genannten Objektes.',
MARGIN_LEFT, y, { width: CONTENT_WIDTH }
);
y += 30;
// Highlighted online box
const boxY = y;
doc.save();
doc.rect(MARGIN_LEFT, boxY, CONTENT_WIDTH, 48).lineWidth(1).strokeColor('#000000').stroke();
doc.rect(MARGIN_LEFT + 0.5, boxY + 0.5, CONTENT_WIDTH - 1, 47).fillColor('#f5f5f5').fill();
doc.fillColor('#000000');
doc.font('Helvetica-Bold').fontSize(10.5);
doc.text('NEU — Zaehlerstand bequem online melden:', MARGIN_LEFT + 8, boxY + 6, { width: CONTENT_WIDTH - 16 });
doc.font('Helvetica').fontSize(10);
doc.text(
'Scannen Sie einfach den QR-Code auf der Antwortkarte unten mit Ihrer Smartphone-Kamera. ' +
'Sie werden automatisch auf unsere Webseite weitergeleitet, auf der Sie Ihren aktuellen ' +
'Zaehlerstand in wenigen Sekunden eingeben koennen.',
MARGIN_LEFT + 8, boxY + 18, { width: CONTENT_WIDTH - 16 }
);
doc.restore();
y = boxY + 56;
doc.font('Helvetica').fontSize(10.5).fillColor('#000000');
doc.text(
`Alternativ koennen Sie den nachstehenden Abschnitt ausgefuellt bis spaetestens 29.08.${year} durch`,
MARGIN_LEFT, y, { width: CONTENT_WIDTH }
);
y += 18;
const bullets = [
'persoenliche Abgabe',
'den Postweg',
'mittels E-Mail linda.raml@weisskirchen.ooe.gv.at',
'oder in den Gemeindebriefkasten der Gemeinde Weisskirchen/Traun',
];
for (const bullet of bullets) {
doc.text(`${bullet}`, MARGIN_LEFT + 16, y, { width: CONTENT_WIDTH - 16 });
y += 13;
}
doc.text('zu retournieren.', MARGIN_LEFT, y);
y += 18;
doc.text(
'Sollten Sie Fragen haben oder Ihnen die Ablesung Schwierigkeiten bereiten, ersuchen wir um ' +
'Ihren Anruf unter der im Kopf genannten Telefonnummer.',
MARGIN_LEFT, y, { width: CONTENT_WIDTH }
);
y += 34;
// Signature
doc.text('Mit freundlichen Gruessen', MARGIN_LEFT, y, { width: CONTENT_WIDTH, align: 'right' });
y += 13;
doc.text('Der Buergermeister:', MARGIN_LEFT, y, { width: CONTENT_WIDTH, align: 'right' });
y += 13;
doc.font('Helvetica-Bold');
doc.text('Patrick Krutzler', MARGIN_LEFT, y, { width: CONTENT_WIDTH, align: 'right' });
doc.font('Helvetica');
// ====== CUT LINE ======
y += 30;
const dashLen = 6;
const gapLen = 4;
let dx = MARGIN_LEFT;
doc.save();
doc.lineWidth(1).strokeColor('#999999');
while (dx < rightX) {
doc.moveTo(dx, y).lineTo(Math.min(dx + dashLen, rightX), y).stroke();
dx += dashLen + gapLen;
}
doc.fontSize(7).fillColor('#999999');
const cutText = 'Hier abtrennen';
const cutW = doc.widthOfString(cutText);
const cutX = (PAGE_WIDTH - cutW) / 2;
doc.rect(cutX - 6, y - 6, cutW + 12, 12).fillColor('#ffffff').fill();
doc.fillColor('#999999');
doc.text(cutText, cutX, y - 4);
doc.restore();
// ====== REPLY CARD ======
y += 16;
const cardY = y;
doc.fillColor('#000000');
// Left side info
doc.font('Helvetica').fontSize(8);
doc.text('Kundennummer:', MARGIN_LEFT, y);
doc.font('Helvetica-Bold').fontSize(11);
doc.text(z.kundennummer || '—', MARGIN_LEFT + 90, y - 1);
y += 18;
doc.font('Helvetica').fontSize(8).fillColor('#000000');
doc.text('Objekt:', MARGIN_LEFT, y);
doc.font('Helvetica-Bold').fontSize(9);
doc.text(z.adresse || '—', MARGIN_LEFT + 90, y);
y += 13;
doc.font('Helvetica').fontSize(8);
doc.text('Name:', MARGIN_LEFT, y);
doc.font('Helvetica-Bold').fontSize(9);
doc.text(z.haushalt_name, MARGIN_LEFT + 90, y);
y += 13;
doc.font('Helvetica').fontSize(8);
doc.text('Zaehlernummer:', MARGIN_LEFT, y);
doc.font('Helvetica-Bold').fontSize(9);
doc.text(z.zaehlernummer, MARGIN_LEFT + 90, y);
y += 18;
doc.font('Helvetica').fontSize(9);
doc.text('Zuletzt abgelesener Zaehlerstand:', MARGIN_LEFT, y);
doc.font('Helvetica-Bold').fontSize(11);
const standStr = z.alter_stand != null ? `${z.alter_stand}` : '—';
doc.text(standStr, MARGIN_LEFT + 190, y - 1);
const standWidth = doc.widthOfString(standStr);
doc.font('Helvetica').fontSize(9);
doc.text('m³', MARGIN_LEFT + 190 + standWidth + 6, y);
y += 22;
// Neuer Zählerstand boxes
doc.font('Helvetica-Bold').fontSize(10);
doc.text('Neuer Zaehlerstand:', MARGIN_LEFT, y);
const boxStartX = MARGIN_LEFT + 130;
const boxSize = 20;
const boxGap = 3;
for (let b = 0; b < 7; b++) {
const bx = boxStartX + b * (boxSize + boxGap);
doc.rect(bx, y - 2, boxSize, boxSize + 4).lineWidth(1).strokeColor('#000000').stroke();
}
doc.font('Helvetica').fontSize(8);
doc.text('m³', boxStartX + 7 * (boxSize + boxGap) + 4, y + 2);
y += 30;
// Abgelesen am
doc.font('Helvetica-Bold').fontSize(10);
doc.text('abgelesen am:', MARGIN_LEFT, y);
doc.moveTo(MARGIN_LEFT + 90, y + 12).lineTo(MARGIN_LEFT + 320, y + 12).lineWidth(0.5).strokeColor('#000000').stroke();
y += 22;
// Confirmation text
doc.font('Helvetica').fontSize(6.5).fillColor('#444444');
doc.text('Der (die) Unterfertigte bestaetigt hiermit die Richtigkeit der Angaben.', MARGIN_LEFT, y);
y += 12;
doc.fontSize(8).fillColor('#000000');
doc.text('Datum/Unterschrift:', MARGIN_LEFT, y);
doc.moveTo(MARGIN_LEFT + 100, y + 10).lineTo(MARGIN_LEFT + 320, y + 10).lineWidth(0.5).strokeColor('#000000').stroke();
y += 18;
doc.font('Helvetica').fontSize(6.5).fillColor('#444444');
doc.text('Eventuelle Anmerkungen/TelNr. fuer Rueckfragen:', MARGIN_LEFT, y);
// ====== RIGHT SIDE: Postage + Address + QR ======
const rightColX = PAGE_WIDTH - MARGIN_RIGHT - 110;
let rY = cardY;
// 1. Postage box (top right — where the stamp goes)
doc.fillColor('#000000');
const postX = rightColX + 25;
doc.rect(postX, rY, 60, 40).lineWidth(0.5).strokeColor('#000000').stroke();
doc.font('Helvetica').fontSize(6);
doc.text('Postgebuehr', postX, rY + 5, { width: 60, align: 'center' });
doc.text('beim', postX, rY + 13, { width: 60, align: 'center' });
doc.text('Empfaenger', postX, rY + 21, { width: 60, align: 'center' });
doc.text('einheben', postX, rY + 29, { width: 60, align: 'center' });
rY += 48;
// 2. Reply address
doc.font('Helvetica-Bold').fontSize(8).fillColor('#000000');
doc.text('Antwortkarte', rightColX, rY, { width: 110, align: 'center' });
rY += 12;
doc.font('Helvetica').fontSize(8);
doc.text('Gemeinde Weisskirchen/Traun', rightColX, rY, { width: 110, align: 'center' });
rY += 10;
doc.text('Gemeindeplatz 1', rightColX, rY, { width: 110, align: 'center' });
rY += 10;
doc.text('4616 Weisskirchen a. d. Traun', rightColX, rY, { width: 110, align: 'center' });
rY += 18;
// 3. QR Code image
doc.image(qrBuffer, rightColX + 10, rY, { width: 90, height: 90 });
rY += 94;
// QR hint
doc.font('Helvetica-Bold').fontSize(6.5).fillColor('#000000');
doc.text('Zaehlerstand online melden:', rightColX, rY, { width: 110, align: 'center' });
rY += 9;
doc.font('Helvetica').fontSize(6).fillColor('#333333');
doc.text('QR-Code mit Smartphone-', rightColX, rY, { width: 110, align: 'center' });
rY += 8;
doc.text('Kamera scannen', rightColX, rY, { width: 110, align: 'center' });
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import Script from 'next/script';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
@@ -42,6 +42,7 @@ export default function PoolBuchungPage() {
const [showConfirmation, setShowConfirmation] = useState(false);
const [captchaToken, setCaptchaToken] = useState('');
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [turnstileReady, setTurnstileReady] = useState(false);
const turnstileWidgetId = useRef<string | null>(null);
const turnstileContainerRef = useRef<HTMLDivElement>(null);
@@ -58,6 +59,15 @@ export default function PoolBuchungPage() {
}
}, []);
// Render Turnstile when Step 2 becomes visible and script is loaded
useEffect(() => {
if (step === 'daten' && turnstileReady) {
// Small delay to ensure the container ref is mounted
const timer = setTimeout(renderTurnstile, 100);
return () => clearTimeout(timer);
}
}, [step, turnstileReady, renderTurnstile]);
const steps = [
{ label: 'Termin', done: step === 'daten' || step === 'fertig', active: step === 'termin' },
{ label: 'Daten', done: step === 'fertig', active: step === 'daten' },
@@ -153,9 +163,7 @@ export default function PoolBuchungPage() {
setFieldErrors({});
setStep('termin');
setShowConfirmation(false);
if (window.turnstile && turnstileWidgetId.current) {
window.turnstile.reset(turnstileWidgetId.current);
}
turnstileWidgetId.current = null;
};
const formattedDate = selectedDate
@@ -168,7 +176,7 @@ export default function PoolBuchungPage() {
<div className="min-h-screen flex flex-col bg-bg">
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
onReady={renderTurnstile}
onReady={() => setTurnstileReady(true)}
/>
<Header back={{ href: '/', label: 'Zurück' }} />

View File

@@ -164,15 +164,11 @@ function WasserzaehlerContent() {
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Vorbefüllte Daten */}
{/* Vorbefüllte Daten (kein Name/Adresse — Datenschutz) */}
<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>
<span className="text-sm text-text-muted">Kundennr.</span>
<span className="text-sm font-semibold font-mono tracking-wide">{zaehler.kundennummer}</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>

View File

@@ -16,10 +16,13 @@ export interface Buchung {
export interface Wasserzaehler {
id: string;
access_token: string;
kundennummer: string;
haushalt_name: string;
adresse: string;
zaehlernummer: string;
alter_stand: number;
letzter_stand: number | null;
letzte_ablesung: string | null;
neuer_stand: number | null;
verbrauch: number | null;
ablesedatum: string | null;