Datenschutz vollmacht fixed, two time counter added
This commit is contained in:
@@ -11,5 +11,7 @@ export declare function getSimCardCredentials(req: Request, res: Response): Prom
|
||||
export declare function getInternetCredentials(req: Request, res: Response): Promise<void>;
|
||||
export declare function getSipCredentials(req: Request, res: Response): Promise<void>;
|
||||
export declare function getCockpit(req: AuthRequest, res: Response): Promise<void>;
|
||||
export declare function addSuccessorMeter(req: AuthRequest, res: Response): Promise<void>;
|
||||
export declare function removeContractMeter(req: AuthRequest, res: Response): Promise<void>;
|
||||
export declare function snoozeContract(req: Request, res: Response): Promise<void>;
|
||||
//# sourceMappingURL=contract.controller.d.ts.map
|
||||
+1
-1
@@ -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"}
|
||||
+67
@@ -44,6 +44,8 @@ exports.getSimCardCredentials = getSimCardCredentials;
|
||||
exports.getInternetCredentials = getInternetCredentials;
|
||||
exports.getSipCredentials = getSipCredentials;
|
||||
exports.getCockpit = getCockpit;
|
||||
exports.addSuccessorMeter = addSuccessorMeter;
|
||||
exports.removeContractMeter = removeContractMeter;
|
||||
exports.snoozeContract = snoozeContract;
|
||||
const client_1 = require("@prisma/client");
|
||||
const contractService = __importStar(require("../services/contract.service.js"));
|
||||
@@ -265,6 +267,71 @@ async function getCockpit(req, res) {
|
||||
});
|
||||
}
|
||||
}
|
||||
// ==================== FOLGEZÄHLER ====================
|
||||
async function addSuccessorMeter(req, res) {
|
||||
try {
|
||||
const contractId = parseInt(req.params.id);
|
||||
const { meterId, installedAt, finalReadingPrevious } = req.body;
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id: contractId },
|
||||
include: { energyDetails: { include: { contractMeters: { orderBy: { position: 'asc' } } } } },
|
||||
});
|
||||
if (!contract?.energyDetails) {
|
||||
res.status(404).json({ success: false, error: 'Energievertrag nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
const ecdId = contract.energyDetails.id;
|
||||
const existingMeters = contract.energyDetails.contractMeters;
|
||||
const nextPosition = existingMeters.length > 0
|
||||
? Math.max(...existingMeters.map(m => m.position)) + 1
|
||||
: 0;
|
||||
// Vorherigen Zähler als gewechselt markieren
|
||||
if (existingMeters.length > 0 && finalReadingPrevious !== undefined) {
|
||||
const prevMeter = existingMeters[existingMeters.length - 1];
|
||||
await prisma.contractMeter.update({
|
||||
where: { id: prevMeter.id },
|
||||
data: {
|
||||
removedAt: installedAt ? new Date(installedAt) : new Date(),
|
||||
finalReading: parseFloat(finalReadingPrevious),
|
||||
},
|
||||
});
|
||||
}
|
||||
const contractMeter = await prisma.contractMeter.create({
|
||||
data: {
|
||||
energyContractDetailsId: ecdId,
|
||||
meterId: parseInt(meterId),
|
||||
position: nextPosition,
|
||||
installedAt: installedAt ? new Date(installedAt) : new Date(),
|
||||
},
|
||||
include: { meter: { include: { readings: true } } },
|
||||
});
|
||||
// Aktuellen Zähler am Vertrag aktualisieren
|
||||
await prisma.energyContractDetails.update({
|
||||
where: { id: ecdId },
|
||||
data: { meterId: parseInt(meterId) },
|
||||
});
|
||||
res.json({ success: true, data: contractMeter });
|
||||
}
|
||||
catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Folgezählers',
|
||||
});
|
||||
}
|
||||
}
|
||||
async function removeContractMeter(req, res) {
|
||||
try {
|
||||
const contractMeterId = parseInt(req.params.contractMeterId);
|
||||
await prisma.contractMeter.delete({ where: { id: contractMeterId } });
|
||||
res.json({ success: true, data: null });
|
||||
}
|
||||
catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Entfernen',
|
||||
});
|
||||
}
|
||||
}
|
||||
// ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ====================
|
||||
async function snoozeContract(req, res) {
|
||||
try {
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAI5C,OAAO,EAAe,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAK7D,wBAAsB,YAAY,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAgB7E;AAED,wBAAsB,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAW5E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAe/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAgB/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAGD,wBAAsB,YAAY,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAO7E;AAED,wBAAsB,aAAa,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU9E;AAED,wBAAsB,aAAa,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU9E;AAED,wBAAsB,aAAa,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU9E;AAGD,wBAAsB,YAAY,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAW7E;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;AAGD,wBAAsB,YAAY,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAW7E;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;AAGD,wBAAsB,SAAS,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAW1E;AAED,wBAAsB,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU5E;AAED,wBAAsB,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU5E;AAED,wBAAsB,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU5E;AAGD,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAOjF;AAED,wBAAsB,eAAe,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBhF;AAED,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAsBnF;AAED,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAanF;AAID,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CA6CvF;AAED,wBAAsB,WAAW,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAuBhF;AAED,wBAAsB,sBAAsB,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAqB3F;AAID,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAwBlF;AAED,wBAAsB,oBAAoB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAcrF;AAED,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAkBlF;AAED,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUlF;AAID,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAWnF;AAED,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAelF;AAED,wBAAsB,oBAAoB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAarF;AAED,wBAAsB,uBAAuB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAkBxF"}
|
||||
+33
-11
@@ -351,7 +351,14 @@ async function getMeterReadings(req, res) {
|
||||
}
|
||||
async function addMeterReading(req, res) {
|
||||
try {
|
||||
const reading = await customerService.addMeterReading(parseInt(req.params.meterId), req.body);
|
||||
const { readingDate, value, valueNt, unit, notes } = req.body;
|
||||
const reading = await customerService.addMeterReading(parseInt(req.params.meterId), {
|
||||
readingDate: new Date(readingDate),
|
||||
value: parseFloat(value),
|
||||
valueNt: valueNt !== undefined && valueNt !== null && valueNt !== '' ? parseFloat(valueNt) : undefined,
|
||||
unit,
|
||||
notes,
|
||||
});
|
||||
res.status(201).json({ success: true, data: reading });
|
||||
}
|
||||
catch (error) {
|
||||
@@ -363,7 +370,19 @@ async function addMeterReading(req, res) {
|
||||
}
|
||||
async function updateMeterReading(req, res) {
|
||||
try {
|
||||
const reading = await customerService.updateMeterReading(parseInt(req.params.meterId), parseInt(req.params.readingId), req.body);
|
||||
const { readingDate, value, valueNt, unit, notes } = req.body;
|
||||
const updateData = {};
|
||||
if (readingDate !== undefined)
|
||||
updateData.readingDate = new Date(readingDate);
|
||||
if (value !== undefined)
|
||||
updateData.value = parseFloat(value);
|
||||
if (valueNt !== undefined)
|
||||
updateData.valueNt = valueNt !== null && valueNt !== '' ? parseFloat(valueNt) : null;
|
||||
if (unit !== undefined)
|
||||
updateData.unit = unit;
|
||||
if (notes !== undefined)
|
||||
updateData.notes = notes;
|
||||
const reading = await customerService.updateMeterReading(parseInt(req.params.meterId), parseInt(req.params.readingId), updateData);
|
||||
res.json({ success: true, data: reading });
|
||||
}
|
||||
catch (error) {
|
||||
@@ -404,15 +423,18 @@ async function reportMeterReading(req, res) {
|
||||
res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Zähler' });
|
||||
return;
|
||||
}
|
||||
const reading = await prisma.meterReading.create({
|
||||
data: {
|
||||
meterId,
|
||||
value: parseFloat(value),
|
||||
readingDate: readingDate ? new Date(readingDate) : new Date(),
|
||||
notes,
|
||||
reportedBy: user.email,
|
||||
status: 'REPORTED',
|
||||
},
|
||||
const parsedDate = readingDate ? new Date(readingDate) : new Date();
|
||||
const parsedValue = parseFloat(value);
|
||||
// Validierung über den Service (monoton steigend)
|
||||
const reading = await customerService.addMeterReading(meterId, {
|
||||
readingDate: parsedDate,
|
||||
value: parsedValue,
|
||||
notes,
|
||||
});
|
||||
// Status auf REPORTED setzen
|
||||
await prisma.meterReading.update({
|
||||
where: { id: reading.id },
|
||||
data: { reportedBy: user.email, status: 'REPORTED' },
|
||||
});
|
||||
res.status(201).json({ success: true, data: reading });
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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"}
|
||||
+3
@@ -48,6 +48,9 @@ router.delete('/:id', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('
|
||||
router.post('/:id/follow-up', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:create'), contractController.createFollowUp);
|
||||
// Snooze (Vertrag zurückstellen)
|
||||
router.patch('/:id/snooze', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:update'), contractController.snoozeContract);
|
||||
// Folgezähler
|
||||
router.post('/:id/successor-meter', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:update'), contractController.addSuccessorMeter);
|
||||
router.delete('/:id/contract-meter/:contractMeterId', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:update'), contractController.removeContractMeter);
|
||||
// Get decrypted password
|
||||
router.get('/:id/password', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:read'), contractController.getContractPassword);
|
||||
// Get decrypted SimCard PIN/PUK
|
||||
|
||||
+1
-1
@@ -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"}
|
||||
+1
-1
@@ -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"}
|
||||
Vendored
+14
@@ -338,6 +338,15 @@ router.post('/customers/:id/privacy-policy', auth_js_1.authenticate, (0, auth_js
|
||||
where: { id: customerId },
|
||||
data: { privacyPolicyPath: relativePath },
|
||||
});
|
||||
// Alle Consents auf GRANTED setzen (PDF = vollständige Einwilligung)
|
||||
const consentTypes = ['DATA_PROCESSING', 'MARKETING_EMAIL', 'MARKETING_PHONE', 'DATA_SHARING_PARTNER'];
|
||||
for (const consentType of consentTypes) {
|
||||
await prisma.customerConsent.upsert({
|
||||
where: { customerId_consentType: { customerId, consentType } },
|
||||
update: { status: 'GRANTED', grantedAt: new Date(), source: 'papier' },
|
||||
create: { customerId, consentType, status: 'GRANTED', grantedAt: new Date(), source: 'papier', createdBy: req.user?.email || 'admin' },
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
@@ -376,6 +385,11 @@ router.delete('/customers/:id/privacy-policy', auth_js_1.authenticate, (0, auth_
|
||||
where: { id: customerId },
|
||||
data: { privacyPolicyPath: null },
|
||||
});
|
||||
// Nur Consents widerrufen die per Papier erteilt wurden
|
||||
await prisma.customerConsent.updateMany({
|
||||
where: { customerId, status: 'GRANTED', source: 'papier' },
|
||||
data: { status: 'WITHDRAWN', withdrawnAt: new Date() },
|
||||
});
|
||||
res.json({ success: true });
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+620
-458
File diff suppressed because it is too large
Load Diff
+1
-1
File diff suppressed because one or more lines are too long
+32
-2
@@ -122,14 +122,14 @@ async function getContractById(id, decryptPassword = false) {
|
||||
previousProvider: true,
|
||||
previousContract: {
|
||||
include: {
|
||||
energyDetails: { include: { meter: { include: { readings: true } }, invoices: true } },
|
||||
energyDetails: { include: { meter: { include: { readings: true } }, contractMeters: { include: { meter: { include: { readings: true } } }, orderBy: { position: 'asc' } }, invoices: true } },
|
||||
internetDetails: { include: { phoneNumbers: true } },
|
||||
mobileDetails: { include: { simCards: true } },
|
||||
tvDetails: true,
|
||||
carInsuranceDetails: true,
|
||||
},
|
||||
},
|
||||
energyDetails: { include: { meter: { include: { readings: true } }, invoices: true } },
|
||||
energyDetails: { include: { meter: { include: { readings: true } }, contractMeters: { include: { meter: { include: { readings: true } } }, orderBy: { position: 'asc' } }, invoices: true } },
|
||||
internetDetails: { include: { phoneNumbers: true } },
|
||||
mobileDetails: { include: { simCards: true } },
|
||||
tvDetails: true,
|
||||
@@ -264,11 +264,41 @@ async function updateContract(id, data) {
|
||||
});
|
||||
// Update type-specific details
|
||||
if (energyDetails) {
|
||||
const existingEcd = await prisma.energyContractDetails.findUnique({
|
||||
where: { contractId: id },
|
||||
select: { id: true, meterId: true },
|
||||
});
|
||||
await prisma.energyContractDetails.upsert({
|
||||
where: { contractId: id },
|
||||
update: energyDetails,
|
||||
create: { contractId: id, ...energyDetails },
|
||||
});
|
||||
// ContractMeter synchronisieren wenn sich der Zähler ändert
|
||||
if (energyDetails.meterId !== undefined && existingEcd) {
|
||||
const oldMeterId = existingEcd.meterId;
|
||||
const newMeterId = energyDetails.meterId;
|
||||
if (oldMeterId !== newMeterId) {
|
||||
// Alle alten ContractMeter-Einträge entfernen
|
||||
await prisma.contractMeter.deleteMany({
|
||||
where: { energyContractDetailsId: existingEcd.id },
|
||||
});
|
||||
// Neuen ContractMeter-Eintrag erstellen (wenn ein Zähler gesetzt)
|
||||
if (newMeterId) {
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id },
|
||||
select: { startDate: true },
|
||||
});
|
||||
await prisma.contractMeter.create({
|
||||
data: {
|
||||
energyContractDetailsId: existingEcd.id,
|
||||
meterId: newMeterId,
|
||||
position: 0,
|
||||
installedAt: contract?.startDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (internetDetails) {
|
||||
const { phoneNumbers, internetPassword, ...internetData } = internetDetails;
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -78,6 +78,10 @@ export interface ReportedMeterReading {
|
||||
customerNumber: string;
|
||||
name: string;
|
||||
};
|
||||
contract?: {
|
||||
id: number;
|
||||
contractNumber: string;
|
||||
};
|
||||
providerPortal?: {
|
||||
providerName: string;
|
||||
portalUrl: string;
|
||||
|
||||
+1
-1
@@ -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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"}
|
||||
@@ -682,6 +682,7 @@ async function getReportedMeterReadings() {
|
||||
contract: {
|
||||
select: {
|
||||
id: true,
|
||||
contractNumber: true,
|
||||
portalUsername: true,
|
||||
provider: {
|
||||
select: { id: true, name: true, portalUrl: true },
|
||||
@@ -717,6 +718,10 @@ async function getReportedMeterReadings() {
|
||||
customerNumber: r.meter.customer.customerNumber,
|
||||
name: `${r.meter.customer.firstName} ${r.meter.customer.lastName}`,
|
||||
},
|
||||
contract: contract ? {
|
||||
id: contract.id,
|
||||
contractNumber: contract.contractNumber,
|
||||
} : undefined,
|
||||
providerPortal: provider?.portalUrl ? {
|
||||
providerName: provider.name,
|
||||
portalUrl: provider.portalUrl,
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+7
-7
@@ -206,17 +206,17 @@ export declare function getAllTasks(filters: AllTasksFilters): Promise<({
|
||||
customerNumber: string;
|
||||
companyName: string | null;
|
||||
};
|
||||
tariff: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
contractNumber: string;
|
||||
providerName: string | null;
|
||||
tariffName: string | null;
|
||||
provider: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
tariffName: string | null;
|
||||
providerName: string | null;
|
||||
contractNumber: string;
|
||||
tariff: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
};
|
||||
subtasks: {
|
||||
id: number;
|
||||
|
||||
+29
-16
@@ -107,13 +107,14 @@ export declare function getCustomerById(id: number): Promise<({
|
||||
createdAt: Date;
|
||||
notes: string | null;
|
||||
readingDate: Date;
|
||||
meterId: number;
|
||||
status: import(".prisma/client").$Enums.MeterReadingStatus;
|
||||
value: number;
|
||||
valueNt: number | null;
|
||||
unit: string;
|
||||
reportedBy: string | null;
|
||||
status: import(".prisma/client").$Enums.MeterReadingStatus;
|
||||
transferredAt: Date | null;
|
||||
transferredBy: string | null;
|
||||
meterId: number;
|
||||
}[];
|
||||
} & {
|
||||
id: number;
|
||||
@@ -123,6 +124,7 @@ export declare function getCustomerById(id: number): Promise<({
|
||||
updatedAt: Date;
|
||||
type: import(".prisma/client").$Enums.MeterType;
|
||||
meterNumber: string;
|
||||
tariffModel: import(".prisma/client").$Enums.MeterTariffModel;
|
||||
location: string | null;
|
||||
})[];
|
||||
stressfreiEmails: {
|
||||
@@ -171,11 +173,8 @@ export declare function getCustomerById(id: number): Promise<({
|
||||
notes: string | null;
|
||||
portalPasswordEncrypted: string | null;
|
||||
startDate: Date | null;
|
||||
status: import(".prisma/client").$Enums.ContractStatus;
|
||||
customerNumberAtProvider: string | null;
|
||||
tariffName: string | null;
|
||||
providerName: string | null;
|
||||
contractNumber: string;
|
||||
status: import(".prisma/client").$Enums.ContractStatus;
|
||||
contractCategoryId: number | null;
|
||||
addressId: number | null;
|
||||
billingAddressId: number | null;
|
||||
@@ -190,6 +189,9 @@ export declare function getCustomerById(id: number): Promise<({
|
||||
previousContractNumber: string | null;
|
||||
providerId: number | null;
|
||||
tariffId: number | null;
|
||||
providerName: string | null;
|
||||
tariffName: string | null;
|
||||
customerNumberAtProvider: string | null;
|
||||
contractNumberAtProvider: string | null;
|
||||
priceFirst12Months: string | null;
|
||||
priceFrom13Months: string | null;
|
||||
@@ -573,13 +575,14 @@ export declare function getCustomerMeters(customerId: number, showInactive?: boo
|
||||
createdAt: Date;
|
||||
notes: string | null;
|
||||
readingDate: Date;
|
||||
meterId: number;
|
||||
status: import(".prisma/client").$Enums.MeterReadingStatus;
|
||||
value: number;
|
||||
valueNt: number | null;
|
||||
unit: string;
|
||||
reportedBy: string | null;
|
||||
status: import(".prisma/client").$Enums.MeterReadingStatus;
|
||||
transferredAt: Date | null;
|
||||
transferredBy: string | null;
|
||||
meterId: number;
|
||||
}[];
|
||||
} & {
|
||||
id: number;
|
||||
@@ -589,6 +592,7 @@ export declare function getCustomerMeters(customerId: number, showInactive?: boo
|
||||
updatedAt: Date;
|
||||
type: import(".prisma/client").$Enums.MeterType;
|
||||
meterNumber: string;
|
||||
tariffModel: import(".prisma/client").$Enums.MeterTariffModel;
|
||||
location: string | null;
|
||||
})[]>;
|
||||
export declare function createMeter(customerId: number, data: {
|
||||
@@ -603,6 +607,7 @@ export declare function createMeter(customerId: number, data: {
|
||||
updatedAt: Date;
|
||||
type: import(".prisma/client").$Enums.MeterType;
|
||||
meterNumber: string;
|
||||
tariffModel: import(".prisma/client").$Enums.MeterTariffModel;
|
||||
location: string | null;
|
||||
}>;
|
||||
export declare function updateMeter(id: number, data: {
|
||||
@@ -618,6 +623,7 @@ export declare function updateMeter(id: number, data: {
|
||||
updatedAt: Date;
|
||||
type: import(".prisma/client").$Enums.MeterType;
|
||||
meterNumber: string;
|
||||
tariffModel: import(".prisma/client").$Enums.MeterTariffModel;
|
||||
location: string | null;
|
||||
}>;
|
||||
export declare function deleteMeter(id: number): Promise<{
|
||||
@@ -628,11 +634,13 @@ export declare function deleteMeter(id: number): Promise<{
|
||||
updatedAt: Date;
|
||||
type: import(".prisma/client").$Enums.MeterType;
|
||||
meterNumber: string;
|
||||
tariffModel: import(".prisma/client").$Enums.MeterTariffModel;
|
||||
location: string | null;
|
||||
}>;
|
||||
export declare function addMeterReading(meterId: number, data: {
|
||||
readingDate: Date;
|
||||
value: number;
|
||||
valueNt?: number;
|
||||
unit?: string;
|
||||
notes?: string;
|
||||
}): Promise<{
|
||||
@@ -640,30 +648,33 @@ export declare function addMeterReading(meterId: number, data: {
|
||||
createdAt: Date;
|
||||
notes: string | null;
|
||||
readingDate: Date;
|
||||
meterId: number;
|
||||
status: import(".prisma/client").$Enums.MeterReadingStatus;
|
||||
value: number;
|
||||
valueNt: number | null;
|
||||
unit: string;
|
||||
reportedBy: string | null;
|
||||
status: import(".prisma/client").$Enums.MeterReadingStatus;
|
||||
transferredAt: Date | null;
|
||||
transferredBy: string | null;
|
||||
meterId: number;
|
||||
}>;
|
||||
export declare function getMeterReadings(meterId: number): Promise<{
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
notes: string | null;
|
||||
readingDate: Date;
|
||||
meterId: number;
|
||||
status: import(".prisma/client").$Enums.MeterReadingStatus;
|
||||
value: number;
|
||||
valueNt: number | null;
|
||||
unit: string;
|
||||
reportedBy: string | null;
|
||||
status: import(".prisma/client").$Enums.MeterReadingStatus;
|
||||
transferredAt: Date | null;
|
||||
transferredBy: string | null;
|
||||
meterId: number;
|
||||
}[]>;
|
||||
export declare function updateMeterReading(meterId: number, readingId: number, data: {
|
||||
readingDate?: Date;
|
||||
value?: number;
|
||||
valueNt?: number | null;
|
||||
unit?: string;
|
||||
notes?: string;
|
||||
}): Promise<{
|
||||
@@ -671,26 +682,28 @@ export declare function updateMeterReading(meterId: number, readingId: number, d
|
||||
createdAt: Date;
|
||||
notes: string | null;
|
||||
readingDate: Date;
|
||||
meterId: number;
|
||||
status: import(".prisma/client").$Enums.MeterReadingStatus;
|
||||
value: number;
|
||||
valueNt: number | null;
|
||||
unit: string;
|
||||
reportedBy: string | null;
|
||||
status: import(".prisma/client").$Enums.MeterReadingStatus;
|
||||
transferredAt: Date | null;
|
||||
transferredBy: string | null;
|
||||
meterId: number;
|
||||
}>;
|
||||
export declare function deleteMeterReading(meterId: number, readingId: number): Promise<{
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
notes: string | null;
|
||||
readingDate: Date;
|
||||
meterId: number;
|
||||
status: import(".prisma/client").$Enums.MeterReadingStatus;
|
||||
value: number;
|
||||
valueNt: number | null;
|
||||
unit: string;
|
||||
reportedBy: string | null;
|
||||
status: import(".prisma/client").$Enums.MeterReadingStatus;
|
||||
transferredAt: Date | null;
|
||||
transferredBy: string | null;
|
||||
meterId: number;
|
||||
}>;
|
||||
export declare function updatePortalSettings(customerId: number, data: {
|
||||
portalEnabled?: boolean;
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+65
-1
@@ -321,13 +321,42 @@ async function updateMeter(id, data) {
|
||||
});
|
||||
}
|
||||
async function deleteMeter(id) {
|
||||
// Prüfen ob der Zähler noch an Verträgen hängt
|
||||
const linkedContracts = await prisma.contractMeter.findMany({
|
||||
where: { meterId: id },
|
||||
include: { energyContractDetails: { include: { contract: { select: { contractNumber: true } } } } },
|
||||
});
|
||||
if (linkedContracts.length > 0) {
|
||||
const contractNumbers = linkedContracts
|
||||
.map(cm => cm.energyContractDetails.contract.contractNumber)
|
||||
.join(', ');
|
||||
throw new Error(`Zähler kann nicht gelöscht werden – noch an Vertrag/Verträgen zugeordnet: ${contractNumbers}`);
|
||||
}
|
||||
// Auch direkte meterId-Referenz auf EnergyContractDetails prüfen
|
||||
const directLinks = await prisma.energyContractDetails.findMany({
|
||||
where: { meterId: id },
|
||||
include: { contract: { select: { contractNumber: true } } },
|
||||
});
|
||||
if (directLinks.length > 0) {
|
||||
const contractNumbers = directLinks.map(d => d.contract.contractNumber).join(', ');
|
||||
throw new Error(`Zähler kann nicht gelöscht werden – noch an Vertrag/Verträgen zugeordnet: ${contractNumbers}`);
|
||||
}
|
||||
return prisma.meter.delete({ where: { id } });
|
||||
}
|
||||
async function addMeterReading(meterId, data) {
|
||||
// Validierung: Zählerstand muss monoton steigend sein
|
||||
await validateReadingValue(meterId, data.readingDate, data.value, undefined, 'HT');
|
||||
if (data.valueNt !== undefined) {
|
||||
await validateReadingValue(meterId, data.readingDate, data.valueNt, undefined, 'NT');
|
||||
}
|
||||
return prisma.meterReading.create({
|
||||
data: {
|
||||
meterId,
|
||||
...data,
|
||||
readingDate: data.readingDate,
|
||||
value: data.value,
|
||||
valueNt: data.valueNt,
|
||||
unit: data.unit,
|
||||
notes: data.notes,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -345,11 +374,46 @@ async function updateMeterReading(meterId, readingId, data) {
|
||||
if (!reading) {
|
||||
throw new Error('Zählerstand nicht gefunden');
|
||||
}
|
||||
// Validierung bei Wertänderung
|
||||
if (data.value !== undefined || data.readingDate !== undefined) {
|
||||
await validateReadingValue(meterId, data.readingDate || reading.readingDate, data.value ?? reading.value, readingId, 'HT');
|
||||
}
|
||||
if (data.valueNt !== undefined || data.readingDate !== undefined) {
|
||||
const ntVal = data.valueNt ?? reading.valueNt;
|
||||
if (ntVal !== undefined && ntVal !== null) {
|
||||
await validateReadingValue(meterId, data.readingDate || reading.readingDate, ntVal, readingId, 'NT');
|
||||
}
|
||||
}
|
||||
return prisma.meterReading.update({
|
||||
where: { id: readingId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Validiert, dass ein Zählerstand monoton steigend ist.
|
||||
* tariffLabel: 'HT' für Hochtarif/Eintarif, 'NT' für Niedertarif
|
||||
*/
|
||||
async function validateReadingValue(meterId, readingDate, value, excludeReadingId, tariffLabel = 'HT') {
|
||||
const existing = await prisma.meterReading.findMany({
|
||||
where: { meterId, ...(excludeReadingId ? { id: { not: excludeReadingId } } : {}) },
|
||||
orderBy: { readingDate: 'asc' },
|
||||
});
|
||||
const fmtDate = (d) => d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const fmtVal = (v) => v.toLocaleString('de-DE');
|
||||
const label = tariffLabel === 'NT' ? 'NT-Zählerstand' : 'Zählerstand';
|
||||
// Vergleichswert aus bestehendem Reading extrahieren
|
||||
const getVal = (r) => tariffLabel === 'NT' ? (r.valueNt ?? 0) : r.value;
|
||||
// Stand vor dem neuen Datum
|
||||
const before = [...existing].filter(r => r.readingDate <= readingDate).pop();
|
||||
if (before && value < getVal(before)) {
|
||||
throw new Error(`${label} (${fmtVal(value)}) darf nicht kleiner sein als der Stand vom ${fmtDate(before.readingDate)} (${fmtVal(getVal(before))})`);
|
||||
}
|
||||
// Stand nach dem neuen Datum
|
||||
const after = existing.find(r => r.readingDate > readingDate);
|
||||
if (after && value > getVal(after)) {
|
||||
throw new Error(`${label} (${fmtVal(value)}) darf nicht größer sein als der spätere Stand vom ${fmtDate(after.readingDate)} (${fmtVal(getVal(after))})`);
|
||||
}
|
||||
}
|
||||
async function deleteMeterReading(meterId, readingId) {
|
||||
// Verify the reading belongs to the meter
|
||||
const reading = await prisma.meterReading.findFirst({
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+23
-3
File diff suppressed because one or more lines are too long
+20
@@ -361,6 +361,7 @@ exports.Prisma.MeterScalarFieldEnum = {
|
||||
customerId: 'customerId',
|
||||
meterNumber: 'meterNumber',
|
||||
type: 'type',
|
||||
tariffModel: 'tariffModel',
|
||||
location: 'location',
|
||||
isActive: 'isActive',
|
||||
createdAt: 'createdAt',
|
||||
@@ -372,6 +373,7 @@ exports.Prisma.MeterReadingScalarFieldEnum = {
|
||||
meterId: 'meterId',
|
||||
readingDate: 'readingDate',
|
||||
value: 'value',
|
||||
valueNt: 'valueNt',
|
||||
unit: 'unit',
|
||||
notes: 'notes',
|
||||
reportedBy: 'reportedBy',
|
||||
@@ -529,11 +531,23 @@ exports.Prisma.EnergyContractDetailsScalarFieldEnum = {
|
||||
annualConsumptionKwh: 'annualConsumptionKwh',
|
||||
basePrice: 'basePrice',
|
||||
unitPrice: 'unitPrice',
|
||||
unitPriceNt: 'unitPriceNt',
|
||||
bonus: 'bonus',
|
||||
previousProviderName: 'previousProviderName',
|
||||
previousCustomerNumber: 'previousCustomerNumber'
|
||||
};
|
||||
|
||||
exports.Prisma.ContractMeterScalarFieldEnum = {
|
||||
id: 'id',
|
||||
energyContractDetailsId: 'energyContractDetailsId',
|
||||
meterId: 'meterId',
|
||||
position: 'position',
|
||||
installedAt: 'installedAt',
|
||||
removedAt: 'removedAt',
|
||||
finalReading: 'finalReading',
|
||||
createdAt: 'createdAt'
|
||||
};
|
||||
|
||||
exports.Prisma.InvoiceScalarFieldEnum = {
|
||||
id: 'id',
|
||||
energyContractDetailsId: 'energyContractDetailsId',
|
||||
@@ -742,6 +756,11 @@ exports.MeterType = exports.$Enums.MeterType = {
|
||||
GAS: 'GAS'
|
||||
};
|
||||
|
||||
exports.MeterTariffModel = exports.$Enums.MeterTariffModel = {
|
||||
SINGLE: 'SINGLE',
|
||||
DUAL: 'DUAL'
|
||||
};
|
||||
|
||||
exports.MeterReadingStatus = exports.$Enums.MeterReadingStatus = {
|
||||
RECORDED: 'RECORDED',
|
||||
REPORTED: 'REPORTED',
|
||||
@@ -855,6 +874,7 @@ exports.Prisma.ModelName = {
|
||||
ContractTask: 'ContractTask',
|
||||
ContractTaskSubtask: 'ContractTaskSubtask',
|
||||
EnergyContractDetails: 'EnergyContractDetails',
|
||||
ContractMeter: 'ContractMeter',
|
||||
Invoice: 'Invoice',
|
||||
InternetContractDetails: 'InternetContractDetails',
|
||||
PhoneNumber: 'PhoneNumber',
|
||||
|
||||
+2068
-57
File diff suppressed because it is too large
Load Diff
+23
-3
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-c6d54e22fa4d6137f643638da5d523e99ce84f9544cc793fd89163f1612953c6",
|
||||
"name": "prisma-client-45a91d7556f300a75a0048d27fac6a72915779fc4e5c2234b54fe3547ddb1605",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "index-browser.js",
|
||||
|
||||
+43
-17
@@ -411,18 +411,25 @@ enum MeterType {
|
||||
GAS
|
||||
}
|
||||
|
||||
enum MeterTariffModel {
|
||||
SINGLE // Eintarifzähler (Standard)
|
||||
DUAL // Zweitarifzähler (HT/NT)
|
||||
}
|
||||
|
||||
model Meter {
|
||||
id Int @id @default(autoincrement())
|
||||
customerId Int
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
meterNumber String
|
||||
type MeterType
|
||||
location String?
|
||||
isActive Boolean @default(true)
|
||||
readings MeterReading[]
|
||||
energyDetails EnergyContractDetails[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
customerId Int
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
meterNumber String
|
||||
type MeterType
|
||||
tariffModel MeterTariffModel @default(SINGLE) // Eintarif oder Zweitarif (HT/NT)
|
||||
location String?
|
||||
isActive Boolean @default(true)
|
||||
readings MeterReading[]
|
||||
energyDetails EnergyContractDetails[]
|
||||
contractMeters ContractMeter[] @relation("ContractMeters")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model MeterReading {
|
||||
@@ -430,7 +437,8 @@ model MeterReading {
|
||||
meterId Int
|
||||
meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade)
|
||||
readingDate DateTime
|
||||
value Float
|
||||
value Float // Bei Eintarif: Gesamtwert. Bei Zweitarif: HT-Wert
|
||||
valueNt Float? // Nur bei Zweitarif: NT-Wert (Niedertarif)
|
||||
unit String @default("kWh")
|
||||
notes String?
|
||||
// Meldung & Übertragung
|
||||
@@ -709,20 +717,38 @@ enum InvoiceType {
|
||||
}
|
||||
|
||||
model EnergyContractDetails {
|
||||
id Int @id @default(autoincrement())
|
||||
contractId Int @unique
|
||||
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
id Int @id @default(autoincrement())
|
||||
contractId Int @unique
|
||||
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
meterId Int?
|
||||
meter Meter? @relation(fields: [meterId], references: [id])
|
||||
meter Meter? @relation(fields: [meterId], references: [id])
|
||||
maloId String? // Marktlokations-ID
|
||||
annualConsumption Float? // kWh für Strom, m³ für Gas
|
||||
annualConsumptionKwh Float? // kWh für Gas (zusätzlich zu m³)
|
||||
basePrice Float? // €/Monat
|
||||
unitPrice Float? // €/kWh (Arbeitspreis)
|
||||
unitPrice Float? // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis
|
||||
unitPriceNt Float? // €/kWh NT-Preis (nur bei Zweitarifzähler)
|
||||
bonus Float?
|
||||
previousProviderName String?
|
||||
previousCustomerNumber String?
|
||||
invoices Invoice[] // Rechnungen
|
||||
contractMeters ContractMeter[] // Zähler-Zuordnungen (inkl. Folgezähler)
|
||||
}
|
||||
|
||||
model ContractMeter {
|
||||
id Int @id @default(autoincrement())
|
||||
energyContractDetailsId Int
|
||||
energyContractDetails EnergyContractDetails @relation(fields: [energyContractDetailsId], references: [id], onDelete: Cascade)
|
||||
meterId Int
|
||||
meter Meter @relation("ContractMeters", fields: [meterId], references: [id])
|
||||
position Int @default(0) // 0 = Original, 1 = erster Folgezähler, etc.
|
||||
installedAt DateTime? // Ab wann wird dieser Zähler am Vertrag genutzt?
|
||||
removedAt DateTime? // Wann wurde der Zähler gewechselt? (null = aktuell)
|
||||
finalReading Float? // Letzter Stand vor dem Wechsel
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([energyContractDetailsId, meterId])
|
||||
@@index([energyContractDetailsId])
|
||||
}
|
||||
|
||||
model Invoice {
|
||||
|
||||
+20
@@ -361,6 +361,7 @@ exports.Prisma.MeterScalarFieldEnum = {
|
||||
customerId: 'customerId',
|
||||
meterNumber: 'meterNumber',
|
||||
type: 'type',
|
||||
tariffModel: 'tariffModel',
|
||||
location: 'location',
|
||||
isActive: 'isActive',
|
||||
createdAt: 'createdAt',
|
||||
@@ -372,6 +373,7 @@ exports.Prisma.MeterReadingScalarFieldEnum = {
|
||||
meterId: 'meterId',
|
||||
readingDate: 'readingDate',
|
||||
value: 'value',
|
||||
valueNt: 'valueNt',
|
||||
unit: 'unit',
|
||||
notes: 'notes',
|
||||
reportedBy: 'reportedBy',
|
||||
@@ -529,11 +531,23 @@ exports.Prisma.EnergyContractDetailsScalarFieldEnum = {
|
||||
annualConsumptionKwh: 'annualConsumptionKwh',
|
||||
basePrice: 'basePrice',
|
||||
unitPrice: 'unitPrice',
|
||||
unitPriceNt: 'unitPriceNt',
|
||||
bonus: 'bonus',
|
||||
previousProviderName: 'previousProviderName',
|
||||
previousCustomerNumber: 'previousCustomerNumber'
|
||||
};
|
||||
|
||||
exports.Prisma.ContractMeterScalarFieldEnum = {
|
||||
id: 'id',
|
||||
energyContractDetailsId: 'energyContractDetailsId',
|
||||
meterId: 'meterId',
|
||||
position: 'position',
|
||||
installedAt: 'installedAt',
|
||||
removedAt: 'removedAt',
|
||||
finalReading: 'finalReading',
|
||||
createdAt: 'createdAt'
|
||||
};
|
||||
|
||||
exports.Prisma.InvoiceScalarFieldEnum = {
|
||||
id: 'id',
|
||||
energyContractDetailsId: 'energyContractDetailsId',
|
||||
@@ -742,6 +756,11 @@ exports.MeterType = exports.$Enums.MeterType = {
|
||||
GAS: 'GAS'
|
||||
};
|
||||
|
||||
exports.MeterTariffModel = exports.$Enums.MeterTariffModel = {
|
||||
SINGLE: 'SINGLE',
|
||||
DUAL: 'DUAL'
|
||||
};
|
||||
|
||||
exports.MeterReadingStatus = exports.$Enums.MeterReadingStatus = {
|
||||
RECORDED: 'RECORDED',
|
||||
REPORTED: 'REPORTED',
|
||||
@@ -855,6 +874,7 @@ exports.Prisma.ModelName = {
|
||||
ContractTask: 'ContractTask',
|
||||
ContractTaskSubtask: 'ContractTaskSubtask',
|
||||
EnergyContractDetails: 'EnergyContractDetails',
|
||||
ContractMeter: 'ContractMeter',
|
||||
Invoice: 'Invoice',
|
||||
InternetContractDetails: 'InternetContractDetails',
|
||||
PhoneNumber: 'PhoneNumber',
|
||||
|
||||
@@ -411,17 +411,24 @@ enum MeterType {
|
||||
GAS
|
||||
}
|
||||
|
||||
enum MeterTariffModel {
|
||||
SINGLE // Eintarifzähler (Standard)
|
||||
DUAL // Zweitarifzähler (HT/NT)
|
||||
}
|
||||
|
||||
model Meter {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
customerId Int
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
meterNumber String
|
||||
type MeterType
|
||||
tariffModel MeterTariffModel @default(SINGLE) // Eintarif oder Zweitarif (HT/NT)
|
||||
location String?
|
||||
isActive Boolean @default(true)
|
||||
readings MeterReading[]
|
||||
energyDetails EnergyContractDetails[]
|
||||
createdAt DateTime @default(now())
|
||||
isActive Boolean @default(true)
|
||||
readings MeterReading[]
|
||||
energyDetails EnergyContractDetails[]
|
||||
contractMeters ContractMeter[] @relation("ContractMeters")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -430,7 +437,8 @@ model MeterReading {
|
||||
meterId Int
|
||||
meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade)
|
||||
readingDate DateTime
|
||||
value Float
|
||||
value Float // Bei Eintarif: Gesamtwert. Bei Zweitarif: HT-Wert
|
||||
valueNt Float? // Nur bei Zweitarif: NT-Wert (Niedertarif)
|
||||
unit String @default("kWh")
|
||||
notes String?
|
||||
// Meldung & Übertragung
|
||||
@@ -718,11 +726,29 @@ model EnergyContractDetails {
|
||||
annualConsumption Float? // kWh für Strom, m³ für Gas
|
||||
annualConsumptionKwh Float? // kWh für Gas (zusätzlich zu m³)
|
||||
basePrice Float? // €/Monat
|
||||
unitPrice Float? // €/kWh (Arbeitspreis)
|
||||
unitPrice Float? // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis
|
||||
unitPriceNt Float? // €/kWh NT-Preis (nur bei Zweitarifzähler)
|
||||
bonus Float?
|
||||
previousProviderName String?
|
||||
previousCustomerNumber String?
|
||||
invoices Invoice[] // Rechnungen
|
||||
contractMeters ContractMeter[] // Zähler-Zuordnungen (inkl. Folgezähler)
|
||||
}
|
||||
|
||||
model ContractMeter {
|
||||
id Int @id @default(autoincrement())
|
||||
energyContractDetailsId Int
|
||||
energyContractDetails EnergyContractDetails @relation(fields: [energyContractDetailsId], references: [id], onDelete: Cascade)
|
||||
meterId Int
|
||||
meter Meter @relation("ContractMeters", fields: [meterId], references: [id])
|
||||
position Int @default(0) // 0 = Original, 1 = erster Folgezähler, etc.
|
||||
installedAt DateTime? // Ab wann wird dieser Zähler am Vertrag genutzt?
|
||||
removedAt DateTime? // Wann wurde der Zähler gewechselt? (null = aktuell)
|
||||
finalReading Float? // Letzter Stand vor dem Wechsel
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([energyContractDetailsId, meterId])
|
||||
@@index([energyContractDetailsId])
|
||||
}
|
||||
|
||||
model Invoice {
|
||||
|
||||
@@ -244,6 +244,79 @@ export async function getCockpit(req: AuthRequest, res: Response): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== FOLGEZÄHLER ====================
|
||||
|
||||
export async function addSuccessorMeter(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.id);
|
||||
const { meterId, installedAt, finalReadingPrevious } = req.body;
|
||||
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id: contractId },
|
||||
include: { energyDetails: { include: { contractMeters: { orderBy: { position: 'asc' } } } } },
|
||||
});
|
||||
|
||||
if (!contract?.energyDetails) {
|
||||
res.status(404).json({ success: false, error: 'Energievertrag nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const ecdId = contract.energyDetails.id;
|
||||
const existingMeters = contract.energyDetails.contractMeters;
|
||||
const nextPosition = existingMeters.length > 0
|
||||
? Math.max(...existingMeters.map(m => m.position)) + 1
|
||||
: 0;
|
||||
|
||||
// Vorherigen Zähler als gewechselt markieren
|
||||
if (existingMeters.length > 0 && finalReadingPrevious !== undefined) {
|
||||
const prevMeter = existingMeters[existingMeters.length - 1];
|
||||
await prisma.contractMeter.update({
|
||||
where: { id: prevMeter.id },
|
||||
data: {
|
||||
removedAt: installedAt ? new Date(installedAt) : new Date(),
|
||||
finalReading: parseFloat(finalReadingPrevious),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const contractMeter = await prisma.contractMeter.create({
|
||||
data: {
|
||||
energyContractDetailsId: ecdId,
|
||||
meterId: parseInt(meterId),
|
||||
position: nextPosition,
|
||||
installedAt: installedAt ? new Date(installedAt) : new Date(),
|
||||
},
|
||||
include: { meter: { include: { readings: true } } },
|
||||
});
|
||||
|
||||
// Aktuellen Zähler am Vertrag aktualisieren
|
||||
await prisma.energyContractDetails.update({
|
||||
where: { id: ecdId },
|
||||
data: { meterId: parseInt(meterId) },
|
||||
});
|
||||
|
||||
res.json({ success: true, data: contractMeter } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Folgezählers',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeContractMeter(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractMeterId = parseInt(req.params.contractMeterId);
|
||||
await prisma.contractMeter.delete({ where: { id: contractMeterId } });
|
||||
res.json({ success: true, data: null } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Entfernen',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ====================
|
||||
|
||||
export async function snoozeContract(req: Request, res: Response): Promise<void> {
|
||||
|
||||
@@ -293,7 +293,14 @@ export async function getMeterReadings(req: Request, res: Response): Promise<voi
|
||||
|
||||
export async function addMeterReading(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const reading = await customerService.addMeterReading(parseInt(req.params.meterId), req.body);
|
||||
const { readingDate, value, valueNt, unit, notes } = req.body;
|
||||
const reading = await customerService.addMeterReading(parseInt(req.params.meterId), {
|
||||
readingDate: new Date(readingDate),
|
||||
value: parseFloat(value),
|
||||
valueNt: valueNt !== undefined && valueNt !== null && valueNt !== '' ? parseFloat(valueNt) : undefined,
|
||||
unit,
|
||||
notes,
|
||||
});
|
||||
res.status(201).json({ success: true, data: reading } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
@@ -305,10 +312,18 @@ export async function addMeterReading(req: Request, res: Response): Promise<void
|
||||
|
||||
export async function updateMeterReading(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { readingDate, value, valueNt, unit, notes } = req.body;
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (readingDate !== undefined) updateData.readingDate = new Date(readingDate);
|
||||
if (value !== undefined) updateData.value = parseFloat(value);
|
||||
if (valueNt !== undefined) updateData.valueNt = valueNt !== null && valueNt !== '' ? parseFloat(valueNt) : null;
|
||||
if (unit !== undefined) updateData.unit = unit;
|
||||
if (notes !== undefined) updateData.notes = notes;
|
||||
|
||||
const reading = await customerService.updateMeterReading(
|
||||
parseInt(req.params.meterId),
|
||||
parseInt(req.params.readingId),
|
||||
req.body
|
||||
updateData as any
|
||||
);
|
||||
res.json({ success: true, data: reading } as ApiResponse);
|
||||
} catch (error) {
|
||||
@@ -358,15 +373,20 @@ export async function reportMeterReading(req: AuthRequest, res: Response): Promi
|
||||
return;
|
||||
}
|
||||
|
||||
const reading = await prisma.meterReading.create({
|
||||
data: {
|
||||
meterId,
|
||||
value: parseFloat(value),
|
||||
readingDate: readingDate ? new Date(readingDate) : new Date(),
|
||||
notes,
|
||||
reportedBy: user.email,
|
||||
status: 'REPORTED',
|
||||
},
|
||||
const parsedDate = readingDate ? new Date(readingDate) : new Date();
|
||||
const parsedValue = parseFloat(value);
|
||||
|
||||
// Validierung über den Service (monoton steigend)
|
||||
const reading = await customerService.addMeterReading(meterId, {
|
||||
readingDate: parsedDate,
|
||||
value: parsedValue,
|
||||
notes,
|
||||
});
|
||||
|
||||
// Status auf REPORTED setzen
|
||||
await prisma.meterReading.update({
|
||||
where: { id: reading.id },
|
||||
data: { reportedBy: user.email, status: 'REPORTED' },
|
||||
});
|
||||
|
||||
res.status(201).json({ success: true, data: reading } as ApiResponse);
|
||||
|
||||
+239
-16
@@ -24,6 +24,9 @@ const RESOURCE_MAPPING: Record<string, { type: string; extractId?: (req: AuthReq
|
||||
'/api/contract-durations': { type: 'ContractDuration', extractId: (req) => req.params.id },
|
||||
'/api/settings': { type: 'AppSetting', extractId: (req) => req.params.key },
|
||||
'/api/email-providers': { type: 'EmailProviderConfig', extractId: (req) => req.params.id },
|
||||
'/api/meters': { type: 'Meter', extractId: (req) => req.params.id || req.params.meterId },
|
||||
'/api/upload': { type: 'Upload' },
|
||||
'/api/email-logs': { type: 'EmailLog' },
|
||||
'/api/auth': { type: 'Authentication' },
|
||||
'/api/audit-logs': { type: 'AuditLog', extractId: (req) => req.params.id },
|
||||
'/api/gdpr': { type: 'GDPR' },
|
||||
@@ -116,6 +119,240 @@ function getClientIp(req: AuthRequest): string {
|
||||
return req.socket.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
// Menschenlesbare Bezeichnungen für Resource-Typen
|
||||
const RESOURCE_TYPE_LABELS: Record<string, string> = {
|
||||
Customer: 'Kunde',
|
||||
Contract: 'Vertrag',
|
||||
BankCard: 'Bankverbindung',
|
||||
IdentityDocument: 'Ausweis',
|
||||
Address: 'Adresse',
|
||||
Meter: 'Zähler',
|
||||
MeterReading: 'Zählerstand',
|
||||
User: 'Benutzer',
|
||||
Provider: 'Anbieter',
|
||||
Tariff: 'Tarif',
|
||||
SalesPlatform: 'Vertriebsplattform',
|
||||
ContractCategory: 'Vertragskategorie',
|
||||
CancellationPeriod: 'Kündigungsfrist',
|
||||
ContractDuration: 'Vertragslaufzeit',
|
||||
EmailProviderConfig: 'E-Mail-Provider',
|
||||
AppSetting: 'Einstellung',
|
||||
CustomerConsent: 'Einwilligung',
|
||||
ContractTask: 'Aufgabe',
|
||||
ContractHistoryEntry: 'Vertragshistorie',
|
||||
GDPR: 'Datenschutz',
|
||||
Authentication: 'Anmeldung',
|
||||
AuditLog: 'Audit-Protokoll',
|
||||
StressfreiEmail: 'Stressfrei-E-Mail',
|
||||
CachedEmail: 'E-Mail',
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
CREATE: 'erstellt',
|
||||
READ: 'aufgerufen',
|
||||
UPDATE: 'aktualisiert',
|
||||
DELETE: 'gelöscht',
|
||||
EXPORT: 'exportiert',
|
||||
ANONYMIZE: 'anonymisiert',
|
||||
LOGIN: 'angemeldet',
|
||||
LOGOUT: 'abgemeldet',
|
||||
LOGIN_FAILED: 'Anmeldung fehlgeschlagen',
|
||||
};
|
||||
|
||||
/**
|
||||
* Erzeugt ein menschenlesbares Label für den Audit-Log-Eintrag
|
||||
*/
|
||||
function generateHumanLabel(
|
||||
action: AuditAction,
|
||||
resourceType: string,
|
||||
req: AuthRequest,
|
||||
responseBody: unknown
|
||||
): string {
|
||||
const typeName = RESOURCE_TYPE_LABELS[resourceType] || resourceType;
|
||||
const actionName = ACTION_LABELS[action] || action;
|
||||
|
||||
// Identifikator aus Response oder Request extrahieren
|
||||
let identifier = '';
|
||||
if (responseBody && typeof responseBody === 'object' && 'data' in responseBody) {
|
||||
const data = (responseBody as { data: Record<string, unknown> }).data;
|
||||
if (data) {
|
||||
identifier =
|
||||
(data.contractNumber as string) ||
|
||||
(data.customerNumber as string) ||
|
||||
(data.meterNumber as string) ||
|
||||
(data.name as string) ||
|
||||
(data.email as string) ||
|
||||
(data.firstName && data.lastName ? `${data.firstName} ${data.lastName}` : '') ||
|
||||
'';
|
||||
}
|
||||
}
|
||||
|
||||
// Spezial-Labels für bestimmte Endpunkte
|
||||
const path = req.path;
|
||||
|
||||
// Auth
|
||||
if (path.includes('/auth/login') || path.includes('/auth/customer-login')) {
|
||||
const email = req.body?.email || '';
|
||||
return action === 'LOGIN'
|
||||
? `Benutzer ${email} hat sich angemeldet`
|
||||
: `Anmeldung fehlgeschlagen für ${email}`;
|
||||
}
|
||||
if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet';
|
||||
|
||||
// Kunden-Operationen
|
||||
if (resourceType === 'Customer') {
|
||||
if (action === 'CREATE') return `Kunde ${identifier} angelegt`;
|
||||
if (action === 'UPDATE') return `Kundendaten ${identifier} aktualisiert`;
|
||||
if (action === 'DELETE') return `Kunde ${identifier} gelöscht`;
|
||||
if (action === 'READ' && req.params.id) return `Kundendaten ${identifier} aufgerufen`;
|
||||
if (action === 'READ') return 'Kundenliste aufgerufen';
|
||||
}
|
||||
|
||||
// Verträge
|
||||
if (resourceType === 'Contract') {
|
||||
if (path.includes('/cockpit')) return 'Vertrags-Cockpit aufgerufen';
|
||||
if (path.includes('/follow-up')) return `Folgevertrag für ${identifier} erstellt`;
|
||||
if (path.includes('/snooze')) return `Vertrag ${identifier} zurückgestellt`;
|
||||
if (path.includes('/password')) return `Passwort für Vertrag ${identifier} abgerufen`;
|
||||
if (path.includes('/sip-credentials')) return 'SIP-Zugangsdaten abgerufen';
|
||||
if (path.includes('/internet-credentials')) return `Internet-Zugangsdaten für Vertrag ${identifier} abgerufen`;
|
||||
if (path.includes('/successor-meter')) return `Folgezähler zu Vertrag ${identifier} hinzugefügt`;
|
||||
if (action === 'CREATE') return `Vertrag ${identifier} angelegt`;
|
||||
if (action === 'UPDATE') return `Vertrag ${identifier} aktualisiert`;
|
||||
if (action === 'DELETE') return `Vertrag ${identifier} gelöscht`;
|
||||
if (action === 'READ' && req.params.id) return `Vertrag ${identifier} aufgerufen`;
|
||||
if (action === 'READ') return 'Vertragsliste aufgerufen';
|
||||
}
|
||||
|
||||
// Bankverbindungen
|
||||
if (resourceType === 'BankCard') {
|
||||
if (action === 'CREATE') return `Bankverbindung hinzugefügt`;
|
||||
if (action === 'UPDATE') return `Bankverbindung aktualisiert`;
|
||||
if (action === 'DELETE') return `Bankverbindung gelöscht`;
|
||||
}
|
||||
|
||||
// Ausweise
|
||||
if (resourceType === 'IdentityDocument') {
|
||||
if (action === 'CREATE') return `Ausweis ${identifier} hinzugefügt`;
|
||||
if (action === 'UPDATE') return `Ausweis ${identifier} aktualisiert`;
|
||||
if (action === 'DELETE') return `Ausweis gelöscht`;
|
||||
}
|
||||
|
||||
// Adressen
|
||||
if (resourceType === 'Address') {
|
||||
if (action === 'CREATE') return `Adresse hinzugefügt`;
|
||||
if (action === 'UPDATE') return `Adresse aktualisiert`;
|
||||
if (action === 'DELETE') return `Adresse gelöscht`;
|
||||
}
|
||||
|
||||
// Zähler
|
||||
if (resourceType === 'Meter') {
|
||||
if (action === 'CREATE') return `Zähler ${identifier} angelegt`;
|
||||
if (action === 'UPDATE') return `Zähler ${identifier} aktualisiert`;
|
||||
if (action === 'DELETE') return `Zähler gelöscht`;
|
||||
}
|
||||
|
||||
// Einwilligungen
|
||||
if (resourceType === 'CustomerConsent') {
|
||||
const consentType = req.params.consentType || '';
|
||||
const consentLabels: Record<string, string> = {
|
||||
DATA_PROCESSING: 'Datenverarbeitung',
|
||||
MARKETING_EMAIL: 'E-Mail-Marketing',
|
||||
MARKETING_PHONE: 'Telefonmarketing',
|
||||
DATA_SHARING_PARTNER: 'Datenweitergabe',
|
||||
};
|
||||
const consentName = consentLabels[consentType] || consentType;
|
||||
if (action === 'UPDATE') {
|
||||
const status = req.body?.status;
|
||||
return status === 'GRANTED'
|
||||
? `Einwilligung "${consentName}" erteilt`
|
||||
: `Einwilligung "${consentName}" widerrufen`;
|
||||
}
|
||||
if (action === 'READ') return 'Einwilligungen abgerufen';
|
||||
}
|
||||
|
||||
// Benutzer
|
||||
if (resourceType === 'User') {
|
||||
if (action === 'CREATE') return `Benutzer ${identifier} angelegt`;
|
||||
if (action === 'UPDATE') return `Benutzer ${identifier} aktualisiert`;
|
||||
if (action === 'DELETE') return `Benutzer ${identifier} gelöscht`;
|
||||
if (action === 'READ' && req.params.id) return `Benutzerdaten ${identifier} aufgerufen`;
|
||||
if (action === 'READ') return 'Benutzerliste aufgerufen';
|
||||
}
|
||||
|
||||
// Aufgaben
|
||||
if (resourceType === 'ContractTask') {
|
||||
if (path.includes('/complete')) return `Aufgabe als erledigt markiert`;
|
||||
if (action === 'CREATE') return `Aufgabe erstellt`;
|
||||
if (action === 'UPDATE') return `Aufgabe aktualisiert`;
|
||||
if (action === 'DELETE') return `Aufgabe gelöscht`;
|
||||
}
|
||||
|
||||
// E-Mail-Provider
|
||||
if (resourceType === 'EmailProviderConfig') {
|
||||
if (path.includes('/test-connection')) return `E-Mail-Provider Verbindungstest`;
|
||||
if (path.includes('/provision')) return `E-Mail-Adresse provisioniert`;
|
||||
if (action === 'CREATE') return `E-Mail-Provider ${identifier} angelegt`;
|
||||
if (action === 'UPDATE') return `E-Mail-Provider ${identifier} aktualisiert`;
|
||||
if (action === 'DELETE') return `E-Mail-Provider ${identifier} gelöscht`;
|
||||
}
|
||||
|
||||
// GDPR
|
||||
if (resourceType === 'GDPR') {
|
||||
if (path.includes('/dashboard')) return 'DSGVO-Dashboard aufgerufen';
|
||||
if (path.includes('/export')) return 'Kundendaten exportiert (DSGVO Art. 15)';
|
||||
if (path.includes('/privacy-policy')) {
|
||||
return action === 'UPDATE' ? 'Datenschutzerklärung aktualisiert' : 'Datenschutzerklärung aufgerufen';
|
||||
}
|
||||
if (path.includes('/authorization-template')) {
|
||||
return action === 'UPDATE' ? 'Vollmacht-Vorlage aktualisiert' : 'Vollmacht-Vorlage aufgerufen';
|
||||
}
|
||||
if (path.includes('/send-consent-link')) return 'Datenschutz-Link versendet';
|
||||
if (path.includes('/authorizations') && path.includes('/send')) return 'Vollmacht-Anfrage versendet';
|
||||
if (path.includes('/authorizations') && path.includes('/grant')) return 'Vollmacht erteilt';
|
||||
if (path.includes('/authorizations') && path.includes('/withdraw')) return 'Vollmacht widerrufen';
|
||||
if (path.includes('/authorizations') && path.includes('/upload')) return 'Vollmacht-PDF hochgeladen';
|
||||
if (path.includes('/authorizations') && path.includes('/document') && action === 'DELETE') return 'Vollmacht-PDF gelöscht';
|
||||
if (path.includes('/my-privacy')) return 'Eigene Datenschutzseite aufgerufen';
|
||||
if (path.includes('/my-consent-status')) return 'Eigener Einwilligungsstatus geprüft';
|
||||
if (path.includes('/my-authorizations')) return 'Eigene Vollmachten aufgerufen';
|
||||
if (path.includes('/deletions')) {
|
||||
if (action === 'CREATE') return 'Löschanfrage erstellt';
|
||||
if (path.includes('/process')) return 'Löschanfrage bearbeitet';
|
||||
return 'Löschanfragen aufgerufen';
|
||||
}
|
||||
if (path.includes('/consent-status')) return 'Einwilligungsstatus geprüft';
|
||||
if (path.includes('/consents/overview')) return 'Einwilligungsübersicht aufgerufen';
|
||||
}
|
||||
|
||||
// Einstellungen
|
||||
if (resourceType === 'AppSetting') {
|
||||
if (action === 'UPDATE') return `Einstellung "${req.params.key || ''}" geändert`;
|
||||
if (action === 'READ') return 'Einstellungen aufgerufen';
|
||||
}
|
||||
|
||||
// Zähler-Readings
|
||||
if (path.includes('/readings')) {
|
||||
if (path.includes('/report')) return 'Zählerstand vom Kunden gemeldet';
|
||||
if (path.includes('/transfer')) return 'Zählerstand als übertragen markiert';
|
||||
if (action === 'CREATE') return 'Zählerstand erfasst';
|
||||
if (action === 'UPDATE') return 'Zählerstand aktualisiert';
|
||||
if (action === 'DELETE') return 'Zählerstand gelöscht';
|
||||
}
|
||||
|
||||
// Upload-Operationen
|
||||
if (path.includes('/upload') || path.includes('/privacy-policy')) {
|
||||
if (path.includes('/privacy-policy') && action === 'DELETE') return 'Datenschutzerklärung-PDF gelöscht';
|
||||
if (path.includes('/privacy-policy')) return 'Datenschutzerklärung-PDF hochgeladen';
|
||||
}
|
||||
|
||||
// Standard-Fallback
|
||||
if (identifier) {
|
||||
return `${typeName} ${identifier} ${actionName}`;
|
||||
}
|
||||
return `${typeName} ${actionName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit Middleware - loggt alle API-Aufrufe asynchron
|
||||
*/
|
||||
@@ -162,22 +399,8 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
|
||||
// Audit-Kontext abrufen (enthält Before/After-Werte von Prisma Middleware)
|
||||
const auditContext = getAuditContext();
|
||||
|
||||
// Label für bessere Lesbarkeit generieren
|
||||
let resourceLabel: string | undefined;
|
||||
if (responseBody && typeof responseBody === 'object' && 'data' in responseBody) {
|
||||
const data = (responseBody as { data: Record<string, unknown> }).data;
|
||||
if (data) {
|
||||
// Versuche verschiedene Label-Felder
|
||||
resourceLabel =
|
||||
(data.contractNumber as string) ||
|
||||
(data.customerNumber as string) ||
|
||||
(data.name as string) ||
|
||||
(data.email as string) ||
|
||||
(data.firstName && data.lastName
|
||||
? `${data.firstName} ${data.lastName}`
|
||||
: undefined);
|
||||
}
|
||||
}
|
||||
// Menschenlesbares Label generieren
|
||||
const resourceLabel = generateHumanLabel(action, mapping.type, req, responseBody);
|
||||
|
||||
await createAuditLog({
|
||||
userId: req.user?.userId,
|
||||
|
||||
@@ -20,6 +20,10 @@ router.post('/:id/follow-up', authenticate, requirePermission('contracts:create'
|
||||
// Snooze (Vertrag zurückstellen)
|
||||
router.patch('/:id/snooze', authenticate, requirePermission('contracts:update'), contractController.snoozeContract);
|
||||
|
||||
// Folgezähler
|
||||
router.post('/:id/successor-meter', authenticate, requirePermission('contracts:update'), contractController.addSuccessorMeter);
|
||||
router.delete('/:id/contract-meter/:contractMeterId', authenticate, requirePermission('contracts:update'), contractController.removeContractMeter);
|
||||
|
||||
// Get decrypted password
|
||||
router.get('/:id/password', authenticate, requirePermission('contracts:read'), contractController.getContractPassword);
|
||||
|
||||
|
||||
@@ -440,6 +440,16 @@ router.post(
|
||||
data: { privacyPolicyPath: relativePath },
|
||||
});
|
||||
|
||||
// Alle Consents auf GRANTED setzen (PDF = vollständige Einwilligung)
|
||||
const consentTypes = ['DATA_PROCESSING', 'MARKETING_EMAIL', 'MARKETING_PHONE', 'DATA_SHARING_PARTNER'] as const;
|
||||
for (const consentType of consentTypes) {
|
||||
await prisma.customerConsent.upsert({
|
||||
where: { customerId_consentType: { customerId, consentType } },
|
||||
update: { status: 'GRANTED', grantedAt: new Date(), source: 'papier' },
|
||||
create: { customerId, consentType, status: 'GRANTED', grantedAt: new Date(), source: 'papier', createdBy: (req as any).user?.email || 'admin' },
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
@@ -488,6 +498,12 @@ router.delete(
|
||||
data: { privacyPolicyPath: null },
|
||||
});
|
||||
|
||||
// Nur Consents widerrufen die per Papier erteilt wurden
|
||||
await prisma.customerConsent.updateMany({
|
||||
where: { customerId, status: 'GRANTED', source: 'papier' },
|
||||
data: { status: 'WITHDRAWN', withdrawnAt: new Date() },
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
|
||||
@@ -146,13 +146,35 @@ export async function updateAuthorizationDocument(
|
||||
* Vollmacht-Dokument löschen
|
||||
*/
|
||||
export async function deleteAuthorizationDocument(customerId: number, representativeId: number) {
|
||||
// Prüfen ob die Vollmacht per Papier erteilt wurde
|
||||
const auth = await prisma.representativeAuthorization.findUnique({
|
||||
where: { customerId_representativeId: { customerId, representativeId } },
|
||||
select: { source: true, documentPath: true },
|
||||
});
|
||||
|
||||
if (!auth) throw new Error('Vollmacht nicht gefunden');
|
||||
|
||||
// Datei löschen
|
||||
if (auth.documentPath) {
|
||||
try {
|
||||
const filePath = path.join(process.cwd(), auth.documentPath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Löschen der Vollmacht-PDF:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn per Papier erteilt → Vollmacht widerrufen
|
||||
// Wenn per Portal/Online erteilt → nur PDF entfernen, Vollmacht bleibt
|
||||
const withdrawData = auth.source === 'papier'
|
||||
? { documentPath: null, isGranted: false, withdrawnAt: new Date() }
|
||||
: { documentPath: null };
|
||||
|
||||
return prisma.representativeAuthorization.update({
|
||||
where: {
|
||||
customerId_representativeId: { customerId, representativeId },
|
||||
},
|
||||
data: {
|
||||
documentPath: null,
|
||||
},
|
||||
where: { customerId_representativeId: { customerId, representativeId } },
|
||||
data: withdrawData,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -125,14 +125,14 @@ export async function getContractById(id: number, decryptPassword = false) {
|
||||
previousProvider: true,
|
||||
previousContract: {
|
||||
include: {
|
||||
energyDetails: { include: { meter: { include: { readings: true } }, invoices: true } },
|
||||
energyDetails: { include: { meter: { include: { readings: true } }, contractMeters: { include: { meter: { include: { readings: true } } }, orderBy: { position: 'asc' as const } }, invoices: true } },
|
||||
internetDetails: { include: { phoneNumbers: true } },
|
||||
mobileDetails: { include: { simCards: true } },
|
||||
tvDetails: true,
|
||||
carInsuranceDetails: true,
|
||||
},
|
||||
},
|
||||
energyDetails: { include: { meter: { include: { readings: true } }, invoices: true } },
|
||||
energyDetails: { include: { meter: { include: { readings: true } }, contractMeters: { include: { meter: { include: { readings: true } } }, orderBy: { position: 'asc' as const } }, invoices: true } },
|
||||
internetDetails: { include: { phoneNumbers: true } },
|
||||
mobileDetails: { include: { simCards: true } },
|
||||
tvDetails: true,
|
||||
@@ -403,11 +403,45 @@ export async function updateContract(
|
||||
|
||||
// Update type-specific details
|
||||
if (energyDetails) {
|
||||
const existingEcd = await prisma.energyContractDetails.findUnique({
|
||||
where: { contractId: id },
|
||||
select: { id: true, meterId: true },
|
||||
});
|
||||
|
||||
await prisma.energyContractDetails.upsert({
|
||||
where: { contractId: id },
|
||||
update: energyDetails,
|
||||
create: { contractId: id, ...energyDetails },
|
||||
});
|
||||
|
||||
// ContractMeter synchronisieren wenn sich der Zähler ändert
|
||||
if (energyDetails.meterId !== undefined && existingEcd) {
|
||||
const oldMeterId = existingEcd.meterId;
|
||||
const newMeterId = energyDetails.meterId;
|
||||
|
||||
if (oldMeterId !== newMeterId) {
|
||||
// Alle alten ContractMeter-Einträge entfernen
|
||||
await prisma.contractMeter.deleteMany({
|
||||
where: { energyContractDetailsId: existingEcd.id },
|
||||
});
|
||||
|
||||
// Neuen ContractMeter-Eintrag erstellen (wenn ein Zähler gesetzt)
|
||||
if (newMeterId) {
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id },
|
||||
select: { startDate: true },
|
||||
});
|
||||
await prisma.contractMeter.create({
|
||||
data: {
|
||||
energyContractDetailsId: existingEcd.id,
|
||||
meterId: newMeterId,
|
||||
position: 0,
|
||||
installedAt: contract?.startDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (internetDetails) {
|
||||
|
||||
@@ -89,6 +89,11 @@ export interface ReportedMeterReading {
|
||||
customerNumber: string;
|
||||
name: string;
|
||||
};
|
||||
// Zugehöriger Vertrag
|
||||
contract?: {
|
||||
id: number;
|
||||
contractNumber: string;
|
||||
};
|
||||
// Anbieter-Info für Quick-Login
|
||||
providerPortal?: {
|
||||
providerName: string;
|
||||
@@ -810,6 +815,7 @@ async function getReportedMeterReadings(): Promise<ReportedMeterReading[]> {
|
||||
contract: {
|
||||
select: {
|
||||
id: true,
|
||||
contractNumber: true,
|
||||
portalUsername: true,
|
||||
provider: {
|
||||
select: { id: true, name: true, portalUrl: true },
|
||||
@@ -847,6 +853,10 @@ async function getReportedMeterReadings(): Promise<ReportedMeterReading[]> {
|
||||
customerNumber: r.meter.customer.customerNumber,
|
||||
name: `${r.meter.customer.firstName} ${r.meter.customer.lastName}`,
|
||||
},
|
||||
contract: contract ? {
|
||||
id: contract.id,
|
||||
contractNumber: contract.contractNumber,
|
||||
} : undefined,
|
||||
providerPortal: provider?.portalUrl ? {
|
||||
providerName: provider.name,
|
||||
portalUrl: provider.portalUrl,
|
||||
|
||||
@@ -443,6 +443,30 @@ export async function updateMeter(
|
||||
}
|
||||
|
||||
export async function deleteMeter(id: number) {
|
||||
// Prüfen ob der Zähler noch an Verträgen hängt
|
||||
const linkedContracts = await prisma.contractMeter.findMany({
|
||||
where: { meterId: id },
|
||||
include: { energyContractDetails: { include: { contract: { select: { contractNumber: true } } } } },
|
||||
});
|
||||
|
||||
if (linkedContracts.length > 0) {
|
||||
const contractNumbers = linkedContracts
|
||||
.map(cm => cm.energyContractDetails.contract.contractNumber)
|
||||
.join(', ');
|
||||
throw new Error(`Zähler kann nicht gelöscht werden – noch an Vertrag/Verträgen zugeordnet: ${contractNumbers}`);
|
||||
}
|
||||
|
||||
// Auch direkte meterId-Referenz auf EnergyContractDetails prüfen
|
||||
const directLinks = await prisma.energyContractDetails.findMany({
|
||||
where: { meterId: id },
|
||||
include: { contract: { select: { contractNumber: true } } },
|
||||
});
|
||||
|
||||
if (directLinks.length > 0) {
|
||||
const contractNumbers = directLinks.map(d => d.contract.contractNumber).join(', ');
|
||||
throw new Error(`Zähler kann nicht gelöscht werden – noch an Vertrag/Verträgen zugeordnet: ${contractNumbers}`);
|
||||
}
|
||||
|
||||
return prisma.meter.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -451,14 +475,25 @@ export async function addMeterReading(
|
||||
data: {
|
||||
readingDate: Date;
|
||||
value: number;
|
||||
valueNt?: number;
|
||||
unit?: string;
|
||||
notes?: string;
|
||||
}
|
||||
) {
|
||||
// Validierung: Zählerstand muss monoton steigend sein
|
||||
await validateReadingValue(meterId, data.readingDate, data.value, undefined, 'HT');
|
||||
if (data.valueNt !== undefined) {
|
||||
await validateReadingValue(meterId, data.readingDate, data.valueNt, undefined, 'NT');
|
||||
}
|
||||
|
||||
return prisma.meterReading.create({
|
||||
data: {
|
||||
meterId,
|
||||
...data,
|
||||
readingDate: data.readingDate,
|
||||
value: data.value,
|
||||
valueNt: data.valueNt,
|
||||
unit: data.unit,
|
||||
notes: data.notes,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -476,6 +511,7 @@ export async function updateMeterReading(
|
||||
data: {
|
||||
readingDate?: Date;
|
||||
value?: number;
|
||||
valueNt?: number | null;
|
||||
unit?: string;
|
||||
notes?: string;
|
||||
}
|
||||
@@ -489,12 +525,65 @@ export async function updateMeterReading(
|
||||
throw new Error('Zählerstand nicht gefunden');
|
||||
}
|
||||
|
||||
// Validierung bei Wertänderung
|
||||
if (data.value !== undefined || data.readingDate !== undefined) {
|
||||
await validateReadingValue(
|
||||
meterId,
|
||||
data.readingDate || reading.readingDate,
|
||||
data.value ?? reading.value,
|
||||
readingId,
|
||||
'HT'
|
||||
);
|
||||
}
|
||||
if (data.valueNt !== undefined || data.readingDate !== undefined) {
|
||||
const ntVal = data.valueNt ?? reading.valueNt;
|
||||
if (ntVal !== undefined && ntVal !== null) {
|
||||
await validateReadingValue(
|
||||
meterId,
|
||||
data.readingDate || reading.readingDate,
|
||||
ntVal,
|
||||
readingId,
|
||||
'NT'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return prisma.meterReading.update({
|
||||
where: { id: readingId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert, dass ein Zählerstand monoton steigend ist.
|
||||
* tariffLabel: 'HT' für Hochtarif/Eintarif, 'NT' für Niedertarif
|
||||
*/
|
||||
async function validateReadingValue(meterId: number, readingDate: Date, value: number, excludeReadingId?: number, tariffLabel: 'HT' | 'NT' = 'HT') {
|
||||
const existing = await prisma.meterReading.findMany({
|
||||
where: { meterId, ...(excludeReadingId ? { id: { not: excludeReadingId } } : {}) },
|
||||
orderBy: { readingDate: 'asc' },
|
||||
});
|
||||
|
||||
const fmtDate = (d: Date) => d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const fmtVal = (v: number) => v.toLocaleString('de-DE');
|
||||
const label = tariffLabel === 'NT' ? 'NT-Zählerstand' : 'Zählerstand';
|
||||
|
||||
// Vergleichswert aus bestehendem Reading extrahieren
|
||||
const getVal = (r: typeof existing[0]) => tariffLabel === 'NT' ? (r.valueNt ?? 0) : r.value;
|
||||
|
||||
// Stand vor dem neuen Datum
|
||||
const before = [...existing].filter(r => r.readingDate <= readingDate).pop();
|
||||
if (before && value < getVal(before)) {
|
||||
throw new Error(`${label} (${fmtVal(value)}) darf nicht kleiner sein als der Stand vom ${fmtDate(before.readingDate)} (${fmtVal(getVal(before))})`);
|
||||
}
|
||||
|
||||
// Stand nach dem neuen Datum
|
||||
const after = existing.find(r => r.readingDate > readingDate);
|
||||
if (after && value > getVal(after)) {
|
||||
throw new Error(`${label} (${fmtVal(value)}) darf nicht größer sein als der spätere Stand vom ${fmtDate(after.readingDate)} (${fmtVal(getVal(after))})`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMeterReading(meterId: number, readingId: number) {
|
||||
// Verify the reading belongs to the meter
|
||||
const reading = await prisma.meterReading.findFirst({
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { pushHistory } from '../../utils/navigation';
|
||||
import { contractApi, meterApi } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
@@ -282,7 +283,7 @@ export default function ContractCockpit() {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Link
|
||||
to={`/contracts/${contract.id}`}
|
||||
state={{ from: '/contracts/cockpit' }}
|
||||
state={pushHistory('/contracts/cockpit')}
|
||||
className="font-medium hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -399,7 +400,7 @@ export default function ContractCockpit() {
|
||||
|
||||
<Link
|
||||
to={`/contracts/${contract.id}`}
|
||||
state={{ from: '/contracts/cockpit' }}
|
||||
state={pushHistory('/contracts/cockpit')}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-50 rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Zum Vertrag"
|
||||
@@ -610,6 +611,15 @@ export default function ContractCockpit() {
|
||||
{reading.customer.name}
|
||||
</Link>
|
||||
<span className="text-xs text-gray-500">({reading.customer.customerNumber})</span>
|
||||
{reading.contract && (
|
||||
<Link
|
||||
to={`/contracts/${reading.contract.id}`}
|
||||
state={pushHistory('/contracts/cockpit')}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
{reading.contract.contractNumber}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
Zähler {reading.meter.meterNumber} – <strong>{reading.value} {reading.unit}</strong> am{' '}
|
||||
@@ -635,6 +645,7 @@ export default function ContractCockpit() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (!confirm(`Zählerstand ${reading.value} ${reading.unit} (Zähler ${reading.meter.meterNumber}) als übertragen markieren?`)) return;
|
||||
await meterApi.markTransferred(reading.meter.id, reading.id);
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-cockpit'] });
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { pushHistory, popHistory } from '../../utils/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdprApi } from '../../services/api';
|
||||
import { ContractEmailsSection } from '../../components/email';
|
||||
@@ -13,9 +14,9 @@ import Input from '../../components/ui/Input';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
import FileUpload from '../../components/ui/FileUpload';
|
||||
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff, Lock, Shield } from 'lucide-react';
|
||||
import { calculateConsumption, calculateCosts } from '../../utils/energyCalculations';
|
||||
import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
|
||||
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask } from '../../types';
|
||||
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter } from '../../types';
|
||||
|
||||
const typeLabels: Record<ContractType, string> = {
|
||||
ELECTRICITY: 'Strom',
|
||||
@@ -197,16 +198,19 @@ function SimCardDisplay({ simCard }: { simCard: SimCard }) {
|
||||
function MeterReadingsSection({
|
||||
meterId,
|
||||
meterType,
|
||||
tariffModel,
|
||||
readings,
|
||||
contractId,
|
||||
canEdit,
|
||||
label,
|
||||
}: {
|
||||
meterId: number;
|
||||
meterType: 'ELECTRICITY' | 'GAS';
|
||||
tariffModel?: string;
|
||||
readings: MeterReading[];
|
||||
contractId: number;
|
||||
canEdit: boolean;
|
||||
label?: string;
|
||||
}) {
|
||||
const isDualTariff = tariffModel === 'DUAL';
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingReading, setEditingReading] = useState<MeterReading | null>(null);
|
||||
@@ -215,7 +219,7 @@ function MeterReadingsSection({
|
||||
const deleteReadingMutation = useMutation({
|
||||
mutationFn: (readingId: number) => meterApi.deleteReading(meterId, readingId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contract'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -231,7 +235,7 @@ function MeterReadingsSection({
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="w-4 h-4 text-gray-500" />
|
||||
<h4 className="text-sm font-medium text-gray-700">Zählerstände</h4>
|
||||
<h4 className="text-sm font-medium text-gray-700">{label || 'Zählerstände'}</h4>
|
||||
<Badge variant="default">{readings.length}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -274,8 +278,12 @@ function MeterReadingsSection({
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono flex items-center gap-1">
|
||||
{reading.value.toLocaleString('de-DE')} {reading.unit}
|
||||
<CopyButton value={reading.value.toString()} title="Nur Wert kopieren" />
|
||||
{isDualTariff && reading.valueNt != null ? (
|
||||
<>HT: {reading.value.toLocaleString('de-DE')} / NT: {reading.valueNt.toLocaleString('de-DE')} {reading.unit}</>
|
||||
) : (
|
||||
<>{reading.value.toLocaleString('de-DE')} {reading.unit}</>
|
||||
)}
|
||||
<CopyButton value={reading.value.toString()} title="Wert kopieren" />
|
||||
</span>
|
||||
{canEdit && (
|
||||
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
|
||||
@@ -324,9 +332,9 @@ function MeterReadingsSection({
|
||||
setEditingReading(null);
|
||||
}}
|
||||
meterId={meterId}
|
||||
contractId={contractId}
|
||||
reading={editingReading}
|
||||
defaultUnit={defaultUnit}
|
||||
tariffModel={tariffModel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -334,22 +342,135 @@ function MeterReadingsSection({
|
||||
}
|
||||
|
||||
// Meter Reading Modal Component
|
||||
function SuccessorMeterButton({
|
||||
contractId,
|
||||
customerId,
|
||||
meterType,
|
||||
existingMeterIds,
|
||||
}: {
|
||||
contractId: number;
|
||||
customerId: number;
|
||||
meterType: string;
|
||||
existingMeterIds: number[];
|
||||
}) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedMeterId, setSelectedMeterId] = useState('');
|
||||
const [installedAt, setInstalledAt] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [finalReading, setFinalReading] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: metersData } = useQuery({
|
||||
queryKey: ['customer-meters', customerId],
|
||||
queryFn: () => meterApi.getByCustomer(customerId),
|
||||
enabled: showForm,
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: { meterId: number; installedAt?: string; finalReadingPrevious?: number }) =>
|
||||
contractApi.addSuccessorMeter(contractId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract'] });
|
||||
setShowForm(false);
|
||||
setSelectedMeterId('');
|
||||
setFinalReading('');
|
||||
},
|
||||
});
|
||||
|
||||
// Nur Zähler gleichen Typs anzeigen die noch nicht am Vertrag sind
|
||||
const availableMeters = (metersData?.data || []).filter(
|
||||
(m) => m.type === meterType && !existingMeterIds.includes(m.id) && m.isActive
|
||||
);
|
||||
|
||||
if (!showForm) {
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Folgezähler hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t bg-blue-50 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium mb-3">Folgezähler hinzufügen (Zählerwechsel)</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Neuer Zähler *</label>
|
||||
<select
|
||||
value={selectedMeterId}
|
||||
onChange={(e) => setSelectedMeterId(e.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{availableMeters.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.meterNumber} {m.location ? `(${m.location})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{availableMeters.length === 0 && (
|
||||
<p className="text-xs text-amber-600 mt-1">Kein passender Zähler verfügbar. Bitte zuerst beim Kunden anlegen.</p>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
label="Wechseldatum"
|
||||
type="date"
|
||||
value={installedAt}
|
||||
onChange={(e) => setInstalledAt(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Letzter Stand alter Zähler"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={finalReading}
|
||||
onChange={(e) => setFinalReading(e.target.value)}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => addMutation.mutate({
|
||||
meterId: parseInt(selectedMeterId),
|
||||
installedAt,
|
||||
finalReadingPrevious: finalReading ? parseFloat(finalReading) : undefined,
|
||||
})}
|
||||
disabled={!selectedMeterId || addMutation.isPending}
|
||||
>
|
||||
{addMutation.isPending ? 'Speichern...' : 'Hinzufügen'}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowForm(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
{addMutation.isError && (
|
||||
<p className="text-xs text-red-600 mt-2">
|
||||
{addMutation.error instanceof Error ? addMutation.error.message : 'Fehler beim Hinzufügen'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MeterReadingModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
meterId,
|
||||
contractId,
|
||||
reading,
|
||||
defaultUnit,
|
||||
tariffModel,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
meterId: number;
|
||||
contractId: number;
|
||||
reading?: MeterReading | null;
|
||||
defaultUnit: string;
|
||||
tariffModel?: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const isDualTariff = tariffModel === 'DUAL';
|
||||
const isEditing = !!reading;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -357,33 +478,47 @@ function MeterReadingModal({
|
||||
? new Date(reading.readingDate).toISOString().split('T')[0]
|
||||
: new Date().toISOString().split('T')[0],
|
||||
value: reading?.value?.toString() || '',
|
||||
valueNt: reading?.valueNt?.toString() || '',
|
||||
notes: reading?.notes || '',
|
||||
});
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Partial<MeterReading>) => meterApi.addReading(meterId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contract'] });
|
||||
setError(null);
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Speichern');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Partial<MeterReading>) => meterApi.updateReading(meterId, reading!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contract'] });
|
||||
setError(null);
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Speichern');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
const data: Record<string, unknown> = {
|
||||
readingDate: new Date(formData.readingDate),
|
||||
value: parseFloat(formData.value),
|
||||
unit: defaultUnit,
|
||||
notes: formData.notes || undefined,
|
||||
};
|
||||
if (isDualTariff && formData.valueNt) {
|
||||
data.valueNt = parseFloat(formData.valueNt);
|
||||
}
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(data as unknown as Partial<MeterReading>);
|
||||
} else {
|
||||
@@ -404,10 +539,10 @@ function MeterReadingModal({
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<div className={`grid ${isDualTariff ? 'grid-cols-2' : 'grid-cols-3'} gap-4`}>
|
||||
<div className={isDualTariff ? '' : 'col-span-2'}>
|
||||
<Input
|
||||
label="Zählerstand"
|
||||
label={isDualTariff ? 'HT-Stand (Hochtarif)' : 'Zählerstand'}
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.value}
|
||||
@@ -415,12 +550,26 @@ function MeterReadingModal({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Einheit</label>
|
||||
<div className="h-10 flex items-center px-3 bg-gray-100 border border-gray-300 rounded-md text-gray-700">
|
||||
{defaultUnit}
|
||||
{isDualTariff && (
|
||||
<div>
|
||||
<Input
|
||||
label="NT-Stand (Niedertarif)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.valueNt}
|
||||
onChange={(e) => setFormData({ ...formData, valueNt: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isDualTariff && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Einheit</label>
|
||||
<div className="h-10 flex items-center px-3 bg-gray-100 border border-gray-300 rounded-md text-gray-700">
|
||||
{defaultUnit}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
@@ -429,6 +578,12 @@ function MeterReadingModal({
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
@@ -450,24 +605,67 @@ function EnergyConsumptionCalculation({
|
||||
endDate,
|
||||
basePrice,
|
||||
unitPrice,
|
||||
unitPriceNt,
|
||||
bonus,
|
||||
hasMeter,
|
||||
contractMeters,
|
||||
}: {
|
||||
contractType: 'ELECTRICITY' | 'GAS';
|
||||
readings: MeterReading[];
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
basePrice?: number;
|
||||
unitPrice?: number;
|
||||
bonus?: number;
|
||||
unitPriceNt?: number;
|
||||
hasMeter?: boolean;
|
||||
contractMeters?: ContractMeter[];
|
||||
}) {
|
||||
// Berechnung durchführen
|
||||
const consumption = calculateConsumption(readings, startDate, endDate, contractType);
|
||||
const costs = consumption.consumptionKwh > 0
|
||||
? calculateCosts(consumption.consumptionKwh, basePrice, unitPrice, bonus)
|
||||
// Fehlende Felder ermitteln
|
||||
const missingFields: string[] = [];
|
||||
if (!startDate) missingFields.push('Vertragsbeginn');
|
||||
if (!endDate) missingFields.push('Vertragsende');
|
||||
if (!hasMeter) missingFields.push('Zähler verknüpft');
|
||||
if (basePrice == null) missingFields.push('Grundpreis');
|
||||
if (unitPrice == null) missingFields.push('Arbeitspreis');
|
||||
|
||||
const hasRequiredDates = !!startDate && !!endDate;
|
||||
|
||||
// Alle Readings über alle Zähler zählen
|
||||
const allReadingsCount = contractMeters && contractMeters.length > 0
|
||||
? contractMeters.reduce((sum, cm) => sum + (cm.meter?.readings?.length || 0), 0)
|
||||
: readings.length;
|
||||
|
||||
if (hasRequiredDates && hasMeter && allReadingsCount < 2) {
|
||||
missingFields.push(`Mindestens 2 Zählerstände im Vertragszeitraum (${allReadingsCount} vorhanden)`);
|
||||
}
|
||||
|
||||
// Berechnung: Multi-Zähler wenn vorhanden, sonst Single
|
||||
let consumption = null;
|
||||
if (hasRequiredDates) {
|
||||
if (contractMeters && contractMeters.length > 1) {
|
||||
consumption = calculateMultiMeterConsumption(contractMeters, startDate!, endDate!, contractType);
|
||||
} else {
|
||||
consumption = calculateConsumption(readings, startDate!, endDate!, contractType);
|
||||
}
|
||||
}
|
||||
|
||||
const htKwh = consumption?.consumptionHt ?? consumption?.consumptionKwh ?? 0;
|
||||
const ntKwh = consumption?.consumptionNt;
|
||||
const costs = consumption && consumption.consumptionKwh > 0
|
||||
? calculateCosts(htKwh, basePrice, unitPrice, bonus, ntKwh, unitPriceNt)
|
||||
: null;
|
||||
|
||||
// Nichts anzeigen wenn keine Daten
|
||||
if (consumption.type === 'none') return null;
|
||||
const canCalculate = consumption && (consumption.type === 'exact' || consumption.type === 'projected');
|
||||
|
||||
// Wenn Berechnung fehlschlägt aber keine Felder fehlen → Hinweis aus der Berechnung anzeigen
|
||||
if (!canCalculate && consumption && missingFields.length === 0) {
|
||||
if (consumption.type === 'insufficient' && consumption.message) {
|
||||
missingFields.push(consumption.message);
|
||||
} else if (consumption.type === 'none') {
|
||||
missingFields.push('Keine Zählerstände im Vertragszeitraum vorhanden');
|
||||
}
|
||||
}
|
||||
|
||||
const formatNumber = (num: number, decimals: number = 2) =>
|
||||
num.toLocaleString('de-DE', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||
@@ -481,40 +679,61 @@ function EnergyConsumptionCalculation({
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Calculator className="w-4 h-4 text-gray-500" />
|
||||
<h4 className="text-sm font-medium text-gray-700">Verbrauch & Kosten</h4>
|
||||
{consumption.type === 'exact' && (
|
||||
{canCalculate && consumption!.type === 'exact' && (
|
||||
<Badge variant="success">Exakt</Badge>
|
||||
)}
|
||||
{consumption.type === 'projected' && (
|
||||
{canCalculate && consumption!.type === 'projected' && (
|
||||
<Badge variant="warning">Hochrechnung</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fall C: Unzureichende Daten */}
|
||||
{consumption.type === 'insufficient' ? (
|
||||
<p className="text-sm text-gray-500 italic">{consumption.message}</p>
|
||||
{/* Fehlende Daten */}
|
||||
{!canCalculate ? (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Für die Berechnung fehlen noch folgende Angaben:
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{missingFields.map((field) => (
|
||||
<li key={field} className="flex items-center gap-2 text-sm">
|
||||
<Circle className="w-3 h-3 text-gray-300 flex-shrink-0" />
|
||||
<span className="text-gray-500">{field}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{consumption?.type === 'insufficient' && (
|
||||
<p className="text-sm text-amber-600 mt-3 italic">{consumption.message}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-50 rounded-lg p-4 space-y-4">
|
||||
{/* Verbrauchsanzeige */}
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-gray-600 mb-2">
|
||||
Berechneter Verbrauch
|
||||
{consumption.type === 'projected' && ' (hochgerechnet)'}
|
||||
{consumption!.type === 'projected' && ' (hochgerechnet)'}
|
||||
</h5>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{contractType === 'GAS' ? (
|
||||
<>
|
||||
<span className="font-mono">{formatNumber(consumption.consumptionM3 || 0)} m³</span>
|
||||
<span className="font-mono">{formatNumber(consumption!.consumptionM3 || 0)} m³</span>
|
||||
<span className="text-gray-500 text-sm ml-2">
|
||||
= {formatNumber(consumption.consumptionKwh)} kWh
|
||||
= {formatNumber(consumption!.consumptionKwh)} kWh
|
||||
</span>
|
||||
</>
|
||||
) : consumption!.consumptionHt !== undefined && consumption!.consumptionNt !== undefined ? (
|
||||
<div className="space-y-1">
|
||||
<div><span className="text-sm text-gray-500">HT:</span> <span className="font-mono">{formatNumber(consumption!.consumptionHt)} kWh</span></div>
|
||||
<div><span className="text-sm text-gray-500">NT:</span> <span className="font-mono">{formatNumber(consumption!.consumptionNt)} kWh</span></div>
|
||||
<div className="text-sm text-gray-500">Gesamt: {formatNumber(consumption!.consumptionKwh)} kWh</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-mono">{formatNumber(consumption.consumptionKwh)} kWh</span>
|
||||
<span className="font-mono">{formatNumber(consumption!.consumptionKwh)} kWh</span>
|
||||
)}
|
||||
</div>
|
||||
{consumption.startReading && consumption.endReading && (
|
||||
{consumption!.startReading && consumption!.endReading && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Basierend auf Zählerständen vom {formatDate(consumption.startReading.readingDate)} bis {formatDate(consumption.endReading.readingDate)}
|
||||
Basierend auf Zählerständen vom {formatDate(consumption!.startReading.readingDate)} bis {formatDate(consumption!.endReading.readingDate)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -533,15 +752,24 @@ function EnergyConsumptionCalculation({
|
||||
<span className="font-mono">{formatNumber(costs.annualBaseCost)} €</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Arbeitspreis */}
|
||||
{/* Arbeitspreis HT */}
|
||||
{unitPrice != null && unitPrice > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">
|
||||
Arbeitspreis: {formatNumber(consumption.consumptionKwh)} kWh × {formatNumber(unitPrice, 4)} €
|
||||
{costs.annualConsumptionCostNt ? 'HT-Arbeitspreis' : 'Arbeitspreis'}: {formatNumber(htKwh)} kWh × {formatNumber(unitPrice, 4)} €
|
||||
</span>
|
||||
<span className="font-mono">{formatNumber(costs.annualConsumptionCost)} €</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Arbeitspreis NT */}
|
||||
{costs.annualConsumptionCostNt != null && costs.annualConsumptionCostNt > 0 && unitPriceNt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">
|
||||
NT-Arbeitspreis: {formatNumber(ntKwh || 0)} kWh × {formatNumber(unitPriceNt, 4)} €
|
||||
</span>
|
||||
<span className="font-mono">{formatNumber(costs.annualConsumptionCostNt)} €</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Trennlinie */}
|
||||
<div className="border-t border-gray-300 pt-2">
|
||||
<div className="flex justify-between font-medium">
|
||||
@@ -1212,7 +1440,7 @@ export default function ContractDetail() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const backTo = (location.state as any)?.from as string | undefined;
|
||||
const currentPath = `/contracts/${id}`;
|
||||
const { hasPermission, isCustomer, isCustomerPortal } = useAuth();
|
||||
const contractId = parseInt(id!);
|
||||
|
||||
@@ -1424,13 +1652,15 @@ export default function ContractDetail() {
|
||||
}
|
||||
|
||||
const c = data.data;
|
||||
const fallbackBack = isCustomerPortal ? '/contracts' : (c.customer ? `/customers/${c.customer.id}?tab=contracts` : '/contracts');
|
||||
const back = popHistory(location.state, fallbackBack);
|
||||
|
||||
// Consent-Sperrung: Vertrag nicht anzeigen wenn Kunde keine Einwilligung hat
|
||||
if (!hasConsentApproval) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(backTo || (isCustomerPortal ? '/contracts' : (c.customer ? `/customers/${c.customer.id}?tab=contracts` : '/contracts')))}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">Vertrag {c.contractNumber}</h1>
|
||||
@@ -1465,7 +1695,7 @@ export default function ContractDetail() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(backTo || (isCustomerPortal ? '/contracts' : (c.customer ? `/customers/${c.customer.id}?tab=contracts` : '/contracts')))}
|
||||
onClick={() => navigate(back.to, { state: back.state })}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -1499,7 +1729,7 @@ export default function ContractDetail() {
|
||||
{c.customer && (
|
||||
<p className="text-gray-500 ml-10">
|
||||
Kunde:{' '}
|
||||
<Link to={`/customers/${c.customer.id}`} state={{ from: `/contracts/${id}` }} className="text-blue-600 hover:underline">
|
||||
<Link to={`/customers/${c.customer.id}`} state={pushHistory(currentPath, location.state)} className="text-blue-600 hover:underline">
|
||||
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
|
||||
</Link>
|
||||
</p>
|
||||
@@ -1534,7 +1764,7 @@ export default function ContractDetail() {
|
||||
</Link>
|
||||
)}
|
||||
{hasPermission('contracts:update') && (
|
||||
<Link to={`/contracts/${id}/edit`} state={{ from: `/contracts/${id}` }}>
|
||||
<Link to={`/contracts/${id}/edit`} state={pushHistory(currentPath, location.state)}>
|
||||
<Button variant="secondary">
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Bearbeiten
|
||||
@@ -2201,7 +2431,12 @@ export default function ContractDetail() {
|
||||
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{c.energyDetails.meter && (
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Zählernummer</dt>
|
||||
<dt className="text-sm text-gray-500 flex items-center gap-1">
|
||||
Zählernummer
|
||||
{c.energyDetails.meter.tariffModel === 'DUAL' && (
|
||||
<Badge variant="default">HT/NT</Badge>
|
||||
)}
|
||||
</dt>
|
||||
<dd className="font-mono flex items-center gap-1">
|
||||
{c.energyDetails.meter.meterNumber}
|
||||
<CopyButton value={c.energyDetails.meter.meterNumber} />
|
||||
@@ -2242,12 +2477,22 @@ export default function ContractDetail() {
|
||||
)}
|
||||
{c.energyDetails.unitPrice != null && (
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Arbeitspreis</dt>
|
||||
<dt className="text-sm text-gray-500">
|
||||
{c.energyDetails.unitPriceNt != null ? 'HT-Arbeitspreis' : 'Arbeitspreis'}
|
||||
</dt>
|
||||
<dd>
|
||||
{c.energyDetails.unitPrice.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 10 })} €/kWh
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{c.energyDetails.unitPriceNt != null && (
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">NT-Arbeitspreis</dt>
|
||||
<dd>
|
||||
{c.energyDetails.unitPriceNt.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 10 })} €/kWh
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{c.energyDetails.bonus && (
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Bonus</dt>
|
||||
@@ -2271,29 +2516,58 @@ export default function ContractDetail() {
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{/* Zählerstände */}
|
||||
{c.energyDetails.meter && (
|
||||
{/* Zählerstände - Multi-Zähler Support */}
|
||||
{c.energyDetails.contractMeters && c.energyDetails.contractMeters.length > 0 ? (
|
||||
<>
|
||||
{c.energyDetails.contractMeters.map((cm: ContractMeter, idx: number) => (
|
||||
cm.meter && (
|
||||
<MeterReadingsSection
|
||||
key={cm.id}
|
||||
meterId={cm.meter.id}
|
||||
meterType={cm.meter.type}
|
||||
tariffModel={cm.meter.tariffModel}
|
||||
readings={cm.meter.readings || []}
|
||||
canEdit={hasPermission('contracts:update') && !isCustomer}
|
||||
label={c.energyDetails!.contractMeters!.length > 1
|
||||
? `Zähler ${idx + 1}: ${cm.meter.meterNumber}${cm.removedAt ? ' (gewechselt)' : ' (aktuell)'}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
{/* Folgezähler hinzufügen */}
|
||||
{hasPermission('contracts:update') && !isCustomer && (
|
||||
<SuccessorMeterButton
|
||||
contractId={contractId}
|
||||
customerId={c.customerId}
|
||||
meterType={c.type as 'ELECTRICITY' | 'GAS'}
|
||||
existingMeterIds={c.energyDetails.contractMeters.map((cm: ContractMeter) => cm.meterId)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : c.energyDetails.meter ? (
|
||||
<MeterReadingsSection
|
||||
meterId={c.energyDetails.meter.id}
|
||||
meterType={c.energyDetails.meter.type}
|
||||
tariffModel={c.energyDetails.meter.tariffModel}
|
||||
readings={c.energyDetails.meter.readings || []}
|
||||
contractId={contractId}
|
||||
canEdit={hasPermission('contracts:update') && !isCustomer}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Verbrauchsberechnung & Kostenvorschau */}
|
||||
{c.energyDetails.meter && c.startDate && c.endDate && (
|
||||
<EnergyConsumptionCalculation
|
||||
contractType={c.type as 'ELECTRICITY' | 'GAS'}
|
||||
readings={c.energyDetails.meter.readings || []}
|
||||
startDate={c.startDate}
|
||||
endDate={c.endDate}
|
||||
basePrice={c.energyDetails.basePrice}
|
||||
unitPrice={c.energyDetails.unitPrice}
|
||||
bonus={c.energyDetails.bonus}
|
||||
/>
|
||||
)}
|
||||
<EnergyConsumptionCalculation
|
||||
contractType={c.type as 'ELECTRICITY' | 'GAS'}
|
||||
readings={c.energyDetails.meter?.readings || []}
|
||||
startDate={c.startDate}
|
||||
endDate={c.endDate}
|
||||
basePrice={c.energyDetails.basePrice}
|
||||
unitPrice={c.energyDetails.unitPrice}
|
||||
unitPriceNt={c.energyDetails.unitPriceNt}
|
||||
bonus={c.energyDetails.bonus}
|
||||
hasMeter={!!c.energyDetails.meter}
|
||||
contractMeters={c.energyDetails.contractMeters}
|
||||
/>
|
||||
|
||||
{/* Rechnungen */}
|
||||
<InvoicesSection
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { popHistory } from '../../utils/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { contractApi, customerApi, platformApi, cancellationPeriodApi, contractDurationApi, providerApi, contractCategoryApi } from '../../services/api';
|
||||
@@ -64,7 +65,7 @@ export default function ContractForm() {
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!id;
|
||||
const backTo = (location.state as any)?.from as string | undefined;
|
||||
const back = popHistory(location.state, isEdit ? `/contracts/${id}` : '/contracts');
|
||||
|
||||
const preselectedCustomerId = searchParams.get('customerId');
|
||||
|
||||
@@ -80,6 +81,7 @@ export default function ContractForm() {
|
||||
const contractType = watch('type') as ContractType;
|
||||
const customerId = watch('customerId');
|
||||
const previousContractId = watch('previousContractId');
|
||||
const selectedMeterId = watch('meterId');
|
||||
|
||||
// Fetch existing contract for edit
|
||||
const { data: contract } = useQuery({
|
||||
@@ -276,6 +278,7 @@ export default function ContractForm() {
|
||||
annualConsumptionKwh: c.energyDetails?.annualConsumptionKwh || '',
|
||||
basePrice: c.energyDetails?.basePrice || '',
|
||||
unitPrice: c.energyDetails?.unitPrice || '',
|
||||
unitPriceNt: c.energyDetails?.unitPriceNt || '',
|
||||
bonus: c.energyDetails?.bonus || '',
|
||||
// Internet details
|
||||
downloadSpeed: c.internetDetails?.downloadSpeed || '',
|
||||
@@ -512,6 +515,7 @@ export default function ContractForm() {
|
||||
annualConsumptionKwh: data.annualConsumptionKwh ? parseFloat(data.annualConsumptionKwh) : null,
|
||||
basePrice: data.basePrice ? parseFloat(data.basePrice) : null,
|
||||
unitPrice: data.unitPrice ? parseFloat(data.unitPrice) : null,
|
||||
unitPriceNt: data.unitPriceNt ? parseFloat(data.unitPriceNt) : null,
|
||||
bonus: data.bonus ? parseFloat(data.bonus) : null,
|
||||
};
|
||||
}
|
||||
@@ -605,7 +609,8 @@ export default function ContractForm() {
|
||||
const addresses = customer?.addresses || [];
|
||||
const bankCards = customer?.bankCards?.filter((c) => c.isActive) || [];
|
||||
const documents = customer?.identityDocuments?.filter((d) => d.isActive) || [];
|
||||
const meters = customer?.meters?.filter((m) => m.isActive) || [];
|
||||
const meters = customer?.meters || [];
|
||||
const selectedMeter = meters.find(m => m.id.toString() === selectedMeterId);
|
||||
const stressfreiEmails = customer?.stressfreiEmails?.filter((e: { isActive: boolean }) => e.isActive) || [];
|
||||
const platforms = platformsData?.data || [];
|
||||
const cancellationPeriods = cancellationPeriodsData?.data || [];
|
||||
@@ -658,7 +663,7 @@ export default function ContractForm() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(backTo || (isEdit ? `/contracts/${id}` : '/contracts'))}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">
|
||||
@@ -951,10 +956,10 @@ export default function ContractForm() {
|
||||
label="Zähler"
|
||||
{...register('meterId')}
|
||||
options={meters
|
||||
.filter((m) => m.type === contractType)
|
||||
.filter((m) => m.type === contractType && (m.isActive || m.id.toString() === watch('meterId')))
|
||||
.map((m) => ({
|
||||
value: m.id,
|
||||
label: `${m.meterNumber}${m.location ? ` (${m.location})` : ''}`,
|
||||
label: `${m.meterNumber}${m.location ? ` (${m.location})` : ''}${!m.isActive ? ' (deaktiviert)' : ''}`,
|
||||
}))}
|
||||
/>
|
||||
<Input
|
||||
@@ -975,11 +980,19 @@ export default function ContractForm() {
|
||||
)}
|
||||
<Input label="Grundpreis (€/Monat)" type="number" step="any" {...register('basePrice')} />
|
||||
<Input
|
||||
label="Arbeitspreis (€/kWh)"
|
||||
label={selectedMeter?.tariffModel === 'DUAL' ? 'HT-Arbeitspreis (€/kWh)' : 'Arbeitspreis (€/kWh)'}
|
||||
type="number"
|
||||
step="any"
|
||||
{...register('unitPrice')}
|
||||
/>
|
||||
{selectedMeter?.tariffModel === 'DUAL' && (
|
||||
<Input
|
||||
label="NT-Arbeitspreis (€/kWh)"
|
||||
type="number"
|
||||
step="any"
|
||||
{...register('unitPriceNt')}
|
||||
/>
|
||||
)}
|
||||
<Input label="Bonus (€)" type="number" step="0.01" {...register('bonus')} />
|
||||
</div>
|
||||
|
||||
@@ -1409,7 +1422,7 @@ export default function ContractForm() {
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button type="button" variant="secondary" onClick={() => navigate(backTo || (isEdit ? `/contracts/${id}` : '/contracts'))}>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate(back.to, { state: back.state })}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query';
|
||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { pushHistory } from '../../utils/navigation';
|
||||
import { contractApi, ContractTreeNode } from '../../services/api';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
@@ -271,7 +272,7 @@ export default function ContractList() {
|
||||
<div className="w-6" /> // Platzhalter für Ausrichtung
|
||||
) : null}
|
||||
|
||||
<Link to={`/contracts/${contract.id}`} state={{ from: '/contracts' }} className="font-mono flex items-center gap-1 hover:text-blue-600 hover:underline">
|
||||
<Link to={`/contracts/${contract.id}`} state={pushHistory('/contracts')} className="font-mono flex items-center gap-1 hover:text-blue-600 hover:underline">
|
||||
{contract.contractNumber}
|
||||
<CopyButton value={contract.contractNumber} />
|
||||
</Link>
|
||||
@@ -456,7 +457,7 @@ export default function ContractList() {
|
||||
{data.data.map((contract) => (
|
||||
<tr key={contract.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4 font-mono text-sm">
|
||||
<Link to={`/contracts/${contract.id}`} state={{ from: '/contracts' }} className="text-blue-600 hover:underline">
|
||||
<Link to={`/contracts/${contract.id}`} state={pushHistory('/contracts')} className="text-blue-600 hover:underline">
|
||||
{contract.contractNumber}
|
||||
</Link>
|
||||
</td>
|
||||
@@ -465,7 +466,7 @@ export default function ContractList() {
|
||||
{contract.customer && (
|
||||
<Link
|
||||
to={`/customers/${contract.customer.id}`}
|
||||
state={{ from: '/contracts' }}
|
||||
state={pushHistory('/contracts')}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{contract.customer.companyName ||
|
||||
@@ -505,7 +506,7 @@ export default function ContractList() {
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
{hasPermission('contracts:update') && !isCustomer && (
|
||||
<Link to={`/contracts/${contract.id}/edit`} state={{ from: '/contracts' }}>
|
||||
<Link to={`/contracts/${contract.id}/edit`} state={pushHistory('/contracts')}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { pushHistory, popHistory } from '../../utils/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, gdprApi, StressfreiEmail, ContractTreeNode } from '../../services/api';
|
||||
import { EmailClientTab } from '../../components/email';
|
||||
@@ -22,7 +23,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
||||
const queryClient = useQueryClient();
|
||||
const { hasPermission, isCustomerPortal } = useAuth();
|
||||
const location = useLocation();
|
||||
const backTo = (location.state as any)?.from as string | undefined;
|
||||
const back = popHistory(location.state, isCustomerPortal ? '/' : '/customers');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const customerId = portalCustomerId || parseInt(id!);
|
||||
const defaultTab = searchParams.get('tab') || 'addresses';
|
||||
@@ -204,7 +205,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(backTo || (isCustomerPortal ? '/' : '/customers'))}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
@@ -221,7 +222,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{hasPermission('customers:update') && (
|
||||
<Link to={`/customers/${id}/edit`} state={{ from: `/customers/${id}` }}>
|
||||
<Link to={`/customers/${id}/edit`} state={pushHistory(location.pathname + location.search, (location as any).state)}>
|
||||
<Button variant="secondary">
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Bearbeiten
|
||||
@@ -649,7 +650,7 @@ function AddressesTab({
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: addressApi.delete,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -746,18 +747,18 @@ function BankCardsTab({
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<BankCard> }) =>
|
||||
bankCardApi.update(id, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: bankCardApi.delete,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
|
||||
});
|
||||
|
||||
const handleDocumentUpload = async (cardId: number, file: File) => {
|
||||
try {
|
||||
await uploadApi.uploadBankCardDocument(cardId, file);
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
} catch (error) {
|
||||
console.error('Upload fehlgeschlagen:', error);
|
||||
alert('Upload fehlgeschlagen');
|
||||
@@ -768,7 +769,7 @@ function BankCardsTab({
|
||||
if (!confirm('Dokument wirklich löschen?')) return;
|
||||
try {
|
||||
await uploadApi.deleteBankCardDocument(cardId);
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
} catch (error) {
|
||||
console.error('Löschen fehlgeschlagen:', error);
|
||||
alert('Löschen fehlgeschlagen');
|
||||
@@ -972,18 +973,18 @@ function DocumentsTab({
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<IdentityDocument> }) =>
|
||||
documentApi.update(id, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: documentApi.delete,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
|
||||
});
|
||||
|
||||
const handleDocumentUpload = async (docId: number, file: File) => {
|
||||
try {
|
||||
await uploadApi.uploadIdentityDocument(docId, file);
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
} catch (error) {
|
||||
console.error('Upload fehlgeschlagen:', error);
|
||||
alert('Upload fehlgeschlagen');
|
||||
@@ -994,7 +995,7 @@ function DocumentsTab({
|
||||
if (!confirm('Dokument wirklich löschen?')) return;
|
||||
try {
|
||||
await uploadApi.deleteIdentityDocument(docId);
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
} catch (error) {
|
||||
console.error('Löschen fehlgeschlagen:', error);
|
||||
alert('Löschen fehlgeschlagen');
|
||||
@@ -1204,26 +1205,34 @@ function MetersTab({
|
||||
onAdd: () => void;
|
||||
onEdit: (meter: Meter) => void;
|
||||
}) {
|
||||
const [showReadingModal, setShowReadingModal] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS' } | null>(null);
|
||||
const [showReadingModal, setShowReadingModal] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS'; tariffModel?: string } | null>(null);
|
||||
const [expandedMeter, setExpandedMeter] = useState<number | null>(null);
|
||||
const [editingReading, setEditingReading] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS'; reading: any } | null>(null);
|
||||
const [editingReading, setEditingReading] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS'; tariffModel?: string; reading: any } | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<Meter> }) =>
|
||||
meterApi.update(id, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
|
||||
});
|
||||
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: meterApi.delete,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
setDeleteError(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Fehler beim Löschen');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteReadingMutation = useMutation({
|
||||
mutationFn: ({ meterId, readingId }: { meterId: number; readingId: number }) =>
|
||||
meterApi.deleteReading(meterId, readingId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
|
||||
});
|
||||
|
||||
const filtered = showInactive ? meters : meters.filter((m) => m.isActive);
|
||||
@@ -1273,6 +1282,9 @@ function MetersTab({
|
||||
<Badge variant={meter.type === 'ELECTRICITY' ? 'warning' : 'info'}>
|
||||
{meter.type === 'ELECTRICITY' ? 'Strom' : 'Gas'}
|
||||
</Badge>
|
||||
{meter.tariffModel === 'DUAL' && (
|
||||
<Badge variant="default">HT/NT</Badge>
|
||||
)}
|
||||
{!meter.isActive && <Badge variant="danger">Inaktiv</Badge>}
|
||||
</div>
|
||||
{canEdit && (
|
||||
@@ -1281,7 +1293,7 @@ function MetersTab({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowReadingModal({ meterId: meter.id, meterType: meter.type })}
|
||||
onClick={() => setShowReadingModal({ meterId: meter.id, meterType: meter.type, tariffModel: meter.tariffModel })}
|
||||
title="Zählerstand hinzufügen"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
@@ -1371,14 +1383,17 @@ function MetersTab({
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono flex items-center gap-1">
|
||||
{reading.value.toLocaleString('de-DE')} {reading.unit}
|
||||
<CopyButton value={reading.value.toString()} title="Nur Wert kopieren" />
|
||||
<CopyButton value={`${reading.value.toLocaleString('de-DE')} ${reading.unit}`} title="Mit Einheit kopieren" />
|
||||
{reading.valueNt !== undefined && reading.valueNt !== null ? (
|
||||
<>HT: {reading.value.toLocaleString('de-DE')} / NT: {reading.valueNt.toLocaleString('de-DE')} {reading.unit}</>
|
||||
) : (
|
||||
<>{reading.value.toLocaleString('de-DE')} {reading.unit}</>
|
||||
)}
|
||||
<CopyButton value={reading.value.toString()} title="Wert kopieren" />
|
||||
</span>
|
||||
{canEdit && (
|
||||
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
|
||||
<button
|
||||
onClick={() => setEditingReading({ meterId: meter.id, meterType: meter.type, reading })}
|
||||
onClick={() => setEditingReading({ meterId: meter.id, meterType: meter.type, tariffModel: meter.tariffModel, reading })}
|
||||
className="text-gray-400 hover:text-blue-600"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
@@ -1417,6 +1432,7 @@ function MetersTab({
|
||||
onClose={() => setShowReadingModal(null)}
|
||||
meterId={showReadingModal.meterId}
|
||||
meterType={showReadingModal.meterType}
|
||||
tariffModel={showReadingModal.tariffModel as any}
|
||||
customerId={customerId}
|
||||
/>
|
||||
)}
|
||||
@@ -1427,10 +1443,43 @@ function MetersTab({
|
||||
onClose={() => setEditingReading(null)}
|
||||
meterId={editingReading.meterId}
|
||||
meterType={editingReading.meterType}
|
||||
tariffModel={editingReading.tariffModel as any}
|
||||
customerId={customerId}
|
||||
reading={editingReading.reading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fehler-Modal beim Löschen (z.B. Zähler noch an Vertrag) */}
|
||||
{deleteError && (
|
||||
<Modal isOpen={true} onClose={() => setDeleteError(null)} title="Zähler kann nicht gelöscht werden">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Der Zähler ist noch folgenden Verträgen zugeordnet und kann daher nicht gelöscht werden:
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{deleteError.match(/[A-Z]+-[A-Z0-9]+/g)?.map((contractNumber) => (
|
||||
<Link
|
||||
key={contractNumber}
|
||||
to={`/contracts?search=${contractNumber}`}
|
||||
onClick={() => setDeleteError(null)}
|
||||
className="flex items-center gap-2 p-3 bg-gray-50 border rounded-lg text-blue-600 hover:bg-blue-50 hover:border-blue-300 transition-colors"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="font-mono">{contractNumber}</span>
|
||||
</Link>
|
||||
)) ?? (
|
||||
<p className="text-sm text-red-600">{deleteError}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-4">
|
||||
Bitte entfernen Sie den Zähler zuerst aus den oben genannten Verträgen.
|
||||
</p>
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button variant="secondary" onClick={() => setDeleteError(null)}>
|
||||
Schließen
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1457,7 +1506,7 @@ function ContractsTab({
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: contractApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-tree', customerId] });
|
||||
@@ -1549,7 +1598,7 @@ function ContractsTab({
|
||||
<div className="w-6" /> // Platzhalter für Ausrichtung
|
||||
) : null}
|
||||
|
||||
<Link to={`/contracts/${contract.id}`} state={{ from: `/customers/${customerId}?tab=contracts` }} className="font-mono flex items-center gap-1 text-blue-600 hover:underline">
|
||||
<Link to={`/contracts/${contract.id}`} state={pushHistory(location.pathname + location.search, (location as any).state)} className="font-mono flex items-center gap-1 text-blue-600 hover:underline">
|
||||
{contract.contractNumber}
|
||||
<CopyButton value={contract.contractNumber} />
|
||||
</Link>
|
||||
@@ -2054,7 +2103,7 @@ function AddressModal({
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: typeof formData) => addressApi.create(customerId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
onClose();
|
||||
setFormData({
|
||||
type: 'DELIVERY_RESIDENCE',
|
||||
@@ -2071,7 +2120,7 @@ function AddressModal({
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: typeof formData) => addressApi.update(address!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
@@ -2201,7 +2250,7 @@ function BankCardModal({
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => bankCardApi.create(customerId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
onClose();
|
||||
setFormData({ accountHolder: '', iban: '', bic: '', bankName: '', expiryDate: '', isActive: true });
|
||||
},
|
||||
@@ -2210,7 +2259,7 @@ function BankCardModal({
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => bankCardApi.update(bankCard!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
@@ -2333,7 +2382,7 @@ function DocumentModal({
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => documentApi.create(customerId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
onClose();
|
||||
setFormData({
|
||||
type: 'ID_CARD',
|
||||
@@ -2351,7 +2400,7 @@ function DocumentModal({
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => documentApi.update(document!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
@@ -2498,6 +2547,7 @@ function MeterModal({
|
||||
const getInitialFormData = () => ({
|
||||
meterNumber: meter?.meterNumber || '',
|
||||
type: meter?.type || 'ELECTRICITY' as const,
|
||||
tariffModel: meter?.tariffModel || 'SINGLE' as const,
|
||||
location: meter?.location || '',
|
||||
isActive: meter?.isActive ?? true,
|
||||
});
|
||||
@@ -2507,16 +2557,16 @@ function MeterModal({
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => meterApi.create(customerId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
onClose();
|
||||
setFormData({ meterNumber: '', type: 'ELECTRICITY', location: '', isActive: true });
|
||||
setFormData({ meterNumber: '', type: 'ELECTRICITY', tariffModel: 'SINGLE', location: '', isActive: true });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => meterApi.update(meter!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
@@ -2557,6 +2607,18 @@ function MeterModal({
|
||||
]}
|
||||
/>
|
||||
|
||||
{formData.type === 'ELECTRICITY' && (
|
||||
<Select
|
||||
label="Tarifmodell"
|
||||
value={formData.tariffModel}
|
||||
onChange={(e) => setFormData({ ...formData, tariffModel: e.target.value as any })}
|
||||
options={[
|
||||
{ value: 'SINGLE', label: 'Eintarifzähler (Standard)' },
|
||||
{ value: 'DUAL', label: 'Zweitarifzähler (HT/NT)' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Standort"
|
||||
value={formData.location}
|
||||
@@ -2594,6 +2656,7 @@ function MeterReadingModal({
|
||||
onClose,
|
||||
meterId,
|
||||
meterType,
|
||||
tariffModel,
|
||||
customerId,
|
||||
reading,
|
||||
}: {
|
||||
@@ -2601,47 +2664,63 @@ function MeterReadingModal({
|
||||
onClose: () => void;
|
||||
meterId: number;
|
||||
meterType: 'ELECTRICITY' | 'GAS';
|
||||
tariffModel?: 'SINGLE' | 'DUAL';
|
||||
customerId: number;
|
||||
reading?: { id: number; readingDate: string; value: number; unit: string; notes?: string } | null;
|
||||
reading?: { id: number; readingDate: string; value: number; valueNt?: number; unit: string; notes?: string } | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!reading;
|
||||
const defaultUnit = meterType === 'ELECTRICITY' ? 'kWh' : 'm³';
|
||||
const isDualTariff = tariffModel === 'DUAL';
|
||||
|
||||
const getInitialFormData = () => ({
|
||||
readingDate: reading?.readingDate
|
||||
? new Date(reading.readingDate).toISOString().split('T')[0]
|
||||
: new Date().toISOString().split('T')[0],
|
||||
value: reading?.value?.toString() || '',
|
||||
valueNt: reading?.valueNt?.toString() || '',
|
||||
notes: reading?.notes || '',
|
||||
});
|
||||
|
||||
const [formData, setFormData] = useState(getInitialFormData);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => meterApi.addReading(meterId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
setError(null);
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Speichern');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => meterApi.updateReading(meterId, reading!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
setError(null);
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Speichern');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
const data: Record<string, unknown> = {
|
||||
readingDate: new Date(formData.readingDate),
|
||||
value: parseFloat(formData.value),
|
||||
unit: defaultUnit,
|
||||
notes: formData.notes || undefined,
|
||||
};
|
||||
if (isDualTariff && formData.valueNt) {
|
||||
data.valueNt = parseFloat(formData.valueNt);
|
||||
}
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(data);
|
||||
} else {
|
||||
@@ -2667,10 +2746,10 @@ function MeterReadingModal({
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<div className={`grid ${isDualTariff ? 'grid-cols-2' : 'grid-cols-3'} gap-4`}>
|
||||
<div className={isDualTariff ? '' : 'col-span-2'}>
|
||||
<Input
|
||||
label="Zählerstand"
|
||||
label={isDualTariff ? 'HT-Stand (Hochtarif)' : 'Zählerstand'}
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.value}
|
||||
@@ -2678,12 +2757,26 @@ function MeterReadingModal({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Einheit</label>
|
||||
<div className="h-10 flex items-center px-3 bg-gray-100 border border-gray-300 rounded-md text-gray-700">
|
||||
{defaultUnit}
|
||||
{isDualTariff && (
|
||||
<div>
|
||||
<Input
|
||||
label="NT-Stand (Niedertarif)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.valueNt}
|
||||
onChange={(e) => setFormData({ ...formData, valueNt: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isDualTariff && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Einheit</label>
|
||||
<div className="h-10 flex items-center px-3 bg-gray-100 border border-gray-300 rounded-md text-gray-700">
|
||||
{defaultUnit}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
@@ -2693,6 +2786,12 @@ function MeterReadingModal({
|
||||
placeholder="Optionale Notizen..."
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
@@ -2732,12 +2831,12 @@ function StressfreiEmailsTab({
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<StressfreiEmail> }) =>
|
||||
stressfreiEmailApi.update(id, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: stressfreiEmailApi.delete,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
|
||||
});
|
||||
|
||||
const filtered = showInactive ? emails : emails.filter((e) => e.isActive);
|
||||
@@ -3087,7 +3186,7 @@ function StressfreiEmailModal({
|
||||
const result = await stressfreiEmailApi.enableMailbox(email.id);
|
||||
if (result.success) {
|
||||
setMailboxEnabled(true);
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
|
||||
} else {
|
||||
setProvisionError(result.error || 'Mailbox-Aktivierung fehlgeschlagen');
|
||||
@@ -3108,7 +3207,7 @@ function StressfreiEmailModal({
|
||||
setMailboxEnabled(result.data.hasMailbox);
|
||||
if (result.data.wasUpdated) {
|
||||
// DB wurde aktualisiert, Query invalidieren
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -3199,7 +3298,7 @@ function StressfreiEmailModal({
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
|
||||
setLocalPart('');
|
||||
setNotes('');
|
||||
@@ -3216,7 +3315,7 @@ function StressfreiEmailModal({
|
||||
mutationFn: (data: Partial<StressfreiEmail>) =>
|
||||
stressfreiEmailApi.update(email!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
@@ -3664,6 +3763,7 @@ function ConsentTab({
|
||||
try {
|
||||
await uploadApi.uploadPrivacyPolicy(customerId, file);
|
||||
onUpdate?.();
|
||||
queryClient.invalidateQueries({ queryKey: ['customer-consents', customerId] });
|
||||
} catch (error) {
|
||||
console.error('Upload fehlgeschlagen:', error);
|
||||
alert('Upload fehlgeschlagen');
|
||||
@@ -3675,6 +3775,7 @@ function ConsentTab({
|
||||
try {
|
||||
await uploadApi.deletePrivacyPolicy(customerId);
|
||||
onUpdate?.();
|
||||
queryClient.invalidateQueries({ queryKey: ['customer-consents', customerId] });
|
||||
} catch (error) {
|
||||
console.error('Löschen fehlgeschlagen:', error);
|
||||
alert('Löschen fehlgeschlagen');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import { popHistory } from '../../utils/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { customerApi } from '../../services/api';
|
||||
@@ -17,7 +18,7 @@ export default function CustomerForm() {
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!id;
|
||||
const backTo = (location.state as any)?.from as string | undefined;
|
||||
const back = popHistory(location.state, isEdit ? `/customers/${id}` : '/customers');
|
||||
|
||||
const { register, handleSubmit, reset, watch, setValue, formState: { errors } } = useForm<CustomerFormData>();
|
||||
const customerType = watch('type');
|
||||
@@ -234,7 +235,7 @@ export default function CustomerForm() {
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button type="button" variant="secondary" onClick={() => navigate(backTo || (isEdit ? `/customers/${id}` : '/customers'))}>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate(back.to, { state: back.state })}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { pushHistory } from '../../utils/navigation';
|
||||
import { customerApi } from '../../services/api';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
@@ -78,12 +79,12 @@ export default function CustomerList() {
|
||||
{data.data.map((customer) => (
|
||||
<tr key={customer.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4 font-mono text-sm">
|
||||
<Link to={`/customers/${customer.id}`} state={{ from: '/customers' }} className="text-blue-600 hover:underline">
|
||||
<Link to={`/customers/${customer.id}`} state={pushHistory('/customers')} className="text-blue-600 hover:underline">
|
||||
{customer.customerNumber}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Link to={`/customers/${customer.id}`} state={{ from: '/customers' }} className="text-blue-600 hover:underline">
|
||||
<Link to={`/customers/${customer.id}`} state={pushHistory('/customers')} className="text-blue-600 hover:underline">
|
||||
{customer.type === 'BUSINESS' && customer.companyName
|
||||
? customer.companyName
|
||||
: `${customer.firstName} ${customer.lastName}`}
|
||||
|
||||
@@ -36,10 +36,15 @@ const RESOURCE_OPTIONS = [
|
||||
{ value: 'Contract', label: 'Verträge' },
|
||||
{ value: 'User', label: 'Benutzer' },
|
||||
{ value: 'BankCard', label: 'Bankdaten' },
|
||||
{ value: 'IdentityDocument', label: 'Ausweisdokumente' },
|
||||
{ value: 'Authentication', label: 'Authentifizierung' },
|
||||
{ value: 'IdentityDocument', label: 'Ausweise' },
|
||||
{ value: 'Address', label: 'Adressen' },
|
||||
{ value: 'Meter', label: 'Zähler' },
|
||||
{ value: 'ContractTask', label: 'Aufgaben' },
|
||||
{ value: 'Authentication', label: 'Anmeldung' },
|
||||
{ value: 'CustomerConsent', label: 'Einwilligungen' },
|
||||
{ value: 'GDPR', label: 'DSGVO' },
|
||||
{ value: 'GDPR', label: 'DSGVO / Vollmacht' },
|
||||
{ value: 'EmailProviderConfig', label: 'E-Mail-Provider' },
|
||||
{ value: 'AppSetting', label: 'Einstellungen' },
|
||||
];
|
||||
|
||||
function formatDate(date: string): string {
|
||||
@@ -354,11 +359,12 @@ export default function AuditLogs() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div>{log.resourceType}</div>
|
||||
{log.resourceLabel && (
|
||||
<div className="text-xs text-gray-500 truncate max-w-[200px]" title={log.resourceLabel}>
|
||||
{log.resourceLabel ? (
|
||||
<div className="text-sm truncate max-w-[300px]" title={log.resourceLabel}>
|
||||
{log.resourceLabel}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">{log.resourceType}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 font-mono text-xs">{log.ipAddress}</td>
|
||||
|
||||
@@ -655,6 +655,15 @@ export const contractApi = {
|
||||
const res = await api.get<ApiResponse<import('../types').CockpitResult>>('/contracts/cockpit');
|
||||
return res.data;
|
||||
},
|
||||
// Folgezähler
|
||||
addSuccessorMeter: async (contractId: number, data: { meterId: number; installedAt?: string; finalReadingPrevious?: number }) => {
|
||||
const res = await api.post<ApiResponse<any>>(`/contracts/${contractId}/successor-meter`, data);
|
||||
return res.data;
|
||||
},
|
||||
removeContractMeter: async (contractId: number, contractMeterId: number) => {
|
||||
const res = await api.delete<ApiResponse<void>>(`/contracts/${contractId}/contract-meter/${contractMeterId}`);
|
||||
return res.data;
|
||||
},
|
||||
// Snooze: Vertrag zurückstellen
|
||||
snooze: async (id: number, data: { nextReviewDate?: string; months?: number }) => {
|
||||
const res = await api.patch<ApiResponse<{ id: number; contractNumber: string; nextReviewDate: string | null }>>(`/contracts/${id}/snooze`, data);
|
||||
|
||||
@@ -152,16 +152,30 @@ export interface IdentityDocument {
|
||||
licenseIssueDate?: string;
|
||||
}
|
||||
|
||||
export type MeterTariffModel = 'SINGLE' | 'DUAL';
|
||||
|
||||
export interface Meter {
|
||||
id: number;
|
||||
customerId: number;
|
||||
meterNumber: string;
|
||||
type: 'ELECTRICITY' | 'GAS';
|
||||
tariffModel: MeterTariffModel;
|
||||
location?: string;
|
||||
isActive: boolean;
|
||||
readings?: MeterReading[];
|
||||
}
|
||||
|
||||
export interface ContractMeter {
|
||||
id: number;
|
||||
energyContractDetailsId: number;
|
||||
meterId: number;
|
||||
meter?: Meter;
|
||||
position: number;
|
||||
installedAt?: string;
|
||||
removedAt?: string;
|
||||
finalReading?: number;
|
||||
}
|
||||
|
||||
export type MeterReadingStatus = 'RECORDED' | 'REPORTED' | 'TRANSFERRED';
|
||||
|
||||
export interface MeterReading {
|
||||
@@ -169,6 +183,7 @@ export interface MeterReading {
|
||||
meterId: number;
|
||||
readingDate: string;
|
||||
value: number;
|
||||
valueNt?: number; // NT-Wert (nur bei Zweitarifzähler)
|
||||
unit: string;
|
||||
notes?: string;
|
||||
reportedBy?: string;
|
||||
@@ -396,11 +411,13 @@ export interface EnergyContractDetails {
|
||||
annualConsumption?: number; // kWh für Strom, m³ für Gas
|
||||
annualConsumptionKwh?: number; // kWh für Gas (zusätzlich zu m³)
|
||||
basePrice?: number; // €/Monat
|
||||
unitPrice?: number; // €/kWh (Arbeitspreis)
|
||||
unitPrice?: number; // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis
|
||||
unitPriceNt?: number; // €/kWh NT-Preis (nur bei Zweitarifzähler)
|
||||
bonus?: number;
|
||||
previousProviderName?: string;
|
||||
previousCustomerNumber?: string;
|
||||
invoices?: Invoice[]; // Rechnungen
|
||||
contractMeters?: ContractMeter[]; // Zähler-Zuordnungen (inkl. Folgezähler)
|
||||
}
|
||||
|
||||
export interface InternetContractDetails {
|
||||
@@ -575,6 +592,7 @@ export interface ReportedMeterReading {
|
||||
createdAt: string;
|
||||
meter: { id: number; meterNumber: string; type: string };
|
||||
customer: { id: number; customerNumber: string; name: string };
|
||||
contract?: { id: number; contractNumber: string };
|
||||
providerPortal?: {
|
||||
providerName: string;
|
||||
portalUrl: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MeterReading } from '../types';
|
||||
import type { MeterReading, ContractMeter } from '../types';
|
||||
|
||||
// Konstante für Gas → kWh Umrechnung (Standard Erdgas H)
|
||||
export const GAS_TO_KWH_FACTOR = 10.5;
|
||||
@@ -8,6 +8,8 @@ export interface ConsumptionCalculation {
|
||||
type: 'exact' | 'projected' | 'insufficient' | 'none';
|
||||
consumptionM3?: number; // Nur bei Gas
|
||||
consumptionKwh: number;
|
||||
consumptionHt?: number; // Nur bei HT/NT: HT-Verbrauch in kWh
|
||||
consumptionNt?: number; // Nur bei HT/NT: NT-Verbrauch in kWh
|
||||
startReading?: MeterReading;
|
||||
endReading?: MeterReading;
|
||||
projectedEndDate?: string; // Nur bei Hochrechnung
|
||||
@@ -17,7 +19,8 @@ export interface ConsumptionCalculation {
|
||||
// Ergebnis der Kostenberechnung
|
||||
export interface CostCalculation {
|
||||
annualBaseCost: number; // basePrice × 12
|
||||
annualConsumptionCost: number; // verbrauch × unitPrice
|
||||
annualConsumptionCost: number; // verbrauch × unitPrice (HT bei Zweitarif)
|
||||
annualConsumptionCostNt?: number; // NT-Verbrauch × unitPriceNt
|
||||
annualTotalCost: number; // Summe
|
||||
monthlyPayment: number; // annualTotalCost / 12
|
||||
bonus?: number;
|
||||
@@ -94,7 +97,6 @@ export function calculateConsumption(
|
||||
(a, b) => new Date(a.readingDate).getTime() - new Date(b.readingDate).getTime()
|
||||
);
|
||||
|
||||
const firstReading = sorted[0];
|
||||
const lastReading = sorted[sorted.length - 1];
|
||||
|
||||
// Prüfen ob Endzählerstand am/nach Vertragsende liegt
|
||||
@@ -103,16 +105,39 @@ export function calculateConsumption(
|
||||
lastReadingDate.setHours(0, 0, 0, 0);
|
||||
contractEndDate.setHours(0, 0, 0, 0);
|
||||
|
||||
// Fall A: Exakter Verbrauch (Endzählerstand am oder nach Vertragsende)
|
||||
// Verbrauch zwischen aufeinanderfolgenden Ständen berechnen
|
||||
// (Erkennt Zählerwechsel: wenn ein Wert sinkt, wird ab dem neuen Stand weitergerechnet)
|
||||
let totalConsumption = 0;
|
||||
let totalConsumptionNt = 0;
|
||||
const hasNt = sorted.some(r => r.valueNt !== undefined && r.valueNt !== null);
|
||||
let effectiveFirstReading = sorted[0];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const diff = sorted[i].value - sorted[i - 1].value;
|
||||
if (diff < 0) {
|
||||
totalConsumption = 0;
|
||||
totalConsumptionNt = 0;
|
||||
effectiveFirstReading = sorted[i];
|
||||
} else {
|
||||
totalConsumption += diff;
|
||||
if (hasNt && sorted[i].valueNt != null && sorted[i - 1].valueNt != null) {
|
||||
const diffNt = sorted[i].valueNt! - sorted[i - 1].valueNt!;
|
||||
if (diffNt >= 0) totalConsumptionNt += diffNt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveLastReading = sorted[sorted.length - 1];
|
||||
|
||||
// Fall A: Exakter Verbrauch
|
||||
if (lastReadingDate >= contractEndDate) {
|
||||
const consumption = lastReading.value - firstReading.value;
|
||||
return formatConsumptionResult('exact', consumption, contractType, firstReading, lastReading);
|
||||
const result = formatConsumptionResult('exact', totalConsumption, contractType, effectiveFirstReading, effectiveLastReading);
|
||||
if (hasNt) { result.consumptionHt = totalConsumption; result.consumptionNt = totalConsumptionNt; }
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fall B: Hochrechnung erforderlich
|
||||
const daysBetweenReadings = daysDiff(firstReading.readingDate, lastReading.readingDate);
|
||||
const daysBetweenReadings = daysDiff(effectiveFirstReading.readingDate, effectiveLastReading.readingDate);
|
||||
|
||||
// Mindestens 1 Tag zwischen den Messungen für sinnvolle Hochrechnung
|
||||
if (daysBetweenReadings < 1) {
|
||||
return {
|
||||
type: 'insufficient',
|
||||
@@ -122,17 +147,26 @@ export function calculateConsumption(
|
||||
}
|
||||
|
||||
const totalContractDays = daysDiff(startDate, endDate);
|
||||
const measuredConsumption = lastReading.value - firstReading.value;
|
||||
const projectedConsumption = (measuredConsumption / daysBetweenReadings) * totalContractDays;
|
||||
const projectedConsumption = (totalConsumption / daysBetweenReadings) * totalContractDays;
|
||||
|
||||
return formatConsumptionResult(
|
||||
const result = formatConsumptionResult(
|
||||
'projected',
|
||||
projectedConsumption,
|
||||
contractType,
|
||||
firstReading,
|
||||
lastReading,
|
||||
effectiveFirstReading,
|
||||
effectiveLastReading,
|
||||
endDate
|
||||
);
|
||||
|
||||
if (hasNt) {
|
||||
const projectedNt = (totalConsumptionNt / daysBetweenReadings) * totalContractDays;
|
||||
result.consumptionHt = projectedConsumption;
|
||||
result.consumptionNt = projectedNt;
|
||||
// Gesamt-kWh = HT + NT
|
||||
result.consumptionKwh = projectedConsumption + projectedNt;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,7 +207,9 @@ export function calculateCosts(
|
||||
consumptionKwh: number,
|
||||
basePrice?: number,
|
||||
unitPrice?: number,
|
||||
bonus?: number
|
||||
bonus?: number,
|
||||
consumptionNtKwh?: number,
|
||||
unitPriceNt?: number
|
||||
): CostCalculation | null {
|
||||
// Mindestens ein Preis muss vorhanden sein
|
||||
if (basePrice == null && unitPrice == null) {
|
||||
@@ -181,17 +217,78 @@ export function calculateCosts(
|
||||
}
|
||||
|
||||
const annualBaseCost = (basePrice ?? 0) * 12;
|
||||
// Bei HT/NT: consumptionKwh ist nur HT, NT wird separat berechnet
|
||||
const annualConsumptionCost = consumptionKwh * (unitPrice ?? 0);
|
||||
const annualTotalCost = annualBaseCost + annualConsumptionCost;
|
||||
const annualConsumptionCostNt = (consumptionNtKwh ?? 0) * (unitPriceNt ?? 0);
|
||||
const annualTotalCost = annualBaseCost + annualConsumptionCost + annualConsumptionCostNt;
|
||||
const effectiveAnnualCost = annualTotalCost - (bonus ?? 0);
|
||||
const monthlyPayment = effectiveAnnualCost / 12;
|
||||
|
||||
return {
|
||||
annualBaseCost,
|
||||
annualConsumptionCost,
|
||||
annualConsumptionCostNt: annualConsumptionCostNt > 0 ? annualConsumptionCostNt : undefined,
|
||||
annualTotalCost,
|
||||
monthlyPayment,
|
||||
bonus: bonus ?? undefined,
|
||||
effectiveAnnualCost,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet den Verbrauch über mehrere Zähler (Folgezähler).
|
||||
* Pro Zähler wird der Verbrauch einzeln berechnet und dann summiert.
|
||||
*/
|
||||
export function calculateMultiMeterConsumption(
|
||||
contractMeters: ContractMeter[],
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
contractType: 'ELECTRICITY' | 'GAS'
|
||||
): ConsumptionCalculation {
|
||||
if (contractMeters.length === 0) {
|
||||
return { type: 'none', consumptionKwh: 0 };
|
||||
}
|
||||
|
||||
let totalConsumption = 0;
|
||||
let totalConsumptionM3 = 0;
|
||||
let hasExact = true;
|
||||
let hasAny = false;
|
||||
let firstStart: MeterReading | undefined;
|
||||
let lastEnd: MeterReading | undefined;
|
||||
|
||||
for (const cm of contractMeters) {
|
||||
const readings = cm.meter?.readings || [];
|
||||
if (readings.length === 0) continue;
|
||||
|
||||
// Zeitraum für diesen Zähler bestimmen
|
||||
const meterStart = cm.installedAt || startDate;
|
||||
const meterEnd = cm.removedAt || endDate;
|
||||
|
||||
const result = calculateConsumption(readings, meterStart, meterEnd, contractType);
|
||||
|
||||
if (result.type === 'none' || result.type === 'insufficient') continue;
|
||||
|
||||
hasAny = true;
|
||||
if (result.type === 'projected') hasExact = false;
|
||||
|
||||
totalConsumption += result.consumptionKwh;
|
||||
if (result.consumptionM3) totalConsumptionM3 += result.consumptionM3;
|
||||
|
||||
if (!firstStart && result.startReading) firstStart = result.startReading;
|
||||
if (result.endReading) lastEnd = result.endReading;
|
||||
}
|
||||
|
||||
if (!hasAny) {
|
||||
// Fallback: Einzelzähler-Berechnung mit allen Readings
|
||||
const allReadings = contractMeters.flatMap(cm => cm.meter?.readings || []);
|
||||
return calculateConsumption(allReadings, startDate, endDate, contractType);
|
||||
}
|
||||
|
||||
return {
|
||||
type: hasExact ? 'exact' : 'projected',
|
||||
consumptionKwh: totalConsumption,
|
||||
consumptionM3: contractType === 'GAS' ? totalConsumptionM3 : undefined,
|
||||
startReading: firstStart,
|
||||
endReading: lastEnd,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
Reference in New Issue
Block a user