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:
230
src/app/admin/qrcodes/page.tsx
Normal file
230
src/app/admin/qrcodes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user