addes cost and usage calculation

This commit is contained in:
duffyduck 2026-02-06 00:14:38 +01:00
parent b281801cdb
commit 1ad4fe0819
5 changed files with 450 additions and 96 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenCRM</title>
<script type="module" crossorigin src="/assets/index-DFHCN9Vs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DWDTTlpk.css">
<script type="module" crossorigin src="/assets/index-CzqYCocn.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-OfL2GqlZ.css">
</head>
<body>
<div id="root"></div>

View File

@ -11,7 +11,8 @@ import Badge from '../../components/ui/Badge';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import FileUpload from '../../components/ui/FileUpload';
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare } from 'lucide-react';
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator } from 'lucide-react';
import { calculateConsumption, calculateCosts } from '../../utils/energyCalculations';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask } from '../../types';
@ -404,6 +405,144 @@ function MeterReadingModal({
);
}
// Energy Consumption and Cost Calculation Component
function EnergyConsumptionCalculation({
contractType,
readings,
startDate,
endDate,
basePrice,
unitPrice,
bonus,
}: {
contractType: 'ELECTRICITY' | 'GAS';
readings: MeterReading[];
startDate: string;
endDate: string;
basePrice?: number;
unitPrice?: number;
bonus?: number;
}) {
// Berechnung durchführen
const consumption = calculateConsumption(readings, startDate, endDate, contractType);
const costs = consumption.consumptionKwh > 0
? calculateCosts(consumption.consumptionKwh, basePrice, unitPrice, bonus)
: null;
// Nichts anzeigen wenn keine Daten
if (consumption.type === 'none') return null;
const formatNumber = (num: number, decimals: number = 2) =>
num.toLocaleString('de-DE', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
const formatDate = (dateStr: string) =>
new Date(dateStr).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
return (
<div className="mt-4 pt-4 border-t">
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<Calculator className="w-4 h-4 text-gray-500" />
<h4 className="text-sm font-medium text-gray-700">Verbrauch & Kosten</h4>
{consumption.type === 'exact' && (
<Badge variant="success">Exakt</Badge>
)}
{consumption.type === 'projected' && (
<Badge variant="warning">Hochrechnung</Badge>
)}
</div>
{/* Fall C: Unzureichende Daten */}
{consumption.type === 'insufficient' ? (
<p className="text-sm text-gray-500 italic">{consumption.message}</p>
) : (
<div className="bg-gray-50 rounded-lg p-4 space-y-4">
{/* Verbrauchsanzeige */}
<div>
<h5 className="text-sm font-medium text-gray-600 mb-2">
Berechneter Verbrauch
{consumption.type === 'projected' && ' (hochgerechnet)'}
</h5>
<div className="text-lg font-semibold text-gray-900">
{contractType === 'GAS' ? (
<>
<span className="font-mono">{formatNumber(consumption.consumptionM3 || 0)} m³</span>
<span className="text-gray-500 text-sm ml-2">
= {formatNumber(consumption.consumptionKwh)} kWh
</span>
</>
) : (
<span className="font-mono">{formatNumber(consumption.consumptionKwh)} kWh</span>
)}
</div>
{consumption.startReading && consumption.endReading && (
<p className="text-xs text-gray-400 mt-1">
Basierend auf Zählerständen vom {formatDate(consumption.startReading.readingDate)} bis {formatDate(consumption.endReading.readingDate)}
</p>
)}
</div>
{/* Kostenrechnung */}
{costs && (
<div className="border-t border-gray-200 pt-4">
<h5 className="text-sm font-medium text-gray-600 mb-3">Kostenvorschau</h5>
<div className="space-y-2 text-sm">
{/* Grundpreis */}
{basePrice != null && basePrice > 0 && (
<div className="flex justify-between">
<span className="text-gray-600">
Grundpreis: {formatNumber(basePrice)} /Mon × 12
</span>
<span className="font-mono">{formatNumber(costs.annualBaseCost)} </span>
</div>
)}
{/* Arbeitspreis */}
{unitPrice != null && unitPrice > 0 && (
<div className="flex justify-between">
<span className="text-gray-600">
Arbeitspreis: {formatNumber(consumption.consumptionKwh)} kWh × {formatNumber(unitPrice, 4)}
</span>
<span className="font-mono">{formatNumber(costs.annualConsumptionCost)} </span>
</div>
)}
{/* Trennlinie */}
<div className="border-t border-gray-300 pt-2">
<div className="flex justify-between font-medium">
<span className="text-gray-700">Jahreskosten</span>
<span className="font-mono">{formatNumber(costs.annualTotalCost)} </span>
</div>
</div>
{/* Bonus */}
{costs.bonus != null && costs.bonus > 0 && (
<>
<div className="flex justify-between text-green-600">
<span>Bonus</span>
<span className="font-mono">- {formatNumber(costs.bonus)} </span>
</div>
<div className="border-t border-gray-300 pt-2">
<div className="flex justify-between font-semibold">
<span className="text-gray-800">Effektive Jahreskosten</span>
<span className="font-mono">{formatNumber(costs.effectiveAnnualCost)} </span>
</div>
</div>
</>
)}
{/* Monatlicher Abschlag */}
<div className="border-t border-gray-300 pt-2 mt-2">
<div className="flex justify-between text-blue-700 font-semibold">
<span>Monatlicher Abschlag</span>
<span className="font-mono">{formatNumber(costs.monthlyPayment)} </span>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}
// Contract Task Item Component (handles subtasks)
function ContractTaskItem({
task,
@ -1999,6 +2138,19 @@ export default function ContractDetail() {
canEdit={hasPermission('contracts:update') && !isCustomer}
/>
)}
{/* Verbrauchsberechnung & Kostenvorschau */}
{c.energyDetails.meter && c.startDate && c.endDate && (
<EnergyConsumptionCalculation
contractType={c.type as 'ELECTRICITY' | 'GAS'}
readings={c.energyDetails.meter.readings || []}
startDate={c.startDate}
endDate={c.endDate}
basePrice={c.energyDetails.basePrice}
unitPrice={c.energyDetails.unitPrice}
bonus={c.energyDetails.bonus}
/>
)}
</Card>
)}

View File

@ -0,0 +1,197 @@
import type { MeterReading } from '../types';
// Konstante für Gas → kWh Umrechnung (Standard Erdgas H)
export const GAS_TO_KWH_FACTOR = 10.5;
// Ergebnis der Verbrauchsberechnung
export interface ConsumptionCalculation {
type: 'exact' | 'projected' | 'insufficient' | 'none';
consumptionM3?: number; // Nur bei Gas
consumptionKwh: number;
startReading?: MeterReading;
endReading?: MeterReading;
projectedEndDate?: string; // Nur bei Hochrechnung
message?: string;
}
// Ergebnis der Kostenberechnung
export interface CostCalculation {
annualBaseCost: number; // basePrice × 12
annualConsumptionCost: number; // verbrauch × unitPrice
annualTotalCost: number; // Summe
monthlyPayment: number; // annualTotalCost / 12
bonus?: number;
effectiveAnnualCost: number; // annualTotalCost - bonus
}
/**
* Berechnet die Differenz in Tagen zwischen zwei Daten
*/
function daysDiff(startDate: string, endDate: string): number {
const start = new Date(startDate);
const end = new Date(endDate);
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
const diffTime = end.getTime() - start.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
/**
* Filtert Zählerstände nach Vertragszeitraum
* Ein Zählerstand gilt als "im Zeitraum" wenn:
* readingDate >= startDate UND readingDate <= endDate
*/
export function filterReadingsByContractPeriod(
readings: MeterReading[],
startDate: string,
endDate: string
): MeterReading[] {
const start = new Date(startDate);
const end = new Date(endDate);
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
return readings.filter((reading) => {
const readingDate = new Date(reading.readingDate);
readingDate.setHours(0, 0, 0, 0);
return readingDate >= start && readingDate <= end;
});
}
/**
* Berechnet den Verbrauch basierend auf den Zählerständen
*
* Fälle:
* A) Anfangs- UND Endzählerstand vorhanden -> exakter Verbrauch
* B) >= 2 Stände, kein Endzählerstand -> Hochrechnung
* C) Nur 1 Stand -> insufficient
* D) Keine Stände -> none
*/
export function calculateConsumption(
readings: MeterReading[],
startDate: string,
endDate: string,
contractType: 'ELECTRICITY' | 'GAS'
): ConsumptionCalculation {
const filteredReadings = filterReadingsByContractPeriod(readings, startDate, endDate);
// Fall D: Keine Zählerstände
if (filteredReadings.length === 0) {
return { type: 'none', consumptionKwh: 0 };
}
// Fall C: Nur 1 Zählerstand
if (filteredReadings.length === 1) {
return {
type: 'insufficient',
consumptionKwh: 0,
message: 'Berechnung auf Grund fehlender Stände nicht möglich',
};
}
// Sortieren nach Datum (älteste zuerst)
const sorted = [...filteredReadings].sort(
(a, b) => new Date(a.readingDate).getTime() - new Date(b.readingDate).getTime()
);
const firstReading = sorted[0];
const lastReading = sorted[sorted.length - 1];
// Prüfen ob Endzählerstand am/nach Vertragsende liegt
const lastReadingDate = new Date(lastReading.readingDate);
const contractEndDate = new Date(endDate);
lastReadingDate.setHours(0, 0, 0, 0);
contractEndDate.setHours(0, 0, 0, 0);
// Fall A: Exakter Verbrauch (Endzählerstand am oder nach Vertragsende)
if (lastReadingDate >= contractEndDate) {
const consumption = lastReading.value - firstReading.value;
return formatConsumptionResult('exact', consumption, contractType, firstReading, lastReading);
}
// Fall B: Hochrechnung erforderlich
const daysBetweenReadings = daysDiff(firstReading.readingDate, lastReading.readingDate);
// Mindestens 1 Tag zwischen den Messungen für sinnvolle Hochrechnung
if (daysBetweenReadings < 1) {
return {
type: 'insufficient',
consumptionKwh: 0,
message: 'Zeitraum zwischen Zählerständen zu kurz für Berechnung',
};
}
const totalContractDays = daysDiff(startDate, endDate);
const measuredConsumption = lastReading.value - firstReading.value;
const projectedConsumption = (measuredConsumption / daysBetweenReadings) * totalContractDays;
return formatConsumptionResult(
'projected',
projectedConsumption,
contractType,
firstReading,
lastReading,
endDate
);
}
/**
* Formatiert das Ergebnis der Verbrauchsberechnung
*/
function formatConsumptionResult(
type: 'exact' | 'projected',
consumption: number,
contractType: 'ELECTRICITY' | 'GAS',
startReading: MeterReading,
endReading: MeterReading,
projectedEndDate?: string
): ConsumptionCalculation {
if (contractType === 'GAS') {
return {
type,
consumptionM3: consumption,
consumptionKwh: consumption * GAS_TO_KWH_FACTOR,
startReading,
endReading,
projectedEndDate,
};
}
return {
type,
consumptionKwh: consumption,
startReading,
endReading,
projectedEndDate,
};
}
/**
* Berechnet die Kosten basierend auf Verbrauch und Preisen
* Gibt null zurück wenn keine Preise vorhanden sind
*/
export function calculateCosts(
consumptionKwh: number,
basePrice?: number,
unitPrice?: number,
bonus?: number
): CostCalculation | null {
// Mindestens ein Preis muss vorhanden sein
if (basePrice == null && unitPrice == null) {
return null;
}
const annualBaseCost = (basePrice ?? 0) * 12;
const annualConsumptionCost = consumptionKwh * (unitPrice ?? 0);
const annualTotalCost = annualBaseCost + annualConsumptionCost;
const effectiveAnnualCost = annualTotalCost - (bonus ?? 0);
const monthlyPayment = effectiveAnnualCost / 12;
return {
annualBaseCost,
annualConsumptionCost,
annualTotalCost,
monthlyPayment,
bonus: bonus ?? undefined,
effectiveAnnualCost,
};
}