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

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

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

142
scripts/import-zaehler.ts Normal file
View 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);
});