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 getInternetCredentials(req: Request, res: Response): Promise<void>;
export declare function getSipCredentials(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 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>; export declare function snoozeContract(req: Request, res: Response): Promise<void>;
//# sourceMappingURL=contract.controller.d.ts.map //# 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.getInternetCredentials = getInternetCredentials;
exports.getSipCredentials = getSipCredentials; exports.getSipCredentials = getSipCredentials;
exports.getCockpit = getCockpit; exports.getCockpit = getCockpit;
exports.addSuccessorMeter = addSuccessorMeter;
exports.removeContractMeter = removeContractMeter;
exports.snoozeContract = snoozeContract; exports.snoozeContract = snoozeContract;
const client_1 = require("@prisma/client"); const client_1 = require("@prisma/client");
const contractService = __importStar(require("../services/contract.service.js")); 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) ==================== // ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ====================
async function snoozeContract(req, res) { async function snoozeContract(req, res) {
try { 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) { async function addMeterReading(req, res) {
try { 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 }); res.status(201).json({ success: true, data: reading });
} }
catch (error) { catch (error) {
@ -363,7 +370,19 @@ async function addMeterReading(req, res) {
} }
async function updateMeterReading(req, res) { async function updateMeterReading(req, res) {
try { 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 }); res.json({ success: true, data: reading });
} }
catch (error) { catch (error) {
@ -404,15 +423,18 @@ async function reportMeterReading(req, res) {
res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Zähler' }); res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Zähler' });
return; return;
} }
const reading = await prisma.meterReading.create({ const parsedDate = readingDate ? new Date(readingDate) : new Date();
data: { const parsedValue = parseFloat(value);
meterId, // Validierung über den Service (monoton steigend)
value: parseFloat(value), const reading = await customerService.addMeterReading(meterId, {
readingDate: readingDate ? new Date(readingDate) : new Date(), readingDate: parsedDate,
notes, value: parsedValue,
reportedBy: user.email, notes,
status: 'REPORTED', });
}, // 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 }); 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); router.post('/:id/follow-up', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:create'), contractController.createFollowUp);
// Snooze (Vertrag zurückstellen) // Snooze (Vertrag zurückstellen)
router.patch('/:id/snooze', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:update'), contractController.snoozeContract); 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 // Get decrypted password
router.get('/:id/password', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:read'), contractController.getContractPassword); router.get('/:id/password', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:read'), contractController.getContractPassword);
// Get decrypted SimCard PIN/PUK // 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 }, where: { id: customerId },
data: { privacyPolicyPath: relativePath }, 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({ res.json({
success: true, success: true,
data: { data: {
@ -376,6 +385,11 @@ router.delete('/customers/:id/privacy-policy', auth_js_1.authenticate, (0, auth_
where: { id: customerId }, where: { id: customerId },
data: { privacyPolicyPath: null }, 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 }); res.json({ success: true });
} }
catch (error) { 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, previousProvider: true,
previousContract: { previousContract: {
include: { 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 } }, internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } }, mobileDetails: { include: { simCards: true } },
tvDetails: true, tvDetails: true,
carInsuranceDetails: 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 } }, internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } }, mobileDetails: { include: { simCards: true } },
tvDetails: true, tvDetails: true,
@ -264,11 +264,41 @@ async function updateContract(id, data) {
}); });
// Update type-specific details // Update type-specific details
if (energyDetails) { if (energyDetails) {
const existingEcd = await prisma.energyContractDetails.findUnique({
where: { contractId: id },
select: { id: true, meterId: true },
});
await prisma.energyContractDetails.upsert({ await prisma.energyContractDetails.upsert({
where: { contractId: id }, where: { contractId: id },
update: energyDetails, update: energyDetails,
create: { contractId: id, ...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) { if (internetDetails) {
const { phoneNumbers, internetPassword, ...internetData } = 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; customerNumber: string;
name: string; name: string;
}; };
contract?: {
id: number;
contractNumber: string;
};
providerPortal?: { providerPortal?: {
providerName: string; providerName: string;
portalUrl: 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: { contract: {
select: { select: {
id: true, id: true,
contractNumber: true,
portalUsername: true, portalUsername: true,
provider: { provider: {
select: { id: true, name: true, portalUrl: true }, select: { id: true, name: true, portalUrl: true },
@ -717,6 +718,10 @@ async function getReportedMeterReadings() {
customerNumber: r.meter.customer.customerNumber, customerNumber: r.meter.customer.customerNumber,
name: `${r.meter.customer.firstName} ${r.meter.customer.lastName}`, name: `${r.meter.customer.firstName} ${r.meter.customer.lastName}`,
}, },
contract: contract ? {
id: contract.id,
contractNumber: contract.contractNumber,
} : undefined,
providerPortal: provider?.portalUrl ? { providerPortal: provider?.portalUrl ? {
providerName: provider.name, providerName: provider.name,
portalUrl: provider.portalUrl, 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; customerNumber: string;
companyName: string | null; companyName: string | null;
}; };
tariff: { contractNumber: string;
id: number; providerName: string | null;
name: string; tariffName: string | null;
} | null;
provider: { provider: {
id: number; id: number;
name: string; name: string;
} | null; } | null;
tariffName: string | null; tariff: {
providerName: string | null; id: number;
contractNumber: string; name: string;
} | null;
}; };
subtasks: { subtasks: {
id: number; id: number;

View File

@ -107,13 +107,14 @@ export declare function getCustomerById(id: number): Promise<({
createdAt: Date; createdAt: Date;
notes: string | null; notes: string | null;
readingDate: Date; readingDate: Date;
meterId: number;
status: import(".prisma/client").$Enums.MeterReadingStatus;
value: number; value: number;
valueNt: number | null;
unit: string; unit: string;
reportedBy: string | null; reportedBy: string | null;
status: import(".prisma/client").$Enums.MeterReadingStatus;
transferredAt: Date | null; transferredAt: Date | null;
transferredBy: string | null; transferredBy: string | null;
meterId: number;
}[]; }[];
} & { } & {
id: number; id: number;
@ -123,6 +124,7 @@ export declare function getCustomerById(id: number): Promise<({
updatedAt: Date; updatedAt: Date;
type: import(".prisma/client").$Enums.MeterType; type: import(".prisma/client").$Enums.MeterType;
meterNumber: string; meterNumber: string;
tariffModel: import(".prisma/client").$Enums.MeterTariffModel;
location: string | null; location: string | null;
})[]; })[];
stressfreiEmails: { stressfreiEmails: {
@ -171,11 +173,8 @@ export declare function getCustomerById(id: number): Promise<({
notes: string | null; notes: string | null;
portalPasswordEncrypted: string | null; portalPasswordEncrypted: string | null;
startDate: Date | null; startDate: Date | null;
status: import(".prisma/client").$Enums.ContractStatus;
customerNumberAtProvider: string | null;
tariffName: string | null;
providerName: string | null;
contractNumber: string; contractNumber: string;
status: import(".prisma/client").$Enums.ContractStatus;
contractCategoryId: number | null; contractCategoryId: number | null;
addressId: number | null; addressId: number | null;
billingAddressId: number | null; billingAddressId: number | null;
@ -190,6 +189,9 @@ export declare function getCustomerById(id: number): Promise<({
previousContractNumber: string | null; previousContractNumber: string | null;
providerId: number | null; providerId: number | null;
tariffId: number | null; tariffId: number | null;
providerName: string | null;
tariffName: string | null;
customerNumberAtProvider: string | null;
contractNumberAtProvider: string | null; contractNumberAtProvider: string | null;
priceFirst12Months: string | null; priceFirst12Months: string | null;
priceFrom13Months: string | null; priceFrom13Months: string | null;
@ -573,13 +575,14 @@ export declare function getCustomerMeters(customerId: number, showInactive?: boo
createdAt: Date; createdAt: Date;
notes: string | null; notes: string | null;
readingDate: Date; readingDate: Date;
meterId: number;
status: import(".prisma/client").$Enums.MeterReadingStatus;
value: number; value: number;
valueNt: number | null;
unit: string; unit: string;
reportedBy: string | null; reportedBy: string | null;
status: import(".prisma/client").$Enums.MeterReadingStatus;
transferredAt: Date | null; transferredAt: Date | null;
transferredBy: string | null; transferredBy: string | null;
meterId: number;
}[]; }[];
} & { } & {
id: number; id: number;
@ -589,6 +592,7 @@ export declare function getCustomerMeters(customerId: number, showInactive?: boo
updatedAt: Date; updatedAt: Date;
type: import(".prisma/client").$Enums.MeterType; type: import(".prisma/client").$Enums.MeterType;
meterNumber: string; meterNumber: string;
tariffModel: import(".prisma/client").$Enums.MeterTariffModel;
location: string | null; location: string | null;
})[]>; })[]>;
export declare function createMeter(customerId: number, data: { export declare function createMeter(customerId: number, data: {
@ -603,6 +607,7 @@ export declare function createMeter(customerId: number, data: {
updatedAt: Date; updatedAt: Date;
type: import(".prisma/client").$Enums.MeterType; type: import(".prisma/client").$Enums.MeterType;
meterNumber: string; meterNumber: string;
tariffModel: import(".prisma/client").$Enums.MeterTariffModel;
location: string | null; location: string | null;
}>; }>;
export declare function updateMeter(id: number, data: { export declare function updateMeter(id: number, data: {
@ -618,6 +623,7 @@ export declare function updateMeter(id: number, data: {
updatedAt: Date; updatedAt: Date;
type: import(".prisma/client").$Enums.MeterType; type: import(".prisma/client").$Enums.MeterType;
meterNumber: string; meterNumber: string;
tariffModel: import(".prisma/client").$Enums.MeterTariffModel;
location: string | null; location: string | null;
}>; }>;
export declare function deleteMeter(id: number): Promise<{ export declare function deleteMeter(id: number): Promise<{
@ -628,11 +634,13 @@ export declare function deleteMeter(id: number): Promise<{
updatedAt: Date; updatedAt: Date;
type: import(".prisma/client").$Enums.MeterType; type: import(".prisma/client").$Enums.MeterType;
meterNumber: string; meterNumber: string;
tariffModel: import(".prisma/client").$Enums.MeterTariffModel;
location: string | null; location: string | null;
}>; }>;
export declare function addMeterReading(meterId: number, data: { export declare function addMeterReading(meterId: number, data: {
readingDate: Date; readingDate: Date;
value: number; value: number;
valueNt?: number;
unit?: string; unit?: string;
notes?: string; notes?: string;
}): Promise<{ }): Promise<{
@ -640,30 +648,33 @@ export declare function addMeterReading(meterId: number, data: {
createdAt: Date; createdAt: Date;
notes: string | null; notes: string | null;
readingDate: Date; readingDate: Date;
meterId: number;
status: import(".prisma/client").$Enums.MeterReadingStatus;
value: number; value: number;
valueNt: number | null;
unit: string; unit: string;
reportedBy: string | null; reportedBy: string | null;
status: import(".prisma/client").$Enums.MeterReadingStatus;
transferredAt: Date | null; transferredAt: Date | null;
transferredBy: string | null; transferredBy: string | null;
meterId: number;
}>; }>;
export declare function getMeterReadings(meterId: number): Promise<{ export declare function getMeterReadings(meterId: number): Promise<{
id: number; id: number;
createdAt: Date; createdAt: Date;
notes: string | null; notes: string | null;
readingDate: Date; readingDate: Date;
meterId: number;
status: import(".prisma/client").$Enums.MeterReadingStatus;
value: number; value: number;
valueNt: number | null;
unit: string; unit: string;
reportedBy: string | null; reportedBy: string | null;
status: import(".prisma/client").$Enums.MeterReadingStatus;
transferredAt: Date | null; transferredAt: Date | null;
transferredBy: string | null; transferredBy: string | null;
meterId: number;
}[]>; }[]>;
export declare function updateMeterReading(meterId: number, readingId: number, data: { export declare function updateMeterReading(meterId: number, readingId: number, data: {
readingDate?: Date; readingDate?: Date;
value?: number; value?: number;
valueNt?: number | null;
unit?: string; unit?: string;
notes?: string; notes?: string;
}): Promise<{ }): Promise<{
@ -671,26 +682,28 @@ export declare function updateMeterReading(meterId: number, readingId: number, d
createdAt: Date; createdAt: Date;
notes: string | null; notes: string | null;
readingDate: Date; readingDate: Date;
meterId: number;
status: import(".prisma/client").$Enums.MeterReadingStatus;
value: number; value: number;
valueNt: number | null;
unit: string; unit: string;
reportedBy: string | null; reportedBy: string | null;
status: import(".prisma/client").$Enums.MeterReadingStatus;
transferredAt: Date | null; transferredAt: Date | null;
transferredBy: string | null; transferredBy: string | null;
meterId: number;
}>; }>;
export declare function deleteMeterReading(meterId: number, readingId: number): Promise<{ export declare function deleteMeterReading(meterId: number, readingId: number): Promise<{
id: number; id: number;
createdAt: Date; createdAt: Date;
notes: string | null; notes: string | null;
readingDate: Date; readingDate: Date;
meterId: number;
status: import(".prisma/client").$Enums.MeterReadingStatus;
value: number; value: number;
valueNt: number | null;
unit: string; unit: string;
reportedBy: string | null; reportedBy: string | null;
status: import(".prisma/client").$Enums.MeterReadingStatus;
transferredAt: Date | null; transferredAt: Date | null;
transferredBy: string | null; transferredBy: string | null;
meterId: number;
}>; }>;
export declare function updatePortalSettings(customerId: number, data: { export declare function updatePortalSettings(customerId: number, data: {
portalEnabled?: boolean; 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) { 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 } }); return prisma.meter.delete({ where: { id } });
} }
async function addMeterReading(meterId, data) { 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({ return prisma.meterReading.create({
data: { data: {
meterId, 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) { if (!reading) {
throw new Error('Zählerstand nicht gefunden'); 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({ return prisma.meterReading.update({
where: { id: readingId }, where: { id: readingId },
data, 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) { async function deleteMeterReading(meterId, readingId) {
// Verify the reading belongs to the meter // Verify the reading belongs to the meter
const reading = await prisma.meterReading.findFirst({ 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', customerId: 'customerId',
meterNumber: 'meterNumber', meterNumber: 'meterNumber',
type: 'type', type: 'type',
tariffModel: 'tariffModel',
location: 'location', location: 'location',
isActive: 'isActive', isActive: 'isActive',
createdAt: 'createdAt', createdAt: 'createdAt',
@ -372,6 +373,7 @@ exports.Prisma.MeterReadingScalarFieldEnum = {
meterId: 'meterId', meterId: 'meterId',
readingDate: 'readingDate', readingDate: 'readingDate',
value: 'value', value: 'value',
valueNt: 'valueNt',
unit: 'unit', unit: 'unit',
notes: 'notes', notes: 'notes',
reportedBy: 'reportedBy', reportedBy: 'reportedBy',
@ -529,11 +531,23 @@ exports.Prisma.EnergyContractDetailsScalarFieldEnum = {
annualConsumptionKwh: 'annualConsumptionKwh', annualConsumptionKwh: 'annualConsumptionKwh',
basePrice: 'basePrice', basePrice: 'basePrice',
unitPrice: 'unitPrice', unitPrice: 'unitPrice',
unitPriceNt: 'unitPriceNt',
bonus: 'bonus', bonus: 'bonus',
previousProviderName: 'previousProviderName', previousProviderName: 'previousProviderName',
previousCustomerNumber: 'previousCustomerNumber' previousCustomerNumber: 'previousCustomerNumber'
}; };
exports.Prisma.ContractMeterScalarFieldEnum = {
id: 'id',
energyContractDetailsId: 'energyContractDetailsId',
meterId: 'meterId',
position: 'position',
installedAt: 'installedAt',
removedAt: 'removedAt',
finalReading: 'finalReading',
createdAt: 'createdAt'
};
exports.Prisma.InvoiceScalarFieldEnum = { exports.Prisma.InvoiceScalarFieldEnum = {
id: 'id', id: 'id',
energyContractDetailsId: 'energyContractDetailsId', energyContractDetailsId: 'energyContractDetailsId',
@ -742,6 +756,11 @@ exports.MeterType = exports.$Enums.MeterType = {
GAS: 'GAS' GAS: 'GAS'
}; };
exports.MeterTariffModel = exports.$Enums.MeterTariffModel = {
SINGLE: 'SINGLE',
DUAL: 'DUAL'
};
exports.MeterReadingStatus = exports.$Enums.MeterReadingStatus = { exports.MeterReadingStatus = exports.$Enums.MeterReadingStatus = {
RECORDED: 'RECORDED', RECORDED: 'RECORDED',
REPORTED: 'REPORTED', REPORTED: 'REPORTED',
@ -855,6 +874,7 @@ exports.Prisma.ModelName = {
ContractTask: 'ContractTask', ContractTask: 'ContractTask',
ContractTaskSubtask: 'ContractTaskSubtask', ContractTaskSubtask: 'ContractTaskSubtask',
EnergyContractDetails: 'EnergyContractDetails', EnergyContractDetails: 'EnergyContractDetails',
ContractMeter: 'ContractMeter',
Invoice: 'Invoice', Invoice: 'Invoice',
InternetContractDetails: 'InternetContractDetails', InternetContractDetails: 'InternetContractDetails',
PhoneNumber: 'PhoneNumber', 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", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"browser": "index-browser.js", "browser": "index-browser.js",

View File

@ -411,18 +411,25 @@ enum MeterType {
GAS GAS
} }
enum MeterTariffModel {
SINGLE // Eintarifzähler (Standard)
DUAL // Zweitarifzähler (HT/NT)
}
model Meter { model Meter {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
customerId Int customerId Int
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
meterNumber String meterNumber String
type MeterType type MeterType
location String? tariffModel MeterTariffModel @default(SINGLE) // Eintarif oder Zweitarif (HT/NT)
isActive Boolean @default(true) location String?
readings MeterReading[] isActive Boolean @default(true)
energyDetails EnergyContractDetails[] readings MeterReading[]
createdAt DateTime @default(now()) energyDetails EnergyContractDetails[]
updatedAt DateTime @updatedAt contractMeters ContractMeter[] @relation("ContractMeters")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model MeterReading { model MeterReading {
@ -430,7 +437,8 @@ model MeterReading {
meterId Int meterId Int
meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade) meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade)
readingDate DateTime 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") unit String @default("kWh")
notes String? notes String?
// Meldung & Übertragung // Meldung & Übertragung
@ -709,20 +717,38 @@ enum InvoiceType {
} }
model EnergyContractDetails { model EnergyContractDetails {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
contractId Int @unique contractId Int @unique
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade) contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
meterId Int? meterId Int?
meter Meter? @relation(fields: [meterId], references: [id]) meter Meter? @relation(fields: [meterId], references: [id])
maloId String? // Marktlokations-ID maloId String? // Marktlokations-ID
annualConsumption Float? // kWh für Strom, m³ für Gas annualConsumption Float? // kWh für Strom, m³ für Gas
annualConsumptionKwh Float? // kWh für Gas (zusätzlich zu m³) annualConsumptionKwh Float? // kWh für Gas (zusätzlich zu m³)
basePrice Float? // €/Monat 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? bonus Float?
previousProviderName String? previousProviderName String?
previousCustomerNumber String? previousCustomerNumber String?
invoices Invoice[] // Rechnungen 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 { model Invoice {

View File

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

View File

@ -411,17 +411,24 @@ enum MeterType {
GAS GAS
} }
enum MeterTariffModel {
SINGLE // Eintarifzähler (Standard)
DUAL // Zweitarifzähler (HT/NT)
}
model Meter { model Meter {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
customerId Int customerId Int
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
meterNumber String meterNumber String
type MeterType type MeterType
tariffModel MeterTariffModel @default(SINGLE) // Eintarif oder Zweitarif (HT/NT)
location String? location String?
isActive Boolean @default(true) isActive Boolean @default(true)
readings MeterReading[] readings MeterReading[]
energyDetails EnergyContractDetails[] energyDetails EnergyContractDetails[]
createdAt DateTime @default(now()) contractMeters ContractMeter[] @relation("ContractMeters")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@ -430,7 +437,8 @@ model MeterReading {
meterId Int meterId Int
meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade) meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade)
readingDate DateTime 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") unit String @default("kWh")
notes String? notes String?
// Meldung & Übertragung // Meldung & Übertragung
@ -718,11 +726,29 @@ model EnergyContractDetails {
annualConsumption Float? // kWh für Strom, m³ für Gas annualConsumption Float? // kWh für Strom, m³ für Gas
annualConsumptionKwh Float? // kWh für Gas (zusätzlich zu m³) annualConsumptionKwh Float? // kWh für Gas (zusätzlich zu m³)
basePrice Float? // €/Monat 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? bonus Float?
previousProviderName String? previousProviderName String?
previousCustomerNumber String? previousCustomerNumber String?
invoices Invoice[] // Rechnungen 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 { 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) ==================== // ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ====================
export async function snoozeContract(req: Request, res: Response): Promise<void> { 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> { export async function addMeterReading(req: Request, res: Response): Promise<void> {
try { 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); res.status(201).json({ success: true, data: reading } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ 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> { export async function updateMeterReading(req: Request, res: Response): Promise<void> {
try { 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( const reading = await customerService.updateMeterReading(
parseInt(req.params.meterId), parseInt(req.params.meterId),
parseInt(req.params.readingId), parseInt(req.params.readingId),
req.body updateData as any
); );
res.json({ success: true, data: reading } as ApiResponse); res.json({ success: true, data: reading } as ApiResponse);
} catch (error) { } catch (error) {
@ -358,15 +373,20 @@ export async function reportMeterReading(req: AuthRequest, res: Response): Promi
return; return;
} }
const reading = await prisma.meterReading.create({ const parsedDate = readingDate ? new Date(readingDate) : new Date();
data: { const parsedValue = parseFloat(value);
meterId,
value: parseFloat(value), // Validierung über den Service (monoton steigend)
readingDate: readingDate ? new Date(readingDate) : new Date(), const reading = await customerService.addMeterReading(meterId, {
notes, readingDate: parsedDate,
reportedBy: user.email, value: parsedValue,
status: 'REPORTED', 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); 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/contract-durations': { type: 'ContractDuration', extractId: (req) => req.params.id },
'/api/settings': { type: 'AppSetting', extractId: (req) => req.params.key }, '/api/settings': { type: 'AppSetting', extractId: (req) => req.params.key },
'/api/email-providers': { type: 'EmailProviderConfig', extractId: (req) => req.params.id }, '/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/auth': { type: 'Authentication' },
'/api/audit-logs': { type: 'AuditLog', extractId: (req) => req.params.id }, '/api/audit-logs': { type: 'AuditLog', extractId: (req) => req.params.id },
'/api/gdpr': { type: 'GDPR' }, '/api/gdpr': { type: 'GDPR' },
@ -116,6 +119,240 @@ function getClientIp(req: AuthRequest): string {
return req.socket.remoteAddress || 'unknown'; 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 * 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) // Audit-Kontext abrufen (enthält Before/After-Werte von Prisma Middleware)
const auditContext = getAuditContext(); const auditContext = getAuditContext();
// Label für bessere Lesbarkeit generieren // Menschenlesbares Label generieren
let resourceLabel: string | undefined; const resourceLabel = generateHumanLabel(action, mapping.type, req, responseBody);
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);
}
}
await createAuditLog({ await createAuditLog({
userId: req.user?.userId, userId: req.user?.userId,

View File

@ -20,6 +20,10 @@ router.post('/:id/follow-up', authenticate, requirePermission('contracts:create'
// Snooze (Vertrag zurückstellen) // Snooze (Vertrag zurückstellen)
router.patch('/:id/snooze', authenticate, requirePermission('contracts:update'), contractController.snoozeContract); 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 // Get decrypted password
router.get('/:id/password', authenticate, requirePermission('contracts:read'), contractController.getContractPassword); router.get('/:id/password', authenticate, requirePermission('contracts:read'), contractController.getContractPassword);

View File

@ -440,6 +440,16 @@ router.post(
data: { privacyPolicyPath: relativePath }, 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({ res.json({
success: true, success: true,
data: { data: {
@ -488,6 +498,12 @@ router.delete(
data: { privacyPolicyPath: null }, 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 }); res.json({ success: true });
} catch (error) { } catch (error) {
console.error('Delete error:', error); console.error('Delete error:', error);

View File

@ -146,13 +146,35 @@ export async function updateAuthorizationDocument(
* Vollmacht-Dokument löschen * Vollmacht-Dokument löschen
*/ */
export async function deleteAuthorizationDocument(customerId: number, representativeId: number) { 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({ return prisma.representativeAuthorization.update({
where: { where: { customerId_representativeId: { customerId, representativeId } },
customerId_representativeId: { customerId, representativeId }, data: withdrawData,
},
data: {
documentPath: null,
},
}); });
} }

View File

@ -125,14 +125,14 @@ export async function getContractById(id: number, decryptPassword = false) {
previousProvider: true, previousProvider: true,
previousContract: { previousContract: {
include: { 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 } }, internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } }, mobileDetails: { include: { simCards: true } },
tvDetails: true, tvDetails: true,
carInsuranceDetails: 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 } }, internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } }, mobileDetails: { include: { simCards: true } },
tvDetails: true, tvDetails: true,
@ -403,11 +403,45 @@ export async function updateContract(
// Update type-specific details // Update type-specific details
if (energyDetails) { if (energyDetails) {
const existingEcd = await prisma.energyContractDetails.findUnique({
where: { contractId: id },
select: { id: true, meterId: true },
});
await prisma.energyContractDetails.upsert({ await prisma.energyContractDetails.upsert({
where: { contractId: id }, where: { contractId: id },
update: energyDetails, update: energyDetails,
create: { contractId: id, ...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) { if (internetDetails) {

View File

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

View File

@ -443,6 +443,30 @@ export async function updateMeter(
} }
export async function deleteMeter(id: number) { 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 } }); return prisma.meter.delete({ where: { id } });
} }
@ -451,14 +475,25 @@ export async function addMeterReading(
data: { data: {
readingDate: Date; readingDate: Date;
value: number; value: number;
valueNt?: number;
unit?: string; unit?: string;
notes?: 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({ return prisma.meterReading.create({
data: { data: {
meterId, 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: { data: {
readingDate?: Date; readingDate?: Date;
value?: number; value?: number;
valueNt?: number | null;
unit?: string; unit?: string;
notes?: string; notes?: string;
} }
@ -489,12 +525,65 @@ export async function updateMeterReading(
throw new Error('Zählerstand nicht gefunden'); 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({ return prisma.meterReading.update({
where: { id: readingId }, where: { id: readingId },
data, 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) { export async function deleteMeterReading(meterId: number, readingId: number) {
// Verify the reading belongs to the meter // Verify the reading belongs to the meter
const reading = await prisma.meterReading.findFirst({ 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 { useState, useMemo, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { pushHistory } from '../../utils/navigation';
import { contractApi, meterApi } from '../../services/api'; import { contractApi, meterApi } from '../../services/api';
import Card from '../../components/ui/Card'; import Card from '../../components/ui/Card';
import Badge from '../../components/ui/Badge'; import Badge from '../../components/ui/Badge';
@ -282,7 +283,7 @@ export default function ContractCockpit() {
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<Link <Link
to={`/contracts/${contract.id}`} to={`/contracts/${contract.id}`}
state={{ from: '/contracts/cockpit' }} state={pushHistory('/contracts/cockpit')}
className="font-medium hover:underline" className="font-medium hover:underline"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@ -399,7 +400,7 @@ export default function ContractCockpit() {
<Link <Link
to={`/contracts/${contract.id}`} to={`/contracts/${contract.id}`}
state={{ from: '/contracts/cockpit' }} state={pushHistory('/contracts/cockpit')}
className="p-2 hover:bg-white hover:bg-opacity-50 rounded" className="p-2 hover:bg-white hover:bg-opacity-50 rounded"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
title="Zum Vertrag" title="Zum Vertrag"
@ -610,6 +611,15 @@ export default function ContractCockpit() {
{reading.customer.name} {reading.customer.name}
</Link> </Link>
<span className="text-xs text-gray-500">({reading.customer.customerNumber})</span> <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> </div>
<p className="text-xs text-gray-600"> <p className="text-xs text-gray-600">
Zähler {reading.meter.meterNumber} <strong>{reading.value} {reading.unit}</strong> am{' '} Zähler {reading.meter.meterNumber} <strong>{reading.value} {reading.unit}</strong> am{' '}
@ -635,6 +645,7 @@ export default function ContractCockpit() {
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={async () => { 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); await meterApi.markTransferred(reading.meter.id, reading.id);
queryClient.invalidateQueries({ queryKey: ['contract-cockpit'] }); queryClient.invalidateQueries({ queryKey: ['contract-cockpit'] });
}} }}

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate, useLocation } from 'react-router-dom'; import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
import { pushHistory, popHistory } from '../../utils/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdprApi } from '../../services/api'; import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdprApi } from '../../services/api';
import { ContractEmailsSection } from '../../components/email'; import { ContractEmailsSection } from '../../components/email';
@ -13,9 +14,9 @@ import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal'; import Modal from '../../components/ui/Modal';
import FileUpload from '../../components/ui/FileUpload'; import FileUpload from '../../components/ui/FileUpload';
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff, Lock, Shield } from 'lucide-react'; 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 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> = { const typeLabels: Record<ContractType, string> = {
ELECTRICITY: 'Strom', ELECTRICITY: 'Strom',
@ -197,16 +198,19 @@ function SimCardDisplay({ simCard }: { simCard: SimCard }) {
function MeterReadingsSection({ function MeterReadingsSection({
meterId, meterId,
meterType, meterType,
tariffModel,
readings, readings,
contractId,
canEdit, canEdit,
label,
}: { }: {
meterId: number; meterId: number;
meterType: 'ELECTRICITY' | 'GAS'; meterType: 'ELECTRICITY' | 'GAS';
tariffModel?: string;
readings: MeterReading[]; readings: MeterReading[];
contractId: number;
canEdit: boolean; canEdit: boolean;
label?: string;
}) { }) {
const isDualTariff = tariffModel === 'DUAL';
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [showAddModal, setShowAddModal] = useState(false); const [showAddModal, setShowAddModal] = useState(false);
const [editingReading, setEditingReading] = useState<MeterReading | null>(null); const [editingReading, setEditingReading] = useState<MeterReading | null>(null);
@ -215,7 +219,7 @@ function MeterReadingsSection({
const deleteReadingMutation = useMutation({ const deleteReadingMutation = useMutation({
mutationFn: (readingId: number) => meterApi.deleteReading(meterId, readingId), mutationFn: (readingId: number) => meterApi.deleteReading(meterId, readingId),
onSuccess: () => { 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 justify-between mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Gauge className="w-4 h-4 text-gray-500" /> <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> <Badge variant="default">{readings.length}</Badge>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -274,8 +278,12 @@ function MeterReadingsSection({
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono flex items-center gap-1"> <span className="font-mono flex items-center gap-1">
{reading.value.toLocaleString('de-DE')} {reading.unit} {isDualTariff && reading.valueNt != null ? (
<CopyButton value={reading.value.toString()} title="Nur Wert kopieren" /> <>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> </span>
{canEdit && ( {canEdit && (
<div className="opacity-0 group-hover:opacity-100 flex gap-1"> <div className="opacity-0 group-hover:opacity-100 flex gap-1">
@ -324,9 +332,9 @@ function MeterReadingsSection({
setEditingReading(null); setEditingReading(null);
}} }}
meterId={meterId} meterId={meterId}
contractId={contractId}
reading={editingReading} reading={editingReading}
defaultUnit={defaultUnit} defaultUnit={defaultUnit}
tariffModel={tariffModel}
/> />
)} )}
</div> </div>
@ -334,22 +342,135 @@ function MeterReadingsSection({
} }
// Meter Reading Modal Component // 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({ function MeterReadingModal({
isOpen, isOpen,
onClose, onClose,
meterId, meterId,
contractId,
reading, reading,
defaultUnit, defaultUnit,
tariffModel,
}: { }: {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
meterId: number; meterId: number;
contractId: number;
reading?: MeterReading | null; reading?: MeterReading | null;
defaultUnit: string; defaultUnit: string;
tariffModel?: string;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isDualTariff = tariffModel === 'DUAL';
const isEditing = !!reading; const isEditing = !!reading;
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@ -357,33 +478,47 @@ function MeterReadingModal({
? new Date(reading.readingDate).toISOString().split('T')[0] ? new Date(reading.readingDate).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0], : new Date().toISOString().split('T')[0],
value: reading?.value?.toString() || '', value: reading?.value?.toString() || '',
valueNt: reading?.valueNt?.toString() || '',
notes: reading?.notes || '', notes: reading?.notes || '',
}); });
const [error, setError] = useState<string | null>(null);
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: Partial<MeterReading>) => meterApi.addReading(meterId, data), mutationFn: (data: Partial<MeterReading>) => meterApi.addReading(meterId, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] }); queryClient.invalidateQueries({ queryKey: ['contract'] });
setError(null);
onClose(); onClose();
}, },
onError: (err) => {
setError(err instanceof Error ? err.message : 'Fehler beim Speichern');
},
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: (data: Partial<MeterReading>) => meterApi.updateReading(meterId, reading!.id, data), mutationFn: (data: Partial<MeterReading>) => meterApi.updateReading(meterId, reading!.id, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] }); queryClient.invalidateQueries({ queryKey: ['contract'] });
setError(null);
onClose(); onClose();
}, },
onError: (err) => {
setError(err instanceof Error ? err.message : 'Fehler beim Speichern');
},
}); });
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const data = { const data: Record<string, unknown> = {
readingDate: new Date(formData.readingDate), readingDate: new Date(formData.readingDate),
value: parseFloat(formData.value), value: parseFloat(formData.value),
unit: defaultUnit, unit: defaultUnit,
notes: formData.notes || undefined, notes: formData.notes || undefined,
}; };
if (isDualTariff && formData.valueNt) {
data.valueNt = parseFloat(formData.valueNt);
}
if (isEditing) { if (isEditing) {
updateMutation.mutate(data as unknown as Partial<MeterReading>); updateMutation.mutate(data as unknown as Partial<MeterReading>);
} else { } else {
@ -404,10 +539,10 @@ function MeterReadingModal({
required required
/> />
<div className="grid grid-cols-3 gap-4"> <div className={`grid ${isDualTariff ? 'grid-cols-2' : 'grid-cols-3'} gap-4`}>
<div className="col-span-2"> <div className={isDualTariff ? '' : 'col-span-2'}>
<Input <Input
label="Zählerstand" label={isDualTariff ? 'HT-Stand (Hochtarif)' : 'Zählerstand'}
type="number" type="number"
step="0.01" step="0.01"
value={formData.value} value={formData.value}
@ -415,12 +550,26 @@ function MeterReadingModal({
required required
/> />
</div> </div>
<div> {isDualTariff && (
<label className="block text-sm font-medium text-gray-700 mb-1">Einheit</label> <div>
<div className="h-10 flex items-center px-3 bg-gray-100 border border-gray-300 rounded-md text-gray-700"> <Input
{defaultUnit} label="NT-Stand (Niedertarif)"
type="number"
step="0.01"
value={formData.valueNt}
onChange={(e) => setFormData({ ...formData, valueNt: e.target.value })}
required
/>
</div> </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> </div>
<Input <Input
@ -429,6 +578,12 @@ function MeterReadingModal({
onChange={(e) => setFormData({ ...formData, notes: e.target.value })} 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"> <div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="secondary" onClick={onClose}> <Button type="button" variant="secondary" onClick={onClose}>
Abbrechen Abbrechen
@ -450,24 +605,67 @@ function EnergyConsumptionCalculation({
endDate, endDate,
basePrice, basePrice,
unitPrice, unitPrice,
unitPriceNt,
bonus, bonus,
hasMeter,
contractMeters,
}: { }: {
contractType: 'ELECTRICITY' | 'GAS'; contractType: 'ELECTRICITY' | 'GAS';
readings: MeterReading[]; readings: MeterReading[];
startDate: string; startDate?: string;
endDate: string; endDate?: string;
basePrice?: number; basePrice?: number;
unitPrice?: number; unitPrice?: number;
bonus?: number; bonus?: number;
unitPriceNt?: number;
hasMeter?: boolean;
contractMeters?: ContractMeter[];
}) { }) {
// Berechnung durchführen // Fehlende Felder ermitteln
const consumption = calculateConsumption(readings, startDate, endDate, contractType); const missingFields: string[] = [];
const costs = consumption.consumptionKwh > 0 if (!startDate) missingFields.push('Vertragsbeginn');
? calculateCosts(consumption.consumptionKwh, basePrice, unitPrice, bonus) 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; : null;
// Nichts anzeigen wenn keine Daten const canCalculate = consumption && (consumption.type === 'exact' || consumption.type === 'projected');
if (consumption.type === 'none') return null;
// 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) => const formatNumber = (num: number, decimals: number = 2) =>
num.toLocaleString('de-DE', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); num.toLocaleString('de-DE', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
@ -481,40 +679,61 @@ function EnergyConsumptionCalculation({
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Calculator className="w-4 h-4 text-gray-500" /> <Calculator className="w-4 h-4 text-gray-500" />
<h4 className="text-sm font-medium text-gray-700">Verbrauch & Kosten</h4> <h4 className="text-sm font-medium text-gray-700">Verbrauch & Kosten</h4>
{consumption.type === 'exact' && ( {canCalculate && consumption!.type === 'exact' && (
<Badge variant="success">Exakt</Badge> <Badge variant="success">Exakt</Badge>
)} )}
{consumption.type === 'projected' && ( {canCalculate && consumption!.type === 'projected' && (
<Badge variant="warning">Hochrechnung</Badge> <Badge variant="warning">Hochrechnung</Badge>
)} )}
</div> </div>
{/* Fall C: Unzureichende Daten */} {/* Fehlende Daten */}
{consumption.type === 'insufficient' ? ( {!canCalculate ? (
<p className="text-sm text-gray-500 italic">{consumption.message}</p> <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"> <div className="bg-gray-50 rounded-lg p-4 space-y-4">
{/* Verbrauchsanzeige */} {/* Verbrauchsanzeige */}
<div> <div>
<h5 className="text-sm font-medium text-gray-600 mb-2"> <h5 className="text-sm font-medium text-gray-600 mb-2">
Berechneter Verbrauch Berechneter Verbrauch
{consumption.type === 'projected' && ' (hochgerechnet)'} {consumption!.type === 'projected' && ' (hochgerechnet)'}
</h5> </h5>
<div className="text-lg font-semibold text-gray-900"> <div className="text-lg font-semibold text-gray-900">
{contractType === 'GAS' ? ( {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"> <span className="text-gray-500 text-sm ml-2">
= {formatNumber(consumption.consumptionKwh)} kWh = {formatNumber(consumption!.consumptionKwh)} kWh
</span> </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> </div>
{consumption.startReading && consumption.endReading && ( {consumption!.startReading && consumption!.endReading && (
<p className="text-xs text-gray-400 mt-1"> <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> </p>
)} )}
</div> </div>
@ -533,15 +752,24 @@ function EnergyConsumptionCalculation({
<span className="font-mono">{formatNumber(costs.annualBaseCost)} </span> <span className="font-mono">{formatNumber(costs.annualBaseCost)} </span>
</div> </div>
)} )}
{/* Arbeitspreis */} {/* Arbeitspreis HT */}
{unitPrice != null && unitPrice > 0 && ( {unitPrice != null && unitPrice > 0 && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600"> <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>
<span className="font-mono">{formatNumber(costs.annualConsumptionCost)} </span> <span className="font-mono">{formatNumber(costs.annualConsumptionCost)} </span>
</div> </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 */} {/* Trennlinie */}
<div className="border-t border-gray-300 pt-2"> <div className="border-t border-gray-300 pt-2">
<div className="flex justify-between font-medium"> <div className="flex justify-between font-medium">
@ -1212,7 +1440,7 @@ export default function ContractDetail() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const backTo = (location.state as any)?.from as string | undefined; const currentPath = `/contracts/${id}`;
const { hasPermission, isCustomer, isCustomerPortal } = useAuth(); const { hasPermission, isCustomer, isCustomerPortal } = useAuth();
const contractId = parseInt(id!); const contractId = parseInt(id!);
@ -1424,13 +1652,15 @@ export default function ContractDetail() {
} }
const c = data.data; 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 // Consent-Sperrung: Vertrag nicht anzeigen wenn Kunde keine Einwilligung hat
if (!hasConsentApproval) { if (!hasConsentApproval) {
return ( return (
<div> <div>
<div className="flex items-center gap-4 mb-6"> <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" /> <ArrowLeft className="w-4 h-4" />
</Button> </Button>
<h1 className="text-2xl font-bold">Vertrag {c.contractNumber}</h1> <h1 className="text-2xl font-bold">Vertrag {c.contractNumber}</h1>
@ -1465,7 +1695,7 @@ export default function ContractDetail() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" 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" /> <ArrowLeft className="w-4 h-4" />
</Button> </Button>
@ -1499,7 +1729,7 @@ export default function ContractDetail() {
{c.customer && ( {c.customer && (
<p className="text-gray-500 ml-10"> <p className="text-gray-500 ml-10">
Kunde:{' '} 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}`} {c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
</Link> </Link>
</p> </p>
@ -1534,7 +1764,7 @@ export default function ContractDetail() {
</Link> </Link>
)} )}
{hasPermission('contracts:update') && ( {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"> <Button variant="secondary">
<Edit className="w-4 h-4 mr-2" /> <Edit className="w-4 h-4 mr-2" />
Bearbeiten Bearbeiten
@ -2201,7 +2431,12 @@ export default function ContractDetail() {
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4"> <dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
{c.energyDetails.meter && ( {c.energyDetails.meter && (
<div> <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"> <dd className="font-mono flex items-center gap-1">
{c.energyDetails.meter.meterNumber} {c.energyDetails.meter.meterNumber}
<CopyButton value={c.energyDetails.meter.meterNumber} /> <CopyButton value={c.energyDetails.meter.meterNumber} />
@ -2242,12 +2477,22 @@ export default function ContractDetail() {
)} )}
{c.energyDetails.unitPrice != null && ( {c.energyDetails.unitPrice != null && (
<div> <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> <dd>
{c.energyDetails.unitPrice.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 10 })} /kWh {c.energyDetails.unitPrice.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 10 })} /kWh
</dd> </dd>
</div> </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 && ( {c.energyDetails.bonus && (
<div> <div>
<dt className="text-sm text-gray-500">Bonus</dt> <dt className="text-sm text-gray-500">Bonus</dt>
@ -2271,29 +2516,58 @@ export default function ContractDetail() {
)} )}
</dl> </dl>
{/* Zählerstände */} {/* Zählerstände - Multi-Zähler Support */}
{c.energyDetails.meter && ( {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 <MeterReadingsSection
meterId={c.energyDetails.meter.id} meterId={c.energyDetails.meter.id}
meterType={c.energyDetails.meter.type} meterType={c.energyDetails.meter.type}
tariffModel={c.energyDetails.meter.tariffModel}
readings={c.energyDetails.meter.readings || []} readings={c.energyDetails.meter.readings || []}
contractId={contractId}
canEdit={hasPermission('contracts:update') && !isCustomer} canEdit={hasPermission('contracts:update') && !isCustomer}
/> />
)} ) : null}
{/* Verbrauchsberechnung & Kostenvorschau */} {/* Verbrauchsberechnung & Kostenvorschau */}
{c.energyDetails.meter && c.startDate && c.endDate && ( <EnergyConsumptionCalculation
<EnergyConsumptionCalculation contractType={c.type as 'ELECTRICITY' | 'GAS'}
contractType={c.type as 'ELECTRICITY' | 'GAS'} readings={c.energyDetails.meter?.readings || []}
readings={c.energyDetails.meter.readings || []} startDate={c.startDate}
startDate={c.startDate} endDate={c.endDate}
endDate={c.endDate} basePrice={c.energyDetails.basePrice}
basePrice={c.energyDetails.basePrice} unitPrice={c.energyDetails.unitPrice}
unitPrice={c.energyDetails.unitPrice} unitPriceNt={c.energyDetails.unitPriceNt}
bonus={c.energyDetails.bonus} bonus={c.energyDetails.bonus}
/> hasMeter={!!c.energyDetails.meter}
)} contractMeters={c.energyDetails.contractMeters}
/>
{/* Rechnungen */} {/* Rechnungen */}
<InvoicesSection <InvoicesSection

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,10 +36,15 @@ const RESOURCE_OPTIONS = [
{ value: 'Contract', label: 'Verträge' }, { value: 'Contract', label: 'Verträge' },
{ value: 'User', label: 'Benutzer' }, { value: 'User', label: 'Benutzer' },
{ value: 'BankCard', label: 'Bankdaten' }, { value: 'BankCard', label: 'Bankdaten' },
{ value: 'IdentityDocument', label: 'Ausweisdokumente' }, { value: 'IdentityDocument', label: 'Ausweise' },
{ value: 'Authentication', label: 'Authentifizierung' }, { value: 'Address', label: 'Adressen' },
{ value: 'Meter', label: 'Zähler' },
{ value: 'ContractTask', label: 'Aufgaben' },
{ value: 'Authentication', label: 'Anmeldung' },
{ value: 'CustomerConsent', label: 'Einwilligungen' }, { 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 { function formatDate(date: string): string {
@ -354,11 +359,12 @@ export default function AuditLogs() {
</span> </span>
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<div>{log.resourceType}</div> {log.resourceLabel ? (
{log.resourceLabel && ( <div className="text-sm truncate max-w-[300px]" title={log.resourceLabel}>
<div className="text-xs text-gray-500 truncate max-w-[200px]" title={log.resourceLabel}>
{log.resourceLabel} {log.resourceLabel}
</div> </div>
) : (
<div className="text-sm text-gray-500">{log.resourceType}</div>
)} )}
</td> </td>
<td className="py-3 px-4 font-mono text-xs">{log.ipAddress}</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'); const res = await api.get<ApiResponse<import('../types').CockpitResult>>('/contracts/cockpit');
return res.data; 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: Vertrag zurückstellen
snooze: async (id: number, data: { nextReviewDate?: string; months?: number }) => { 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); 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; licenseIssueDate?: string;
} }
export type MeterTariffModel = 'SINGLE' | 'DUAL';
export interface Meter { export interface Meter {
id: number; id: number;
customerId: number; customerId: number;
meterNumber: string; meterNumber: string;
type: 'ELECTRICITY' | 'GAS'; type: 'ELECTRICITY' | 'GAS';
tariffModel: MeterTariffModel;
location?: string; location?: string;
isActive: boolean; isActive: boolean;
readings?: MeterReading[]; 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 type MeterReadingStatus = 'RECORDED' | 'REPORTED' | 'TRANSFERRED';
export interface MeterReading { export interface MeterReading {
@ -169,6 +183,7 @@ export interface MeterReading {
meterId: number; meterId: number;
readingDate: string; readingDate: string;
value: number; value: number;
valueNt?: number; // NT-Wert (nur bei Zweitarifzähler)
unit: string; unit: string;
notes?: string; notes?: string;
reportedBy?: string; reportedBy?: string;
@ -396,11 +411,13 @@ export interface EnergyContractDetails {
annualConsumption?: number; // kWh für Strom, m³ für Gas annualConsumption?: number; // kWh für Strom, m³ für Gas
annualConsumptionKwh?: number; // kWh für Gas (zusätzlich zu m³) annualConsumptionKwh?: number; // kWh für Gas (zusätzlich zu m³)
basePrice?: number; // €/Monat 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; bonus?: number;
previousProviderName?: string; previousProviderName?: string;
previousCustomerNumber?: string; previousCustomerNumber?: string;
invoices?: Invoice[]; // Rechnungen invoices?: Invoice[]; // Rechnungen
contractMeters?: ContractMeter[]; // Zähler-Zuordnungen (inkl. Folgezähler)
} }
export interface InternetContractDetails { export interface InternetContractDetails {
@ -575,6 +592,7 @@ export interface ReportedMeterReading {
createdAt: string; createdAt: string;
meter: { id: number; meterNumber: string; type: string }; meter: { id: number; meterNumber: string; type: string };
customer: { id: number; customerNumber: string; name: string }; customer: { id: number; customerNumber: string; name: string };
contract?: { id: number; contractNumber: string };
providerPortal?: { providerPortal?: {
providerName: string; providerName: string;
portalUrl: 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) // Konstante für Gas → kWh Umrechnung (Standard Erdgas H)
export const GAS_TO_KWH_FACTOR = 10.5; export const GAS_TO_KWH_FACTOR = 10.5;
@ -8,6 +8,8 @@ export interface ConsumptionCalculation {
type: 'exact' | 'projected' | 'insufficient' | 'none'; type: 'exact' | 'projected' | 'insufficient' | 'none';
consumptionM3?: number; // Nur bei Gas consumptionM3?: number; // Nur bei Gas
consumptionKwh: number; consumptionKwh: number;
consumptionHt?: number; // Nur bei HT/NT: HT-Verbrauch in kWh
consumptionNt?: number; // Nur bei HT/NT: NT-Verbrauch in kWh
startReading?: MeterReading; startReading?: MeterReading;
endReading?: MeterReading; endReading?: MeterReading;
projectedEndDate?: string; // Nur bei Hochrechnung projectedEndDate?: string; // Nur bei Hochrechnung
@ -17,7 +19,8 @@ export interface ConsumptionCalculation {
// Ergebnis der Kostenberechnung // Ergebnis der Kostenberechnung
export interface CostCalculation { export interface CostCalculation {
annualBaseCost: number; // basePrice × 12 annualBaseCost: number; // basePrice × 12
annualConsumptionCost: number; // verbrauch × unitPrice annualConsumptionCost: number; // verbrauch × unitPrice (HT bei Zweitarif)
annualConsumptionCostNt?: number; // NT-Verbrauch × unitPriceNt
annualTotalCost: number; // Summe annualTotalCost: number; // Summe
monthlyPayment: number; // annualTotalCost / 12 monthlyPayment: number; // annualTotalCost / 12
bonus?: number; bonus?: number;
@ -94,7 +97,6 @@ export function calculateConsumption(
(a, b) => new Date(a.readingDate).getTime() - new Date(b.readingDate).getTime() (a, b) => new Date(a.readingDate).getTime() - new Date(b.readingDate).getTime()
); );
const firstReading = sorted[0];
const lastReading = sorted[sorted.length - 1]; const lastReading = sorted[sorted.length - 1];
// Prüfen ob Endzählerstand am/nach Vertragsende liegt // Prüfen ob Endzählerstand am/nach Vertragsende liegt
@ -103,16 +105,39 @@ export function calculateConsumption(
lastReadingDate.setHours(0, 0, 0, 0); lastReadingDate.setHours(0, 0, 0, 0);
contractEndDate.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) { if (lastReadingDate >= contractEndDate) {
const consumption = lastReading.value - firstReading.value; const result = formatConsumptionResult('exact', totalConsumption, contractType, effectiveFirstReading, effectiveLastReading);
return formatConsumptionResult('exact', consumption, contractType, firstReading, lastReading); if (hasNt) { result.consumptionHt = totalConsumption; result.consumptionNt = totalConsumptionNt; }
return result;
} }
// Fall B: Hochrechnung erforderlich // 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) { if (daysBetweenReadings < 1) {
return { return {
type: 'insufficient', type: 'insufficient',
@ -122,17 +147,26 @@ export function calculateConsumption(
} }
const totalContractDays = daysDiff(startDate, endDate); const totalContractDays = daysDiff(startDate, endDate);
const measuredConsumption = lastReading.value - firstReading.value; const projectedConsumption = (totalConsumption / daysBetweenReadings) * totalContractDays;
const projectedConsumption = (measuredConsumption / daysBetweenReadings) * totalContractDays;
return formatConsumptionResult( const result = formatConsumptionResult(
'projected', 'projected',
projectedConsumption, projectedConsumption,
contractType, contractType,
firstReading, effectiveFirstReading,
lastReading, effectiveLastReading,
endDate 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, consumptionKwh: number,
basePrice?: number, basePrice?: number,
unitPrice?: number, unitPrice?: number,
bonus?: number bonus?: number,
consumptionNtKwh?: number,
unitPriceNt?: number
): CostCalculation | null { ): CostCalculation | null {
// Mindestens ein Preis muss vorhanden sein // Mindestens ein Preis muss vorhanden sein
if (basePrice == null && unitPrice == null) { if (basePrice == null && unitPrice == null) {
@ -181,17 +217,78 @@ export function calculateCosts(
} }
const annualBaseCost = (basePrice ?? 0) * 12; const annualBaseCost = (basePrice ?? 0) * 12;
// Bei HT/NT: consumptionKwh ist nur HT, NT wird separat berechnet
const annualConsumptionCost = consumptionKwh * (unitPrice ?? 0); 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 effectiveAnnualCost = annualTotalCost - (bonus ?? 0);
const monthlyPayment = effectiveAnnualCost / 12; const monthlyPayment = effectiveAnnualCost / 12;
return { return {
annualBaseCost, annualBaseCost,
annualConsumptionCost, annualConsumptionCost,
annualConsumptionCostNt: annualConsumptionCostNt > 0 ? annualConsumptionCostNt : undefined,
annualTotalCost, annualTotalCost,
monthlyPayment, monthlyPayment,
bonus: bonus ?? undefined, bonus: bonus ?? undefined,
effectiveAnnualCost, 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 } };
}