Datenschutz vollmacht fixed, two time counter added

This commit is contained in:
duffyduck 2026-03-21 16:42:31 +01:00
parent 0121c82412
commit 4f359df161
56 changed files with 4401 additions and 789 deletions

View File

@ -11,5 +11,7 @@ export declare function getSimCardCredentials(req: Request, res: Response): Prom
export declare function getInternetCredentials(req: Request, res: Response): Promise<void>;
export declare function getSipCredentials(req: Request, res: Response): Promise<void>;
export declare function getCockpit(req: AuthRequest, res: Response): Promise<void>;
export declare function addSuccessorMeter(req: AuthRequest, res: Response): Promise<void>;
export declare function removeContractMeter(req: AuthRequest, res: Response): Promise<void>;
export declare function snoozeContract(req: Request, res: Response): Promise<void>;
//# sourceMappingURL=contract.controller.d.ts.map

View File

@ -1 +1 @@
{"version":3,"file":"contract.controller.d.ts","sourceRoot":"","sources":["../../src/controllers/contract.controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAM5C,OAAO,EAAe,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAI7D,wBAAsB,YAAY,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAgDjF;AAED,wBAAsB,WAAW,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAqChF;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAuCnF;AAED,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBpF;AAED,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUtF;AAED,wBAAsB,sBAAsB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUvF;AAED,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUlF;AAID,wBAAsB,UAAU,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/E;AAID,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAyC/E"}
{"version":3,"file":"contract.controller.d.ts","sourceRoot":"","sources":["../../src/controllers/contract.controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAM5C,OAAO,EAAe,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAI7D,wBAAsB,YAAY,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAgDjF;AAED,wBAAsB,WAAW,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAqChF;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAuCnF;AAED,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBpF;AAED,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUtF;AAED,wBAAsB,sBAAsB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUvF;AAED,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUlF;AAID,wBAAsB,UAAU,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/E;AAID,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAwDtF;AAED,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAWxF;AAID,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAyC/E"}

View File

@ -44,6 +44,8 @@ exports.getSimCardCredentials = getSimCardCredentials;
exports.getInternetCredentials = getInternetCredentials;
exports.getSipCredentials = getSipCredentials;
exports.getCockpit = getCockpit;
exports.addSuccessorMeter = addSuccessorMeter;
exports.removeContractMeter = removeContractMeter;
exports.snoozeContract = snoozeContract;
const client_1 = require("@prisma/client");
const contractService = __importStar(require("../services/contract.service.js"));
@ -265,6 +267,71 @@ async function getCockpit(req, res) {
});
}
}
// ==================== FOLGEZÄHLER ====================
async function addSuccessorMeter(req, res) {
try {
const contractId = parseInt(req.params.id);
const { meterId, installedAt, finalReadingPrevious } = req.body;
const contract = await prisma.contract.findUnique({
where: { id: contractId },
include: { energyDetails: { include: { contractMeters: { orderBy: { position: 'asc' } } } } },
});
if (!contract?.energyDetails) {
res.status(404).json({ success: false, error: 'Energievertrag nicht gefunden' });
return;
}
const ecdId = contract.energyDetails.id;
const existingMeters = contract.energyDetails.contractMeters;
const nextPosition = existingMeters.length > 0
? Math.max(...existingMeters.map(m => m.position)) + 1
: 0;
// Vorherigen Zähler als gewechselt markieren
if (existingMeters.length > 0 && finalReadingPrevious !== undefined) {
const prevMeter = existingMeters[existingMeters.length - 1];
await prisma.contractMeter.update({
where: { id: prevMeter.id },
data: {
removedAt: installedAt ? new Date(installedAt) : new Date(),
finalReading: parseFloat(finalReadingPrevious),
},
});
}
const contractMeter = await prisma.contractMeter.create({
data: {
energyContractDetailsId: ecdId,
meterId: parseInt(meterId),
position: nextPosition,
installedAt: installedAt ? new Date(installedAt) : new Date(),
},
include: { meter: { include: { readings: true } } },
});
// Aktuellen Zähler am Vertrag aktualisieren
await prisma.energyContractDetails.update({
where: { id: ecdId },
data: { meterId: parseInt(meterId) },
});
res.json({ success: true, data: contractMeter });
}
catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Folgezählers',
});
}
}
async function removeContractMeter(req, res) {
try {
const contractMeterId = parseInt(req.params.contractMeterId);
await prisma.contractMeter.delete({ where: { id: contractMeterId } });
res.json({ success: true, data: null });
}
catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Entfernen',
});
}
}
// ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ====================
async function snoozeContract(req, res) {
try {

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
{"version":3,"file":"customer.controller.d.ts","sourceRoot":"","sources":["../../src/controllers/customer.controller.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"customer.controller.d.ts","sourceRoot":"","sources":["../../src/controllers/customer.controller.ts"],"names":[],"mappings":"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"}

View File

@ -351,7 +351,14 @@ async function getMeterReadings(req, res) {
}
async function addMeterReading(req, res) {
try {
const reading = await customerService.addMeterReading(parseInt(req.params.meterId), req.body);
const { readingDate, value, valueNt, unit, notes } = req.body;
const reading = await customerService.addMeterReading(parseInt(req.params.meterId), {
readingDate: new Date(readingDate),
value: parseFloat(value),
valueNt: valueNt !== undefined && valueNt !== null && valueNt !== '' ? parseFloat(valueNt) : undefined,
unit,
notes,
});
res.status(201).json({ success: true, data: reading });
}
catch (error) {
@ -363,7 +370,19 @@ async function addMeterReading(req, res) {
}
async function updateMeterReading(req, res) {
try {
const reading = await customerService.updateMeterReading(parseInt(req.params.meterId), parseInt(req.params.readingId), req.body);
const { readingDate, value, valueNt, unit, notes } = req.body;
const updateData = {};
if (readingDate !== undefined)
updateData.readingDate = new Date(readingDate);
if (value !== undefined)
updateData.value = parseFloat(value);
if (valueNt !== undefined)
updateData.valueNt = valueNt !== null && valueNt !== '' ? parseFloat(valueNt) : null;
if (unit !== undefined)
updateData.unit = unit;
if (notes !== undefined)
updateData.notes = notes;
const reading = await customerService.updateMeterReading(parseInt(req.params.meterId), parseInt(req.params.readingId), updateData);
res.json({ success: true, data: reading });
}
catch (error) {
@ -404,15 +423,18 @@ async function reportMeterReading(req, res) {
res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Zähler' });
return;
}
const reading = await prisma.meterReading.create({
data: {
meterId,
value: parseFloat(value),
readingDate: readingDate ? new Date(readingDate) : new Date(),
notes,
reportedBy: user.email,
status: 'REPORTED',
},
const parsedDate = readingDate ? new Date(readingDate) : new Date();
const parsedValue = parseFloat(value);
// Validierung über den Service (monoton steigend)
const reading = await customerService.addMeterReading(meterId, {
readingDate: parsedDate,
value: parsedValue,
notes,
});
// Status auf REPORTED setzen
await prisma.meterReading.update({
where: { id: reading.id },
data: { reportedBy: user.email, status: 'REPORTED' },
});
res.status(201).json({ success: true, data: reading });
}

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
{"version":3,"file":"contract.routes.d.ts","sourceRoot":"","sources":["../../src/routes/contract.routes.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA8BxB,eAAe,MAAM,CAAC"}
{"version":3,"file":"contract.routes.d.ts","sourceRoot":"","sources":["../../src/routes/contract.routes.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAkCxB,eAAe,MAAM,CAAC"}

View File

@ -48,6 +48,9 @@ router.delete('/:id', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('
router.post('/:id/follow-up', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:create'), contractController.createFollowUp);
// Snooze (Vertrag zurückstellen)
router.patch('/:id/snooze', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:update'), contractController.snoozeContract);
// Folgezähler
router.post('/:id/successor-meter', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:update'), contractController.addSuccessorMeter);
router.delete('/:id/contract-meter/:contractMeterId', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:update'), contractController.removeContractMeter);
// Get decrypted password
router.get('/:id/password', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:read'), contractController.getContractPassword);
// Get decrypted SimCard PIN/PUK

View File

@ -1 +1 @@
{"version":3,"file":"contract.routes.js","sourceRoot":"","sources":["../../src/routes/contract.routes.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,qCAAiC;AACjC,0FAA4E;AAC5E,mDAAwE;AAExE,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAExB,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC,YAAY,CAAC,CAAC;AACpG,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC,cAAc,CAAC,CAAC;AAEzG,2CAA2C;AAC3C,MAAM,CAAC,GAAG,CAAC,UAAU,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC,UAAU,CAAC,CAAC;AAEzG,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC,WAAW,CAAC,CAAC;AACtG,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC,cAAc,CAAC,CAAC;AAC3G,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC,cAAc,CAAC,CAAC;AAE9G,qBAAqB;AACrB,MAAM,CAAC,IAAI,CAAC,gBAAgB,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC,cAAc,CAAC,CAAC;AAEtH,iCAAiC;AACjC,MAAM,CAAC,KAAK,CAAC,aAAa,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC,cAAc,CAAC,CAAC;AAEpH,yBAAyB;AACzB,MAAM,CAAC,GAAG,CAAC,eAAe,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC,mBAAmB,CAAC,CAAC;AAEvH,gCAAgC;AAChC,MAAM,CAAC,GAAG,CAAC,iCAAiC,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC,qBAAqB,CAAC,CAAC;AAE3I,kCAAkC;AAClC,MAAM,CAAC,GAAG,CAAC,2BAA2B,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC,sBAAsB,CAAC,CAAC;AAEtI,6BAA6B;AAC7B,MAAM,CAAC,GAAG,CAAC,6CAA6C,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC,iBAAiB,CAAC,CAAC;AAEnJ,kBAAe,MAAM,CAAC"}
{"version":3,"file":"contract.routes.js","sourceRoot":"","sources":["../../src/routes/contract.routes.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,qCAAiC;AACjC,0FAA4E;AAC5E,mDAAwE;AAExE,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAExB,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC,YAAY,CAAC,CAAC;AACpG,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC,cAAc,CAAC,CAAC;AAEzG,2CAA2C;AAC3C,MAAM,CAAC,GAAG,CAAC,UAAU,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC,UAAU,CAAC,CAAC;AAEzG,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC,WAAW,CAAC,CAAC;AACtG,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC,cAAc,CAAC,CAAC;AAC3G,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC,cAAc,CAAC,CAAC;AAE9G,qBAAqB;AACrB,MAAM,CAAC,IAAI,CAAC,gBAAgB,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC,cAAc,CAAC,CAAC;AAEtH,iCAAiC;AACjC,MAAM,CAAC,KAAK,CAAC,aAAa,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC,cAAc,CAAC,CAAC;AAEpH,cAAc;AACd,MAAM,CAAC,IAAI,CAAC,sBAAsB,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC,iBAAiB,CAAC,CAAC;AAC/H,MAAM,CAAC,MAAM,CAAC,sCAAsC,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC,mBAAmB,CAAC,CAAC;AAEnJ,yBAAyB;AACzB,MAAM,CAAC,GAAG,CAAC,eAAe,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC,mBAAmB,CAAC,CAAC;AAEvH,gCAAgC;AAChC,MAAM,CAAC,GAAG,CAAC,iCAAiC,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC,qBAAqB,CAAC,CAAC;AAE3I,kCAAkC;AAClC,MAAM,CAAC,GAAG,CAAC,2BAA2B,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC,sBAAsB,CAAC,CAAC;AAEtI,6BAA6B;AAC7B,MAAM,CAAC,GAAG,CAAC,6CAA6C,EAAE,sBAAY,EAAE,IAAA,2BAAiB,EAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC,iBAAiB,CAAC,CAAC;AAEnJ,kBAAe,MAAM,CAAC"}

View File

@ -1 +1 @@
{"version":3,"file":"upload.routes.d.ts","sourceRoot":"","sources":["../../src/routes/upload.routes.ts"],"names":[],"mappings":"AAQA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA2uBxB,eAAe,MAAM,CAAC"}
{"version":3,"file":"upload.routes.d.ts","sourceRoot":"","sources":["../../src/routes/upload.routes.ts"],"names":[],"mappings":"AAQA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA2vBxB,eAAe,MAAM,CAAC"}

View File

@ -338,6 +338,15 @@ router.post('/customers/:id/privacy-policy', auth_js_1.authenticate, (0, auth_js
where: { id: customerId },
data: { privacyPolicyPath: relativePath },
});
// Alle Consents auf GRANTED setzen (PDF = vollständige Einwilligung)
const consentTypes = ['DATA_PROCESSING', 'MARKETING_EMAIL', 'MARKETING_PHONE', 'DATA_SHARING_PARTNER'];
for (const consentType of consentTypes) {
await prisma.customerConsent.upsert({
where: { customerId_consentType: { customerId, consentType } },
update: { status: 'GRANTED', grantedAt: new Date(), source: 'papier' },
create: { customerId, consentType, status: 'GRANTED', grantedAt: new Date(), source: 'papier', createdBy: req.user?.email || 'admin' },
});
}
res.json({
success: true,
data: {
@ -376,6 +385,11 @@ router.delete('/customers/:id/privacy-policy', auth_js_1.authenticate, (0, auth_
where: { id: customerId },
data: { privacyPolicyPath: null },
});
// Nur Consents widerrufen die per Papier erteilt wurden
await prisma.customerConsent.updateMany({
where: { customerId, status: 'GRANTED', source: 'papier' },
data: { status: 'WITHDRAWN', withdrawnAt: new Date() },
});
res.json({ success: true });
}
catch (error) {

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -122,14 +122,14 @@ async function getContractById(id, decryptPassword = false) {
previousProvider: true,
previousContract: {
include: {
energyDetails: { include: { meter: { include: { readings: true } }, invoices: true } },
energyDetails: { include: { meter: { include: { readings: true } }, contractMeters: { include: { meter: { include: { readings: true } } }, orderBy: { position: 'asc' } }, invoices: true } },
internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } },
tvDetails: true,
carInsuranceDetails: true,
},
},
energyDetails: { include: { meter: { include: { readings: true } }, invoices: true } },
energyDetails: { include: { meter: { include: { readings: true } }, contractMeters: { include: { meter: { include: { readings: true } } }, orderBy: { position: 'asc' } }, invoices: true } },
internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } },
tvDetails: true,
@ -264,11 +264,41 @@ async function updateContract(id, data) {
});
// Update type-specific details
if (energyDetails) {
const existingEcd = await prisma.energyContractDetails.findUnique({
where: { contractId: id },
select: { id: true, meterId: true },
});
await prisma.energyContractDetails.upsert({
where: { contractId: id },
update: energyDetails,
create: { contractId: id, ...energyDetails },
});
// ContractMeter synchronisieren wenn sich der Zähler ändert
if (energyDetails.meterId !== undefined && existingEcd) {
const oldMeterId = existingEcd.meterId;
const newMeterId = energyDetails.meterId;
if (oldMeterId !== newMeterId) {
// Alle alten ContractMeter-Einträge entfernen
await prisma.contractMeter.deleteMany({
where: { energyContractDetailsId: existingEcd.id },
});
// Neuen ContractMeter-Eintrag erstellen (wenn ein Zähler gesetzt)
if (newMeterId) {
const contract = await prisma.contract.findUnique({
where: { id },
select: { startDate: true },
});
await prisma.contractMeter.create({
data: {
energyContractDetailsId: existingEcd.id,
meterId: newMeterId,
position: 0,
installedAt: contract?.startDate,
},
});
}
}
}
}
if (internetDetails) {
const { phoneNumbers, internetPassword, ...internetData } = internetDetails;

File diff suppressed because one or more lines are too long

View File

@ -78,6 +78,10 @@ export interface ReportedMeterReading {
customerNumber: string;
name: string;
};
contract?: {
id: number;
contractNumber: string;
};
providerPortal?: {
providerName: string;
portalUrl: string;

View File

@ -1 +1 @@
{"version":3,"file":"contractCockpit.service.d.ts","sourceRoot":"","sources":["../../src/services/contractCockpit.service.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"contractCockpit.service.d.ts","sourceRoot":"","sources":["../../src/services/contractCockpit.service.ts"],"names":[],"mappings":"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"}

View File

@ -682,6 +682,7 @@ async function getReportedMeterReadings() {
contract: {
select: {
id: true,
contractNumber: true,
portalUsername: true,
provider: {
select: { id: true, name: true, portalUrl: true },
@ -717,6 +718,10 @@ async function getReportedMeterReadings() {
customerNumber: r.meter.customer.customerNumber,
name: `${r.meter.customer.firstName} ${r.meter.customer.lastName}`,
},
contract: contract ? {
id: contract.id,
contractNumber: contract.contractNumber,
} : undefined,
providerPortal: provider?.portalUrl ? {
providerName: provider.name,
portalUrl: provider.portalUrl,

File diff suppressed because one or more lines are too long

View File

@ -206,17 +206,17 @@ export declare function getAllTasks(filters: AllTasksFilters): Promise<({
customerNumber: string;
companyName: string | null;
};
tariff: {
id: number;
name: string;
} | null;
contractNumber: string;
providerName: string | null;
tariffName: string | null;
provider: {
id: number;
name: string;
} | null;
tariffName: string | null;
providerName: string | null;
contractNumber: string;
tariff: {
id: number;
name: string;
} | null;
};
subtasks: {
id: number;

View File

@ -107,13 +107,14 @@ export declare function getCustomerById(id: number): Promise<({
createdAt: Date;
notes: string | null;
readingDate: Date;
meterId: number;
status: import(".prisma/client").$Enums.MeterReadingStatus;
value: number;
valueNt: number | null;
unit: string;
reportedBy: string | null;
status: import(".prisma/client").$Enums.MeterReadingStatus;
transferredAt: Date | null;
transferredBy: string | null;
meterId: number;
}[];
} & {
id: number;
@ -123,6 +124,7 @@ export declare function getCustomerById(id: number): Promise<({
updatedAt: Date;
type: import(".prisma/client").$Enums.MeterType;
meterNumber: string;
tariffModel: import(".prisma/client").$Enums.MeterTariffModel;
location: string | null;
})[];
stressfreiEmails: {
@ -171,11 +173,8 @@ export declare function getCustomerById(id: number): Promise<({
notes: string | null;
portalPasswordEncrypted: string | null;
startDate: Date | null;
status: import(".prisma/client").$Enums.ContractStatus;
customerNumberAtProvider: string | null;
tariffName: string | null;
providerName: string | null;
contractNumber: string;
status: import(".prisma/client").$Enums.ContractStatus;
contractCategoryId: number | null;
addressId: number | null;
billingAddressId: number | null;
@ -190,6 +189,9 @@ export declare function getCustomerById(id: number): Promise<({
previousContractNumber: string | null;
providerId: number | null;
tariffId: number | null;
providerName: string | null;
tariffName: string | null;
customerNumberAtProvider: string | null;
contractNumberAtProvider: string | null;
priceFirst12Months: string | null;
priceFrom13Months: string | null;
@ -573,13 +575,14 @@ export declare function getCustomerMeters(customerId: number, showInactive?: boo
createdAt: Date;
notes: string | null;
readingDate: Date;
meterId: number;
status: import(".prisma/client").$Enums.MeterReadingStatus;
value: number;
valueNt: number | null;
unit: string;
reportedBy: string | null;
status: import(".prisma/client").$Enums.MeterReadingStatus;
transferredAt: Date | null;
transferredBy: string | null;
meterId: number;
}[];
} & {
id: number;
@ -589,6 +592,7 @@ export declare function getCustomerMeters(customerId: number, showInactive?: boo
updatedAt: Date;
type: import(".prisma/client").$Enums.MeterType;
meterNumber: string;
tariffModel: import(".prisma/client").$Enums.MeterTariffModel;
location: string | null;
})[]>;
export declare function createMeter(customerId: number, data: {
@ -603,6 +607,7 @@ export declare function createMeter(customerId: number, data: {
updatedAt: Date;
type: import(".prisma/client").$Enums.MeterType;
meterNumber: string;
tariffModel: import(".prisma/client").$Enums.MeterTariffModel;
location: string | null;
}>;
export declare function updateMeter(id: number, data: {
@ -618,6 +623,7 @@ export declare function updateMeter(id: number, data: {
updatedAt: Date;
type: import(".prisma/client").$Enums.MeterType;
meterNumber: string;
tariffModel: import(".prisma/client").$Enums.MeterTariffModel;
location: string | null;
}>;
export declare function deleteMeter(id: number): Promise<{
@ -628,11 +634,13 @@ export declare function deleteMeter(id: number): Promise<{
updatedAt: Date;
type: import(".prisma/client").$Enums.MeterType;
meterNumber: string;
tariffModel: import(".prisma/client").$Enums.MeterTariffModel;
location: string | null;
}>;
export declare function addMeterReading(meterId: number, data: {
readingDate: Date;
value: number;
valueNt?: number;
unit?: string;
notes?: string;
}): Promise<{
@ -640,30 +648,33 @@ export declare function addMeterReading(meterId: number, data: {
createdAt: Date;
notes: string | null;
readingDate: Date;
meterId: number;
status: import(".prisma/client").$Enums.MeterReadingStatus;
value: number;
valueNt: number | null;
unit: string;
reportedBy: string | null;
status: import(".prisma/client").$Enums.MeterReadingStatus;
transferredAt: Date | null;
transferredBy: string | null;
meterId: number;
}>;
export declare function getMeterReadings(meterId: number): Promise<{
id: number;
createdAt: Date;
notes: string | null;
readingDate: Date;
meterId: number;
status: import(".prisma/client").$Enums.MeterReadingStatus;
value: number;
valueNt: number | null;
unit: string;
reportedBy: string | null;
status: import(".prisma/client").$Enums.MeterReadingStatus;
transferredAt: Date | null;
transferredBy: string | null;
meterId: number;
}[]>;
export declare function updateMeterReading(meterId: number, readingId: number, data: {
readingDate?: Date;
value?: number;
valueNt?: number | null;
unit?: string;
notes?: string;
}): Promise<{
@ -671,26 +682,28 @@ export declare function updateMeterReading(meterId: number, readingId: number, d
createdAt: Date;
notes: string | null;
readingDate: Date;
meterId: number;
status: import(".prisma/client").$Enums.MeterReadingStatus;
value: number;
valueNt: number | null;
unit: string;
reportedBy: string | null;
status: import(".prisma/client").$Enums.MeterReadingStatus;
transferredAt: Date | null;
transferredBy: string | null;
meterId: number;
}>;
export declare function deleteMeterReading(meterId: number, readingId: number): Promise<{
id: number;
createdAt: Date;
notes: string | null;
readingDate: Date;
meterId: number;
status: import(".prisma/client").$Enums.MeterReadingStatus;
value: number;
valueNt: number | null;
unit: string;
reportedBy: string | null;
status: import(".prisma/client").$Enums.MeterReadingStatus;
transferredAt: Date | null;
transferredBy: string | null;
meterId: number;
}>;
export declare function updatePortalSettings(customerId: number, data: {
portalEnabled?: boolean;

File diff suppressed because one or more lines are too long

View File

@ -321,13 +321,42 @@ async function updateMeter(id, data) {
});
}
async function deleteMeter(id) {
// Prüfen ob der Zähler noch an Verträgen hängt
const linkedContracts = await prisma.contractMeter.findMany({
where: { meterId: id },
include: { energyContractDetails: { include: { contract: { select: { contractNumber: true } } } } },
});
if (linkedContracts.length > 0) {
const contractNumbers = linkedContracts
.map(cm => cm.energyContractDetails.contract.contractNumber)
.join(', ');
throw new Error(`Zähler kann nicht gelöscht werden noch an Vertrag/Verträgen zugeordnet: ${contractNumbers}`);
}
// Auch direkte meterId-Referenz auf EnergyContractDetails prüfen
const directLinks = await prisma.energyContractDetails.findMany({
where: { meterId: id },
include: { contract: { select: { contractNumber: true } } },
});
if (directLinks.length > 0) {
const contractNumbers = directLinks.map(d => d.contract.contractNumber).join(', ');
throw new Error(`Zähler kann nicht gelöscht werden noch an Vertrag/Verträgen zugeordnet: ${contractNumbers}`);
}
return prisma.meter.delete({ where: { id } });
}
async function addMeterReading(meterId, data) {
// Validierung: Zählerstand muss monoton steigend sein
await validateReadingValue(meterId, data.readingDate, data.value, undefined, 'HT');
if (data.valueNt !== undefined) {
await validateReadingValue(meterId, data.readingDate, data.valueNt, undefined, 'NT');
}
return prisma.meterReading.create({
data: {
meterId,
...data,
readingDate: data.readingDate,
value: data.value,
valueNt: data.valueNt,
unit: data.unit,
notes: data.notes,
},
});
}
@ -345,11 +374,46 @@ async function updateMeterReading(meterId, readingId, data) {
if (!reading) {
throw new Error('Zählerstand nicht gefunden');
}
// Validierung bei Wertänderung
if (data.value !== undefined || data.readingDate !== undefined) {
await validateReadingValue(meterId, data.readingDate || reading.readingDate, data.value ?? reading.value, readingId, 'HT');
}
if (data.valueNt !== undefined || data.readingDate !== undefined) {
const ntVal = data.valueNt ?? reading.valueNt;
if (ntVal !== undefined && ntVal !== null) {
await validateReadingValue(meterId, data.readingDate || reading.readingDate, ntVal, readingId, 'NT');
}
}
return prisma.meterReading.update({
where: { id: readingId },
data,
});
}
/**
* Validiert, dass ein Zählerstand monoton steigend ist.
* tariffLabel: 'HT' für Hochtarif/Eintarif, 'NT' für Niedertarif
*/
async function validateReadingValue(meterId, readingDate, value, excludeReadingId, tariffLabel = 'HT') {
const existing = await prisma.meterReading.findMany({
where: { meterId, ...(excludeReadingId ? { id: { not: excludeReadingId } } : {}) },
orderBy: { readingDate: 'asc' },
});
const fmtDate = (d) => d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
const fmtVal = (v) => v.toLocaleString('de-DE');
const label = tariffLabel === 'NT' ? 'NT-Zählerstand' : 'Zählerstand';
// Vergleichswert aus bestehendem Reading extrahieren
const getVal = (r) => tariffLabel === 'NT' ? (r.valueNt ?? 0) : r.value;
// Stand vor dem neuen Datum
const before = [...existing].filter(r => r.readingDate <= readingDate).pop();
if (before && value < getVal(before)) {
throw new Error(`${label} (${fmtVal(value)}) darf nicht kleiner sein als der Stand vom ${fmtDate(before.readingDate)} (${fmtVal(getVal(before))})`);
}
// Stand nach dem neuen Datum
const after = existing.find(r => r.readingDate > readingDate);
if (after && value > getVal(after)) {
throw new Error(`${label} (${fmtVal(value)}) darf nicht größer sein als der spätere Stand vom ${fmtDate(after.readingDate)} (${fmtVal(getVal(after))})`);
}
}
async function deleteMeterReading(meterId, readingId) {
// Verify the reading belongs to the meter
const reading = await prisma.meterReading.findFirst({

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -361,6 +361,7 @@ exports.Prisma.MeterScalarFieldEnum = {
customerId: 'customerId',
meterNumber: 'meterNumber',
type: 'type',
tariffModel: 'tariffModel',
location: 'location',
isActive: 'isActive',
createdAt: 'createdAt',
@ -372,6 +373,7 @@ exports.Prisma.MeterReadingScalarFieldEnum = {
meterId: 'meterId',
readingDate: 'readingDate',
value: 'value',
valueNt: 'valueNt',
unit: 'unit',
notes: 'notes',
reportedBy: 'reportedBy',
@ -529,11 +531,23 @@ exports.Prisma.EnergyContractDetailsScalarFieldEnum = {
annualConsumptionKwh: 'annualConsumptionKwh',
basePrice: 'basePrice',
unitPrice: 'unitPrice',
unitPriceNt: 'unitPriceNt',
bonus: 'bonus',
previousProviderName: 'previousProviderName',
previousCustomerNumber: 'previousCustomerNumber'
};
exports.Prisma.ContractMeterScalarFieldEnum = {
id: 'id',
energyContractDetailsId: 'energyContractDetailsId',
meterId: 'meterId',
position: 'position',
installedAt: 'installedAt',
removedAt: 'removedAt',
finalReading: 'finalReading',
createdAt: 'createdAt'
};
exports.Prisma.InvoiceScalarFieldEnum = {
id: 'id',
energyContractDetailsId: 'energyContractDetailsId',
@ -742,6 +756,11 @@ exports.MeterType = exports.$Enums.MeterType = {
GAS: 'GAS'
};
exports.MeterTariffModel = exports.$Enums.MeterTariffModel = {
SINGLE: 'SINGLE',
DUAL: 'DUAL'
};
exports.MeterReadingStatus = exports.$Enums.MeterReadingStatus = {
RECORDED: 'RECORDED',
REPORTED: 'REPORTED',
@ -855,6 +874,7 @@ exports.Prisma.ModelName = {
ContractTask: 'ContractTask',
ContractTaskSubtask: 'ContractTaskSubtask',
EnergyContractDetails: 'EnergyContractDetails',
ContractMeter: 'ContractMeter',
Invoice: 'Invoice',
InternetContractDetails: 'InternetContractDetails',
PhoneNumber: 'PhoneNumber',

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{
"name": "prisma-client-c6d54e22fa4d6137f643638da5d523e99ce84f9544cc793fd89163f1612953c6",
"name": "prisma-client-45a91d7556f300a75a0048d27fac6a72915779fc4e5c2234b54fe3547ddb1605",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js",

View File

@ -411,18 +411,25 @@ enum MeterType {
GAS
}
enum MeterTariffModel {
SINGLE // Eintarifzähler (Standard)
DUAL // Zweitarifzähler (HT/NT)
}
model Meter {
id Int @id @default(autoincrement())
customerId Int
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
meterNumber String
type MeterType
location String?
isActive Boolean @default(true)
readings MeterReading[]
energyDetails EnergyContractDetails[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
customerId Int
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
meterNumber String
type MeterType
tariffModel MeterTariffModel @default(SINGLE) // Eintarif oder Zweitarif (HT/NT)
location String?
isActive Boolean @default(true)
readings MeterReading[]
energyDetails EnergyContractDetails[]
contractMeters ContractMeter[] @relation("ContractMeters")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model MeterReading {
@ -430,7 +437,8 @@ model MeterReading {
meterId Int
meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade)
readingDate DateTime
value Float
value Float // Bei Eintarif: Gesamtwert. Bei Zweitarif: HT-Wert
valueNt Float? // Nur bei Zweitarif: NT-Wert (Niedertarif)
unit String @default("kWh")
notes String?
// Meldung & Übertragung
@ -709,20 +717,38 @@ enum InvoiceType {
}
model EnergyContractDetails {
id Int @id @default(autoincrement())
contractId Int @unique
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
contractId Int @unique
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
meterId Int?
meter Meter? @relation(fields: [meterId], references: [id])
meter Meter? @relation(fields: [meterId], references: [id])
maloId String? // Marktlokations-ID
annualConsumption Float? // kWh für Strom, m³ für Gas
annualConsumptionKwh Float? // kWh für Gas (zusätzlich zu m³)
basePrice Float? // €/Monat
unitPrice Float? // €/kWh (Arbeitspreis)
unitPrice Float? // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis
unitPriceNt Float? // €/kWh NT-Preis (nur bei Zweitarifzähler)
bonus Float?
previousProviderName String?
previousCustomerNumber String?
invoices Invoice[] // Rechnungen
contractMeters ContractMeter[] // Zähler-Zuordnungen (inkl. Folgezähler)
}
model ContractMeter {
id Int @id @default(autoincrement())
energyContractDetailsId Int
energyContractDetails EnergyContractDetails @relation(fields: [energyContractDetailsId], references: [id], onDelete: Cascade)
meterId Int
meter Meter @relation("ContractMeters", fields: [meterId], references: [id])
position Int @default(0) // 0 = Original, 1 = erster Folgezähler, etc.
installedAt DateTime? // Ab wann wird dieser Zähler am Vertrag genutzt?
removedAt DateTime? // Wann wurde der Zähler gewechselt? (null = aktuell)
finalReading Float? // Letzter Stand vor dem Wechsel
createdAt DateTime @default(now())
@@unique([energyContractDetailsId, meterId])
@@index([energyContractDetailsId])
}
model Invoice {

View File

@ -361,6 +361,7 @@ exports.Prisma.MeterScalarFieldEnum = {
customerId: 'customerId',
meterNumber: 'meterNumber',
type: 'type',
tariffModel: 'tariffModel',
location: 'location',
isActive: 'isActive',
createdAt: 'createdAt',
@ -372,6 +373,7 @@ exports.Prisma.MeterReadingScalarFieldEnum = {
meterId: 'meterId',
readingDate: 'readingDate',
value: 'value',
valueNt: 'valueNt',
unit: 'unit',
notes: 'notes',
reportedBy: 'reportedBy',
@ -529,11 +531,23 @@ exports.Prisma.EnergyContractDetailsScalarFieldEnum = {
annualConsumptionKwh: 'annualConsumptionKwh',
basePrice: 'basePrice',
unitPrice: 'unitPrice',
unitPriceNt: 'unitPriceNt',
bonus: 'bonus',
previousProviderName: 'previousProviderName',
previousCustomerNumber: 'previousCustomerNumber'
};
exports.Prisma.ContractMeterScalarFieldEnum = {
id: 'id',
energyContractDetailsId: 'energyContractDetailsId',
meterId: 'meterId',
position: 'position',
installedAt: 'installedAt',
removedAt: 'removedAt',
finalReading: 'finalReading',
createdAt: 'createdAt'
};
exports.Prisma.InvoiceScalarFieldEnum = {
id: 'id',
energyContractDetailsId: 'energyContractDetailsId',
@ -742,6 +756,11 @@ exports.MeterType = exports.$Enums.MeterType = {
GAS: 'GAS'
};
exports.MeterTariffModel = exports.$Enums.MeterTariffModel = {
SINGLE: 'SINGLE',
DUAL: 'DUAL'
};
exports.MeterReadingStatus = exports.$Enums.MeterReadingStatus = {
RECORDED: 'RECORDED',
REPORTED: 'REPORTED',
@ -855,6 +874,7 @@ exports.Prisma.ModelName = {
ContractTask: 'ContractTask',
ContractTaskSubtask: 'ContractTaskSubtask',
EnergyContractDetails: 'EnergyContractDetails',
ContractMeter: 'ContractMeter',
Invoice: 'Invoice',
InternetContractDetails: 'InternetContractDetails',
PhoneNumber: 'PhoneNumber',

View File

@ -411,17 +411,24 @@ enum MeterType {
GAS
}
enum MeterTariffModel {
SINGLE // Eintarifzähler (Standard)
DUAL // Zweitarifzähler (HT/NT)
}
model Meter {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
customerId Int
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
meterNumber String
type MeterType
tariffModel MeterTariffModel @default(SINGLE) // Eintarif oder Zweitarif (HT/NT)
location String?
isActive Boolean @default(true)
readings MeterReading[]
energyDetails EnergyContractDetails[]
createdAt DateTime @default(now())
isActive Boolean @default(true)
readings MeterReading[]
energyDetails EnergyContractDetails[]
contractMeters ContractMeter[] @relation("ContractMeters")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@ -430,7 +437,8 @@ model MeterReading {
meterId Int
meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade)
readingDate DateTime
value Float
value Float // Bei Eintarif: Gesamtwert. Bei Zweitarif: HT-Wert
valueNt Float? // Nur bei Zweitarif: NT-Wert (Niedertarif)
unit String @default("kWh")
notes String?
// Meldung & Übertragung
@ -718,11 +726,29 @@ model EnergyContractDetails {
annualConsumption Float? // kWh für Strom, m³ für Gas
annualConsumptionKwh Float? // kWh für Gas (zusätzlich zu m³)
basePrice Float? // €/Monat
unitPrice Float? // €/kWh (Arbeitspreis)
unitPrice Float? // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis
unitPriceNt Float? // €/kWh NT-Preis (nur bei Zweitarifzähler)
bonus Float?
previousProviderName String?
previousCustomerNumber String?
invoices Invoice[] // Rechnungen
contractMeters ContractMeter[] // Zähler-Zuordnungen (inkl. Folgezähler)
}
model ContractMeter {
id Int @id @default(autoincrement())
energyContractDetailsId Int
energyContractDetails EnergyContractDetails @relation(fields: [energyContractDetailsId], references: [id], onDelete: Cascade)
meterId Int
meter Meter @relation("ContractMeters", fields: [meterId], references: [id])
position Int @default(0) // 0 = Original, 1 = erster Folgezähler, etc.
installedAt DateTime? // Ab wann wird dieser Zähler am Vertrag genutzt?
removedAt DateTime? // Wann wurde der Zähler gewechselt? (null = aktuell)
finalReading Float? // Letzter Stand vor dem Wechsel
createdAt DateTime @default(now())
@@unique([energyContractDetailsId, meterId])
@@index([energyContractDetailsId])
}
model Invoice {

View File

@ -244,6 +244,79 @@ export async function getCockpit(req: AuthRequest, res: Response): Promise<void>
}
}
// ==================== FOLGEZÄHLER ====================
export async function addSuccessorMeter(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
const { meterId, installedAt, finalReadingPrevious } = req.body;
const contract = await prisma.contract.findUnique({
where: { id: contractId },
include: { energyDetails: { include: { contractMeters: { orderBy: { position: 'asc' } } } } },
});
if (!contract?.energyDetails) {
res.status(404).json({ success: false, error: 'Energievertrag nicht gefunden' } as ApiResponse);
return;
}
const ecdId = contract.energyDetails.id;
const existingMeters = contract.energyDetails.contractMeters;
const nextPosition = existingMeters.length > 0
? Math.max(...existingMeters.map(m => m.position)) + 1
: 0;
// Vorherigen Zähler als gewechselt markieren
if (existingMeters.length > 0 && finalReadingPrevious !== undefined) {
const prevMeter = existingMeters[existingMeters.length - 1];
await prisma.contractMeter.update({
where: { id: prevMeter.id },
data: {
removedAt: installedAt ? new Date(installedAt) : new Date(),
finalReading: parseFloat(finalReadingPrevious),
},
});
}
const contractMeter = await prisma.contractMeter.create({
data: {
energyContractDetailsId: ecdId,
meterId: parseInt(meterId),
position: nextPosition,
installedAt: installedAt ? new Date(installedAt) : new Date(),
},
include: { meter: { include: { readings: true } } },
});
// Aktuellen Zähler am Vertrag aktualisieren
await prisma.energyContractDetails.update({
where: { id: ecdId },
data: { meterId: parseInt(meterId) },
});
res.json({ success: true, data: contractMeter } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Folgezählers',
} as ApiResponse);
}
}
export async function removeContractMeter(req: AuthRequest, res: Response): Promise<void> {
try {
const contractMeterId = parseInt(req.params.contractMeterId);
await prisma.contractMeter.delete({ where: { id: contractMeterId } });
res.json({ success: true, data: null } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Entfernen',
} as ApiResponse);
}
}
// ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ====================
export async function snoozeContract(req: Request, res: Response): Promise<void> {

View File

@ -293,7 +293,14 @@ export async function getMeterReadings(req: Request, res: Response): Promise<voi
export async function addMeterReading(req: Request, res: Response): Promise<void> {
try {
const reading = await customerService.addMeterReading(parseInt(req.params.meterId), req.body);
const { readingDate, value, valueNt, unit, notes } = req.body;
const reading = await customerService.addMeterReading(parseInt(req.params.meterId), {
readingDate: new Date(readingDate),
value: parseFloat(value),
valueNt: valueNt !== undefined && valueNt !== null && valueNt !== '' ? parseFloat(valueNt) : undefined,
unit,
notes,
});
res.status(201).json({ success: true, data: reading } as ApiResponse);
} catch (error) {
res.status(400).json({
@ -305,10 +312,18 @@ export async function addMeterReading(req: Request, res: Response): Promise<void
export async function updateMeterReading(req: Request, res: Response): Promise<void> {
try {
const { readingDate, value, valueNt, unit, notes } = req.body;
const updateData: Record<string, unknown> = {};
if (readingDate !== undefined) updateData.readingDate = new Date(readingDate);
if (value !== undefined) updateData.value = parseFloat(value);
if (valueNt !== undefined) updateData.valueNt = valueNt !== null && valueNt !== '' ? parseFloat(valueNt) : null;
if (unit !== undefined) updateData.unit = unit;
if (notes !== undefined) updateData.notes = notes;
const reading = await customerService.updateMeterReading(
parseInt(req.params.meterId),
parseInt(req.params.readingId),
req.body
updateData as any
);
res.json({ success: true, data: reading } as ApiResponse);
} catch (error) {
@ -358,15 +373,20 @@ export async function reportMeterReading(req: AuthRequest, res: Response): Promi
return;
}
const reading = await prisma.meterReading.create({
data: {
meterId,
value: parseFloat(value),
readingDate: readingDate ? new Date(readingDate) : new Date(),
notes,
reportedBy: user.email,
status: 'REPORTED',
},
const parsedDate = readingDate ? new Date(readingDate) : new Date();
const parsedValue = parseFloat(value);
// Validierung über den Service (monoton steigend)
const reading = await customerService.addMeterReading(meterId, {
readingDate: parsedDate,
value: parsedValue,
notes,
});
// Status auf REPORTED setzen
await prisma.meterReading.update({
where: { id: reading.id },
data: { reportedBy: user.email, status: 'REPORTED' },
});
res.status(201).json({ success: true, data: reading } as ApiResponse);

View File

@ -24,6 +24,9 @@ const RESOURCE_MAPPING: Record<string, { type: string; extractId?: (req: AuthReq
'/api/contract-durations': { type: 'ContractDuration', extractId: (req) => req.params.id },
'/api/settings': { type: 'AppSetting', extractId: (req) => req.params.key },
'/api/email-providers': { type: 'EmailProviderConfig', extractId: (req) => req.params.id },
'/api/meters': { type: 'Meter', extractId: (req) => req.params.id || req.params.meterId },
'/api/upload': { type: 'Upload' },
'/api/email-logs': { type: 'EmailLog' },
'/api/auth': { type: 'Authentication' },
'/api/audit-logs': { type: 'AuditLog', extractId: (req) => req.params.id },
'/api/gdpr': { type: 'GDPR' },
@ -116,6 +119,240 @@ function getClientIp(req: AuthRequest): string {
return req.socket.remoteAddress || 'unknown';
}
// Menschenlesbare Bezeichnungen für Resource-Typen
const RESOURCE_TYPE_LABELS: Record<string, string> = {
Customer: 'Kunde',
Contract: 'Vertrag',
BankCard: 'Bankverbindung',
IdentityDocument: 'Ausweis',
Address: 'Adresse',
Meter: 'Zähler',
MeterReading: 'Zählerstand',
User: 'Benutzer',
Provider: 'Anbieter',
Tariff: 'Tarif',
SalesPlatform: 'Vertriebsplattform',
ContractCategory: 'Vertragskategorie',
CancellationPeriod: 'Kündigungsfrist',
ContractDuration: 'Vertragslaufzeit',
EmailProviderConfig: 'E-Mail-Provider',
AppSetting: 'Einstellung',
CustomerConsent: 'Einwilligung',
ContractTask: 'Aufgabe',
ContractHistoryEntry: 'Vertragshistorie',
GDPR: 'Datenschutz',
Authentication: 'Anmeldung',
AuditLog: 'Audit-Protokoll',
StressfreiEmail: 'Stressfrei-E-Mail',
CachedEmail: 'E-Mail',
};
const ACTION_LABELS: Record<string, string> = {
CREATE: 'erstellt',
READ: 'aufgerufen',
UPDATE: 'aktualisiert',
DELETE: 'gelöscht',
EXPORT: 'exportiert',
ANONYMIZE: 'anonymisiert',
LOGIN: 'angemeldet',
LOGOUT: 'abgemeldet',
LOGIN_FAILED: 'Anmeldung fehlgeschlagen',
};
/**
* Erzeugt ein menschenlesbares Label für den Audit-Log-Eintrag
*/
function generateHumanLabel(
action: AuditAction,
resourceType: string,
req: AuthRequest,
responseBody: unknown
): string {
const typeName = RESOURCE_TYPE_LABELS[resourceType] || resourceType;
const actionName = ACTION_LABELS[action] || action;
// Identifikator aus Response oder Request extrahieren
let identifier = '';
if (responseBody && typeof responseBody === 'object' && 'data' in responseBody) {
const data = (responseBody as { data: Record<string, unknown> }).data;
if (data) {
identifier =
(data.contractNumber as string) ||
(data.customerNumber as string) ||
(data.meterNumber as string) ||
(data.name as string) ||
(data.email as string) ||
(data.firstName && data.lastName ? `${data.firstName} ${data.lastName}` : '') ||
'';
}
}
// Spezial-Labels für bestimmte Endpunkte
const path = req.path;
// Auth
if (path.includes('/auth/login') || path.includes('/auth/customer-login')) {
const email = req.body?.email || '';
return action === 'LOGIN'
? `Benutzer ${email} hat sich angemeldet`
: `Anmeldung fehlgeschlagen für ${email}`;
}
if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet';
// Kunden-Operationen
if (resourceType === 'Customer') {
if (action === 'CREATE') return `Kunde ${identifier} angelegt`;
if (action === 'UPDATE') return `Kundendaten ${identifier} aktualisiert`;
if (action === 'DELETE') return `Kunde ${identifier} gelöscht`;
if (action === 'READ' && req.params.id) return `Kundendaten ${identifier} aufgerufen`;
if (action === 'READ') return 'Kundenliste aufgerufen';
}
// Verträge
if (resourceType === 'Contract') {
if (path.includes('/cockpit')) return 'Vertrags-Cockpit aufgerufen';
if (path.includes('/follow-up')) return `Folgevertrag für ${identifier} erstellt`;
if (path.includes('/snooze')) return `Vertrag ${identifier} zurückgestellt`;
if (path.includes('/password')) return `Passwort für Vertrag ${identifier} abgerufen`;
if (path.includes('/sip-credentials')) return 'SIP-Zugangsdaten abgerufen';
if (path.includes('/internet-credentials')) return `Internet-Zugangsdaten für Vertrag ${identifier} abgerufen`;
if (path.includes('/successor-meter')) return `Folgezähler zu Vertrag ${identifier} hinzugefügt`;
if (action === 'CREATE') return `Vertrag ${identifier} angelegt`;
if (action === 'UPDATE') return `Vertrag ${identifier} aktualisiert`;
if (action === 'DELETE') return `Vertrag ${identifier} gelöscht`;
if (action === 'READ' && req.params.id) return `Vertrag ${identifier} aufgerufen`;
if (action === 'READ') return 'Vertragsliste aufgerufen';
}
// Bankverbindungen
if (resourceType === 'BankCard') {
if (action === 'CREATE') return `Bankverbindung hinzugefügt`;
if (action === 'UPDATE') return `Bankverbindung aktualisiert`;
if (action === 'DELETE') return `Bankverbindung gelöscht`;
}
// Ausweise
if (resourceType === 'IdentityDocument') {
if (action === 'CREATE') return `Ausweis ${identifier} hinzugefügt`;
if (action === 'UPDATE') return `Ausweis ${identifier} aktualisiert`;
if (action === 'DELETE') return `Ausweis gelöscht`;
}
// Adressen
if (resourceType === 'Address') {
if (action === 'CREATE') return `Adresse hinzugefügt`;
if (action === 'UPDATE') return `Adresse aktualisiert`;
if (action === 'DELETE') return `Adresse gelöscht`;
}
// Zähler
if (resourceType === 'Meter') {
if (action === 'CREATE') return `Zähler ${identifier} angelegt`;
if (action === 'UPDATE') return `Zähler ${identifier} aktualisiert`;
if (action === 'DELETE') return `Zähler gelöscht`;
}
// Einwilligungen
if (resourceType === 'CustomerConsent') {
const consentType = req.params.consentType || '';
const consentLabels: Record<string, string> = {
DATA_PROCESSING: 'Datenverarbeitung',
MARKETING_EMAIL: 'E-Mail-Marketing',
MARKETING_PHONE: 'Telefonmarketing',
DATA_SHARING_PARTNER: 'Datenweitergabe',
};
const consentName = consentLabels[consentType] || consentType;
if (action === 'UPDATE') {
const status = req.body?.status;
return status === 'GRANTED'
? `Einwilligung "${consentName}" erteilt`
: `Einwilligung "${consentName}" widerrufen`;
}
if (action === 'READ') return 'Einwilligungen abgerufen';
}
// Benutzer
if (resourceType === 'User') {
if (action === 'CREATE') return `Benutzer ${identifier} angelegt`;
if (action === 'UPDATE') return `Benutzer ${identifier} aktualisiert`;
if (action === 'DELETE') return `Benutzer ${identifier} gelöscht`;
if (action === 'READ' && req.params.id) return `Benutzerdaten ${identifier} aufgerufen`;
if (action === 'READ') return 'Benutzerliste aufgerufen';
}
// Aufgaben
if (resourceType === 'ContractTask') {
if (path.includes('/complete')) return `Aufgabe als erledigt markiert`;
if (action === 'CREATE') return `Aufgabe erstellt`;
if (action === 'UPDATE') return `Aufgabe aktualisiert`;
if (action === 'DELETE') return `Aufgabe gelöscht`;
}
// E-Mail-Provider
if (resourceType === 'EmailProviderConfig') {
if (path.includes('/test-connection')) return `E-Mail-Provider Verbindungstest`;
if (path.includes('/provision')) return `E-Mail-Adresse provisioniert`;
if (action === 'CREATE') return `E-Mail-Provider ${identifier} angelegt`;
if (action === 'UPDATE') return `E-Mail-Provider ${identifier} aktualisiert`;
if (action === 'DELETE') return `E-Mail-Provider ${identifier} gelöscht`;
}
// GDPR
if (resourceType === 'GDPR') {
if (path.includes('/dashboard')) return 'DSGVO-Dashboard aufgerufen';
if (path.includes('/export')) return 'Kundendaten exportiert (DSGVO Art. 15)';
if (path.includes('/privacy-policy')) {
return action === 'UPDATE' ? 'Datenschutzerklärung aktualisiert' : 'Datenschutzerklärung aufgerufen';
}
if (path.includes('/authorization-template')) {
return action === 'UPDATE' ? 'Vollmacht-Vorlage aktualisiert' : 'Vollmacht-Vorlage aufgerufen';
}
if (path.includes('/send-consent-link')) return 'Datenschutz-Link versendet';
if (path.includes('/authorizations') && path.includes('/send')) return 'Vollmacht-Anfrage versendet';
if (path.includes('/authorizations') && path.includes('/grant')) return 'Vollmacht erteilt';
if (path.includes('/authorizations') && path.includes('/withdraw')) return 'Vollmacht widerrufen';
if (path.includes('/authorizations') && path.includes('/upload')) return 'Vollmacht-PDF hochgeladen';
if (path.includes('/authorizations') && path.includes('/document') && action === 'DELETE') return 'Vollmacht-PDF gelöscht';
if (path.includes('/my-privacy')) return 'Eigene Datenschutzseite aufgerufen';
if (path.includes('/my-consent-status')) return 'Eigener Einwilligungsstatus geprüft';
if (path.includes('/my-authorizations')) return 'Eigene Vollmachten aufgerufen';
if (path.includes('/deletions')) {
if (action === 'CREATE') return 'Löschanfrage erstellt';
if (path.includes('/process')) return 'Löschanfrage bearbeitet';
return 'Löschanfragen aufgerufen';
}
if (path.includes('/consent-status')) return 'Einwilligungsstatus geprüft';
if (path.includes('/consents/overview')) return 'Einwilligungsübersicht aufgerufen';
}
// Einstellungen
if (resourceType === 'AppSetting') {
if (action === 'UPDATE') return `Einstellung "${req.params.key || ''}" geändert`;
if (action === 'READ') return 'Einstellungen aufgerufen';
}
// Zähler-Readings
if (path.includes('/readings')) {
if (path.includes('/report')) return 'Zählerstand vom Kunden gemeldet';
if (path.includes('/transfer')) return 'Zählerstand als übertragen markiert';
if (action === 'CREATE') return 'Zählerstand erfasst';
if (action === 'UPDATE') return 'Zählerstand aktualisiert';
if (action === 'DELETE') return 'Zählerstand gelöscht';
}
// Upload-Operationen
if (path.includes('/upload') || path.includes('/privacy-policy')) {
if (path.includes('/privacy-policy') && action === 'DELETE') return 'Datenschutzerklärung-PDF gelöscht';
if (path.includes('/privacy-policy')) return 'Datenschutzerklärung-PDF hochgeladen';
}
// Standard-Fallback
if (identifier) {
return `${typeName} ${identifier} ${actionName}`;
}
return `${typeName} ${actionName}`;
}
/**
* Audit Middleware - loggt alle API-Aufrufe asynchron
*/
@ -162,22 +399,8 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
// Audit-Kontext abrufen (enthält Before/After-Werte von Prisma Middleware)
const auditContext = getAuditContext();
// Label für bessere Lesbarkeit generieren
let resourceLabel: string | undefined;
if (responseBody && typeof responseBody === 'object' && 'data' in responseBody) {
const data = (responseBody as { data: Record<string, unknown> }).data;
if (data) {
// Versuche verschiedene Label-Felder
resourceLabel =
(data.contractNumber as string) ||
(data.customerNumber as string) ||
(data.name as string) ||
(data.email as string) ||
(data.firstName && data.lastName
? `${data.firstName} ${data.lastName}`
: undefined);
}
}
// Menschenlesbares Label generieren
const resourceLabel = generateHumanLabel(action, mapping.type, req, responseBody);
await createAuditLog({
userId: req.user?.userId,

View File

@ -20,6 +20,10 @@ router.post('/:id/follow-up', authenticate, requirePermission('contracts:create'
// Snooze (Vertrag zurückstellen)
router.patch('/:id/snooze', authenticate, requirePermission('contracts:update'), contractController.snoozeContract);
// Folgezähler
router.post('/:id/successor-meter', authenticate, requirePermission('contracts:update'), contractController.addSuccessorMeter);
router.delete('/:id/contract-meter/:contractMeterId', authenticate, requirePermission('contracts:update'), contractController.removeContractMeter);
// Get decrypted password
router.get('/:id/password', authenticate, requirePermission('contracts:read'), contractController.getContractPassword);

View File

@ -440,6 +440,16 @@ router.post(
data: { privacyPolicyPath: relativePath },
});
// Alle Consents auf GRANTED setzen (PDF = vollständige Einwilligung)
const consentTypes = ['DATA_PROCESSING', 'MARKETING_EMAIL', 'MARKETING_PHONE', 'DATA_SHARING_PARTNER'] as const;
for (const consentType of consentTypes) {
await prisma.customerConsent.upsert({
where: { customerId_consentType: { customerId, consentType } },
update: { status: 'GRANTED', grantedAt: new Date(), source: 'papier' },
create: { customerId, consentType, status: 'GRANTED', grantedAt: new Date(), source: 'papier', createdBy: (req as any).user?.email || 'admin' },
});
}
res.json({
success: true,
data: {
@ -488,6 +498,12 @@ router.delete(
data: { privacyPolicyPath: null },
});
// Nur Consents widerrufen die per Papier erteilt wurden
await prisma.customerConsent.updateMany({
where: { customerId, status: 'GRANTED', source: 'papier' },
data: { status: 'WITHDRAWN', withdrawnAt: new Date() },
});
res.json({ success: true });
} catch (error) {
console.error('Delete error:', error);

View File

@ -146,13 +146,35 @@ export async function updateAuthorizationDocument(
* Vollmacht-Dokument löschen
*/
export async function deleteAuthorizationDocument(customerId: number, representativeId: number) {
// Prüfen ob die Vollmacht per Papier erteilt wurde
const auth = await prisma.representativeAuthorization.findUnique({
where: { customerId_representativeId: { customerId, representativeId } },
select: { source: true, documentPath: true },
});
if (!auth) throw new Error('Vollmacht nicht gefunden');
// Datei löschen
if (auth.documentPath) {
try {
const filePath = path.join(process.cwd(), auth.documentPath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (err) {
console.error('Fehler beim Löschen der Vollmacht-PDF:', err);
}
}
// Wenn per Papier erteilt → Vollmacht widerrufen
// Wenn per Portal/Online erteilt → nur PDF entfernen, Vollmacht bleibt
const withdrawData = auth.source === 'papier'
? { documentPath: null, isGranted: false, withdrawnAt: new Date() }
: { documentPath: null };
return prisma.representativeAuthorization.update({
where: {
customerId_representativeId: { customerId, representativeId },
},
data: {
documentPath: null,
},
where: { customerId_representativeId: { customerId, representativeId } },
data: withdrawData,
});
}

View File

@ -125,14 +125,14 @@ export async function getContractById(id: number, decryptPassword = false) {
previousProvider: true,
previousContract: {
include: {
energyDetails: { include: { meter: { include: { readings: true } }, invoices: true } },
energyDetails: { include: { meter: { include: { readings: true } }, contractMeters: { include: { meter: { include: { readings: true } } }, orderBy: { position: 'asc' as const } }, invoices: true } },
internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } },
tvDetails: true,
carInsuranceDetails: true,
},
},
energyDetails: { include: { meter: { include: { readings: true } }, invoices: true } },
energyDetails: { include: { meter: { include: { readings: true } }, contractMeters: { include: { meter: { include: { readings: true } } }, orderBy: { position: 'asc' as const } }, invoices: true } },
internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } },
tvDetails: true,
@ -403,11 +403,45 @@ export async function updateContract(
// Update type-specific details
if (energyDetails) {
const existingEcd = await prisma.energyContractDetails.findUnique({
where: { contractId: id },
select: { id: true, meterId: true },
});
await prisma.energyContractDetails.upsert({
where: { contractId: id },
update: energyDetails,
create: { contractId: id, ...energyDetails },
});
// ContractMeter synchronisieren wenn sich der Zähler ändert
if (energyDetails.meterId !== undefined && existingEcd) {
const oldMeterId = existingEcd.meterId;
const newMeterId = energyDetails.meterId;
if (oldMeterId !== newMeterId) {
// Alle alten ContractMeter-Einträge entfernen
await prisma.contractMeter.deleteMany({
where: { energyContractDetailsId: existingEcd.id },
});
// Neuen ContractMeter-Eintrag erstellen (wenn ein Zähler gesetzt)
if (newMeterId) {
const contract = await prisma.contract.findUnique({
where: { id },
select: { startDate: true },
});
await prisma.contractMeter.create({
data: {
energyContractDetailsId: existingEcd.id,
meterId: newMeterId,
position: 0,
installedAt: contract?.startDate,
},
});
}
}
}
}
if (internetDetails) {

View File

@ -89,6 +89,11 @@ export interface ReportedMeterReading {
customerNumber: string;
name: string;
};
// Zugehöriger Vertrag
contract?: {
id: number;
contractNumber: string;
};
// Anbieter-Info für Quick-Login
providerPortal?: {
providerName: string;
@ -810,6 +815,7 @@ async function getReportedMeterReadings(): Promise<ReportedMeterReading[]> {
contract: {
select: {
id: true,
contractNumber: true,
portalUsername: true,
provider: {
select: { id: true, name: true, portalUrl: true },
@ -847,6 +853,10 @@ async function getReportedMeterReadings(): Promise<ReportedMeterReading[]> {
customerNumber: r.meter.customer.customerNumber,
name: `${r.meter.customer.firstName} ${r.meter.customer.lastName}`,
},
contract: contract ? {
id: contract.id,
contractNumber: contract.contractNumber,
} : undefined,
providerPortal: provider?.portalUrl ? {
providerName: provider.name,
portalUrl: provider.portalUrl,

View File

@ -443,6 +443,30 @@ export async function updateMeter(
}
export async function deleteMeter(id: number) {
// Prüfen ob der Zähler noch an Verträgen hängt
const linkedContracts = await prisma.contractMeter.findMany({
where: { meterId: id },
include: { energyContractDetails: { include: { contract: { select: { contractNumber: true } } } } },
});
if (linkedContracts.length > 0) {
const contractNumbers = linkedContracts
.map(cm => cm.energyContractDetails.contract.contractNumber)
.join(', ');
throw new Error(`Zähler kann nicht gelöscht werden noch an Vertrag/Verträgen zugeordnet: ${contractNumbers}`);
}
// Auch direkte meterId-Referenz auf EnergyContractDetails prüfen
const directLinks = await prisma.energyContractDetails.findMany({
where: { meterId: id },
include: { contract: { select: { contractNumber: true } } },
});
if (directLinks.length > 0) {
const contractNumbers = directLinks.map(d => d.contract.contractNumber).join(', ');
throw new Error(`Zähler kann nicht gelöscht werden noch an Vertrag/Verträgen zugeordnet: ${contractNumbers}`);
}
return prisma.meter.delete({ where: { id } });
}
@ -451,14 +475,25 @@ export async function addMeterReading(
data: {
readingDate: Date;
value: number;
valueNt?: number;
unit?: string;
notes?: string;
}
) {
// Validierung: Zählerstand muss monoton steigend sein
await validateReadingValue(meterId, data.readingDate, data.value, undefined, 'HT');
if (data.valueNt !== undefined) {
await validateReadingValue(meterId, data.readingDate, data.valueNt, undefined, 'NT');
}
return prisma.meterReading.create({
data: {
meterId,
...data,
readingDate: data.readingDate,
value: data.value,
valueNt: data.valueNt,
unit: data.unit,
notes: data.notes,
},
});
}
@ -476,6 +511,7 @@ export async function updateMeterReading(
data: {
readingDate?: Date;
value?: number;
valueNt?: number | null;
unit?: string;
notes?: string;
}
@ -489,12 +525,65 @@ export async function updateMeterReading(
throw new Error('Zählerstand nicht gefunden');
}
// Validierung bei Wertänderung
if (data.value !== undefined || data.readingDate !== undefined) {
await validateReadingValue(
meterId,
data.readingDate || reading.readingDate,
data.value ?? reading.value,
readingId,
'HT'
);
}
if (data.valueNt !== undefined || data.readingDate !== undefined) {
const ntVal = data.valueNt ?? reading.valueNt;
if (ntVal !== undefined && ntVal !== null) {
await validateReadingValue(
meterId,
data.readingDate || reading.readingDate,
ntVal,
readingId,
'NT'
);
}
}
return prisma.meterReading.update({
where: { id: readingId },
data,
});
}
/**
* Validiert, dass ein Zählerstand monoton steigend ist.
* tariffLabel: 'HT' für Hochtarif/Eintarif, 'NT' für Niedertarif
*/
async function validateReadingValue(meterId: number, readingDate: Date, value: number, excludeReadingId?: number, tariffLabel: 'HT' | 'NT' = 'HT') {
const existing = await prisma.meterReading.findMany({
where: { meterId, ...(excludeReadingId ? { id: { not: excludeReadingId } } : {}) },
orderBy: { readingDate: 'asc' },
});
const fmtDate = (d: Date) => d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
const fmtVal = (v: number) => v.toLocaleString('de-DE');
const label = tariffLabel === 'NT' ? 'NT-Zählerstand' : 'Zählerstand';
// Vergleichswert aus bestehendem Reading extrahieren
const getVal = (r: typeof existing[0]) => tariffLabel === 'NT' ? (r.valueNt ?? 0) : r.value;
// Stand vor dem neuen Datum
const before = [...existing].filter(r => r.readingDate <= readingDate).pop();
if (before && value < getVal(before)) {
throw new Error(`${label} (${fmtVal(value)}) darf nicht kleiner sein als der Stand vom ${fmtDate(before.readingDate)} (${fmtVal(getVal(before))})`);
}
// Stand nach dem neuen Datum
const after = existing.find(r => r.readingDate > readingDate);
if (after && value > getVal(after)) {
throw new Error(`${label} (${fmtVal(value)}) darf nicht größer sein als der spätere Stand vom ${fmtDate(after.readingDate)} (${fmtVal(getVal(after))})`);
}
}
export async function deleteMeterReading(meterId: number, readingId: number) {
// Verify the reading belongs to the meter
const reading = await prisma.meterReading.findFirst({

34
backend/todo.md Normal file
View File

@ -0,0 +1,34 @@
Vertragliste bei Energie mit Anschlussadresse/Lieferadresse noch in der Liste
Bei Mobilfunk die Mobilfunknummer und wenn vorhanden Karteninhaber
Bei Festnetz, die Anschlussadresse/Lieferadresse
Bei KFZ das Kennzeichen
#erledigt
Datenschutzerklärung wenn PDF hinterlegt wurde, alle Haken auf Grün setzten.
Und wenn von Kunde im Kundenportal ein Haken weg, pdf wieder löschen und gesperrt setzen. bis endweder alle haken wieder gesetzt sind, oder pdf erneut hochgeladen
Aktuell zählt das PDF als Alternative zu den Online-Haken. Du willst es so:
PDF hochgeladen → alle 4 Online-Consents automatisch auf GRANTED setzen
Kunde entfernt einen Haken im Portal → PDF löschen + Tabs sperren
Entsperrung nur durch: alle Haken wieder setzen ODER neues PDF hochladen
#erledigt
Zweitarif (Gibt es auch 3 Tarifuzähler?) Zähler HT/NT bei Strom Zähler hinzufügen.
Auch in die Berechnung die Verbäuche dann darstellen
Alle Datumsfelder mit 0 davor wenn es ne einstellige Zahl ist
Jetzt : 1.1.2026
Und gewollt 01.01.2026
Die Auditmeldungen aussagekräftig
Email Log und system testen
Security System testen

View File

@ -1,6 +1,7 @@
import { useState, useMemo, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom';
import { pushHistory } from '../../utils/navigation';
import { contractApi, meterApi } from '../../services/api';
import Card from '../../components/ui/Card';
import Badge from '../../components/ui/Badge';
@ -282,7 +283,7 @@ export default function ContractCockpit() {
<div className="flex items-center gap-2 flex-wrap">
<Link
to={`/contracts/${contract.id}`}
state={{ from: '/contracts/cockpit' }}
state={pushHistory('/contracts/cockpit')}
className="font-medium hover:underline"
onClick={(e) => e.stopPropagation()}
>
@ -399,7 +400,7 @@ export default function ContractCockpit() {
<Link
to={`/contracts/${contract.id}`}
state={{ from: '/contracts/cockpit' }}
state={pushHistory('/contracts/cockpit')}
className="p-2 hover:bg-white hover:bg-opacity-50 rounded"
onClick={(e) => e.stopPropagation()}
title="Zum Vertrag"
@ -610,6 +611,15 @@ export default function ContractCockpit() {
{reading.customer.name}
</Link>
<span className="text-xs text-gray-500">({reading.customer.customerNumber})</span>
{reading.contract && (
<Link
to={`/contracts/${reading.contract.id}`}
state={pushHistory('/contracts/cockpit')}
className="text-xs text-blue-600 hover:underline"
>
{reading.contract.contractNumber}
</Link>
)}
</div>
<p className="text-xs text-gray-600">
Zähler {reading.meter.meterNumber} <strong>{reading.value} {reading.unit}</strong> am{' '}
@ -635,6 +645,7 @@ export default function ContractCockpit() {
variant="secondary"
size="sm"
onClick={async () => {
if (!confirm(`Zählerstand ${reading.value} ${reading.unit} (Zähler ${reading.meter.meterNumber}) als übertragen markieren?`)) return;
await meterApi.markTransferred(reading.meter.id, reading.id);
queryClient.invalidateQueries({ queryKey: ['contract-cockpit'] });
}}

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
import { pushHistory, popHistory } from '../../utils/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdprApi } from '../../services/api';
import { ContractEmailsSection } from '../../components/email';
@ -13,9 +14,9 @@ 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, Calculator, Info, X, BellOff, Lock, Shield } from 'lucide-react';
import { calculateConsumption, calculateCosts } from '../../utils/energyCalculations';
import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
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, ContractMeter } from '../../types';
const typeLabels: Record<ContractType, string> = {
ELECTRICITY: 'Strom',
@ -197,16 +198,19 @@ function SimCardDisplay({ simCard }: { simCard: SimCard }) {
function MeterReadingsSection({
meterId,
meterType,
tariffModel,
readings,
contractId,
canEdit,
label,
}: {
meterId: number;
meterType: 'ELECTRICITY' | 'GAS';
tariffModel?: string;
readings: MeterReading[];
contractId: number;
canEdit: boolean;
label?: string;
}) {
const isDualTariff = tariffModel === 'DUAL';
const [isExpanded, setIsExpanded] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
const [editingReading, setEditingReading] = useState<MeterReading | null>(null);
@ -215,7 +219,7 @@ function MeterReadingsSection({
const deleteReadingMutation = useMutation({
mutationFn: (readingId: number) => meterApi.deleteReading(meterId, readingId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
queryClient.invalidateQueries({ queryKey: ['contract'] });
},
});
@ -231,7 +235,7 @@ function MeterReadingsSection({
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Gauge className="w-4 h-4 text-gray-500" />
<h4 className="text-sm font-medium text-gray-700">Zählerstände</h4>
<h4 className="text-sm font-medium text-gray-700">{label || 'Zählerstände'}</h4>
<Badge variant="default">{readings.length}</Badge>
</div>
<div className="flex items-center gap-2">
@ -274,8 +278,12 @@ function MeterReadingsSection({
</span>
<div className="flex items-center gap-2">
<span className="font-mono flex items-center gap-1">
{reading.value.toLocaleString('de-DE')} {reading.unit}
<CopyButton value={reading.value.toString()} title="Nur Wert kopieren" />
{isDualTariff && reading.valueNt != null ? (
<>HT: {reading.value.toLocaleString('de-DE')} / NT: {reading.valueNt.toLocaleString('de-DE')} {reading.unit}</>
) : (
<>{reading.value.toLocaleString('de-DE')} {reading.unit}</>
)}
<CopyButton value={reading.value.toString()} title="Wert kopieren" />
</span>
{canEdit && (
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
@ -324,9 +332,9 @@ function MeterReadingsSection({
setEditingReading(null);
}}
meterId={meterId}
contractId={contractId}
reading={editingReading}
defaultUnit={defaultUnit}
tariffModel={tariffModel}
/>
)}
</div>
@ -334,22 +342,135 @@ function MeterReadingsSection({
}
// Meter Reading Modal Component
function SuccessorMeterButton({
contractId,
customerId,
meterType,
existingMeterIds,
}: {
contractId: number;
customerId: number;
meterType: string;
existingMeterIds: number[];
}) {
const [showForm, setShowForm] = useState(false);
const [selectedMeterId, setSelectedMeterId] = useState('');
const [installedAt, setInstalledAt] = useState(new Date().toISOString().split('T')[0]);
const [finalReading, setFinalReading] = useState('');
const queryClient = useQueryClient();
const { data: metersData } = useQuery({
queryKey: ['customer-meters', customerId],
queryFn: () => meterApi.getByCustomer(customerId),
enabled: showForm,
});
const addMutation = useMutation({
mutationFn: (data: { meterId: number; installedAt?: string; finalReadingPrevious?: number }) =>
contractApi.addSuccessorMeter(contractId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract'] });
setShowForm(false);
setSelectedMeterId('');
setFinalReading('');
},
});
// Nur Zähler gleichen Typs anzeigen die noch nicht am Vertrag sind
const availableMeters = (metersData?.data || []).filter(
(m) => m.type === meterType && !existingMeterIds.includes(m.id) && m.isActive
);
if (!showForm) {
return (
<div className="mt-3 pt-3 border-t">
<Button variant="secondary" size="sm" onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4 mr-1" />
Folgezähler hinzufügen
</Button>
</div>
);
}
return (
<div className="mt-3 pt-3 border-t bg-blue-50 rounded-lg p-4">
<h4 className="text-sm font-medium mb-3">Folgezähler hinzufügen (Zählerwechsel)</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Neuer Zähler *</label>
<select
value={selectedMeterId}
onChange={(e) => setSelectedMeterId(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">Bitte wählen...</option>
{availableMeters.map((m) => (
<option key={m.id} value={m.id}>
{m.meterNumber} {m.location ? `(${m.location})` : ''}
</option>
))}
</select>
{availableMeters.length === 0 && (
<p className="text-xs text-amber-600 mt-1">Kein passender Zähler verfügbar. Bitte zuerst beim Kunden anlegen.</p>
)}
</div>
<Input
label="Wechseldatum"
type="date"
value={installedAt}
onChange={(e) => setInstalledAt(e.target.value)}
/>
<Input
label="Letzter Stand alter Zähler"
type="number"
step="0.01"
value={finalReading}
onChange={(e) => setFinalReading(e.target.value)}
placeholder="Optional"
/>
</div>
<div className="flex gap-2 mt-3">
<Button
size="sm"
onClick={() => addMutation.mutate({
meterId: parseInt(selectedMeterId),
installedAt,
finalReadingPrevious: finalReading ? parseFloat(finalReading) : undefined,
})}
disabled={!selectedMeterId || addMutation.isPending}
>
{addMutation.isPending ? 'Speichern...' : 'Hinzufügen'}
</Button>
<Button variant="secondary" size="sm" onClick={() => setShowForm(false)}>
Abbrechen
</Button>
</div>
{addMutation.isError && (
<p className="text-xs text-red-600 mt-2">
{addMutation.error instanceof Error ? addMutation.error.message : 'Fehler beim Hinzufügen'}
</p>
)}
</div>
);
}
function MeterReadingModal({
isOpen,
onClose,
meterId,
contractId,
reading,
defaultUnit,
tariffModel,
}: {
isOpen: boolean;
onClose: () => void;
meterId: number;
contractId: number;
reading?: MeterReading | null;
defaultUnit: string;
tariffModel?: string;
}) {
const queryClient = useQueryClient();
const isDualTariff = tariffModel === 'DUAL';
const isEditing = !!reading;
const [formData, setFormData] = useState({
@ -357,33 +478,47 @@ function MeterReadingModal({
? new Date(reading.readingDate).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0],
value: reading?.value?.toString() || '',
valueNt: reading?.valueNt?.toString() || '',
notes: reading?.notes || '',
});
const [error, setError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: (data: Partial<MeterReading>) => meterApi.addReading(meterId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
queryClient.invalidateQueries({ queryKey: ['contract'] });
setError(null);
onClose();
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Fehler beim Speichern');
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<MeterReading>) => meterApi.updateReading(meterId, reading!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
queryClient.invalidateQueries({ queryKey: ['contract'] });
setError(null);
onClose();
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Fehler beim Speichern');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data = {
const data: Record<string, unknown> = {
readingDate: new Date(formData.readingDate),
value: parseFloat(formData.value),
unit: defaultUnit,
notes: formData.notes || undefined,
};
if (isDualTariff && formData.valueNt) {
data.valueNt = parseFloat(formData.valueNt);
}
if (isEditing) {
updateMutation.mutate(data as unknown as Partial<MeterReading>);
} else {
@ -404,10 +539,10 @@ function MeterReadingModal({
required
/>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<div className={`grid ${isDualTariff ? 'grid-cols-2' : 'grid-cols-3'} gap-4`}>
<div className={isDualTariff ? '' : 'col-span-2'}>
<Input
label="Zählerstand"
label={isDualTariff ? 'HT-Stand (Hochtarif)' : 'Zählerstand'}
type="number"
step="0.01"
value={formData.value}
@ -415,12 +550,26 @@ function MeterReadingModal({
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Einheit</label>
<div className="h-10 flex items-center px-3 bg-gray-100 border border-gray-300 rounded-md text-gray-700">
{defaultUnit}
{isDualTariff && (
<div>
<Input
label="NT-Stand (Niedertarif)"
type="number"
step="0.01"
value={formData.valueNt}
onChange={(e) => setFormData({ ...formData, valueNt: e.target.value })}
required
/>
</div>
</div>
)}
{!isDualTariff && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Einheit</label>
<div className="h-10 flex items-center px-3 bg-gray-100 border border-gray-300 rounded-md text-gray-700">
{defaultUnit}
</div>
</div>
)}
</div>
<Input
@ -429,6 +578,12 @@ function MeterReadingModal({
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
/>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
@ -450,24 +605,67 @@ function EnergyConsumptionCalculation({
endDate,
basePrice,
unitPrice,
unitPriceNt,
bonus,
hasMeter,
contractMeters,
}: {
contractType: 'ELECTRICITY' | 'GAS';
readings: MeterReading[];
startDate: string;
endDate: string;
startDate?: string;
endDate?: string;
basePrice?: number;
unitPrice?: number;
bonus?: number;
unitPriceNt?: number;
hasMeter?: boolean;
contractMeters?: ContractMeter[];
}) {
// Berechnung durchführen
const consumption = calculateConsumption(readings, startDate, endDate, contractType);
const costs = consumption.consumptionKwh > 0
? calculateCosts(consumption.consumptionKwh, basePrice, unitPrice, bonus)
// Fehlende Felder ermitteln
const missingFields: string[] = [];
if (!startDate) missingFields.push('Vertragsbeginn');
if (!endDate) missingFields.push('Vertragsende');
if (!hasMeter) missingFields.push('Zähler verknüpft');
if (basePrice == null) missingFields.push('Grundpreis');
if (unitPrice == null) missingFields.push('Arbeitspreis');
const hasRequiredDates = !!startDate && !!endDate;
// Alle Readings über alle Zähler zählen
const allReadingsCount = contractMeters && contractMeters.length > 0
? contractMeters.reduce((sum, cm) => sum + (cm.meter?.readings?.length || 0), 0)
: readings.length;
if (hasRequiredDates && hasMeter && allReadingsCount < 2) {
missingFields.push(`Mindestens 2 Zählerstände im Vertragszeitraum (${allReadingsCount} vorhanden)`);
}
// Berechnung: Multi-Zähler wenn vorhanden, sonst Single
let consumption = null;
if (hasRequiredDates) {
if (contractMeters && contractMeters.length > 1) {
consumption = calculateMultiMeterConsumption(contractMeters, startDate!, endDate!, contractType);
} else {
consumption = calculateConsumption(readings, startDate!, endDate!, contractType);
}
}
const htKwh = consumption?.consumptionHt ?? consumption?.consumptionKwh ?? 0;
const ntKwh = consumption?.consumptionNt;
const costs = consumption && consumption.consumptionKwh > 0
? calculateCosts(htKwh, basePrice, unitPrice, bonus, ntKwh, unitPriceNt)
: null;
// Nichts anzeigen wenn keine Daten
if (consumption.type === 'none') return null;
const canCalculate = consumption && (consumption.type === 'exact' || consumption.type === 'projected');
// Wenn Berechnung fehlschlägt aber keine Felder fehlen → Hinweis aus der Berechnung anzeigen
if (!canCalculate && consumption && missingFields.length === 0) {
if (consumption.type === 'insufficient' && consumption.message) {
missingFields.push(consumption.message);
} else if (consumption.type === 'none') {
missingFields.push('Keine Zählerstände im Vertragszeitraum vorhanden');
}
}
const formatNumber = (num: number, decimals: number = 2) =>
num.toLocaleString('de-DE', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
@ -481,40 +679,61 @@ function EnergyConsumptionCalculation({
<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' && (
{canCalculate && consumption!.type === 'exact' && (
<Badge variant="success">Exakt</Badge>
)}
{consumption.type === 'projected' && (
{canCalculate && 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>
{/* Fehlende Daten */}
{!canCalculate ? (
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-3">
Für die Berechnung fehlen noch folgende Angaben:
</p>
<ul className="space-y-1.5">
{missingFields.map((field) => (
<li key={field} className="flex items-center gap-2 text-sm">
<Circle className="w-3 h-3 text-gray-300 flex-shrink-0" />
<span className="text-gray-500">{field}</span>
</li>
))}
</ul>
{consumption?.type === 'insufficient' && (
<p className="text-sm text-amber-600 mt-3 italic">{consumption.message}</p>
)}
</div>
) : (
<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)'}
{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="font-mono">{formatNumber(consumption!.consumptionM3 || 0)} m³</span>
<span className="text-gray-500 text-sm ml-2">
= {formatNumber(consumption.consumptionKwh)} kWh
= {formatNumber(consumption!.consumptionKwh)} kWh
</span>
</>
) : consumption!.consumptionHt !== undefined && consumption!.consumptionNt !== undefined ? (
<div className="space-y-1">
<div><span className="text-sm text-gray-500">HT:</span> <span className="font-mono">{formatNumber(consumption!.consumptionHt)} kWh</span></div>
<div><span className="text-sm text-gray-500">NT:</span> <span className="font-mono">{formatNumber(consumption!.consumptionNt)} kWh</span></div>
<div className="text-sm text-gray-500">Gesamt: {formatNumber(consumption!.consumptionKwh)} kWh</div>
</div>
) : (
<span className="font-mono">{formatNumber(consumption.consumptionKwh)} kWh</span>
<span className="font-mono">{formatNumber(consumption!.consumptionKwh)} kWh</span>
)}
</div>
{consumption.startReading && consumption.endReading && (
{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)}
Basierend auf Zählerständen vom {formatDate(consumption!.startReading.readingDate)} bis {formatDate(consumption!.endReading.readingDate)}
</p>
)}
</div>
@ -533,15 +752,24 @@ function EnergyConsumptionCalculation({
<span className="font-mono">{formatNumber(costs.annualBaseCost)} </span>
</div>
)}
{/* Arbeitspreis */}
{/* Arbeitspreis HT */}
{unitPrice != null && unitPrice > 0 && (
<div className="flex justify-between">
<span className="text-gray-600">
Arbeitspreis: {formatNumber(consumption.consumptionKwh)} kWh × {formatNumber(unitPrice, 4)}
{costs.annualConsumptionCostNt ? 'HT-Arbeitspreis' : 'Arbeitspreis'}: {formatNumber(htKwh)} kWh × {formatNumber(unitPrice, 4)}
</span>
<span className="font-mono">{formatNumber(costs.annualConsumptionCost)} </span>
</div>
)}
{/* Arbeitspreis NT */}
{costs.annualConsumptionCostNt != null && costs.annualConsumptionCostNt > 0 && unitPriceNt && (
<div className="flex justify-between">
<span className="text-gray-600">
NT-Arbeitspreis: {formatNumber(ntKwh || 0)} kWh × {formatNumber(unitPriceNt, 4)}
</span>
<span className="font-mono">{formatNumber(costs.annualConsumptionCostNt)} </span>
</div>
)}
{/* Trennlinie */}
<div className="border-t border-gray-300 pt-2">
<div className="flex justify-between font-medium">
@ -1212,7 +1440,7 @@ export default function ContractDetail() {
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const backTo = (location.state as any)?.from as string | undefined;
const currentPath = `/contracts/${id}`;
const { hasPermission, isCustomer, isCustomerPortal } = useAuth();
const contractId = parseInt(id!);
@ -1424,13 +1652,15 @@ export default function ContractDetail() {
}
const c = data.data;
const fallbackBack = isCustomerPortal ? '/contracts' : (c.customer ? `/customers/${c.customer.id}?tab=contracts` : '/contracts');
const back = popHistory(location.state, fallbackBack);
// Consent-Sperrung: Vertrag nicht anzeigen wenn Kunde keine Einwilligung hat
if (!hasConsentApproval) {
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" size="sm" onClick={() => navigate(backTo || (isCustomerPortal ? '/contracts' : (c.customer ? `/customers/${c.customer.id}?tab=contracts` : '/contracts')))}>
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
<ArrowLeft className="w-4 h-4" />
</Button>
<h1 className="text-2xl font-bold">Vertrag {c.contractNumber}</h1>
@ -1465,7 +1695,7 @@ export default function ContractDetail() {
<Button
variant="ghost"
size="sm"
onClick={() => navigate(backTo || (isCustomerPortal ? '/contracts' : (c.customer ? `/customers/${c.customer.id}?tab=contracts` : '/contracts')))}
onClick={() => navigate(back.to, { state: back.state })}
>
<ArrowLeft className="w-4 h-4" />
</Button>
@ -1499,7 +1729,7 @@ export default function ContractDetail() {
{c.customer && (
<p className="text-gray-500 ml-10">
Kunde:{' '}
<Link to={`/customers/${c.customer.id}`} state={{ from: `/contracts/${id}` }} className="text-blue-600 hover:underline">
<Link to={`/customers/${c.customer.id}`} state={pushHistory(currentPath, location.state)} className="text-blue-600 hover:underline">
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
</Link>
</p>
@ -1534,7 +1764,7 @@ export default function ContractDetail() {
</Link>
)}
{hasPermission('contracts:update') && (
<Link to={`/contracts/${id}/edit`} state={{ from: `/contracts/${id}` }}>
<Link to={`/contracts/${id}/edit`} state={pushHistory(currentPath, location.state)}>
<Button variant="secondary">
<Edit className="w-4 h-4 mr-2" />
Bearbeiten
@ -2201,7 +2431,12 @@ export default function ContractDetail() {
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
{c.energyDetails.meter && (
<div>
<dt className="text-sm text-gray-500">Zählernummer</dt>
<dt className="text-sm text-gray-500 flex items-center gap-1">
Zählernummer
{c.energyDetails.meter.tariffModel === 'DUAL' && (
<Badge variant="default">HT/NT</Badge>
)}
</dt>
<dd className="font-mono flex items-center gap-1">
{c.energyDetails.meter.meterNumber}
<CopyButton value={c.energyDetails.meter.meterNumber} />
@ -2242,12 +2477,22 @@ export default function ContractDetail() {
)}
{c.energyDetails.unitPrice != null && (
<div>
<dt className="text-sm text-gray-500">Arbeitspreis</dt>
<dt className="text-sm text-gray-500">
{c.energyDetails.unitPriceNt != null ? 'HT-Arbeitspreis' : 'Arbeitspreis'}
</dt>
<dd>
{c.energyDetails.unitPrice.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 10 })} /kWh
</dd>
</div>
)}
{c.energyDetails.unitPriceNt != null && (
<div>
<dt className="text-sm text-gray-500">NT-Arbeitspreis</dt>
<dd>
{c.energyDetails.unitPriceNt.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 10 })} /kWh
</dd>
</div>
)}
{c.energyDetails.bonus && (
<div>
<dt className="text-sm text-gray-500">Bonus</dt>
@ -2271,29 +2516,58 @@ export default function ContractDetail() {
)}
</dl>
{/* Zählerstände */}
{c.energyDetails.meter && (
{/* Zählerstände - Multi-Zähler Support */}
{c.energyDetails.contractMeters && c.energyDetails.contractMeters.length > 0 ? (
<>
{c.energyDetails.contractMeters.map((cm: ContractMeter, idx: number) => (
cm.meter && (
<MeterReadingsSection
key={cm.id}
meterId={cm.meter.id}
meterType={cm.meter.type}
tariffModel={cm.meter.tariffModel}
readings={cm.meter.readings || []}
canEdit={hasPermission('contracts:update') && !isCustomer}
label={c.energyDetails!.contractMeters!.length > 1
? `Zähler ${idx + 1}: ${cm.meter.meterNumber}${cm.removedAt ? ' (gewechselt)' : ' (aktuell)'}`
: undefined
}
/>
)
))}
{/* Folgezähler hinzufügen */}
{hasPermission('contracts:update') && !isCustomer && (
<SuccessorMeterButton
contractId={contractId}
customerId={c.customerId}
meterType={c.type as 'ELECTRICITY' | 'GAS'}
existingMeterIds={c.energyDetails.contractMeters.map((cm: ContractMeter) => cm.meterId)}
/>
)}
</>
) : c.energyDetails.meter ? (
<MeterReadingsSection
meterId={c.energyDetails.meter.id}
meterType={c.energyDetails.meter.type}
tariffModel={c.energyDetails.meter.tariffModel}
readings={c.energyDetails.meter.readings || []}
contractId={contractId}
canEdit={hasPermission('contracts:update') && !isCustomer}
/>
)}
) : null}
{/* 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}
/>
)}
<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}
unitPriceNt={c.energyDetails.unitPriceNt}
bonus={c.energyDetails.bonus}
hasMeter={!!c.energyDetails.meter}
contractMeters={c.energyDetails.contractMeters}
/>
{/* Rechnungen */}
<InvoicesSection

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams, useSearchParams, useLocation } from 'react-router-dom';
import { popHistory } from '../../utils/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { contractApi, customerApi, platformApi, cancellationPeriodApi, contractDurationApi, providerApi, contractCategoryApi } from '../../services/api';
@ -64,7 +65,7 @@ export default function ContractForm() {
const location = useLocation();
const queryClient = useQueryClient();
const isEdit = !!id;
const backTo = (location.state as any)?.from as string | undefined;
const back = popHistory(location.state, isEdit ? `/contracts/${id}` : '/contracts');
const preselectedCustomerId = searchParams.get('customerId');
@ -80,6 +81,7 @@ export default function ContractForm() {
const contractType = watch('type') as ContractType;
const customerId = watch('customerId');
const previousContractId = watch('previousContractId');
const selectedMeterId = watch('meterId');
// Fetch existing contract for edit
const { data: contract } = useQuery({
@ -276,6 +278,7 @@ export default function ContractForm() {
annualConsumptionKwh: c.energyDetails?.annualConsumptionKwh || '',
basePrice: c.energyDetails?.basePrice || '',
unitPrice: c.energyDetails?.unitPrice || '',
unitPriceNt: c.energyDetails?.unitPriceNt || '',
bonus: c.energyDetails?.bonus || '',
// Internet details
downloadSpeed: c.internetDetails?.downloadSpeed || '',
@ -512,6 +515,7 @@ export default function ContractForm() {
annualConsumptionKwh: data.annualConsumptionKwh ? parseFloat(data.annualConsumptionKwh) : null,
basePrice: data.basePrice ? parseFloat(data.basePrice) : null,
unitPrice: data.unitPrice ? parseFloat(data.unitPrice) : null,
unitPriceNt: data.unitPriceNt ? parseFloat(data.unitPriceNt) : null,
bonus: data.bonus ? parseFloat(data.bonus) : null,
};
}
@ -605,7 +609,8 @@ export default function ContractForm() {
const addresses = customer?.addresses || [];
const bankCards = customer?.bankCards?.filter((c) => c.isActive) || [];
const documents = customer?.identityDocuments?.filter((d) => d.isActive) || [];
const meters = customer?.meters?.filter((m) => m.isActive) || [];
const meters = customer?.meters || [];
const selectedMeter = meters.find(m => m.id.toString() === selectedMeterId);
const stressfreiEmails = customer?.stressfreiEmails?.filter((e: { isActive: boolean }) => e.isActive) || [];
const platforms = platformsData?.data || [];
const cancellationPeriods = cancellationPeriodsData?.data || [];
@ -658,7 +663,7 @@ export default function ContractForm() {
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" size="sm" onClick={() => navigate(backTo || (isEdit ? `/contracts/${id}` : '/contracts'))}>
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
<ArrowLeft className="w-4 h-4" />
</Button>
<h1 className="text-2xl font-bold">
@ -951,10 +956,10 @@ export default function ContractForm() {
label="Zähler"
{...register('meterId')}
options={meters
.filter((m) => m.type === contractType)
.filter((m) => m.type === contractType && (m.isActive || m.id.toString() === watch('meterId')))
.map((m) => ({
value: m.id,
label: `${m.meterNumber}${m.location ? ` (${m.location})` : ''}`,
label: `${m.meterNumber}${m.location ? ` (${m.location})` : ''}${!m.isActive ? ' (deaktiviert)' : ''}`,
}))}
/>
<Input
@ -975,11 +980,19 @@ export default function ContractForm() {
)}
<Input label="Grundpreis (€/Monat)" type="number" step="any" {...register('basePrice')} />
<Input
label="Arbeitspreis (€/kWh)"
label={selectedMeter?.tariffModel === 'DUAL' ? 'HT-Arbeitspreis (€/kWh)' : 'Arbeitspreis (€/kWh)'}
type="number"
step="any"
{...register('unitPrice')}
/>
{selectedMeter?.tariffModel === 'DUAL' && (
<Input
label="NT-Arbeitspreis (€/kWh)"
type="number"
step="any"
{...register('unitPriceNt')}
/>
)}
<Input label="Bonus (€)" type="number" step="0.01" {...register('bonus')} />
</div>
@ -1409,7 +1422,7 @@ export default function ContractForm() {
</Card>
<div className="flex justify-end gap-4">
<Button type="button" variant="secondary" onClick={() => navigate(backTo || (isEdit ? `/contracts/${id}` : '/contracts'))}>
<Button type="button" variant="secondary" onClick={() => navigate(back.to, { state: back.state })}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query';
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
import { pushHistory } from '../../utils/navigation';
import { contractApi, ContractTreeNode } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
@ -271,7 +272,7 @@ export default function ContractList() {
<div className="w-6" /> // Platzhalter für Ausrichtung
) : null}
<Link to={`/contracts/${contract.id}`} state={{ from: '/contracts' }} className="font-mono flex items-center gap-1 hover:text-blue-600 hover:underline">
<Link to={`/contracts/${contract.id}`} state={pushHistory('/contracts')} className="font-mono flex items-center gap-1 hover:text-blue-600 hover:underline">
{contract.contractNumber}
<CopyButton value={contract.contractNumber} />
</Link>
@ -456,7 +457,7 @@ export default function ContractList() {
{data.data.map((contract) => (
<tr key={contract.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-mono text-sm">
<Link to={`/contracts/${contract.id}`} state={{ from: '/contracts' }} className="text-blue-600 hover:underline">
<Link to={`/contracts/${contract.id}`} state={pushHistory('/contracts')} className="text-blue-600 hover:underline">
{contract.contractNumber}
</Link>
</td>
@ -465,7 +466,7 @@ export default function ContractList() {
{contract.customer && (
<Link
to={`/customers/${contract.customer.id}`}
state={{ from: '/contracts' }}
state={pushHistory('/contracts')}
className="text-blue-600 hover:underline"
>
{contract.customer.companyName ||
@ -505,7 +506,7 @@ export default function ContractList() {
<Eye className="w-4 h-4" />
</Button>
{hasPermission('contracts:update') && !isCustomer && (
<Link to={`/contracts/${contract.id}/edit`} state={{ from: '/contracts' }}>
<Link to={`/contracts/${contract.id}/edit`} state={pushHistory('/contracts')}>
<Button variant="ghost" size="sm">
<Edit className="w-4 h-4" />
</Button>

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { pushHistory, popHistory } from '../../utils/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, gdprApi, StressfreiEmail, ContractTreeNode } from '../../services/api';
import { EmailClientTab } from '../../components/email';
@ -22,7 +23,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
const queryClient = useQueryClient();
const { hasPermission, isCustomerPortal } = useAuth();
const location = useLocation();
const backTo = (location.state as any)?.from as string | undefined;
const back = popHistory(location.state, isCustomerPortal ? '/' : '/customers');
const [searchParams, setSearchParams] = useSearchParams();
const customerId = portalCustomerId || parseInt(id!);
const defaultTab = searchParams.get('tab') || 'addresses';
@ -204,7 +205,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
<div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate(backTo || (isCustomerPortal ? '/' : '/customers'))}>
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
@ -221,7 +222,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
</div>
<div className="flex gap-2">
{hasPermission('customers:update') && (
<Link to={`/customers/${id}/edit`} state={{ from: `/customers/${id}` }}>
<Link to={`/customers/${id}/edit`} state={pushHistory(location.pathname + location.search, (location as any).state)}>
<Button variant="secondary">
<Edit className="w-4 h-4 mr-2" />
Bearbeiten
@ -649,7 +650,7 @@ function AddressesTab({
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: addressApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
return (
@ -746,18 +747,18 @@ function BankCardsTab({
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<BankCard> }) =>
bankCardApi.update(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const deleteMutation = useMutation({
mutationFn: bankCardApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const handleDocumentUpload = async (cardId: number, file: File) => {
try {
await uploadApi.uploadBankCardDocument(cardId, file);
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
} catch (error) {
console.error('Upload fehlgeschlagen:', error);
alert('Upload fehlgeschlagen');
@ -768,7 +769,7 @@ function BankCardsTab({
if (!confirm('Dokument wirklich löschen?')) return;
try {
await uploadApi.deleteBankCardDocument(cardId);
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
} catch (error) {
console.error('Löschen fehlgeschlagen:', error);
alert('Löschen fehlgeschlagen');
@ -972,18 +973,18 @@ function DocumentsTab({
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<IdentityDocument> }) =>
documentApi.update(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const deleteMutation = useMutation({
mutationFn: documentApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const handleDocumentUpload = async (docId: number, file: File) => {
try {
await uploadApi.uploadIdentityDocument(docId, file);
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
} catch (error) {
console.error('Upload fehlgeschlagen:', error);
alert('Upload fehlgeschlagen');
@ -994,7 +995,7 @@ function DocumentsTab({
if (!confirm('Dokument wirklich löschen?')) return;
try {
await uploadApi.deleteIdentityDocument(docId);
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
} catch (error) {
console.error('Löschen fehlgeschlagen:', error);
alert('Löschen fehlgeschlagen');
@ -1204,26 +1205,34 @@ function MetersTab({
onAdd: () => void;
onEdit: (meter: Meter) => void;
}) {
const [showReadingModal, setShowReadingModal] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS' } | null>(null);
const [showReadingModal, setShowReadingModal] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS'; tariffModel?: string } | null>(null);
const [expandedMeter, setExpandedMeter] = useState<number | null>(null);
const [editingReading, setEditingReading] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS'; reading: any } | null>(null);
const [editingReading, setEditingReading] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS'; tariffModel?: string; reading: any } | null>(null);
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<Meter> }) =>
meterApi.update(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const [deleteError, setDeleteError] = useState<string | null>(null);
const deleteMutation = useMutation({
mutationFn: meterApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
setDeleteError(null);
},
onError: (err) => {
setDeleteError(err instanceof Error ? err.message : 'Fehler beim Löschen');
},
});
const deleteReadingMutation = useMutation({
mutationFn: ({ meterId, readingId }: { meterId: number; readingId: number }) =>
meterApi.deleteReading(meterId, readingId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const filtered = showInactive ? meters : meters.filter((m) => m.isActive);
@ -1273,6 +1282,9 @@ function MetersTab({
<Badge variant={meter.type === 'ELECTRICITY' ? 'warning' : 'info'}>
{meter.type === 'ELECTRICITY' ? 'Strom' : 'Gas'}
</Badge>
{meter.tariffModel === 'DUAL' && (
<Badge variant="default">HT/NT</Badge>
)}
{!meter.isActive && <Badge variant="danger">Inaktiv</Badge>}
</div>
{canEdit && (
@ -1281,7 +1293,7 @@ function MetersTab({
<Button
variant="ghost"
size="sm"
onClick={() => setShowReadingModal({ meterId: meter.id, meterType: meter.type })}
onClick={() => setShowReadingModal({ meterId: meter.id, meterType: meter.type, tariffModel: meter.tariffModel })}
title="Zählerstand hinzufügen"
>
<Plus className="w-4 h-4" />
@ -1371,14 +1383,17 @@ function MetersTab({
</span>
<div className="flex items-center gap-2">
<span className="font-mono flex items-center gap-1">
{reading.value.toLocaleString('de-DE')} {reading.unit}
<CopyButton value={reading.value.toString()} title="Nur Wert kopieren" />
<CopyButton value={`${reading.value.toLocaleString('de-DE')} ${reading.unit}`} title="Mit Einheit kopieren" />
{reading.valueNt !== undefined && reading.valueNt !== null ? (
<>HT: {reading.value.toLocaleString('de-DE')} / NT: {reading.valueNt.toLocaleString('de-DE')} {reading.unit}</>
) : (
<>{reading.value.toLocaleString('de-DE')} {reading.unit}</>
)}
<CopyButton value={reading.value.toString()} title="Wert kopieren" />
</span>
{canEdit && (
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
<button
onClick={() => setEditingReading({ meterId: meter.id, meterType: meter.type, reading })}
onClick={() => setEditingReading({ meterId: meter.id, meterType: meter.type, tariffModel: meter.tariffModel, reading })}
className="text-gray-400 hover:text-blue-600"
title="Bearbeiten"
>
@ -1417,6 +1432,7 @@ function MetersTab({
onClose={() => setShowReadingModal(null)}
meterId={showReadingModal.meterId}
meterType={showReadingModal.meterType}
tariffModel={showReadingModal.tariffModel as any}
customerId={customerId}
/>
)}
@ -1427,10 +1443,43 @@ function MetersTab({
onClose={() => setEditingReading(null)}
meterId={editingReading.meterId}
meterType={editingReading.meterType}
tariffModel={editingReading.tariffModel as any}
customerId={customerId}
reading={editingReading.reading}
/>
)}
{/* Fehler-Modal beim Löschen (z.B. Zähler noch an Vertrag) */}
{deleteError && (
<Modal isOpen={true} onClose={() => setDeleteError(null)} title="Zähler kann nicht gelöscht werden">
<p className="text-sm text-gray-600 mb-4">
Der Zähler ist noch folgenden Verträgen zugeordnet und kann daher nicht gelöscht werden:
</p>
<div className="space-y-2">
{deleteError.match(/[A-Z]+-[A-Z0-9]+/g)?.map((contractNumber) => (
<Link
key={contractNumber}
to={`/contracts?search=${contractNumber}`}
onClick={() => setDeleteError(null)}
className="flex items-center gap-2 p-3 bg-gray-50 border rounded-lg text-blue-600 hover:bg-blue-50 hover:border-blue-300 transition-colors"
>
<FileText className="w-4 h-4" />
<span className="font-mono">{contractNumber}</span>
</Link>
)) ?? (
<p className="text-sm text-red-600">{deleteError}</p>
)}
</div>
<p className="text-xs text-gray-500 mt-4">
Bitte entfernen Sie den Zähler zuerst aus den oben genannten Verträgen.
</p>
<div className="flex justify-end mt-4">
<Button variant="secondary" onClick={() => setDeleteError(null)}>
Schließen
</Button>
</div>
</Modal>
)}
</div>
);
}
@ -1457,7 +1506,7 @@ function ContractsTab({
const deleteMutation = useMutation({
mutationFn: contractApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['customers'] });
queryClient.invalidateQueries({ queryKey: ['contracts'] });
queryClient.invalidateQueries({ queryKey: ['contract-tree', customerId] });
@ -1549,7 +1598,7 @@ function ContractsTab({
<div className="w-6" /> // Platzhalter für Ausrichtung
) : null}
<Link to={`/contracts/${contract.id}`} state={{ from: `/customers/${customerId}?tab=contracts` }} className="font-mono flex items-center gap-1 text-blue-600 hover:underline">
<Link to={`/contracts/${contract.id}`} state={pushHistory(location.pathname + location.search, (location as any).state)} className="font-mono flex items-center gap-1 text-blue-600 hover:underline">
{contract.contractNumber}
<CopyButton value={contract.contractNumber} />
</Link>
@ -2054,7 +2103,7 @@ function AddressModal({
const createMutation = useMutation({
mutationFn: (data: typeof formData) => addressApi.create(customerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
setFormData({
type: 'DELIVERY_RESIDENCE',
@ -2071,7 +2120,7 @@ function AddressModal({
const updateMutation = useMutation({
mutationFn: (data: typeof formData) => addressApi.update(address!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
},
});
@ -2201,7 +2250,7 @@ function BankCardModal({
const createMutation = useMutation({
mutationFn: (data: any) => bankCardApi.create(customerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
setFormData({ accountHolder: '', iban: '', bic: '', bankName: '', expiryDate: '', isActive: true });
},
@ -2210,7 +2259,7 @@ function BankCardModal({
const updateMutation = useMutation({
mutationFn: (data: any) => bankCardApi.update(bankCard!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
},
});
@ -2333,7 +2382,7 @@ function DocumentModal({
const createMutation = useMutation({
mutationFn: (data: any) => documentApi.create(customerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
setFormData({
type: 'ID_CARD',
@ -2351,7 +2400,7 @@ function DocumentModal({
const updateMutation = useMutation({
mutationFn: (data: any) => documentApi.update(document!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
},
});
@ -2498,6 +2547,7 @@ function MeterModal({
const getInitialFormData = () => ({
meterNumber: meter?.meterNumber || '',
type: meter?.type || 'ELECTRICITY' as const,
tariffModel: meter?.tariffModel || 'SINGLE' as const,
location: meter?.location || '',
isActive: meter?.isActive ?? true,
});
@ -2507,16 +2557,16 @@ function MeterModal({
const createMutation = useMutation({
mutationFn: (data: any) => meterApi.create(customerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
setFormData({ meterNumber: '', type: 'ELECTRICITY', location: '', isActive: true });
setFormData({ meterNumber: '', type: 'ELECTRICITY', tariffModel: 'SINGLE', location: '', isActive: true });
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => meterApi.update(meter!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
},
});
@ -2557,6 +2607,18 @@ function MeterModal({
]}
/>
{formData.type === 'ELECTRICITY' && (
<Select
label="Tarifmodell"
value={formData.tariffModel}
onChange={(e) => setFormData({ ...formData, tariffModel: e.target.value as any })}
options={[
{ value: 'SINGLE', label: 'Eintarifzähler (Standard)' },
{ value: 'DUAL', label: 'Zweitarifzähler (HT/NT)' },
]}
/>
)}
<Input
label="Standort"
value={formData.location}
@ -2594,6 +2656,7 @@ function MeterReadingModal({
onClose,
meterId,
meterType,
tariffModel,
customerId,
reading,
}: {
@ -2601,47 +2664,63 @@ function MeterReadingModal({
onClose: () => void;
meterId: number;
meterType: 'ELECTRICITY' | 'GAS';
tariffModel?: 'SINGLE' | 'DUAL';
customerId: number;
reading?: { id: number; readingDate: string; value: number; unit: string; notes?: string } | null;
reading?: { id: number; readingDate: string; value: number; valueNt?: number; unit: string; notes?: string } | null;
}) {
const queryClient = useQueryClient();
const isEditing = !!reading;
const defaultUnit = meterType === 'ELECTRICITY' ? 'kWh' : 'm³';
const isDualTariff = tariffModel === 'DUAL';
const getInitialFormData = () => ({
readingDate: reading?.readingDate
? new Date(reading.readingDate).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0],
value: reading?.value?.toString() || '',
valueNt: reading?.valueNt?.toString() || '',
notes: reading?.notes || '',
});
const [formData, setFormData] = useState(getInitialFormData);
const [error, setError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: (data: any) => meterApi.addReading(meterId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
setError(null);
onClose();
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Fehler beim Speichern');
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => meterApi.updateReading(meterId, reading!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
setError(null);
onClose();
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Fehler beim Speichern');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data = {
const data: Record<string, unknown> = {
readingDate: new Date(formData.readingDate),
value: parseFloat(formData.value),
unit: defaultUnit,
notes: formData.notes || undefined,
};
if (isDualTariff && formData.valueNt) {
data.valueNt = parseFloat(formData.valueNt);
}
if (isEditing) {
updateMutation.mutate(data);
} else {
@ -2667,10 +2746,10 @@ function MeterReadingModal({
required
/>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<div className={`grid ${isDualTariff ? 'grid-cols-2' : 'grid-cols-3'} gap-4`}>
<div className={isDualTariff ? '' : 'col-span-2'}>
<Input
label="Zählerstand"
label={isDualTariff ? 'HT-Stand (Hochtarif)' : 'Zählerstand'}
type="number"
step="0.01"
value={formData.value}
@ -2678,12 +2757,26 @@ function MeterReadingModal({
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Einheit</label>
<div className="h-10 flex items-center px-3 bg-gray-100 border border-gray-300 rounded-md text-gray-700">
{defaultUnit}
{isDualTariff && (
<div>
<Input
label="NT-Stand (Niedertarif)"
type="number"
step="0.01"
value={formData.valueNt}
onChange={(e) => setFormData({ ...formData, valueNt: e.target.value })}
required
/>
</div>
</div>
)}
{!isDualTariff && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Einheit</label>
<div className="h-10 flex items-center px-3 bg-gray-100 border border-gray-300 rounded-md text-gray-700">
{defaultUnit}
</div>
</div>
)}
</div>
<Input
@ -2693,6 +2786,12 @@ function MeterReadingModal({
placeholder="Optionale Notizen..."
/>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
@ -2732,12 +2831,12 @@ function StressfreiEmailsTab({
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<StressfreiEmail> }) =>
stressfreiEmailApi.update(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const deleteMutation = useMutation({
mutationFn: stressfreiEmailApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const filtered = showInactive ? emails : emails.filter((e) => e.isActive);
@ -3087,7 +3186,7 @@ function StressfreiEmailModal({
const result = await stressfreiEmailApi.enableMailbox(email.id);
if (result.success) {
setMailboxEnabled(true);
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
} else {
setProvisionError(result.error || 'Mailbox-Aktivierung fehlgeschlagen');
@ -3108,7 +3207,7 @@ function StressfreiEmailModal({
setMailboxEnabled(result.data.hasMailbox);
if (result.data.wasUpdated) {
// DB wurde aktualisiert, Query invalidieren
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
}
}
} catch (error) {
@ -3199,7 +3298,7 @@ function StressfreiEmailModal({
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
setLocalPart('');
setNotes('');
@ -3216,7 +3315,7 @@ function StressfreiEmailModal({
mutationFn: (data: Partial<StressfreiEmail>) =>
stressfreiEmailApi.update(email!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
},
});
@ -3664,6 +3763,7 @@ function ConsentTab({
try {
await uploadApi.uploadPrivacyPolicy(customerId, file);
onUpdate?.();
queryClient.invalidateQueries({ queryKey: ['customer-consents', customerId] });
} catch (error) {
console.error('Upload fehlgeschlagen:', error);
alert('Upload fehlgeschlagen');
@ -3675,6 +3775,7 @@ function ConsentTab({
try {
await uploadApi.deletePrivacyPolicy(customerId);
onUpdate?.();
queryClient.invalidateQueries({ queryKey: ['customer-consents', customerId] });
} catch (error) {
console.error('Löschen fehlgeschlagen:', error);
alert('Löschen fehlgeschlagen');

View File

@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { popHistory } from '../../utils/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { customerApi } from '../../services/api';
@ -17,7 +18,7 @@ export default function CustomerForm() {
const location = useLocation();
const queryClient = useQueryClient();
const isEdit = !!id;
const backTo = (location.state as any)?.from as string | undefined;
const back = popHistory(location.state, isEdit ? `/customers/${id}` : '/customers');
const { register, handleSubmit, reset, watch, setValue, formState: { errors } } = useForm<CustomerFormData>();
const customerType = watch('type');
@ -234,7 +235,7 @@ export default function CustomerForm() {
</Card>
<div className="flex justify-end gap-4">
<Button type="button" variant="secondary" onClick={() => navigate(backTo || (isEdit ? `/customers/${id}` : '/customers'))}>
<Button type="button" variant="secondary" onClick={() => navigate(back.to, { state: back.state })}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>

View File

@ -1,6 +1,7 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { pushHistory } from '../../utils/navigation';
import { customerApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
@ -78,12 +79,12 @@ export default function CustomerList() {
{data.data.map((customer) => (
<tr key={customer.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-mono text-sm">
<Link to={`/customers/${customer.id}`} state={{ from: '/customers' }} className="text-blue-600 hover:underline">
<Link to={`/customers/${customer.id}`} state={pushHistory('/customers')} className="text-blue-600 hover:underline">
{customer.customerNumber}
</Link>
</td>
<td className="py-3 px-4">
<Link to={`/customers/${customer.id}`} state={{ from: '/customers' }} className="text-blue-600 hover:underline">
<Link to={`/customers/${customer.id}`} state={pushHistory('/customers')} className="text-blue-600 hover:underline">
{customer.type === 'BUSINESS' && customer.companyName
? customer.companyName
: `${customer.firstName} ${customer.lastName}`}

View File

@ -36,10 +36,15 @@ const RESOURCE_OPTIONS = [
{ value: 'Contract', label: 'Verträge' },
{ value: 'User', label: 'Benutzer' },
{ value: 'BankCard', label: 'Bankdaten' },
{ value: 'IdentityDocument', label: 'Ausweisdokumente' },
{ value: 'Authentication', label: 'Authentifizierung' },
{ value: 'IdentityDocument', label: 'Ausweise' },
{ value: 'Address', label: 'Adressen' },
{ value: 'Meter', label: 'Zähler' },
{ value: 'ContractTask', label: 'Aufgaben' },
{ value: 'Authentication', label: 'Anmeldung' },
{ value: 'CustomerConsent', label: 'Einwilligungen' },
{ value: 'GDPR', label: 'DSGVO' },
{ value: 'GDPR', label: 'DSGVO / Vollmacht' },
{ value: 'EmailProviderConfig', label: 'E-Mail-Provider' },
{ value: 'AppSetting', label: 'Einstellungen' },
];
function formatDate(date: string): string {
@ -354,11 +359,12 @@ export default function AuditLogs() {
</span>
</td>
<td className="py-3 px-4">
<div>{log.resourceType}</div>
{log.resourceLabel && (
<div className="text-xs text-gray-500 truncate max-w-[200px]" title={log.resourceLabel}>
{log.resourceLabel ? (
<div className="text-sm truncate max-w-[300px]" title={log.resourceLabel}>
{log.resourceLabel}
</div>
) : (
<div className="text-sm text-gray-500">{log.resourceType}</div>
)}
</td>
<td className="py-3 px-4 font-mono text-xs">{log.ipAddress}</td>

View File

@ -655,6 +655,15 @@ export const contractApi = {
const res = await api.get<ApiResponse<import('../types').CockpitResult>>('/contracts/cockpit');
return res.data;
},
// Folgezähler
addSuccessorMeter: async (contractId: number, data: { meterId: number; installedAt?: string; finalReadingPrevious?: number }) => {
const res = await api.post<ApiResponse<any>>(`/contracts/${contractId}/successor-meter`, data);
return res.data;
},
removeContractMeter: async (contractId: number, contractMeterId: number) => {
const res = await api.delete<ApiResponse<void>>(`/contracts/${contractId}/contract-meter/${contractMeterId}`);
return res.data;
},
// Snooze: Vertrag zurückstellen
snooze: async (id: number, data: { nextReviewDate?: string; months?: number }) => {
const res = await api.patch<ApiResponse<{ id: number; contractNumber: string; nextReviewDate: string | null }>>(`/contracts/${id}/snooze`, data);

View File

@ -152,16 +152,30 @@ export interface IdentityDocument {
licenseIssueDate?: string;
}
export type MeterTariffModel = 'SINGLE' | 'DUAL';
export interface Meter {
id: number;
customerId: number;
meterNumber: string;
type: 'ELECTRICITY' | 'GAS';
tariffModel: MeterTariffModel;
location?: string;
isActive: boolean;
readings?: MeterReading[];
}
export interface ContractMeter {
id: number;
energyContractDetailsId: number;
meterId: number;
meter?: Meter;
position: number;
installedAt?: string;
removedAt?: string;
finalReading?: number;
}
export type MeterReadingStatus = 'RECORDED' | 'REPORTED' | 'TRANSFERRED';
export interface MeterReading {
@ -169,6 +183,7 @@ export interface MeterReading {
meterId: number;
readingDate: string;
value: number;
valueNt?: number; // NT-Wert (nur bei Zweitarifzähler)
unit: string;
notes?: string;
reportedBy?: string;
@ -396,11 +411,13 @@ export interface EnergyContractDetails {
annualConsumption?: number; // kWh für Strom, m³ für Gas
annualConsumptionKwh?: number; // kWh für Gas (zusätzlich zu m³)
basePrice?: number; // €/Monat
unitPrice?: number; // €/kWh (Arbeitspreis)
unitPrice?: number; // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis
unitPriceNt?: number; // €/kWh NT-Preis (nur bei Zweitarifzähler)
bonus?: number;
previousProviderName?: string;
previousCustomerNumber?: string;
invoices?: Invoice[]; // Rechnungen
contractMeters?: ContractMeter[]; // Zähler-Zuordnungen (inkl. Folgezähler)
}
export interface InternetContractDetails {
@ -575,6 +592,7 @@ export interface ReportedMeterReading {
createdAt: string;
meter: { id: number; meterNumber: string; type: string };
customer: { id: number; customerNumber: string; name: string };
contract?: { id: number; contractNumber: string };
providerPortal?: {
providerName: string;
portalUrl: string;

View File

@ -1,4 +1,4 @@
import type { MeterReading } from '../types';
import type { MeterReading, ContractMeter } from '../types';
// Konstante für Gas → kWh Umrechnung (Standard Erdgas H)
export const GAS_TO_KWH_FACTOR = 10.5;
@ -8,6 +8,8 @@ export interface ConsumptionCalculation {
type: 'exact' | 'projected' | 'insufficient' | 'none';
consumptionM3?: number; // Nur bei Gas
consumptionKwh: number;
consumptionHt?: number; // Nur bei HT/NT: HT-Verbrauch in kWh
consumptionNt?: number; // Nur bei HT/NT: NT-Verbrauch in kWh
startReading?: MeterReading;
endReading?: MeterReading;
projectedEndDate?: string; // Nur bei Hochrechnung
@ -17,7 +19,8 @@ export interface ConsumptionCalculation {
// Ergebnis der Kostenberechnung
export interface CostCalculation {
annualBaseCost: number; // basePrice × 12
annualConsumptionCost: number; // verbrauch × unitPrice
annualConsumptionCost: number; // verbrauch × unitPrice (HT bei Zweitarif)
annualConsumptionCostNt?: number; // NT-Verbrauch × unitPriceNt
annualTotalCost: number; // Summe
monthlyPayment: number; // annualTotalCost / 12
bonus?: number;
@ -94,7 +97,6 @@ export function calculateConsumption(
(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
@ -103,16 +105,39 @@ export function calculateConsumption(
lastReadingDate.setHours(0, 0, 0, 0);
contractEndDate.setHours(0, 0, 0, 0);
// Fall A: Exakter Verbrauch (Endzählerstand am oder nach Vertragsende)
// Verbrauch zwischen aufeinanderfolgenden Ständen berechnen
// (Erkennt Zählerwechsel: wenn ein Wert sinkt, wird ab dem neuen Stand weitergerechnet)
let totalConsumption = 0;
let totalConsumptionNt = 0;
const hasNt = sorted.some(r => r.valueNt !== undefined && r.valueNt !== null);
let effectiveFirstReading = sorted[0];
for (let i = 1; i < sorted.length; i++) {
const diff = sorted[i].value - sorted[i - 1].value;
if (diff < 0) {
totalConsumption = 0;
totalConsumptionNt = 0;
effectiveFirstReading = sorted[i];
} else {
totalConsumption += diff;
if (hasNt && sorted[i].valueNt != null && sorted[i - 1].valueNt != null) {
const diffNt = sorted[i].valueNt! - sorted[i - 1].valueNt!;
if (diffNt >= 0) totalConsumptionNt += diffNt;
}
}
}
const effectiveLastReading = sorted[sorted.length - 1];
// Fall A: Exakter Verbrauch
if (lastReadingDate >= contractEndDate) {
const consumption = lastReading.value - firstReading.value;
return formatConsumptionResult('exact', consumption, contractType, firstReading, lastReading);
const result = formatConsumptionResult('exact', totalConsumption, contractType, effectiveFirstReading, effectiveLastReading);
if (hasNt) { result.consumptionHt = totalConsumption; result.consumptionNt = totalConsumptionNt; }
return result;
}
// Fall B: Hochrechnung erforderlich
const daysBetweenReadings = daysDiff(firstReading.readingDate, lastReading.readingDate);
const daysBetweenReadings = daysDiff(effectiveFirstReading.readingDate, effectiveLastReading.readingDate);
// Mindestens 1 Tag zwischen den Messungen für sinnvolle Hochrechnung
if (daysBetweenReadings < 1) {
return {
type: 'insufficient',
@ -122,17 +147,26 @@ export function calculateConsumption(
}
const totalContractDays = daysDiff(startDate, endDate);
const measuredConsumption = lastReading.value - firstReading.value;
const projectedConsumption = (measuredConsumption / daysBetweenReadings) * totalContractDays;
const projectedConsumption = (totalConsumption / daysBetweenReadings) * totalContractDays;
return formatConsumptionResult(
const result = formatConsumptionResult(
'projected',
projectedConsumption,
contractType,
firstReading,
lastReading,
effectiveFirstReading,
effectiveLastReading,
endDate
);
if (hasNt) {
const projectedNt = (totalConsumptionNt / daysBetweenReadings) * totalContractDays;
result.consumptionHt = projectedConsumption;
result.consumptionNt = projectedNt;
// Gesamt-kWh = HT + NT
result.consumptionKwh = projectedConsumption + projectedNt;
}
return result;
}
/**
@ -173,7 +207,9 @@ export function calculateCosts(
consumptionKwh: number,
basePrice?: number,
unitPrice?: number,
bonus?: number
bonus?: number,
consumptionNtKwh?: number,
unitPriceNt?: number
): CostCalculation | null {
// Mindestens ein Preis muss vorhanden sein
if (basePrice == null && unitPrice == null) {
@ -181,17 +217,78 @@ export function calculateCosts(
}
const annualBaseCost = (basePrice ?? 0) * 12;
// Bei HT/NT: consumptionKwh ist nur HT, NT wird separat berechnet
const annualConsumptionCost = consumptionKwh * (unitPrice ?? 0);
const annualTotalCost = annualBaseCost + annualConsumptionCost;
const annualConsumptionCostNt = (consumptionNtKwh ?? 0) * (unitPriceNt ?? 0);
const annualTotalCost = annualBaseCost + annualConsumptionCost + annualConsumptionCostNt;
const effectiveAnnualCost = annualTotalCost - (bonus ?? 0);
const monthlyPayment = effectiveAnnualCost / 12;
return {
annualBaseCost,
annualConsumptionCost,
annualConsumptionCostNt: annualConsumptionCostNt > 0 ? annualConsumptionCostNt : undefined,
annualTotalCost,
monthlyPayment,
bonus: bonus ?? undefined,
effectiveAnnualCost,
};
}
/**
* Berechnet den Verbrauch über mehrere Zähler (Folgezähler).
* Pro Zähler wird der Verbrauch einzeln berechnet und dann summiert.
*/
export function calculateMultiMeterConsumption(
contractMeters: ContractMeter[],
startDate: string,
endDate: string,
contractType: 'ELECTRICITY' | 'GAS'
): ConsumptionCalculation {
if (contractMeters.length === 0) {
return { type: 'none', consumptionKwh: 0 };
}
let totalConsumption = 0;
let totalConsumptionM3 = 0;
let hasExact = true;
let hasAny = false;
let firstStart: MeterReading | undefined;
let lastEnd: MeterReading | undefined;
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;
const result = calculateConsumption(readings, meterStart, meterEnd, contractType);
if (result.type === 'none' || result.type === 'insufficient') continue;
hasAny = true;
if (result.type === 'projected') hasExact = false;
totalConsumption += result.consumptionKwh;
if (result.consumptionM3) totalConsumptionM3 += result.consumptionM3;
if (!firstStart && result.startReading) firstStart = result.startReading;
if (result.endReading) lastEnd = result.endReading;
}
if (!hasAny) {
// Fallback: Einzelzähler-Berechnung mit allen Readings
const allReadings = contractMeters.flatMap(cm => cm.meter?.readings || []);
return calculateConsumption(allReadings, startDate, endDate, contractType);
}
return {
type: hasExact ? 'exact' : 'projected',
consumptionKwh: totalConsumption,
consumptionM3: contractType === 'GAS' ? totalConsumptionM3 : undefined,
startReading: firstStart,
endReading: lastEnd,
};
}

View File

@ -0,0 +1,26 @@
/**
* Navigation-History über location.state.
* Jeder Link fügt die aktuelle URL zum Stack hinzu.
* Der Zurück-Button poppt den letzten Eintrag und gibt den Rest weiter.
*/
export interface NavState {
history?: string[];
}
/**
* Erstellt den state für einen Link - fügt die aktuelle URL zum History-Stack hinzu.
*/
export function pushHistory(currentPath: string, locationState?: unknown): NavState {
const prev = (locationState as NavState)?.history || [];
return { history: [...prev, currentPath] };
}
/**
* Gibt die Zurück-URL und den verbleibenden History-Stack zurück.
*/
export function popHistory(locationState?: unknown, fallback?: string): { to: string; state: NavState } {
const history = [...((locationState as NavState)?.history || [])];
const to = history.pop() || fallback || '/';
return { to, state: { history } };
}