addes cost and usage calculation
This commit is contained in:
parent
b281801cdb
commit
1ad4fe0819
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -5,8 +5,8 @@
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>OpenCRM</title>
|
<title>OpenCRM</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DFHCN9Vs.js"></script>
|
<script type="module" crossorigin src="/assets/index-CzqYCocn.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DWDTTlpk.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-OfL2GqlZ.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import Badge from '../../components/ui/Badge';
|
||||||
import Input from '../../components/ui/Input';
|
import Input from '../../components/ui/Input';
|
||||||
import Modal from '../../components/ui/Modal';
|
import Modal from '../../components/ui/Modal';
|
||||||
import FileUpload from '../../components/ui/FileUpload';
|
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 CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||||
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask } from '../../types';
|
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)
|
// Contract Task Item Component (handles subtasks)
|
||||||
function ContractTaskItem({
|
function ContractTaskItem({
|
||||||
task,
|
task,
|
||||||
|
|
@ -1999,6 +2138,19 @@ export default function ContractDetail() {
|
||||||
canEdit={hasPermission('contracts:update') && !isCustomer}
|
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>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue