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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -39,3 +39,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
.vercel
|
||||||
|
|||||||
1554
package-lock.json
generated
1554
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/ssr": "^0.9.0",
|
"@supabase/ssr": "^0.9.0",
|
||||||
"@supabase/supabase-js": "^2.98.0",
|
"@supabase/supabase-js": "^2.98.0",
|
||||||
|
"@types/pdfkit": "^0.17.5",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"pdfkit": "^0.17.2",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"resend": "^6.9.3"
|
"resend": "^6.9.3"
|
||||||
@@ -21,6 +26,7 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
196
scripts/generate-qr-excel.ts
Normal file
196
scripts/generate-qr-excel.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Generiert eine Excel-Datei mit QR-Codes für alle Wasserzähler.
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* npx tsx scripts/generate-qr-excel.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync, writeFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
// .env.local laden
|
||||||
|
function loadEnv() {
|
||||||
|
const envPath = resolve(__dirname, '..', '.env.local');
|
||||||
|
if (!existsSync(envPath)) {
|
||||||
|
console.error('FEHLER: .env.local nicht gefunden');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const content = readFileSync(envPath, 'utf-8');
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const eqIndex = trimmed.indexOf('=');
|
||||||
|
if (eqIndex === -1) continue;
|
||||||
|
const key = trimmed.slice(0, eqIndex).trim();
|
||||||
|
const value = trimmed.slice(eqIndex + 1).trim();
|
||||||
|
if (!process.env[key]) process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'https://gemeindeportal.vercel.app';
|
||||||
|
|
||||||
|
const supabase = createClient(SUPABASE_URL, SERVICE_KEY);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Lade Wasserzähler aus Supabase...');
|
||||||
|
|
||||||
|
const { data: zaehler, error } = await supabase
|
||||||
|
.from('wasserzaehler')
|
||||||
|
.select('*')
|
||||||
|
.order('haushalt_name', { ascending: true });
|
||||||
|
|
||||||
|
if (error || !zaehler || zaehler.length === 0) {
|
||||||
|
console.error('Fehler oder keine Daten:', error?.message || 'Keine Einträge');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${zaehler.length} Einträge gefunden. Generiere Excel...\n`);
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
workbook.creator = 'Gemeindeportal';
|
||||||
|
|
||||||
|
// --- Blatt 1: Übersicht ---
|
||||||
|
const listSheet = workbook.addWorksheet('Übersicht', {
|
||||||
|
pageSetup: { paperSize: 9, 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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const headerRow = listSheet.getRow(1);
|
||||||
|
headerRow.font = { bold: true, size: 11, color: { argb: 'FFFFFFFF' } };
|
||||||
|
headerRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0D2B4E' } };
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listSheet.autoFilter = {
|
||||||
|
from: { row: 1, column: 1 },
|
||||||
|
to: { row: zaehler.length + 1, column: 6 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Je 1 Blatt pro Kunde mit QR-Code ---
|
||||||
|
for (let i = 0; i < zaehler.length; i++) {
|
||||||
|
const z = zaehler[i];
|
||||||
|
const url = `${BASE_URL}/wasserzaehler?token=${z.access_token}`;
|
||||||
|
|
||||||
|
const qrBuffer = await QRCode.toBuffer(url, {
|
||||||
|
width: 400, margin: 2, errorCorrectionLevel: 'M', type: 'png',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sheetName = (z.kundennummer || z.haushalt_name || `Kunde ${i + 1}`).slice(0, 31);
|
||||||
|
const sheet = workbook.addWorksheet(sheetName, {
|
||||||
|
pageSetup: {
|
||||||
|
paperSize: 9, 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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
sheet.getColumn(1).width = 5;
|
||||||
|
sheet.getColumn(2).width = 25;
|
||||||
|
sheet.getColumn(3).width = 35;
|
||||||
|
sheet.getColumn(4).width = 5;
|
||||||
|
|
||||||
|
// Titel
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 Bild
|
||||||
|
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 } });
|
||||||
|
|
||||||
|
for (let row = 5; row <= 20; row++) sheet.getRow(row).height = 18;
|
||||||
|
|
||||||
|
// Kundennummer
|
||||||
|
const r = 22;
|
||||||
|
sheet.mergeCells(`B${r}:C${r}`);
|
||||||
|
sheet.getCell(`B${r}`).value = 'Kundennummer';
|
||||||
|
sheet.getCell(`B${r}`).font = { size: 10, color: { argb: 'FF999999' } };
|
||||||
|
sheet.getCell(`B${r}`).alignment = { horizontal: 'center' };
|
||||||
|
|
||||||
|
sheet.mergeCells(`B${r + 1}:C${r + 1}`);
|
||||||
|
sheet.getCell(`B${r + 1}`).value = z.kundennummer || '—';
|
||||||
|
sheet.getCell(`B${r + 1}`).font = { bold: true, size: 22 };
|
||||||
|
sheet.getCell(`B${r + 1}`).alignment = { horizontal: 'center' };
|
||||||
|
sheet.getRow(r + 1).height = 32;
|
||||||
|
|
||||||
|
// Zählernummer
|
||||||
|
sheet.mergeCells(`B${r + 3}:C${r + 3}`);
|
||||||
|
sheet.getCell(`B${r + 3}`).value = 'Zählernummer';
|
||||||
|
sheet.getCell(`B${r + 3}`).font = { size: 10, color: { argb: 'FF999999' } };
|
||||||
|
sheet.getCell(`B${r + 3}`).alignment = { horizontal: 'center' };
|
||||||
|
|
||||||
|
sheet.mergeCells(`B${r + 4}:C${r + 4}`);
|
||||||
|
sheet.getCell(`B${r + 4}`).value = z.zaehlernummer;
|
||||||
|
sheet.getCell(`B${r + 4}`).font = { bold: true, size: 16 };
|
||||||
|
sheet.getCell(`B${r + 4}`).alignment = { horizontal: 'center' };
|
||||||
|
sheet.getRow(r + 4).height = 26;
|
||||||
|
|
||||||
|
// Name (klein, für Zuordnung)
|
||||||
|
sheet.mergeCells(`B${r + 6}:C${r + 6}`);
|
||||||
|
sheet.getCell(`B${r + 6}`).value = z.haushalt_name;
|
||||||
|
sheet.getCell(`B${r + 6}`).font = { size: 11, color: { argb: 'FF999999' } };
|
||||||
|
sheet.getCell(`B${r + 6}`).alignment = { horizontal: 'center' };
|
||||||
|
|
||||||
|
// Anleitung
|
||||||
|
sheet.mergeCells(`B${r + 8}:C${r + 8}`);
|
||||||
|
sheet.getCell(`B${r + 8}`).value = 'Scannen Sie den QR-Code mit Ihrem Smartphone';
|
||||||
|
sheet.getCell(`B${r + 8}`).font = { size: 11, color: { argb: 'FF666666' } };
|
||||||
|
sheet.getCell(`B${r + 8}`).alignment = { horizontal: 'center', wrapText: true };
|
||||||
|
|
||||||
|
sheet.mergeCells(`B${r + 9}:C${r + 9}`);
|
||||||
|
sheet.getCell(`B${r + 9}`).value = 'um Ihren aktuellen Zählerstand zu melden.';
|
||||||
|
sheet.getCell(`B${r + 9}`).font = { size: 11, color: { argb: 'FF666666' } };
|
||||||
|
sheet.getCell(`B${r + 9}`).alignment = { horizontal: 'center', wrapText: true };
|
||||||
|
|
||||||
|
console.log(` ✓ ${z.haushalt_name} (${z.kundennummer || z.zaehlernummer})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = resolve(__dirname, '..', `wasserzaehler_qrcodes_${new Date().toISOString().split('T')[0]}.xlsx`);
|
||||||
|
await workbook.xlsx.writeFile(outputPath);
|
||||||
|
|
||||||
|
console.log(`\nExcel gespeichert: ${outputPath}`);
|
||||||
|
console.log(`\nBlatt 1 "Übersicht": Tabelle mit allen Kunden + URLs`);
|
||||||
|
console.log(`Blätter 2-${zaehler.length + 1}: Je 1 Druckseite pro Kunde mit QR-Code`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Fehler:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
142
scripts/import-zaehler.ts
Normal file
142
scripts/import-zaehler.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Import-Script: Excel → Supabase (Wasserzähler)
|
||||||
|
*
|
||||||
|
* Liest zaehlerablesen_2025.xlsx und importiert Kundendaten in die
|
||||||
|
* Supabase-Tabelle `wasserzaehler`. Generiert pro Zeile einen access_token.
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* npx tsx scripts/import-zaehler.ts [pfad-zur-excel-datei]
|
||||||
|
*
|
||||||
|
* Erwartet .env.local im Projekt-Root mit:
|
||||||
|
* NEXT_PUBLIC_SUPABASE_URL
|
||||||
|
* SUPABASE_SERVICE_ROLE_KEY
|
||||||
|
* NEXT_PUBLIC_BASE_URL (optional, default: https://gemeindeportal.vercel.app)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
// .env.local laden
|
||||||
|
function loadEnv() {
|
||||||
|
const envPath = resolve(__dirname, '..', '.env.local');
|
||||||
|
if (!existsSync(envPath)) {
|
||||||
|
console.error('FEHLER: .env.local nicht gefunden unter:', envPath);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const content = readFileSync(envPath, 'utf-8');
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const eqIndex = trimmed.indexOf('=');
|
||||||
|
if (eqIndex === -1) continue;
|
||||||
|
const key = trimmed.slice(0, eqIndex).trim();
|
||||||
|
const value = trimmed.slice(eqIndex + 1).trim();
|
||||||
|
if (!process.env[key]) {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'https://gemeindeportal.vercel.app';
|
||||||
|
|
||||||
|
if (!SUPABASE_URL || !SERVICE_KEY) {
|
||||||
|
console.error('FEHLER: NEXT_PUBLIC_SUPABASE_URL und SUPABASE_SERVICE_ROLE_KEY müssen gesetzt sein.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient(SUPABASE_URL, SERVICE_KEY);
|
||||||
|
|
||||||
|
interface ExcelRow {
|
||||||
|
Kundennummer: string;
|
||||||
|
Zählernummer: string;
|
||||||
|
Name: string;
|
||||||
|
Adresse: string;
|
||||||
|
'Letzter Stand (m³)': number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const excelPath = process.argv[2] || resolve(__dirname, '..', 'zaehlerablesen_2025.xlsx');
|
||||||
|
|
||||||
|
if (!existsSync(excelPath)) {
|
||||||
|
console.error(`FEHLER: Excel-Datei nicht gefunden: ${excelPath}`);
|
||||||
|
console.error('Verwendung: npx tsx scripts/import-zaehler.ts [pfad-zur-excel-datei]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Lese Excel-Datei: ${excelPath}\n`);
|
||||||
|
|
||||||
|
const workbook = XLSX.readFile(excelPath);
|
||||||
|
const sheetName = workbook.SheetNames[0];
|
||||||
|
const sheet = workbook.Sheets[sheetName];
|
||||||
|
const rows = XLSX.utils.sheet_to_json<ExcelRow>(sheet);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.error('FEHLER: Keine Daten in der Excel-Datei gefunden.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${rows.length} Zeile(n) gefunden.\n`);
|
||||||
|
|
||||||
|
const results: { kundennummer: string; name: string; token: string; url: string }[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const kundennummer = String(row.Kundennummer || '').trim();
|
||||||
|
const zaehlernummer = String(row.Zählernummer || row['Zaehlernummer'] || '').trim();
|
||||||
|
const name = String(row.Name || '').trim();
|
||||||
|
const adresse = String(row.Adresse || '').trim();
|
||||||
|
const alterStandRaw = row['Letzter Stand (m³)'] ?? row['Letzter Stand'] ?? 0;
|
||||||
|
const alter_stand = typeof alterStandRaw === 'number' ? alterStandRaw : parseFloat(String(alterStandRaw)) || 0;
|
||||||
|
|
||||||
|
if (!kundennummer || !zaehlernummer) {
|
||||||
|
console.warn(`WARNUNG: Zeile übersprungen (fehlende Kundennummer/Zählernummer):`, row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const access_token = randomUUID();
|
||||||
|
|
||||||
|
const { error } = await supabase.from('wasserzaehler').insert({
|
||||||
|
kundennummer,
|
||||||
|
zaehlernummer,
|
||||||
|
haushalt_name: name,
|
||||||
|
adresse,
|
||||||
|
alter_stand,
|
||||||
|
access_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(`FEHLER bei Kundennr. ${kundennummer}:`, error.message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${BASE_URL}/wasserzaehler?token=${access_token}`;
|
||||||
|
results.push({ kundennummer, name, token: access_token, url });
|
||||||
|
console.log(`OK: ${kundennummer} — ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n' + '='.repeat(80));
|
||||||
|
console.log('ERGEBNIS — QR-Code URLs');
|
||||||
|
console.log('='.repeat(80));
|
||||||
|
|
||||||
|
console.log('\n%-15s %-25s %-40s'.replace('%-15s', 'Kundennr.').replace('%-25s', 'Name').replace('%-40s', 'URL'));
|
||||||
|
console.log('-'.repeat(80));
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
console.log(`${r.kundennummer.padEnd(15)} ${r.name.padEnd(25)} ${r.url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${results.length} von ${rows.length} Einträgen erfolgreich importiert.`);
|
||||||
|
console.log(`\nAdmin-Seite mit QR-Codes: ${BASE_URL}/admin/qrcodes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Unerwarteter Fehler:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -359,8 +359,45 @@ export default function AdminDashboardPage() {
|
|||||||
{/* Wasserzähler Tab */}
|
{/* Wasserzähler Tab */}
|
||||||
{activeTab === 'wasserzaehler' && (
|
{activeTab === 'wasserzaehler' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<div>
|
||||||
<h3 className="text-lg font-bold text-primary">Wasserzähler</h3>
|
<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
|
<button
|
||||||
onClick={() => exportCSV(zaehler as unknown as Record<string, unknown>[], 'wasserzaehler')}
|
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"
|
className="border border-border rounded-xl px-3 py-2.5 text-sm font-medium hover:bg-bg transition-colors"
|
||||||
@@ -368,11 +405,13 @@ export default function AdminDashboardPage() {
|
|||||||
CSV Export
|
CSV Export
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="bg-white rounded-2xl border border-border overflow-hidden">
|
<div className="bg-white rounded-2xl border border-border overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-bg/80 text-text-muted text-[11px] uppercase tracking-wider">
|
<thead className="bg-bg/80 text-text-muted text-[11px] uppercase tracking-wider">
|
||||||
<tr>
|
<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">Haushalt</th>
|
||||||
<th className="px-4 py-3 text-left font-semibold">Adresse</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-left font-semibold">Zählernr.</th>
|
||||||
@@ -386,13 +425,14 @@ export default function AdminDashboardPage() {
|
|||||||
<tbody className="divide-y divide-border/50">
|
<tbody className="divide-y divide-border/50">
|
||||||
{zaehler.length === 0 ? (
|
{zaehler.length === 0 ? (
|
||||||
<tr>
|
<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.
|
Keine Wasserzähler gefunden.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
zaehler.map((z) => (
|
zaehler.map((z) => (
|
||||||
<tr key={z.id} className="hover:bg-bg/30 transition-colors">
|
<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 font-medium">{z.haushalt_name}</td>
|
||||||
<td className="px-4 py-3 text-text-muted">{z.adresse}</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 font-mono text-xs">{z.zaehlernummer}</td>
|
||||||
|
|||||||
@@ -1,22 +1,92 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Script from 'next/script';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import Header from '@/components/Header';
|
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() {
|
export default function AdminLoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
|
if (!captchaToken) {
|
||||||
|
setError('Bitte bestätigen Sie das CAPTCHA.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
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 supabase = createClient();
|
||||||
const { error: authError } = await supabase.auth.signInWithPassword({
|
const { error: authError } = await supabase.auth.signInWithPassword({
|
||||||
email,
|
email,
|
||||||
@@ -25,6 +95,10 @@ export default function AdminLoginPage() {
|
|||||||
|
|
||||||
if (authError) {
|
if (authError) {
|
||||||
setError('Ungültige Anmeldedaten. Bitte versuchen Sie es erneut.');
|
setError('Ungültige Anmeldedaten. Bitte versuchen Sie es erneut.');
|
||||||
|
setCaptchaToken('');
|
||||||
|
if (turnstileWidgetId.current && window.turnstile) {
|
||||||
|
window.turnstile.reset(turnstileWidgetId.current);
|
||||||
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -34,6 +108,11 @@ export default function AdminLoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-bg">
|
<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' }} />
|
<Header back={{ href: '/', label: 'Zurück' }} />
|
||||||
<main className="flex-1 flex items-center justify-center px-4 py-12">
|
<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="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>
|
</div>
|
||||||
|
|
||||||
|
{/* Turnstile CAPTCHA */}
|
||||||
|
<div ref={turnstileContainerRef} className="flex justify-center" />
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm">
|
<div className="p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm">
|
||||||
{error}
|
{error}
|
||||||
@@ -85,7 +167,7 @@ export default function AdminLoginPage() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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 ? (
|
{loading ? (
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
676
src/app/admin/serienbrief/page.tsx
Normal file
676
src/app/admin/serienbrief/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
603
src/app/admin/zaehler-import/page.tsx
Normal file
603
src/app/admin/zaehler-import/page.tsx
Normal 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} m³` : '—'}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createServiceClient } from '@/lib/supabase/server';
|
import { createServiceClient, createServerSupabaseClient } from '@/lib/supabase/server';
|
||||||
import { Resend } from 'resend';
|
import { Resend } from 'resend';
|
||||||
|
|
||||||
function getResend() {
|
function getResend() {
|
||||||
@@ -11,7 +11,13 @@ export async function POST(request: NextRequest) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, strasse, telefon, email, wasserquelle, wassermenge_m3, wunschdatum, captchaToken } = body;
|
const { name, strasse, telefon, email, wasserquelle, wassermenge_m3, wunschdatum, captchaToken } = body;
|
||||||
|
|
||||||
// CAPTCHA-Verifizierung
|
// Prüfen ob Admin eingeloggt ist (kein CAPTCHA nötig)
|
||||||
|
const authClient = await createServerSupabaseClient();
|
||||||
|
const { data: { user } } = await authClient.auth.getUser();
|
||||||
|
const isAdmin = !!user;
|
||||||
|
|
||||||
|
// CAPTCHA-Verifizierung nur für Bürger (nicht für Admins)
|
||||||
|
if (!isAdmin) {
|
||||||
if (!captchaToken) {
|
if (!captchaToken) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'CAPTCHA-Verifizierung fehlt.' },
|
{ error: 'CAPTCHA-Verifizierung fehlt.' },
|
||||||
@@ -38,6 +44,7 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 403 }
|
{ status: 403 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validierung
|
// Validierung
|
||||||
if (!name || !strasse || !telefon || !email || !wasserquelle || !wunschdatum) {
|
if (!name || !strasse || !telefon || !email || !wasserquelle || !wunschdatum) {
|
||||||
@@ -104,7 +111,7 @@ export async function POST(request: NextRequest) {
|
|||||||
wassermenge_m3: wassermenge_m3 || null,
|
wassermenge_m3: wassermenge_m3 || null,
|
||||||
wunschdatum,
|
wunschdatum,
|
||||||
status: 'aktiv',
|
status: 'aktiv',
|
||||||
erstellt_von: 'buerger',
|
erstellt_von: isAdmin ? 'admin' : 'buerger',
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
@@ -127,7 +134,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await getResend().emails.send({
|
await getResend().emails.send({
|
||||||
from: 'Gemeindeamt Weißkirchen <noreply@resend.dev>',
|
from: 'Gemeindeamt Weißkirchen <gemeinde@datacrew.at>',
|
||||||
to: email,
|
to: email,
|
||||||
subject: `Ihre Anmeldung zur Pool-Befüllung — ${datumFormatiert}`,
|
subject: `Ihre Anmeldung zur Pool-Befüllung — ${datumFormatiert}`,
|
||||||
html: `
|
html: `
|
||||||
|
|||||||
41
src/app/api/verify-captcha/route.ts
Normal file
41
src/app/api/verify-captcha/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/app/api/zaehler-import/route.ts
Normal file
140
src/app/api/zaehler-import/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
223
src/app/api/zaehler-qrcodes-excel/route.ts
Normal file
223
src/app/api/zaehler-qrcodes-excel/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
369
src/app/api/zaehler-serienbrief-pdf/route.ts
Normal file
369
src/app/api/zaehler-serienbrief-pdf/route.ts
Normal 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' });
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import Script from 'next/script';
|
import Script from 'next/script';
|
||||||
import Header from '@/components/Header';
|
import Header from '@/components/Header';
|
||||||
import Footer from '@/components/Footer';
|
import Footer from '@/components/Footer';
|
||||||
@@ -42,6 +42,7 @@ export default function PoolBuchungPage() {
|
|||||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||||
const [captchaToken, setCaptchaToken] = useState('');
|
const [captchaToken, setCaptchaToken] = useState('');
|
||||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
const [turnstileReady, setTurnstileReady] = useState(false);
|
||||||
const turnstileWidgetId = useRef<string | null>(null);
|
const turnstileWidgetId = useRef<string | null>(null);
|
||||||
const turnstileContainerRef = useRef<HTMLDivElement>(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 = [
|
const steps = [
|
||||||
{ label: 'Termin', done: step === 'daten' || step === 'fertig', active: step === 'termin' },
|
{ label: 'Termin', done: step === 'daten' || step === 'fertig', active: step === 'termin' },
|
||||||
{ label: 'Daten', done: step === 'fertig', active: step === 'daten' },
|
{ label: 'Daten', done: step === 'fertig', active: step === 'daten' },
|
||||||
@@ -153,9 +163,7 @@ export default function PoolBuchungPage() {
|
|||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
setStep('termin');
|
setStep('termin');
|
||||||
setShowConfirmation(false);
|
setShowConfirmation(false);
|
||||||
if (window.turnstile && turnstileWidgetId.current) {
|
turnstileWidgetId.current = null;
|
||||||
window.turnstile.reset(turnstileWidgetId.current);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formattedDate = selectedDate
|
const formattedDate = selectedDate
|
||||||
@@ -168,7 +176,7 @@ export default function PoolBuchungPage() {
|
|||||||
<div className="min-h-screen flex flex-col bg-bg">
|
<div className="min-h-screen flex flex-col bg-bg">
|
||||||
<Script
|
<Script
|
||||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
|
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
|
||||||
onReady={renderTurnstile}
|
onReady={() => setTurnstileReady(true)}
|
||||||
/>
|
/>
|
||||||
<Header back={{ href: '/', label: 'Zurück' }} />
|
<Header back={{ href: '/', label: 'Zurück' }} />
|
||||||
|
|
||||||
|
|||||||
@@ -164,15 +164,11 @@ function WasserzaehlerContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<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="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">
|
<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 text-text-muted">Kundennr.</span>
|
||||||
<span className="text-sm font-medium">{zaehler.haushalt_name}</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">Adresse</span>
|
|
||||||
<span className="text-sm font-medium">{zaehler.adresse}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between py-2.5 border-b border-border/40">
|
<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 text-text-muted">Zählernr.</span>
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ export interface Buchung {
|
|||||||
export interface Wasserzaehler {
|
export interface Wasserzaehler {
|
||||||
id: string;
|
id: string;
|
||||||
access_token: string;
|
access_token: string;
|
||||||
|
kundennummer: string;
|
||||||
haushalt_name: string;
|
haushalt_name: string;
|
||||||
adresse: string;
|
adresse: string;
|
||||||
zaehlernummer: string;
|
zaehlernummer: string;
|
||||||
alter_stand: number;
|
alter_stand: number;
|
||||||
|
letzter_stand: number | null;
|
||||||
|
letzte_ablesung: string | null;
|
||||||
neuer_stand: number | null;
|
neuer_stand: number | null;
|
||||||
verbrauch: number | null;
|
verbrauch: number | null;
|
||||||
ablesedatum: string | null;
|
ablesedatum: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user