Compare commits

...

4 Commits

Author SHA1 Message Date
duffyduck a20e331f83 Strom/Gas-Details: "Zähler verwalten"-Link neben Card-Titel
Zusätzlich zum bestehenden Link im Folgezähler-Form bekommt auch
der Card-Header der Strom/Gas-Details einen Link "Zähler verwalten",
der die Zähler-Übersicht des Kunden in einem neuen Tab öffnet –
damit der Link immer sichtbar ist, nicht nur wenn die Folgezähler-
Form aufgeklappt ist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 15:11:01 +02:00
duffyduck 43aaf697a1 Fix: Multi-Meter-Verbrauch auf Vertragslaufzeit clampen
Bei Verträgen, die Vorgänger einer Folgevertrags-Kette sind, sind
über ContractMeter auch Folgezähler verknüpft, die nach Vertragsende
installiert wurden. Die Berechnung nahm cm.installedAt..cm.removedAt
1:1 ohne Clamp gegen Contract.startDate/endDate – damit flossen
Zählerstände aus der Folgevertrags-Phase in den Verbrauch dieses
Vertrags ein.

Fix: meterStart = max(installedAt, contractStart),
meterEnd = min(removedAt, contractEnd). Zähler komplett außerhalb
der Laufzeit werden übersprungen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 15:07:19 +02:00
duffyduck b0e45c0ea0 Fix: "Zähler ohne Verträge anzeigen" filtert auf orphans, nicht additiv
Die Checkbox war falsch implementiert (additiv: zeigt auch Orphans).
Soll laut User filternd wirken: gecheckt = nur Zähler ohne Vertrag.

Logik:
- beide aus: alle aktiven Zähler (Default)
- nur "Inaktive": alle Zähler (aktiv + inaktiv)
- nur "ohne Verträge": aktive Zähler OHNE Vertrag
- beide an: alle Zähler ohne Vertrag (aktiv + inaktiv)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 15:00:21 +02:00
duffyduck 95b7261227 Anzeige-Fix: HTML in providerName/tariffName etc. beim Read strippen
In der Vertragsübersicht tauchen rohe <script>/<img>-Payloads als
Plaintext auf – React escaped sie zwar (kein XSS), sie sehen aber
hässlich aus. Ursprung: Daten aus pre-Pentest-Zeit, bevor
sanitizeContractBody beim Write existierte.

Fix: sanitizeContract und sanitizeCustomer strippen jetzt zusätzlich
HTML in den definierten Display-Feldern (providerName, tariffName,
customerNumberAtProvider, firstName, lastName, companyName, etc.).
Wirkt auch auf nested previousContract + energyDetails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:55:59 +02:00
4 changed files with 96 additions and 5 deletions
+57
View File
@@ -59,6 +59,39 @@ const PORTAL_HIDDEN_CONTRACT_FIELDS = [
'nextReviewDate', // Snooze-Workflow ist internes Cockpit-Feature
] as const;
// User-eingabe String-Felder am Contract, die in der UI dargestellt werden.
// Werden beim Read über stripHtml geschickt, damit Alt-Daten mit rohen
// XSS-Payloads (vor Einführung von sanitizeContractBody) nicht mehr als
// `<script>alert(...)</script>` in der Liste auftauchen. Neue Daten sind
// schon beim Write gestrippt, aber doppelt hält besser.
const CONTRACT_DISPLAY_STRING_FIELDS = [
'providerName',
'tariffName',
'customerNumberAtProvider',
'contractNumberAtProvider',
'portalUsername',
'previousProviderName',
'previousCustomerNumber',
'previousContractNumber',
'notes',
] as const;
// User-eingabe String-Felder am Customer für dieselbe Read-Time-Defensive.
const CUSTOMER_DISPLAY_STRING_FIELDS = [
'firstName',
'lastName',
'companyName',
'salutation',
'email',
'phone',
'mobile',
'portalEmail',
'portalUsername',
'taxNumber',
'commercialRegisterNumber',
'notes',
] as const;
const SENSITIVE_USER_FIELDS = [
'password',
'passwordResetToken',
@@ -79,6 +112,11 @@ export function sanitizeCustomer<T extends Record<string, unknown>>(customer: T
for (const field of SENSITIVE_CUSTOMER_FIELDS) {
delete copy[field];
}
for (const field of CUSTOMER_DISPLAY_STRING_FIELDS) {
if (typeof copy[field] === 'string') {
copy[field] = stripHtml(copy[field]);
}
}
if (Array.isArray(copy.contracts)) {
copy.contracts = (copy.contracts as Record<string, unknown>[]).map((c) => sanitizeContract(c));
}
@@ -126,6 +164,25 @@ export function sanitizeContract<T extends Record<string, unknown>>(contract: T
for (const field of SENSITIVE_CONTRACT_FIELDS) {
delete copy[field];
}
for (const field of CONTRACT_DISPLAY_STRING_FIELDS) {
if (typeof copy[field] === 'string') {
copy[field] = stripHtml(copy[field]);
}
}
// Nested: previousProviderName liegt im energyDetails-Sub-Objekt
if (copy.energyDetails && typeof copy.energyDetails === 'object') {
const ed = copy.energyDetails as Record<string, unknown>;
if (typeof ed.previousProviderName === 'string') {
ed.previousProviderName = stripHtml(ed.previousProviderName);
}
if (typeof ed.previousCustomerNumber === 'string') {
ed.previousCustomerNumber = stripHtml(ed.previousCustomerNumber);
}
}
// Nested: previousContract wird rekursiv auch sanitisiert
if (copy.previousContract && typeof copy.previousContract === 'object') {
copy.previousContract = sanitizeContract(copy.previousContract as Record<string, unknown>);
}
if (copy.customer && typeof copy.customer === 'object') {
copy.customer = sanitizeCustomer(copy.customer as Record<string, unknown>);
}
@@ -2591,7 +2591,24 @@ export default function ContractDetail() {
{/* Type-specific details */}
{c.energyDetails && (
<Card className="mb-6" title={c.type === 'ELECTRICITY' ? 'Strom-Details' : 'Gas-Details'}>
<Card
className="mb-6"
title={
<div className="flex items-center gap-3">
<span>{c.type === 'ELECTRICITY' ? 'Strom-Details' : 'Gas-Details'}</span>
<Link
to={`/customers/${c.customerId}?tab=meters`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 hover:underline font-normal"
title="Zähler-Übersicht des Kunden in neuem Tab öffnen"
>
<ExternalLink className="w-3 h-3" />
Zähler verwalten
</Link>
</div>
}
>
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
{c.energyDetails.meter && (
<div>
@@ -1304,9 +1304,15 @@ function MetersTab({
(m.energyDetails?.some((ed) => ed.contract) ?? false)
|| (m.contractMeters?.some((cm) => cm.energyContractDetails?.contract) ?? false);
// Aktiv-Filter (Default: nur aktive) und "Nur Zähler ohne Verträge"-Filter
// wirken unabhängig: erst Aktiv, dann optional auf orphans einschränken.
// - beide aus: alle aktiven Zähler
// - nur "Inaktive": alle Zähler (aktiv + inaktiv)
// - nur "ohne Verträge": aktive Zähler OHNE Vertrag
// - beide an: alle Zähler ohne Vertrag (aktiv + inaktiv)
const filtered = meters
.filter((m) => showInactive ? true : m.isActive)
.filter((m) => showWithoutContracts ? true : hasAnyContract(m));
.filter((m) => showWithoutContracts ? !hasAnyContract(m) : true);
// Sort readings by date (newest first)
const getSortedReadings = (readings: any[] | undefined) => {
+14 -3
View File
@@ -262,13 +262,24 @@ export function calculateMultiMeterConsumption(
let firstStart: MeterReading | undefined;
let lastEnd: MeterReading | undefined;
const contractStartMs = new Date(startDate).getTime();
const contractEndMs = new Date(endDate).getTime();
for (const cm of contractMeters) {
const readings = cm.meter?.readings || [];
if (readings.length === 0) continue;
// Zeitraum für diesen Zähler bestimmen
const meterStart = cm.installedAt || startDate;
const meterEnd = cm.removedAt || endDate;
// Zeitraum für diesen Zähler bestimmen, GE-CLAMPED auf die Vertragslaufzeit.
// Ohne Clamp würden Folgezähler, die nach Vertragsende installiert wurden
// (typisch bei Vorgängerverträgen einer Folgevertrags-Kette), zukünftige
// Zählerstände in den Verbrauch dieses Vertrags einrechnen.
const installedMs = cm.installedAt ? new Date(cm.installedAt).getTime() : contractStartMs;
const removedMs = cm.removedAt ? new Date(cm.removedAt).getTime() : contractEndMs;
const meterStartMs = Math.max(installedMs, contractStartMs);
const meterEndMs = Math.min(removedMs, contractEndMs);
if (meterStartMs > meterEndMs) continue; // Zähler liegt komplett außerhalb der Laufzeit
const meterStart = new Date(meterStartMs).toISOString();
const meterEnd = new Date(meterEndMs).toISOString();
const result = calculateConsumption(readings, meterStart, meterEnd, contractType);