added invoices and status in cockpit, created info button for contract status types
This commit is contained in:
Vendored
+2
@@ -28,6 +28,7 @@ const contractTask_routes_js_1 = __importDefault(require("./routes/contractTask.
|
|||||||
const appSetting_routes_js_1 = __importDefault(require("./routes/appSetting.routes.js"));
|
const appSetting_routes_js_1 = __importDefault(require("./routes/appSetting.routes.js"));
|
||||||
const emailProvider_routes_js_1 = __importDefault(require("./routes/emailProvider.routes.js"));
|
const emailProvider_routes_js_1 = __importDefault(require("./routes/emailProvider.routes.js"));
|
||||||
const cachedEmail_routes_js_1 = __importDefault(require("./routes/cachedEmail.routes.js"));
|
const cachedEmail_routes_js_1 = __importDefault(require("./routes/cachedEmail.routes.js"));
|
||||||
|
const invoice_routes_js_1 = __importDefault(require("./routes/invoice.routes.js"));
|
||||||
dotenv_1.default.config();
|
dotenv_1.default.config();
|
||||||
const app = (0, express_1.default)();
|
const app = (0, express_1.default)();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -58,6 +59,7 @@ app.use('/api', contractTask_routes_js_1.default);
|
|||||||
app.use('/api/settings', appSetting_routes_js_1.default);
|
app.use('/api/settings', appSetting_routes_js_1.default);
|
||||||
app.use('/api/email-providers', emailProvider_routes_js_1.default);
|
app.use('/api/email-providers', emailProvider_routes_js_1.default);
|
||||||
app.use('/api', cachedEmail_routes_js_1.default);
|
app.use('/api', cachedEmail_routes_js_1.default);
|
||||||
|
app.use('/api/energy-details', invoice_routes_js_1.default);
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;AAAA,sDAA8B;AAC9B,gDAAwB;AACxB,gDAAwB;AACxB,oDAA4B;AAE5B,6EAAiD;AACjD,qFAAyD;AACzD,mFAAuD;AACvD,qFAAyD;AACzD,qFAAyD;AACzD,+EAAmD;AACnD,mGAAuE;AACvE,qFAAyD;AACzD,qFAAyD;AACzD,2GAA8E;AAC9E,uGAA0E;AAC1E,qFAAyD;AACzD,iFAAqD;AACrD,6EAAiD;AACjD,iFAAqD;AACrD,uFAA2D;AAC3D,qGAAyE;AACzE,6FAAiE;AACjE,yFAA6D;AAC7D,+FAAmE;AACnE,2FAA+D;AAE/D,gBAAM,CAAC,MAAM,EAAE,CAAC;AAEhB,MAAM,GAAG,GAAG,IAAA,iBAAO,GAAE,CAAC;AACtB,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC;AAEtC,aAAa;AACb,GAAG,CAAC,GAAG,CAAC,IAAA,cAAI,GAAE,CAAC,CAAC;AAChB,GAAG,CAAC,GAAG,CAAC,iBAAO,CAAC,IAAI,EAAE,CAAC,CAAC;AAExB,gCAAgC;AAChC,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,iBAAO,CAAC,MAAM,CAAC,cAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC;AAE7E,SAAS;AACT,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,wBAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,4BAAc,CAAC,CAAC;AAC1C,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,2BAAa,CAAC,CAAC;AACzC,GAAG,CAAC,GAAG,CAAC,iBAAiB,EAAE,4BAAc,CAAC,CAAC;AAC3C,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,4BAAc,CAAC,CAAC;AAC1C,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,yBAAW,CAAC,CAAC;AACpC,GAAG,CAAC,GAAG,CAAC,wBAAwB,EAAE,mCAAqB,CAAC,CAAC;AACzD,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,4BAAc,CAAC,CAAC;AAC1C,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,4BAAc,CAAC,CAAC;AAC1C,GAAG,CAAC,GAAG,CAAC,2BAA2B,EAAE,uCAAwB,CAAC,CAAC;AAC/D,GAAG,CAAC,GAAG,CAAC,yBAAyB,EAAE,qCAAsB,CAAC,CAAC;AAC3D,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,4BAAc,CAAC,CAAC;AAC1C,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,0BAAY,CAAC,CAAC;AACtC,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,wBAAU,CAAC,CAAC;AAClC,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,0BAAY,CAAC,CAAC;AACrC,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,6BAAe,CAAC,CAAC;AAC3C,GAAG,CAAC,GAAG,CAAC,0BAA0B,EAAE,oCAAsB,CAAC,CAAC;AAC5D,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,gCAAkB,CAAC,CAAC;AACpC,GAAG,CAAC,GAAG,CAAC,eAAe,EAAE,8BAAgB,CAAC,CAAC;AAC3C,GAAG,CAAC,GAAG,CAAC,sBAAsB,EAAE,iCAAmB,CAAC,CAAC;AACrD,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,+BAAiB,CAAC,CAAC;AAEnC,eAAe;AACf,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAClC,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;AAClE,CAAC,CAAC,CAAC;AAEH,iBAAiB;AACjB,GAAG,CAAC,GAAG,CAAC,CAAC,GAAU,EAAE,GAAoB,EAAE,GAAqB,EAAE,IAA0B,EAAE,EAAE;IAC9F,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IACzB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,uBAAuB,EAAE,CAAC,CAAC;AAC3E,CAAC,CAAC,CAAC;AAEH,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,yBAAyB,IAAI,EAAE,CAAC,CAAC;AAC/C,CAAC,CAAC,CAAC"}
|
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;AAAA,sDAA8B;AAC9B,gDAAwB;AACxB,gDAAwB;AACxB,oDAA4B;AAE5B,6EAAiD;AACjD,qFAAyD;AACzD,mFAAuD;AACvD,qFAAyD;AACzD,qFAAyD;AACzD,+EAAmD;AACnD,mGAAuE;AACvE,qFAAyD;AACzD,qFAAyD;AACzD,2GAA8E;AAC9E,uGAA0E;AAC1E,qFAAyD;AACzD,iFAAqD;AACrD,6EAAiD;AACjD,iFAAqD;AACrD,uFAA2D;AAC3D,qGAAyE;AACzE,6FAAiE;AACjE,yFAA6D;AAC7D,+FAAmE;AACnE,2FAA+D;AAC/D,mFAAuD;AAEvD,gBAAM,CAAC,MAAM,EAAE,CAAC;AAEhB,MAAM,GAAG,GAAG,IAAA,iBAAO,GAAE,CAAC;AACtB,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC;AAEtC,aAAa;AACb,GAAG,CAAC,GAAG,CAAC,IAAA,cAAI,GAAE,CAAC,CAAC;AAChB,GAAG,CAAC,GAAG,CAAC,iBAAO,CAAC,IAAI,EAAE,CAAC,CAAC;AAExB,gCAAgC;AAChC,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,iBAAO,CAAC,MAAM,CAAC,cAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC;AAE7E,SAAS;AACT,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,wBAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,4BAAc,CAAC,CAAC;AAC1C,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,2BAAa,CAAC,CAAC;AACzC,GAAG,CAAC,GAAG,CAAC,iBAAiB,EAAE,4BAAc,CAAC,CAAC;AAC3C,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,4BAAc,CAAC,CAAC;AAC1C,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,yBAAW,CAAC,CAAC;AACpC,GAAG,CAAC,GAAG,CAAC,wBAAwB,EAAE,mCAAqB,CAAC,CAAC;AACzD,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,4BAAc,CAAC,CAAC;AAC1C,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,4BAAc,CAAC,CAAC;AAC1C,GAAG,CAAC,GAAG,CAAC,2BAA2B,EAAE,uCAAwB,CAAC,CAAC;AAC/D,GAAG,CAAC,GAAG,CAAC,yBAAyB,EAAE,qCAAsB,CAAC,CAAC;AAC3D,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,4BAAc,CAAC,CAAC;AAC1C,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,0BAAY,CAAC,CAAC;AACtC,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,wBAAU,CAAC,CAAC;AAClC,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,0BAAY,CAAC,CAAC;AACrC,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,6BAAe,CAAC,CAAC;AAC3C,GAAG,CAAC,GAAG,CAAC,0BAA0B,EAAE,oCAAsB,CAAC,CAAC;AAC5D,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,gCAAkB,CAAC,CAAC;AACpC,GAAG,CAAC,GAAG,CAAC,eAAe,EAAE,8BAAgB,CAAC,CAAC;AAC3C,GAAG,CAAC,GAAG,CAAC,sBAAsB,EAAE,iCAAmB,CAAC,CAAC;AACrD,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,+BAAiB,CAAC,CAAC;AACnC,GAAG,CAAC,GAAG,CAAC,qBAAqB,EAAE,2BAAa,CAAC,CAAC;AAE9C,eAAe;AACf,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAClC,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;AAClE,CAAC,CAAC,CAAC;AAEH,iBAAiB;AACjB,GAAG,CAAC,GAAG,CAAC,CAAC,GAAU,EAAE,GAAoB,EAAE,GAAqB,EAAE,IAA0B,EAAE,EAAE;IAC9F,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IACzB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,uBAAuB,EAAE,CAAC,CAAC;AAC3E,CAAC,CAAC,CAAC;AAEH,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,yBAAyB,IAAI,EAAE,CAAC,CAAC;AAC/C,CAAC,CAAC,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;AA4oBxB,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;AA2uBxB,eAAe,MAAM,CAAC"}
|
||||||
Vendored
+72
@@ -469,5 +469,77 @@ router.delete('/contracts/:id/cancellation-letter-options', auth_js_1.authentica
|
|||||||
// Kündigungsbestätigung Optionen
|
// Kündigungsbestätigung Optionen
|
||||||
router.post('/contracts/:id/cancellation-confirmation-options', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:update'), setUploadDir('cancellation-confirmations-options'), upload.single('document'), (req, res) => handleContractDocumentUpload(req, res, 'cancellationConfirmationOptionsPath', 'cancellation-confirmations-options'));
|
router.post('/contracts/:id/cancellation-confirmation-options', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:update'), setUploadDir('cancellation-confirmations-options'), upload.single('document'), (req, res) => handleContractDocumentUpload(req, res, 'cancellationConfirmationOptionsPath', 'cancellation-confirmations-options'));
|
||||||
router.delete('/contracts/:id/cancellation-confirmation-options', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:update'), (req, res) => handleContractDocumentDelete(req, res, 'cancellationConfirmationOptionsPath'));
|
router.delete('/contracts/:id/cancellation-confirmation-options', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:update'), (req, res) => handleContractDocumentDelete(req, res, 'cancellationConfirmationOptionsPath'));
|
||||||
|
// ==================== RECHNUNGS-DOKUMENTE ====================
|
||||||
|
// Upload für Rechnungs-Dokument
|
||||||
|
router.post('/invoices/:id', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:update'), setUploadDir('invoices'), upload.single('document'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const invoiceId = parseInt(req.params.id);
|
||||||
|
const relativePath = `/uploads/invoices/${req.file.filename}`;
|
||||||
|
// Alte Datei löschen falls vorhanden
|
||||||
|
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
|
||||||
|
if (!invoice) {
|
||||||
|
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (invoice.documentPath) {
|
||||||
|
const oldPath = path_1.default.join(process.cwd(), invoice.documentPath);
|
||||||
|
if (fs_1.default.existsSync(oldPath)) {
|
||||||
|
fs_1.default.unlinkSync(oldPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Invoice in der DB aktualisieren
|
||||||
|
await prisma.invoice.update({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
data: { documentPath: relativePath },
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
path: relativePath,
|
||||||
|
filename: req.file.filename,
|
||||||
|
originalName: req.file.originalname,
|
||||||
|
size: req.file.size,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Invoice upload error:', error);
|
||||||
|
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Löschen von Rechnungs-Dokument
|
||||||
|
router.delete('/invoices/:id', auth_js_1.authenticate, (0, auth_js_1.requirePermission)('contracts:update'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const invoiceId = parseInt(req.params.id);
|
||||||
|
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
|
||||||
|
if (!invoice) {
|
||||||
|
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!invoice.documentPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Datei löschen
|
||||||
|
const filePath = path_1.default.join(process.cwd(), invoice.documentPath);
|
||||||
|
if (fs_1.default.existsSync(filePath)) {
|
||||||
|
fs_1.default.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
// documentPath in DB auf null setzen
|
||||||
|
await prisma.invoice.update({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
data: { documentPath: null },
|
||||||
|
});
|
||||||
|
res.json({ success: true });
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Invoice delete error:', error);
|
||||||
|
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' });
|
||||||
|
}
|
||||||
|
});
|
||||||
exports.default = router;
|
exports.default = router;
|
||||||
//# sourceMappingURL=upload.routes.js.map
|
//# sourceMappingURL=upload.routes.js.map
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
+40
@@ -242,6 +242,16 @@ export declare function getContractById(id: number, decryptPassword?: boolean):
|
|||||||
meterNumber: string;
|
meterNumber: string;
|
||||||
location: string | null;
|
location: string | null;
|
||||||
}) | null;
|
}) | null;
|
||||||
|
invoices: {
|
||||||
|
id: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
notes: string | null;
|
||||||
|
documentPath: string | null;
|
||||||
|
energyContractDetailsId: number;
|
||||||
|
invoiceDate: Date;
|
||||||
|
invoiceType: import(".prisma/client").$Enums.InvoiceType;
|
||||||
|
}[];
|
||||||
} & {
|
} & {
|
||||||
id: number;
|
id: number;
|
||||||
meterId: number | null;
|
meterId: number | null;
|
||||||
@@ -421,6 +431,16 @@ export declare function getContractById(id: number, decryptPassword?: boolean):
|
|||||||
meterNumber: string;
|
meterNumber: string;
|
||||||
location: string | null;
|
location: string | null;
|
||||||
}) | null;
|
}) | null;
|
||||||
|
invoices: {
|
||||||
|
id: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
notes: string | null;
|
||||||
|
documentPath: string | null;
|
||||||
|
energyContractDetailsId: number;
|
||||||
|
invoiceDate: Date;
|
||||||
|
invoiceType: import(".prisma/client").$Enums.InvoiceType;
|
||||||
|
}[];
|
||||||
} & {
|
} & {
|
||||||
id: number;
|
id: number;
|
||||||
meterId: number | null;
|
meterId: number | null;
|
||||||
@@ -974,6 +994,16 @@ export declare function updateContract(id: number, data: Partial<ContractCreateD
|
|||||||
meterNumber: string;
|
meterNumber: string;
|
||||||
location: string | null;
|
location: string | null;
|
||||||
}) | null;
|
}) | null;
|
||||||
|
invoices: {
|
||||||
|
id: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
notes: string | null;
|
||||||
|
documentPath: string | null;
|
||||||
|
energyContractDetailsId: number;
|
||||||
|
invoiceDate: Date;
|
||||||
|
invoiceType: import(".prisma/client").$Enums.InvoiceType;
|
||||||
|
}[];
|
||||||
} & {
|
} & {
|
||||||
id: number;
|
id: number;
|
||||||
meterId: number | null;
|
meterId: number | null;
|
||||||
@@ -1153,6 +1183,16 @@ export declare function updateContract(id: number, data: Partial<ContractCreateD
|
|||||||
meterNumber: string;
|
meterNumber: string;
|
||||||
location: string | null;
|
location: string | null;
|
||||||
}) | null;
|
}) | null;
|
||||||
|
invoices: {
|
||||||
|
id: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
notes: string | null;
|
||||||
|
documentPath: string | null;
|
||||||
|
energyContractDetailsId: number;
|
||||||
|
invoiceDate: Date;
|
||||||
|
invoiceType: import(".prisma/client").$Enums.InvoiceType;
|
||||||
|
}[];
|
||||||
} & {
|
} & {
|
||||||
id: number;
|
id: number;
|
||||||
meterId: number | null;
|
meterId: number | null;
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -121,14 +121,14 @@ async function getContractById(id, decryptPassword = false) {
|
|||||||
contractCategory: true,
|
contractCategory: true,
|
||||||
previousContract: {
|
previousContract: {
|
||||||
include: {
|
include: {
|
||||||
energyDetails: { include: { meter: { include: { readings: true } } } },
|
energyDetails: { include: { meter: { include: { readings: true } }, invoices: true } },
|
||||||
internetDetails: { include: { phoneNumbers: true } },
|
internetDetails: { include: { phoneNumbers: true } },
|
||||||
mobileDetails: { include: { simCards: true } },
|
mobileDetails: { include: { simCards: true } },
|
||||||
tvDetails: true,
|
tvDetails: true,
|
||||||
carInsuranceDetails: true,
|
carInsuranceDetails: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
energyDetails: { include: { meter: { include: { readings: true } } } },
|
energyDetails: { include: { meter: { include: { readings: true } }, invoices: true } },
|
||||||
internetDetails: { include: { phoneNumbers: true } },
|
internetDetails: { include: { phoneNumbers: true } },
|
||||||
mobileDetails: { include: { simCards: true } },
|
mobileDetails: { include: { simCards: true } },
|
||||||
tvDetails: true,
|
tvDetails: true,
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -40,6 +40,7 @@ export interface CockpitSummary {
|
|||||||
contractEnding: number;
|
contractEnding: number;
|
||||||
missingCredentials: number;
|
missingCredentials: number;
|
||||||
missingData: number;
|
missingData: number;
|
||||||
|
missingInvoices: number;
|
||||||
openTasks: number;
|
openTasks: number;
|
||||||
pendingContracts: number;
|
pendingContracts: number;
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"contractCockpit.service.d.ts","sourceRoot":"","sources":["../../src/services/contractCockpit.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,cAAc,EACpC,MAAM,gBAAgB,CAAC;AAMxB,MAAM,MAAM,YAAY,GAAG,UAAU,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,CAAC;AAElE,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,YAAY,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,cAAc,EAAE,MAAM,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,cAAc,CAAC;IACvB,QAAQ,EAAE;QACR,EAAE,EAAE,MAAM,CAAC;QACX,cAAc,EAAE,MAAM,CAAC;QACvB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,QAAQ,CAAC,EAAE;QACT,EAAE,EAAE,MAAM,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,MAAM,CAAC,EAAE;QACP,EAAE,EAAE,MAAM,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,cAAc,EAAE,YAAY,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC7B,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE;QACV,qBAAqB,EAAE,MAAM,CAAC;QAC9B,cAAc,EAAE,MAAM,CAAC;QACvB,kBAAkB,EAAE,MAAM,CAAC;QAC3B,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE,MAAM,CAAC;QAClB,gBAAgB,EAAE,MAAM,CAAC;KAC1B,CAAC;CACH;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,eAAe,EAAE,CAAC;IAC7B,OAAO,EAAE,cAAc,CAAC;IACxB,UAAU,EAAE;QACV,YAAY,EAAE,MAAM,CAAC;QACrB,WAAW,EAAE,MAAM,CAAC;QACpB,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;CACH;AAyED,wBAAsB,cAAc,IAAI,OAAO,CAAC,aAAa,CAAC,CA8V7D"}
|
{"version":3,"file":"contractCockpit.service.d.ts","sourceRoot":"","sources":["../../src/services/contractCockpit.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,cAAc,EACpC,MAAM,gBAAgB,CAAC;AAMxB,MAAM,MAAM,YAAY,GAAG,UAAU,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,CAAC;AAElE,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,YAAY,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,cAAc,EAAE,MAAM,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,cAAc,CAAC;IACvB,QAAQ,EAAE;QACR,EAAE,EAAE,MAAM,CAAC;QACX,cAAc,EAAE,MAAM,CAAC;QACvB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,QAAQ,CAAC,EAAE;QACT,EAAE,EAAE,MAAM,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,MAAM,CAAC,EAAE;QACP,EAAE,EAAE,MAAM,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,cAAc,EAAE,YAAY,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC7B,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE;QACV,qBAAqB,EAAE,MAAM,CAAC;QAC9B,cAAc,EAAE,MAAM,CAAC;QACvB,kBAAkB,EAAE,MAAM,CAAC;QAC3B,WAAW,EAAE,MAAM,CAAC;QACpB,eAAe,EAAE,MAAM,CAAC;QACxB,SAAS,EAAE,MAAM,CAAC;QAClB,gBAAgB,EAAE,MAAM,CAAC;KAC1B,CAAC;CACH;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,eAAe,EAAE,CAAC;IAC7B,OAAO,EAAE,cAAc,CAAC;IACxB,UAAU,EAAE;QACV,YAAY,EAAE,MAAM,CAAC;QACrB,WAAW,EAAE,MAAM,CAAC;QACpB,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;CACH;AAyED,wBAAsB,cAAc,IAAI,OAAO,CAAC,aAAa,CAAC,CAqa7D"}
|
||||||
+66
-2
@@ -105,11 +105,11 @@ async function getCockpitData() {
|
|||||||
const criticalDays = parseInt(settings.deadlineCriticalDays) || 14;
|
const criticalDays = parseInt(settings.deadlineCriticalDays) || 14;
|
||||||
const warningDays = parseInt(settings.deadlineWarningDays) || 42;
|
const warningDays = parseInt(settings.deadlineWarningDays) || 42;
|
||||||
const okDays = parseInt(settings.deadlineOkDays) || 90;
|
const okDays = parseInt(settings.deadlineOkDays) || 90;
|
||||||
// Lade alle aktiven/pending Verträge mit allen relevanten Daten
|
// Lade alle relevanten Verträge (inkl. CANCELLED/DEACTIVATED für Schlussrechnung-Check)
|
||||||
const contracts = await prisma.contract.findMany({
|
const contracts = await prisma.contract.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: {
|
status: {
|
||||||
in: ['ACTIVE', 'PENDING', 'DRAFT'],
|
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -145,6 +145,7 @@ async function getCockpitData() {
|
|||||||
energyDetails: {
|
energyDetails: {
|
||||||
include: {
|
include: {
|
||||||
meter: true,
|
meter: true,
|
||||||
|
invoices: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
internetDetails: {
|
internetDetails: {
|
||||||
@@ -186,6 +187,7 @@ async function getCockpitData() {
|
|||||||
contractEnding: 0,
|
contractEnding: 0,
|
||||||
missingCredentials: 0,
|
missingCredentials: 0,
|
||||||
missingData: 0,
|
missingData: 0,
|
||||||
|
missingInvoices: 0,
|
||||||
openTasks: 0,
|
openTasks: 0,
|
||||||
pendingContracts: 0,
|
pendingContracts: 0,
|
||||||
},
|
},
|
||||||
@@ -367,6 +369,68 @@ async function getCockpitData() {
|
|||||||
});
|
});
|
||||||
summary.byCategory.pendingContracts++;
|
summary.byCategory.pendingContracts++;
|
||||||
}
|
}
|
||||||
|
// 13. ENERGIE-RECHNUNGEN (nur für ELECTRICITY und GAS)
|
||||||
|
if (['ELECTRICITY', 'GAS'].includes(contract.type) && contract.energyDetails) {
|
||||||
|
const invoices = contract.energyDetails.invoices || [];
|
||||||
|
const now = new Date();
|
||||||
|
now.setHours(0, 0, 0, 0);
|
||||||
|
// 13a. SCHLUSSRECHNUNG FEHLT (nur wenn Vertrag gekündigt/deaktiviert ist)
|
||||||
|
// "Beendet" = CANCELLED oder DEACTIVATED (nicht nur Laufzeit abgelaufen!)
|
||||||
|
const isContractTerminated = contract.status === 'CANCELLED' || contract.status === 'DEACTIVATED';
|
||||||
|
if (isContractTerminated) {
|
||||||
|
const hasFinalInvoice = invoices.some(inv => inv.invoiceType === 'FINAL');
|
||||||
|
const hasNotAvailable = invoices.some(inv => inv.invoiceType === 'NOT_AVAILABLE');
|
||||||
|
if (!hasFinalInvoice && !hasNotAvailable) {
|
||||||
|
issues.push({
|
||||||
|
type: 'missing_final_invoice',
|
||||||
|
label: 'Schlussrechnung fehlt',
|
||||||
|
urgency: 'warning',
|
||||||
|
details: 'Vertrag gekündigt/deaktiviert, aber keine Schlussrechnung vorhanden',
|
||||||
|
});
|
||||||
|
summary.byCategory.missingInvoices++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 13b. ZWISCHENRECHNUNG FEHLT/ÜBERFÄLLIG (wenn Vertrag > 12 Monate läuft)
|
||||||
|
// Für alle Status außer DRAFT und nicht gekündigt/deaktiviert
|
||||||
|
// Auch EXPIRED zählt hier, da der Vertrag ohne Kündigung weiterläuft!
|
||||||
|
if (contract.startDate && contract.status !== 'DRAFT' && !isContractTerminated) {
|
||||||
|
const startDate = new Date(contract.startDate);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
const daysSinceStart = Math.floor((now.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
if (daysSinceStart > 365) {
|
||||||
|
// Vertrag läuft > 12 Monate
|
||||||
|
if (invoices.length === 0) {
|
||||||
|
// Keine Rechnungen vorhanden
|
||||||
|
issues.push({
|
||||||
|
type: 'missing_interim_invoice',
|
||||||
|
label: 'Zwischenrechnung fehlt',
|
||||||
|
urgency: 'warning',
|
||||||
|
details: 'Vertrag läuft über 12 Monate ohne Rechnung',
|
||||||
|
});
|
||||||
|
summary.byCategory.missingInvoices++;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Prüfen ob letzte Rechnung > 12 Monate alt
|
||||||
|
const latestInvoice = invoices
|
||||||
|
.filter(inv => inv.invoiceType !== 'NOT_AVAILABLE')
|
||||||
|
.sort((a, b) => new Date(b.invoiceDate).getTime() - new Date(a.invoiceDate).getTime())[0];
|
||||||
|
if (latestInvoice) {
|
||||||
|
const invoiceDate = new Date(latestInvoice.invoiceDate);
|
||||||
|
const daysSinceInvoice = Math.floor((now.getTime() - invoiceDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
if (daysSinceInvoice > 365) {
|
||||||
|
issues.push({
|
||||||
|
type: 'overdue_interim_invoice',
|
||||||
|
label: 'Zwischenrechnung überfällig',
|
||||||
|
urgency: 'warning',
|
||||||
|
details: `Letzte Rechnung vor ${Math.floor(daysSinceInvoice / 30)} Monaten`,
|
||||||
|
});
|
||||||
|
summary.byCategory.missingInvoices++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Nur Verträge mit Issues hinzufügen
|
// Nur Verträge mit Issues hinzufügen
|
||||||
if (issues.length > 0) {
|
if (issues.length > 0) {
|
||||||
const highestUrgency = getHighestUrgency(issues);
|
const highestUrgency = getHighestUrgency(issues);
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+21
-3
File diff suppressed because one or more lines are too long
+18
@@ -476,6 +476,17 @@ exports.Prisma.EnergyContractDetailsScalarFieldEnum = {
|
|||||||
previousCustomerNumber: 'previousCustomerNumber'
|
previousCustomerNumber: 'previousCustomerNumber'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.InvoiceScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
energyContractDetailsId: 'energyContractDetailsId',
|
||||||
|
invoiceDate: 'invoiceDate',
|
||||||
|
invoiceType: 'invoiceType',
|
||||||
|
documentPath: 'documentPath',
|
||||||
|
notes: 'notes',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.InternetContractDetailsScalarFieldEnum = {
|
exports.Prisma.InternetContractDetailsScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
contractId: 'contractId',
|
contractId: 'contractId',
|
||||||
@@ -624,6 +635,12 @@ exports.ContractTaskStatus = exports.$Enums.ContractTaskStatus = {
|
|||||||
COMPLETED: 'COMPLETED'
|
COMPLETED: 'COMPLETED'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.InvoiceType = exports.$Enums.InvoiceType = {
|
||||||
|
INTERIM: 'INTERIM',
|
||||||
|
FINAL: 'FINAL',
|
||||||
|
NOT_AVAILABLE: 'NOT_AVAILABLE'
|
||||||
|
};
|
||||||
|
|
||||||
exports.InsuranceType = exports.$Enums.InsuranceType = {
|
exports.InsuranceType = exports.$Enums.InsuranceType = {
|
||||||
LIABILITY: 'LIABILITY',
|
LIABILITY: 'LIABILITY',
|
||||||
PARTIAL: 'PARTIAL',
|
PARTIAL: 'PARTIAL',
|
||||||
@@ -657,6 +674,7 @@ exports.Prisma.ModelName = {
|
|||||||
ContractTask: 'ContractTask',
|
ContractTask: 'ContractTask',
|
||||||
ContractTaskSubtask: 'ContractTaskSubtask',
|
ContractTaskSubtask: 'ContractTaskSubtask',
|
||||||
EnergyContractDetails: 'EnergyContractDetails',
|
EnergyContractDetails: 'EnergyContractDetails',
|
||||||
|
Invoice: 'Invoice',
|
||||||
InternetContractDetails: 'InternetContractDetails',
|
InternetContractDetails: 'InternetContractDetails',
|
||||||
PhoneNumber: 'PhoneNumber',
|
PhoneNumber: 'PhoneNumber',
|
||||||
MobileContractDetails: 'MobileContractDetails',
|
MobileContractDetails: 'MobileContractDetails',
|
||||||
|
|||||||
+1612
-1
File diff suppressed because it is too large
Load Diff
+21
-3
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "prisma-client-86f9a11dd446473e587830adde1df64246ce246e4602839fa2ced48c6aa2f902",
|
"name": "prisma-client-a41de88ab23bcfe6707e2a70a25cdd4736b5f2e024b64f8f8760b0e23260313f",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
"browser": "index-browser.js",
|
"browser": "index-browser.js",
|
||||||
|
|||||||
+21
@@ -596,6 +596,12 @@ model ContractTaskSubtask {
|
|||||||
|
|
||||||
// ==================== ENERGY CONTRACT DETAILS ====================
|
// ==================== ENERGY CONTRACT DETAILS ====================
|
||||||
|
|
||||||
|
enum InvoiceType {
|
||||||
|
INTERIM // Zwischenrechnung
|
||||||
|
FINAL // Schlussrechnung
|
||||||
|
NOT_AVAILABLE // Rechnung nicht mehr zu bekommen
|
||||||
|
}
|
||||||
|
|
||||||
model EnergyContractDetails {
|
model EnergyContractDetails {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
contractId Int @unique
|
contractId Int @unique
|
||||||
@@ -610,6 +616,21 @@ model EnergyContractDetails {
|
|||||||
bonus Float?
|
bonus Float?
|
||||||
previousProviderName String?
|
previousProviderName String?
|
||||||
previousCustomerNumber String?
|
previousCustomerNumber String?
|
||||||
|
invoices Invoice[] // Rechnungen
|
||||||
|
}
|
||||||
|
|
||||||
|
model Invoice {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
energyContractDetailsId Int
|
||||||
|
energyContractDetails EnergyContractDetails @relation(fields: [energyContractDetailsId], references: [id], onDelete: Cascade)
|
||||||
|
invoiceDate DateTime
|
||||||
|
invoiceType InvoiceType
|
||||||
|
documentPath String? // Pflicht, außer bei NOT_AVAILABLE
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([energyContractDetailsId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== INTERNET CONTRACT DETAILS ====================
|
// ==================== INTERNET CONTRACT DETAILS ====================
|
||||||
|
|||||||
+18
@@ -476,6 +476,17 @@ exports.Prisma.EnergyContractDetailsScalarFieldEnum = {
|
|||||||
previousCustomerNumber: 'previousCustomerNumber'
|
previousCustomerNumber: 'previousCustomerNumber'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.InvoiceScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
energyContractDetailsId: 'energyContractDetailsId',
|
||||||
|
invoiceDate: 'invoiceDate',
|
||||||
|
invoiceType: 'invoiceType',
|
||||||
|
documentPath: 'documentPath',
|
||||||
|
notes: 'notes',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.InternetContractDetailsScalarFieldEnum = {
|
exports.Prisma.InternetContractDetailsScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
contractId: 'contractId',
|
contractId: 'contractId',
|
||||||
@@ -624,6 +635,12 @@ exports.ContractTaskStatus = exports.$Enums.ContractTaskStatus = {
|
|||||||
COMPLETED: 'COMPLETED'
|
COMPLETED: 'COMPLETED'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.InvoiceType = exports.$Enums.InvoiceType = {
|
||||||
|
INTERIM: 'INTERIM',
|
||||||
|
FINAL: 'FINAL',
|
||||||
|
NOT_AVAILABLE: 'NOT_AVAILABLE'
|
||||||
|
};
|
||||||
|
|
||||||
exports.InsuranceType = exports.$Enums.InsuranceType = {
|
exports.InsuranceType = exports.$Enums.InsuranceType = {
|
||||||
LIABILITY: 'LIABILITY',
|
LIABILITY: 'LIABILITY',
|
||||||
PARTIAL: 'PARTIAL',
|
PARTIAL: 'PARTIAL',
|
||||||
@@ -657,6 +674,7 @@ exports.Prisma.ModelName = {
|
|||||||
ContractTask: 'ContractTask',
|
ContractTask: 'ContractTask',
|
||||||
ContractTaskSubtask: 'ContractTaskSubtask',
|
ContractTaskSubtask: 'ContractTaskSubtask',
|
||||||
EnergyContractDetails: 'EnergyContractDetails',
|
EnergyContractDetails: 'EnergyContractDetails',
|
||||||
|
Invoice: 'Invoice',
|
||||||
InternetContractDetails: 'InternetContractDetails',
|
InternetContractDetails: 'InternetContractDetails',
|
||||||
PhoneNumber: 'PhoneNumber',
|
PhoneNumber: 'PhoneNumber',
|
||||||
MobileContractDetails: 'MobileContractDetails',
|
MobileContractDetails: 'MobileContractDetails',
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Invoice` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`energyContractDetailsId` INTEGER NOT NULL,
|
||||||
|
`invoiceDate` DATETIME(3) NOT NULL,
|
||||||
|
`invoiceType` ENUM('INTERIM', 'FINAL', 'NOT_AVAILABLE') NOT NULL,
|
||||||
|
`documentPath` VARCHAR(191) NULL,
|
||||||
|
`notes` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `Invoice_energyContractDetailsId_idx`(`energyContractDetailsId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Invoice` ADD CONSTRAINT `Invoice_energyContractDetailsId_fkey` FOREIGN KEY (`energyContractDetailsId`) REFERENCES `EnergyContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -596,6 +596,12 @@ model ContractTaskSubtask {
|
|||||||
|
|
||||||
// ==================== ENERGY CONTRACT DETAILS ====================
|
// ==================== ENERGY CONTRACT DETAILS ====================
|
||||||
|
|
||||||
|
enum InvoiceType {
|
||||||
|
INTERIM // Zwischenrechnung
|
||||||
|
FINAL // Schlussrechnung
|
||||||
|
NOT_AVAILABLE // Rechnung nicht mehr zu bekommen
|
||||||
|
}
|
||||||
|
|
||||||
model EnergyContractDetails {
|
model EnergyContractDetails {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
contractId Int @unique
|
contractId Int @unique
|
||||||
@@ -610,6 +616,21 @@ model EnergyContractDetails {
|
|||||||
bonus Float?
|
bonus Float?
|
||||||
previousProviderName String?
|
previousProviderName String?
|
||||||
previousCustomerNumber String?
|
previousCustomerNumber String?
|
||||||
|
invoices Invoice[] // Rechnungen
|
||||||
|
}
|
||||||
|
|
||||||
|
model Invoice {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
energyContractDetailsId Int
|
||||||
|
energyContractDetails EnergyContractDetails @relation(fields: [energyContractDetailsId], references: [id], onDelete: Cascade)
|
||||||
|
invoiceDate DateTime
|
||||||
|
invoiceType InvoiceType
|
||||||
|
documentPath String? // Pflicht, außer bei NOT_AVAILABLE
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([energyContractDetailsId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== INTERNET CONTRACT DETAILS ====================
|
// ==================== INTERNET CONTRACT DETAILS ====================
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as cachedEmailService from '../services/cachedEmail.service.js';
|
import * as cachedEmailService from '../services/cachedEmail.service.js';
|
||||||
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
||||||
|
import * as invoiceService from '../services/invoice.service.js';
|
||||||
import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '../services/smtpService.js';
|
import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '../services/smtpService.js';
|
||||||
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
||||||
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
||||||
@@ -885,6 +886,8 @@ export async function getAttachmentTargets(req: Request, res: Response): Promise
|
|||||||
contract?: {
|
contract?: {
|
||||||
id: number;
|
id: number;
|
||||||
contractNumber: string;
|
contractNumber: string;
|
||||||
|
type: string;
|
||||||
|
energyDetailsId?: number;
|
||||||
slots: TargetSlot[];
|
slots: TargetSlot[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -954,10 +957,14 @@ export async function getAttachmentTargets(req: Request, res: Response): Promise
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
contractNumber: true,
|
contractNumber: true,
|
||||||
|
type: true,
|
||||||
cancellationLetterPath: true,
|
cancellationLetterPath: true,
|
||||||
cancellationConfirmationPath: true,
|
cancellationConfirmationPath: true,
|
||||||
cancellationLetterOptionsPath: true,
|
cancellationLetterOptionsPath: true,
|
||||||
cancellationConfirmationOptionsPath: true,
|
cancellationConfirmationOptionsPath: true,
|
||||||
|
energyDetails: {
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -966,6 +973,8 @@ export async function getAttachmentTargets(req: Request, res: Response): Promise
|
|||||||
response.contract = {
|
response.contract = {
|
||||||
id: contract.id,
|
id: contract.id,
|
||||||
contractNumber: contract.contractNumber,
|
contractNumber: contract.contractNumber,
|
||||||
|
type: contract.type,
|
||||||
|
energyDetailsId: contract.energyDetails?.id,
|
||||||
slots: contractTargets.map(target => ({
|
slots: contractTargets.map(target => ({
|
||||||
key: target.key,
|
key: target.key,
|
||||||
label: target.label,
|
label: target.label,
|
||||||
@@ -1518,3 +1527,309 @@ export async function saveEmailAsPdf(req: Request, res: Response): Promise<void>
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== SAVE EMAIL AS INVOICE ====================
|
||||||
|
|
||||||
|
// E-Mail als PDF exportieren und als Rechnung speichern
|
||||||
|
export async function saveEmailAsInvoice(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const emailId = parseInt(req.params.id);
|
||||||
|
const { invoiceDate, invoiceType, notes } = req.body;
|
||||||
|
|
||||||
|
console.log('[saveEmailAsInvoice] Request:', { emailId, invoiceDate, invoiceType, notes });
|
||||||
|
|
||||||
|
// Validierung
|
||||||
|
if (!invoiceDate || !invoiceType) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'invoiceDate und invoiceType sind erforderlich',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validiere invoiceType
|
||||||
|
if (!['INTERIM', 'FINAL', 'NOT_AVAILABLE'].includes(invoiceType)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Ungültiger Rechnungstyp',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// E-Mail aus Cache laden
|
||||||
|
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||||
|
if (!email) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'E-Mail nicht gefunden',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen ob E-Mail einem Vertrag zugeordnet ist
|
||||||
|
if (!email.contractId) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'E-Mail ist keinem Vertrag zugeordnet',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertrag laden und prüfen ob es ein Energievertrag ist
|
||||||
|
const contract = await prisma.contract.findUnique({
|
||||||
|
where: { id: email.contractId },
|
||||||
|
include: { energyDetails: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contract) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Vertrag nicht gefunden',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['ELECTRICITY', 'GAS'].includes(contract.type)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Nur für Strom- und Gas-Verträge verfügbar',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contract.energyDetails) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Keine Energie-Details für diesen Vertrag vorhanden',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empfänger-Adressen parsen (JSON Array)
|
||||||
|
let toAddresses: string[] = [];
|
||||||
|
let ccAddresses: string[] = [];
|
||||||
|
try {
|
||||||
|
toAddresses = JSON.parse(email.toAddresses);
|
||||||
|
} catch { toAddresses = [email.toAddresses]; }
|
||||||
|
try {
|
||||||
|
if (email.ccAddresses) ccAddresses = JSON.parse(email.ccAddresses);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// PDF generieren
|
||||||
|
const pdfBuffer = await generateEmailPdf({
|
||||||
|
from: email.fromAddress,
|
||||||
|
to: toAddresses.join(', '),
|
||||||
|
cc: ccAddresses.length > 0 ? ccAddresses.join(', ') : undefined,
|
||||||
|
subject: email.subject || '(Kein Betreff)',
|
||||||
|
date: email.receivedAt,
|
||||||
|
bodyText: email.textBody || undefined,
|
||||||
|
bodyHtml: email.htmlBody || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uploads-Verzeichnis erstellen
|
||||||
|
const uploadsDir = path.join(process.cwd(), 'uploads', 'invoices');
|
||||||
|
if (!fs.existsSync(uploadsDir)) {
|
||||||
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eindeutigen Dateinamen generieren
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
|
const newFilename = `invoice-email-${uniqueSuffix}.pdf`;
|
||||||
|
const filePath = path.join(uploadsDir, newFilename);
|
||||||
|
const relativePath = `/uploads/invoices/${newFilename}`;
|
||||||
|
|
||||||
|
// PDF speichern
|
||||||
|
fs.writeFileSync(filePath, pdfBuffer);
|
||||||
|
|
||||||
|
// Invoice in DB erstellen
|
||||||
|
const invoice = await invoiceService.addInvoice(contract.energyDetails.id, {
|
||||||
|
invoiceDate: new Date(invoiceDate),
|
||||||
|
invoiceType,
|
||||||
|
documentPath: relativePath,
|
||||||
|
notes: notes || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: invoice,
|
||||||
|
} as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('saveEmailAsInvoice error:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SAVE ATTACHMENT AS INVOICE ====================
|
||||||
|
|
||||||
|
// E-Mail-Anhang als Rechnung speichern
|
||||||
|
export async function saveAttachmentAsInvoice(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const emailId = parseInt(req.params.id);
|
||||||
|
const filename = decodeURIComponent(req.params.filename);
|
||||||
|
const { invoiceDate, invoiceType, notes } = req.body;
|
||||||
|
|
||||||
|
console.log('[saveAttachmentAsInvoice] Request:', { emailId, filename, invoiceDate, invoiceType, notes });
|
||||||
|
|
||||||
|
// Validierung
|
||||||
|
if (!invoiceDate || !invoiceType) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'invoiceDate und invoiceType sind erforderlich',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validiere invoiceType
|
||||||
|
if (!['INTERIM', 'FINAL', 'NOT_AVAILABLE'].includes(invoiceType)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Ungültiger Rechnungstyp',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// E-Mail aus Cache laden
|
||||||
|
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||||
|
if (!email) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'E-Mail nicht gefunden',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen ob E-Mail einem Vertrag zugeordnet ist
|
||||||
|
if (!email.contractId) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'E-Mail ist keinem Vertrag zugeordnet',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertrag laden und prüfen ob es ein Energievertrag ist
|
||||||
|
const contract = await prisma.contract.findUnique({
|
||||||
|
where: { id: email.contractId },
|
||||||
|
include: { energyDetails: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contract) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Vertrag nicht gefunden',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['ELECTRICITY', 'GAS'].includes(contract.type)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Nur für Strom- und Gas-Verträge verfügbar',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contract.energyDetails) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Keine Energie-Details für diesen Vertrag vorhanden',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für gesendete E-Mails: Prüfen ob UID vorhanden
|
||||||
|
if (email.folder === 'SENT' && email.uid === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Anhang nicht verfügbar - E-Mail wurde vor der IMAP-Speicherung gesendet',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// StressfreiEmail laden um Zugangsdaten zu bekommen
|
||||||
|
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(email.stressfreiEmailId);
|
||||||
|
if (!stressfreiEmail || !stressfreiEmail.emailPasswordEncrypted) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Keine Mailbox-Zugangsdaten verfügbar',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMAP-Einstellungen laden
|
||||||
|
const settings = await getImapSmtpSettings();
|
||||||
|
if (!settings) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Keine E-Mail-Provider-Einstellungen gefunden',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passwort entschlüsseln
|
||||||
|
const password = decrypt(stressfreiEmail.emailPasswordEncrypted);
|
||||||
|
|
||||||
|
// IMAP-Credentials zusammenstellen
|
||||||
|
const credentials: ImapCredentials = {
|
||||||
|
host: settings.imapServer,
|
||||||
|
port: settings.imapPort,
|
||||||
|
user: stressfreiEmail.email,
|
||||||
|
password,
|
||||||
|
encryption: settings.imapEncryption,
|
||||||
|
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||||||
|
};
|
||||||
|
|
||||||
|
// IMAP-Ordner bestimmen
|
||||||
|
const imapFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
|
||||||
|
|
||||||
|
// Anhang vom IMAP-Server laden
|
||||||
|
const attachment = await fetchAttachment(credentials, email.uid, filename, imapFolder);
|
||||||
|
if (!attachment) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Anhang nicht gefunden oder nicht mehr verfügbar',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uploads-Verzeichnis erstellen
|
||||||
|
const uploadsDir = path.join(process.cwd(), 'uploads', 'invoices');
|
||||||
|
if (!fs.existsSync(uploadsDir)) {
|
||||||
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dateiendung extrahieren
|
||||||
|
const ext = path.extname(filename) || '.pdf';
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
|
const newFilename = `invoice-attachment-${uniqueSuffix}${ext}`;
|
||||||
|
const filePath = path.join(uploadsDir, newFilename);
|
||||||
|
const relativePath = `/uploads/invoices/${newFilename}`;
|
||||||
|
|
||||||
|
// Datei speichern
|
||||||
|
fs.writeFileSync(filePath, attachment.content);
|
||||||
|
|
||||||
|
// Invoice in DB erstellen
|
||||||
|
const invoice = await invoiceService.addInvoice(contract.energyDetails.id, {
|
||||||
|
invoiceDate: new Date(invoiceDate),
|
||||||
|
invoiceType,
|
||||||
|
documentPath: relativePath,
|
||||||
|
notes: notes || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: invoice,
|
||||||
|
} as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('saveAttachmentAsInvoice error:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import * as invoiceService from '../services/invoice.service.js';
|
||||||
|
import { ApiResponse } from '../types/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Rechnungen für ein EnergyContractDetails abrufen
|
||||||
|
*/
|
||||||
|
export async function getInvoices(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
|
const invoices = await invoiceService.getInvoices(ecdId);
|
||||||
|
res.json({ success: true, data: invoices } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getInvoices error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Fehler beim Laden der Rechnungen',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einzelne Rechnung abrufen
|
||||||
|
*/
|
||||||
|
export async function getInvoice(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
|
const invoiceId = parseInt(req.params.invoiceId);
|
||||||
|
const invoice = await invoiceService.getInvoice(ecdId, invoiceId);
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Rechnung nicht gefunden',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: invoice } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getInvoice error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Fehler beim Laden der Rechnung',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neue Rechnung hinzufügen
|
||||||
|
*/
|
||||||
|
export async function addInvoice(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
|
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
||||||
|
|
||||||
|
if (!invoiceDate || !invoiceType) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'invoiceDate und invoiceType sind erforderlich',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = await invoiceService.addInvoice(ecdId, {
|
||||||
|
invoiceDate: new Date(invoiceDate),
|
||||||
|
invoiceType,
|
||||||
|
documentPath,
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: invoice } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('addInvoice error:', error);
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen der Rechnung',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnung aktualisieren
|
||||||
|
*/
|
||||||
|
export async function updateInvoice(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
|
const invoiceId = parseInt(req.params.invoiceId);
|
||||||
|
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
||||||
|
|
||||||
|
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
|
||||||
|
invoiceDate: invoiceDate ? new Date(invoiceDate) : undefined,
|
||||||
|
invoiceType,
|
||||||
|
documentPath,
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: invoice } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('updateInvoice error:', error);
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Rechnung',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnung löschen
|
||||||
|
*/
|
||||||
|
export async function deleteInvoice(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
|
const invoiceId = parseInt(req.params.invoiceId);
|
||||||
|
|
||||||
|
await invoiceService.deleteInvoice(ecdId, invoiceId);
|
||||||
|
|
||||||
|
res.json({ success: true, data: null } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('deleteInvoice error:', error);
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Rechnung',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import contractTaskRoutes from './routes/contractTask.routes.js';
|
|||||||
import appSettingRoutes from './routes/appSetting.routes.js';
|
import appSettingRoutes from './routes/appSetting.routes.js';
|
||||||
import emailProviderRoutes from './routes/emailProvider.routes.js';
|
import emailProviderRoutes from './routes/emailProvider.routes.js';
|
||||||
import cachedEmailRoutes from './routes/cachedEmail.routes.js';
|
import cachedEmailRoutes from './routes/cachedEmail.routes.js';
|
||||||
|
import invoiceRoutes from './routes/invoice.routes.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ app.use('/api', contractTaskRoutes);
|
|||||||
app.use('/api/settings', appSettingRoutes);
|
app.use('/api/settings', appSettingRoutes);
|
||||||
app.use('/api/email-providers', emailProviderRoutes);
|
app.use('/api/email-providers', emailProviderRoutes);
|
||||||
app.use('/api', cachedEmailRoutes);
|
app.use('/api', cachedEmailRoutes);
|
||||||
|
app.use('/api/energy-details', invoiceRoutes);
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
|
|||||||
@@ -185,6 +185,24 @@ router.post(
|
|||||||
cachedEmailController.saveEmailAsPdf
|
cachedEmailController.saveEmailAsPdf
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// E-Mail als PDF exportieren und als Rechnung speichern
|
||||||
|
// POST /api/emails/:id/save-as-invoice { invoiceDate, invoiceType, notes? }
|
||||||
|
router.post(
|
||||||
|
'/emails/:id/save-as-invoice',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('contracts:update'),
|
||||||
|
cachedEmailController.saveEmailAsInvoice
|
||||||
|
);
|
||||||
|
|
||||||
|
// Anhang als Rechnung speichern
|
||||||
|
// POST /api/emails/:id/attachments/:filename/save-as-invoice { invoiceDate, invoiceType, notes? }
|
||||||
|
router.post(
|
||||||
|
'/emails/:id/attachments/:filename/save-as-invoice',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('contracts:update'),
|
||||||
|
cachedEmailController.saveAttachmentAsInvoice
|
||||||
|
);
|
||||||
|
|
||||||
// ==================== VERTRAGSZUORDNUNG ====================
|
// ==================== VERTRAGSZUORDNUNG ====================
|
||||||
|
|
||||||
// E-Mail Vertrag zuordnen
|
// E-Mail Vertrag zuordnen
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import * as invoiceController from '../controllers/invoice.controller.js';
|
||||||
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ==================== INVOICE CRUD ====================
|
||||||
|
|
||||||
|
// Alle Rechnungen für ein EnergyContractDetails abrufen
|
||||||
|
// GET /api/energy-details/:ecdId/invoices
|
||||||
|
router.get(
|
||||||
|
'/:ecdId/invoices',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('contracts:read'),
|
||||||
|
invoiceController.getInvoices
|
||||||
|
);
|
||||||
|
|
||||||
|
// Einzelne Rechnung abrufen
|
||||||
|
// GET /api/energy-details/:ecdId/invoices/:invoiceId
|
||||||
|
router.get(
|
||||||
|
'/:ecdId/invoices/:invoiceId',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('contracts:read'),
|
||||||
|
invoiceController.getInvoice
|
||||||
|
);
|
||||||
|
|
||||||
|
// Neue Rechnung hinzufügen
|
||||||
|
// POST /api/energy-details/:ecdId/invoices
|
||||||
|
router.post(
|
||||||
|
'/:ecdId/invoices',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('contracts:update'),
|
||||||
|
invoiceController.addInvoice
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rechnung aktualisieren
|
||||||
|
// PUT /api/energy-details/:ecdId/invoices/:invoiceId
|
||||||
|
router.put(
|
||||||
|
'/:ecdId/invoices/:invoiceId',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('contracts:update'),
|
||||||
|
invoiceController.updateInvoice
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rechnung löschen
|
||||||
|
// DELETE /api/energy-details/:ecdId/invoices/:invoiceId
|
||||||
|
router.delete(
|
||||||
|
'/:ecdId/invoices/:invoiceId',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('contracts:delete'),
|
||||||
|
invoiceController.deleteInvoice
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -658,4 +658,99 @@ router.delete(
|
|||||||
(req: AuthRequest, res: Response) => handleContractDocumentDelete(req, res, 'cancellationConfirmationOptionsPath')
|
(req: AuthRequest, res: Response) => handleContractDocumentDelete(req, res, 'cancellationConfirmationOptionsPath')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ==================== RECHNUNGS-DOKUMENTE ====================
|
||||||
|
|
||||||
|
// Upload für Rechnungs-Dokument
|
||||||
|
router.post(
|
||||||
|
'/invoices/:id',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('contracts:update'),
|
||||||
|
setUploadDir('invoices'),
|
||||||
|
upload.single('document'),
|
||||||
|
async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceId = parseInt(req.params.id);
|
||||||
|
const relativePath = `/uploads/invoices/${req.file.filename}`;
|
||||||
|
|
||||||
|
// Alte Datei löschen falls vorhanden
|
||||||
|
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
|
||||||
|
if (!invoice) {
|
||||||
|
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.documentPath) {
|
||||||
|
const oldPath = path.join(process.cwd(), invoice.documentPath);
|
||||||
|
if (fs.existsSync(oldPath)) {
|
||||||
|
fs.unlinkSync(oldPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoice in der DB aktualisieren
|
||||||
|
await prisma.invoice.update({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
data: { documentPath: relativePath },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
path: relativePath,
|
||||||
|
filename: req.file.filename,
|
||||||
|
originalName: req.file.originalname,
|
||||||
|
size: req.file.size,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Invoice upload error:', error);
|
||||||
|
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Löschen von Rechnungs-Dokument
|
||||||
|
router.delete(
|
||||||
|
'/invoices/:id',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('contracts:update'),
|
||||||
|
async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const invoiceId = parseInt(req.params.id);
|
||||||
|
|
||||||
|
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
|
||||||
|
if (!invoice) {
|
||||||
|
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice.documentPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datei löschen
|
||||||
|
const filePath = path.join(process.cwd(), invoice.documentPath);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// documentPath in DB auf null setzen
|
||||||
|
await prisma.invoice.update({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
data: { documentPath: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Invoice delete error:', error);
|
||||||
|
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -124,14 +124,14 @@ export async function getContractById(id: number, decryptPassword = false) {
|
|||||||
contractCategory: true,
|
contractCategory: true,
|
||||||
previousContract: {
|
previousContract: {
|
||||||
include: {
|
include: {
|
||||||
energyDetails: { include: { meter: { include: { readings: true } } } },
|
energyDetails: { include: { meter: { include: { readings: true } }, invoices: true } },
|
||||||
internetDetails: { include: { phoneNumbers: true } },
|
internetDetails: { include: { phoneNumbers: true } },
|
||||||
mobileDetails: { include: { simCards: true } },
|
mobileDetails: { include: { simCards: true } },
|
||||||
tvDetails: true,
|
tvDetails: true,
|
||||||
carInsuranceDetails: true,
|
carInsuranceDetails: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
energyDetails: { include: { meter: { include: { readings: true } } } },
|
energyDetails: { include: { meter: { include: { readings: true } }, invoices: true } },
|
||||||
internetDetails: { include: { phoneNumbers: true } },
|
internetDetails: { include: { phoneNumbers: true } },
|
||||||
mobileDetails: { include: { simCards: true } },
|
mobileDetails: { include: { simCards: true } },
|
||||||
tvDetails: true,
|
tvDetails: true,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface CockpitSummary {
|
|||||||
contractEnding: number;
|
contractEnding: number;
|
||||||
missingCredentials: number;
|
missingCredentials: number;
|
||||||
missingData: number;
|
missingData: number;
|
||||||
|
missingInvoices: number;
|
||||||
openTasks: number;
|
openTasks: number;
|
||||||
pendingContracts: number;
|
pendingContracts: number;
|
||||||
};
|
};
|
||||||
@@ -142,11 +143,11 @@ export async function getCockpitData(): Promise<CockpitResult> {
|
|||||||
const warningDays = parseInt(settings.deadlineWarningDays) || 42;
|
const warningDays = parseInt(settings.deadlineWarningDays) || 42;
|
||||||
const okDays = parseInt(settings.deadlineOkDays) || 90;
|
const okDays = parseInt(settings.deadlineOkDays) || 90;
|
||||||
|
|
||||||
// Lade alle aktiven/pending Verträge mit allen relevanten Daten
|
// Lade alle relevanten Verträge (inkl. CANCELLED/DEACTIVATED für Schlussrechnung-Check)
|
||||||
const contracts = await prisma.contract.findMany({
|
const contracts = await prisma.contract.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: {
|
status: {
|
||||||
in: ['ACTIVE', 'PENDING', 'DRAFT'],
|
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -182,6 +183,7 @@ export async function getCockpitData(): Promise<CockpitResult> {
|
|||||||
energyDetails: {
|
energyDetails: {
|
||||||
include: {
|
include: {
|
||||||
meter: true,
|
meter: true,
|
||||||
|
invoices: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
internetDetails: {
|
internetDetails: {
|
||||||
@@ -224,6 +226,7 @@ export async function getCockpitData(): Promise<CockpitResult> {
|
|||||||
contractEnding: 0,
|
contractEnding: 0,
|
||||||
missingCredentials: 0,
|
missingCredentials: 0,
|
||||||
missingData: 0,
|
missingData: 0,
|
||||||
|
missingInvoices: 0,
|
||||||
openTasks: 0,
|
openTasks: 0,
|
||||||
pendingContracts: 0,
|
pendingContracts: 0,
|
||||||
},
|
},
|
||||||
@@ -426,6 +429,75 @@ export async function getCockpitData(): Promise<CockpitResult> {
|
|||||||
summary.byCategory.pendingContracts++;
|
summary.byCategory.pendingContracts++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 13. ENERGIE-RECHNUNGEN (nur für ELECTRICITY und GAS)
|
||||||
|
if (['ELECTRICITY', 'GAS'].includes(contract.type) && contract.energyDetails) {
|
||||||
|
const invoices = contract.energyDetails.invoices || [];
|
||||||
|
const now = new Date();
|
||||||
|
now.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// 13a. SCHLUSSRECHNUNG FEHLT (nur wenn Vertrag gekündigt/deaktiviert ist)
|
||||||
|
// "Beendet" = CANCELLED oder DEACTIVATED (nicht nur Laufzeit abgelaufen!)
|
||||||
|
const isContractTerminated = contract.status === 'CANCELLED' || contract.status === 'DEACTIVATED';
|
||||||
|
|
||||||
|
if (isContractTerminated) {
|
||||||
|
const hasFinalInvoice = invoices.some(inv => inv.invoiceType === 'FINAL');
|
||||||
|
const hasNotAvailable = invoices.some(inv => inv.invoiceType === 'NOT_AVAILABLE');
|
||||||
|
|
||||||
|
if (!hasFinalInvoice && !hasNotAvailable) {
|
||||||
|
issues.push({
|
||||||
|
type: 'missing_final_invoice',
|
||||||
|
label: 'Schlussrechnung fehlt',
|
||||||
|
urgency: 'warning',
|
||||||
|
details: 'Vertrag gekündigt/deaktiviert, aber keine Schlussrechnung vorhanden',
|
||||||
|
});
|
||||||
|
summary.byCategory.missingInvoices++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13b. ZWISCHENRECHNUNG FEHLT/ÜBERFÄLLIG (wenn Vertrag > 12 Monate läuft)
|
||||||
|
// Für alle Status außer DRAFT und nicht gekündigt/deaktiviert
|
||||||
|
// Auch EXPIRED zählt hier, da der Vertrag ohne Kündigung weiterläuft!
|
||||||
|
if (contract.startDate && contract.status !== 'DRAFT' && !isContractTerminated) {
|
||||||
|
const startDate = new Date(contract.startDate);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
const daysSinceStart = Math.floor((now.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (daysSinceStart > 365) {
|
||||||
|
// Vertrag läuft > 12 Monate
|
||||||
|
if (invoices.length === 0) {
|
||||||
|
// Keine Rechnungen vorhanden
|
||||||
|
issues.push({
|
||||||
|
type: 'missing_interim_invoice',
|
||||||
|
label: 'Zwischenrechnung fehlt',
|
||||||
|
urgency: 'warning',
|
||||||
|
details: 'Vertrag läuft über 12 Monate ohne Rechnung',
|
||||||
|
});
|
||||||
|
summary.byCategory.missingInvoices++;
|
||||||
|
} else {
|
||||||
|
// Prüfen ob letzte Rechnung > 12 Monate alt
|
||||||
|
const latestInvoice = invoices
|
||||||
|
.filter(inv => inv.invoiceType !== 'NOT_AVAILABLE')
|
||||||
|
.sort((a, b) => new Date(b.invoiceDate).getTime() - new Date(a.invoiceDate).getTime())[0];
|
||||||
|
|
||||||
|
if (latestInvoice) {
|
||||||
|
const invoiceDate = new Date(latestInvoice.invoiceDate);
|
||||||
|
const daysSinceInvoice = Math.floor((now.getTime() - invoiceDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (daysSinceInvoice > 365) {
|
||||||
|
issues.push({
|
||||||
|
type: 'overdue_interim_invoice',
|
||||||
|
label: 'Zwischenrechnung überfällig',
|
||||||
|
urgency: 'warning',
|
||||||
|
details: `Letzte Rechnung vor ${Math.floor(daysSinceInvoice / 30)} Monaten`,
|
||||||
|
});
|
||||||
|
summary.byCategory.missingInvoices++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Nur Verträge mit Issues hinzufügen
|
// Nur Verträge mit Issues hinzufügen
|
||||||
if (issues.length > 0) {
|
if (issues.length > 0) {
|
||||||
const highestUrgency = getHighestUrgency(issues);
|
const highestUrgency = getHighestUrgency(issues);
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { PrismaClient, InvoiceType } from '@prisma/client';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export interface CreateInvoiceData {
|
||||||
|
invoiceDate: Date;
|
||||||
|
invoiceType: InvoiceType;
|
||||||
|
documentPath?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateInvoiceData {
|
||||||
|
invoiceDate?: Date;
|
||||||
|
invoiceType?: InvoiceType;
|
||||||
|
documentPath?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Rechnungen für ein EnergyContractDetails abrufen
|
||||||
|
*/
|
||||||
|
export async function getInvoices(energyContractDetailsId: number) {
|
||||||
|
return prisma.invoice.findMany({
|
||||||
|
where: { energyContractDetailsId },
|
||||||
|
orderBy: { invoiceDate: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einzelne Rechnung abrufen
|
||||||
|
*/
|
||||||
|
export async function getInvoice(energyContractDetailsId: number, invoiceId: number) {
|
||||||
|
return prisma.invoice.findFirst({
|
||||||
|
where: { id: invoiceId, energyContractDetailsId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neue Rechnung hinzufügen
|
||||||
|
*/
|
||||||
|
export async function addInvoice(energyContractDetailsId: number, data: CreateInvoiceData) {
|
||||||
|
// Validierung: documentPath ist Pflicht, außer bei NOT_AVAILABLE
|
||||||
|
if (data.invoiceType !== 'NOT_AVAILABLE' && !data.documentPath) {
|
||||||
|
throw new Error('Dokument ist Pflicht (außer bei Typ "Nicht verfügbar")');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen ob EnergyContractDetails existiert
|
||||||
|
const energyDetails = await prisma.energyContractDetails.findUnique({
|
||||||
|
where: { id: energyContractDetailsId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!energyDetails) {
|
||||||
|
throw new Error('Energievertrag nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.invoice.create({
|
||||||
|
data: {
|
||||||
|
energyContractDetailsId,
|
||||||
|
invoiceDate: data.invoiceDate,
|
||||||
|
invoiceType: data.invoiceType,
|
||||||
|
documentPath: data.documentPath,
|
||||||
|
notes: data.notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnung aktualisieren
|
||||||
|
*/
|
||||||
|
export async function updateInvoice(
|
||||||
|
energyContractDetailsId: number,
|
||||||
|
invoiceId: number,
|
||||||
|
data: UpdateInvoiceData
|
||||||
|
) {
|
||||||
|
// Prüfen ob Rechnung existiert und zum EnergyContractDetails gehört
|
||||||
|
const invoice = await prisma.invoice.findFirst({
|
||||||
|
where: { id: invoiceId, energyContractDetailsId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
throw new Error('Rechnung nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validierung bei Typ-Änderung
|
||||||
|
const newType = data.invoiceType ?? invoice.invoiceType;
|
||||||
|
const newPath = data.documentPath !== undefined ? data.documentPath : invoice.documentPath;
|
||||||
|
|
||||||
|
if (newType !== 'NOT_AVAILABLE' && !newPath) {
|
||||||
|
throw new Error('Dokument ist Pflicht (außer bei Typ "Nicht verfügbar")');
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.invoice.update({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
data: {
|
||||||
|
invoiceDate: data.invoiceDate,
|
||||||
|
invoiceType: data.invoiceType,
|
||||||
|
documentPath: data.documentPath,
|
||||||
|
notes: data.notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnung löschen
|
||||||
|
*/
|
||||||
|
export async function deleteInvoice(energyContractDetailsId: number, invoiceId: number) {
|
||||||
|
const invoice = await prisma.invoice.findFirst({
|
||||||
|
where: { id: invoiceId, energyContractDetailsId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
throw new Error('Rechnung nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datei löschen falls vorhanden
|
||||||
|
if (invoice.documentPath) {
|
||||||
|
const filePath = path.join(process.cwd(), invoice.documentPath);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.invoice.delete({ where: { id: invoiceId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnung direkt erstellen (für E-Mail-Integration)
|
||||||
|
* Erstellt eine Rechnung mit bereits vorhandenem Dokument
|
||||||
|
*/
|
||||||
|
export async function createInvoiceWithDocument(
|
||||||
|
energyContractDetailsId: number,
|
||||||
|
invoiceDate: Date,
|
||||||
|
invoiceType: InvoiceType,
|
||||||
|
documentPath: string,
|
||||||
|
notes?: string
|
||||||
|
) {
|
||||||
|
// Prüfen ob EnergyContractDetails existiert
|
||||||
|
const energyDetails = await prisma.energyContractDetails.findUnique({
|
||||||
|
where: { id: energyContractDetailsId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!energyDetails) {
|
||||||
|
throw new Error('Energievertrag nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.invoice.create({
|
||||||
|
data: {
|
||||||
|
energyContractDetailsId,
|
||||||
|
invoiceDate,
|
||||||
|
invoiceType,
|
||||||
|
documentPath,
|
||||||
|
notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+1
File diff suppressed because one or more lines are too long
+710
File diff suppressed because one or more lines are too long
-705
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>OpenCRM</title>
|
<title>OpenCRM</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CzqYCocn.js"></script>
|
<script type="module" crossorigin src="/assets/index-BZmzqt4I.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-OfL2GqlZ.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BKXieHhr.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Edit, Trash2, ChevronDown, ChevronUp, FileText, Download, AlertTriangle, Check, Eye } from 'lucide-react';
|
||||||
|
import Modal from '../ui/Modal';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import Input from '../ui/Input';
|
||||||
|
import Select from '../ui/Select';
|
||||||
|
import Badge from '../ui/Badge';
|
||||||
|
import { invoiceApi } from '../../services/api';
|
||||||
|
import type { Invoice, InvoiceType } from '../../types';
|
||||||
|
|
||||||
|
const invoiceTypeLabels: Record<InvoiceType, string> = {
|
||||||
|
INTERIM: 'Zwischenrechnung',
|
||||||
|
FINAL: 'Schlussrechnung',
|
||||||
|
NOT_AVAILABLE: 'Nicht verfügbar',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface InvoicesSectionProps {
|
||||||
|
ecdId: number; // energyContractDetailsId
|
||||||
|
invoices: Invoice[];
|
||||||
|
contractId: number;
|
||||||
|
canEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InvoicesSection({
|
||||||
|
ecdId,
|
||||||
|
invoices,
|
||||||
|
contractId,
|
||||||
|
canEdit,
|
||||||
|
}: InvoicesSectionProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [editingInvoice, setEditingInvoice] = useState<Invoice | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const deleteInvoiceMutation = useMutation({
|
||||||
|
mutationFn: (invoiceId: number) => invoiceApi.deleteInvoice(ecdId, invoiceId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort invoices by date (newest first)
|
||||||
|
const sortedInvoices = [...invoices].sort(
|
||||||
|
(a, b) => new Date(b.invoiceDate).getTime() - new Date(a.invoiceDate).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasFinalInvoice = invoices.some(i => i.invoiceType === 'FINAL');
|
||||||
|
const hasNotAvailable = invoices.some(i => i.invoiceType === 'NOT_AVAILABLE');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 pt-4 border-t">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-gray-500" />
|
||||||
|
<h4 className="text-sm font-medium text-gray-700">Rechnungen</h4>
|
||||||
|
<Badge variant="default">{invoices.length}</Badge>
|
||||||
|
{/* Status-Indicator */}
|
||||||
|
{hasFinalInvoice ? (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-green-100 text-green-800">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
Schlussrechnung
|
||||||
|
</span>
|
||||||
|
) : hasNotAvailable ? (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-yellow-100 text-yellow-800">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Nicht verfügbar
|
||||||
|
</span>
|
||||||
|
) : invoices.length > 0 ? (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-orange-100 text-orange-800">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Schlussrechnung fehlt
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{canEdit && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowAddModal(true)}>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{invoices.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsed view - show latest invoice */}
|
||||||
|
{!isExpanded && sortedInvoices.length > 0 && (
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
Letzte: {new Date(sortedInvoices[0].invoiceDate).toLocaleDateString('de-DE')} - {invoiceTypeLabels[sortedInvoices[0].invoiceType]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expanded view */}
|
||||||
|
{isExpanded && sortedInvoices.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedInvoices.map((invoice) => (
|
||||||
|
<div
|
||||||
|
key={invoice.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{new Date(invoice.invoiceDate).toLocaleDateString('de-DE')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{invoiceTypeLabels[invoice.invoiceType]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{invoice.documentPath && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={`/api${invoice.documentPath}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
|
||||||
|
title="Anzeigen"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`/api${invoice.documentPath}`}
|
||||||
|
download
|
||||||
|
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invoice.notes && (
|
||||||
|
<span className="text-xs text-gray-400 italic">{invoice.notes}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingInvoice(invoice)}
|
||||||
|
className="text-gray-500 hover:text-blue-600"
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Rechnung wirklich löschen?')) {
|
||||||
|
deleteInvoiceMutation.mutate(invoice.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-gray-500 hover:text-red-600"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isExpanded && sortedInvoices.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-500 italic">Keine Rechnungen vorhanden.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add/Edit Invoice Modal */}
|
||||||
|
{(showAddModal || editingInvoice) && (
|
||||||
|
<InvoiceModal
|
||||||
|
isOpen={true}
|
||||||
|
onClose={() => {
|
||||||
|
setShowAddModal(false);
|
||||||
|
setEditingInvoice(null);
|
||||||
|
}}
|
||||||
|
ecdId={ecdId}
|
||||||
|
contractId={contractId}
|
||||||
|
invoice={editingInvoice}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoice Modal Component
|
||||||
|
function InvoiceModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
ecdId,
|
||||||
|
contractId,
|
||||||
|
invoice,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
ecdId: number;
|
||||||
|
contractId: number;
|
||||||
|
invoice?: Invoice | null;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isEditing = !!invoice;
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
invoiceDate: invoice?.invoiceDate
|
||||||
|
? new Date(invoice.invoiceDate).toISOString().split('T')[0]
|
||||||
|
: new Date().toISOString().split('T')[0],
|
||||||
|
invoiceType: invoice?.invoiceType || 'INTERIM' as InvoiceType,
|
||||||
|
notes: invoice?.notes || '',
|
||||||
|
});
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
// Validierung: Dokument ist Pflicht, außer bei NOT_AVAILABLE
|
||||||
|
if (formData.invoiceType !== 'NOT_AVAILABLE' && !selectedFile) {
|
||||||
|
throw new Error('Bitte laden Sie ein Dokument hoch');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Invoice erstellen
|
||||||
|
const result = await invoiceApi.addInvoice(ecdId, {
|
||||||
|
invoiceDate: formData.invoiceDate,
|
||||||
|
invoiceType: formData.invoiceType,
|
||||||
|
notes: formData.notes || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Upload file if selected
|
||||||
|
if (selectedFile && result.data?.id) {
|
||||||
|
await invoiceApi.uploadDocument(result.data.id, selectedFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
setError(err.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
// Validierung: Dokument ist Pflicht, außer bei NOT_AVAILABLE
|
||||||
|
if (formData.invoiceType !== 'NOT_AVAILABLE' && !invoice?.documentPath && !selectedFile) {
|
||||||
|
throw new Error('Bitte laden Sie ein Dokument hoch');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Invoice aktualisieren
|
||||||
|
const result = await invoiceApi.updateInvoice(ecdId, invoice!.id, {
|
||||||
|
invoiceDate: formData.invoiceDate,
|
||||||
|
invoiceType: formData.invoiceType,
|
||||||
|
notes: formData.notes || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Upload file if selected
|
||||||
|
if (selectedFile) {
|
||||||
|
await invoiceApi.uploadDocument(invoice!.id, selectedFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
setError(err.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
updateMutation.mutate();
|
||||||
|
} else {
|
||||||
|
createMutation.mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (file.type !== 'application/pdf') {
|
||||||
|
setError('Nur PDF-Dateien sind erlaubt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
setError('Datei ist zu groß (max. 10 MB)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedFile(file);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Rechnung bearbeiten' : 'Rechnung hinzufügen'}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Rechnungsdatum"
|
||||||
|
type="date"
|
||||||
|
value={formData.invoiceDate}
|
||||||
|
onChange={(e) => setFormData({ ...formData, invoiceDate: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Rechnungstyp"
|
||||||
|
value={formData.invoiceType}
|
||||||
|
onChange={(e) => setFormData({ ...formData, invoiceType: e.target.value as InvoiceType })}
|
||||||
|
options={[
|
||||||
|
{ value: 'INTERIM', label: 'Zwischenrechnung' },
|
||||||
|
{ value: 'FINAL', label: 'Schlussrechnung' },
|
||||||
|
{ value: 'NOT_AVAILABLE', label: 'Nicht verfügbar' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formData.invoiceType !== 'NOT_AVAILABLE' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Dokument (PDF) *
|
||||||
|
</label>
|
||||||
|
{invoice?.documentPath && !selectedFile && (
|
||||||
|
<div className="mb-2 text-sm text-green-600 flex items-center gap-1">
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
Dokument vorhanden
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedFile && (
|
||||||
|
<div className="mb-2 text-sm text-blue-600 flex items-center gap-1">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
{selectedFile.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept=".pdf"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{invoice?.documentPath || selectedFile ? 'Ersetzen' : 'PDF hochladen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.invoiceType === 'NOT_AVAILABLE' && (
|
||||||
|
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800 text-sm">
|
||||||
|
Bei diesem Typ wird kein Dokument benötigt. Die Rechnung wird als "nicht mehr zu bekommen" markiert.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Notizen (optional)"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||||
|
placeholder="Optionale Anmerkungen..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? 'Wird gespeichert...' : isEditing ? 'Speichern' : 'Hinzufügen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight } from 'lucide-react';
|
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight, Receipt } from 'lucide-react';
|
||||||
import Modal from '../ui/Modal';
|
import Modal from '../ui/Modal';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
|
import Input from '../ui/Input';
|
||||||
|
import Select from '../ui/Select';
|
||||||
import { cachedEmailApi, AttachmentTargetSlot, AttachmentEntityWithSlots } from '../../services/api';
|
import { cachedEmailApi, AttachmentTargetSlot, AttachmentEntityWithSlots } from '../../services/api';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import type { InvoiceType } from '../../types';
|
||||||
|
|
||||||
interface SaveAttachmentModalProps {
|
interface SaveAttachmentModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -22,6 +25,8 @@ type SelectedTarget = {
|
|||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SaveMode = 'document' | 'invoice';
|
||||||
|
|
||||||
export default function SaveAttachmentModal({
|
export default function SaveAttachmentModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -31,6 +36,12 @@ export default function SaveAttachmentModal({
|
|||||||
}: SaveAttachmentModalProps) {
|
}: SaveAttachmentModalProps) {
|
||||||
const [selectedTarget, setSelectedTarget] = useState<SelectedTarget | null>(null);
|
const [selectedTarget, setSelectedTarget] = useState<SelectedTarget | null>(null);
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['customer']));
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['customer']));
|
||||||
|
const [saveMode, setSaveMode] = useState<SaveMode>('document');
|
||||||
|
const [invoiceData, setInvoiceData] = useState({
|
||||||
|
invoiceDate: new Date().toISOString().split('T')[0],
|
||||||
|
invoiceType: 'INTERIM' as InvoiceType,
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Ziele laden
|
// Ziele laden
|
||||||
@@ -42,6 +53,9 @@ export default function SaveAttachmentModal({
|
|||||||
|
|
||||||
const targets = targetsData?.data;
|
const targets = targetsData?.data;
|
||||||
|
|
||||||
|
// Prüfen ob es ein Energievertrag ist
|
||||||
|
const isEnergyContract = targets?.contract?.type === 'ELECTRICITY' || targets?.contract?.type === 'GAS';
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
if (!selectedTarget) throw new Error('Kein Ziel ausgewählt');
|
if (!selectedTarget) throw new Error('Kein Ziel ausgewählt');
|
||||||
@@ -73,8 +87,40 @@ export default function SaveAttachmentModal({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveInvoiceMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
return cachedEmailApi.saveAttachmentAsInvoice(emailId, attachmentFilename, {
|
||||||
|
invoiceDate: invoiceData.invoiceDate,
|
||||||
|
invoiceType: invoiceData.invoiceType,
|
||||||
|
notes: invoiceData.notes || undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Anhang als Rechnung gespeichert');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['attachment-targets', emailId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||||
|
|
||||||
|
if (targets?.contract?.id) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contract', targets.contract.id.toString()] });
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || 'Fehler beim Speichern der Rechnung');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setSelectedTarget(null);
|
setSelectedTarget(null);
|
||||||
|
setSaveMode('document');
|
||||||
|
setInvoiceData({
|
||||||
|
invoiceDate: new Date().toISOString().split('T')[0],
|
||||||
|
invoiceType: 'INTERIM',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,8 +261,38 @@ export default function SaveAttachmentModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Targets */}
|
|
||||||
{targets && (
|
{targets && (
|
||||||
|
<>
|
||||||
|
{/* Mode Toggle für Energieverträge */}
|
||||||
|
{isEnergyContract && (
|
||||||
|
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setSaveMode('document')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
saveMode === 'document'
|
||||||
|
? 'bg-white text-blue-600 shadow-sm'
|
||||||
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
Als Dokument
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSaveMode('invoice')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
saveMode === 'invoice'
|
||||||
|
? 'bg-white text-green-600 shadow-sm'
|
||||||
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Receipt className="w-4 h-4" />
|
||||||
|
Als Rechnung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Document Mode */}
|
||||||
|
{saveMode === 'document' && (
|
||||||
<div className="space-y-3 max-h-96 overflow-auto">
|
<div className="space-y-3 max-h-96 overflow-auto">
|
||||||
{/* Kunde */}
|
{/* Kunde */}
|
||||||
{renderSection(
|
{renderSection(
|
||||||
@@ -266,8 +342,46 @@ export default function SaveAttachmentModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Invoice Mode */}
|
||||||
|
{saveMode === 'invoice' && isEnergyContract && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-3 bg-green-50 rounded-lg">
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
Der Anhang wird als Rechnung für den Vertrag <strong>{targets.contract?.contractNumber}</strong> gespeichert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Rechnungsdatum"
|
||||||
|
type="date"
|
||||||
|
value={invoiceData.invoiceDate}
|
||||||
|
onChange={(e) => setInvoiceData({ ...invoiceData, invoiceDate: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Rechnungstyp"
|
||||||
|
value={invoiceData.invoiceType}
|
||||||
|
onChange={(e) => setInvoiceData({ ...invoiceData, invoiceType: e.target.value as InvoiceType })}
|
||||||
|
options={[
|
||||||
|
{ value: 'INTERIM', label: 'Zwischenrechnung' },
|
||||||
|
{ value: 'FINAL', label: 'Schlussrechnung' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Notizen (optional)"
|
||||||
|
value={invoiceData.notes}
|
||||||
|
onChange={(e) => setInvoiceData({ ...invoiceData, notes: e.target.value })}
|
||||||
|
placeholder="Optionale Anmerkungen..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Warning if replacing */}
|
{/* Warning if replacing */}
|
||||||
{selectedTarget?.hasDocument && (
|
{saveMode === 'document' && selectedTarget?.hasDocument && (
|
||||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
|
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
|
||||||
<AlertTriangle className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
<AlertTriangle className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||||
<div className="text-sm text-yellow-800">
|
<div className="text-sm text-yellow-800">
|
||||||
@@ -282,12 +396,21 @@ export default function SaveAttachmentModal({
|
|||||||
<Button variant="secondary" onClick={handleClose}>
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
|
{saveMode === 'document' ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => saveMutation.mutate()}
|
onClick={() => saveMutation.mutate()}
|
||||||
disabled={!selectedTarget || saveMutation.isPending}
|
disabled={!selectedTarget || saveMutation.isPending || saveInvoiceMutation.isPending}
|
||||||
>
|
>
|
||||||
{saveMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
|
{saveMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => saveInvoiceMutation.mutate()}
|
||||||
|
disabled={!invoiceData.invoiceDate || saveMutation.isPending || saveInvoiceMutation.isPending}
|
||||||
|
>
|
||||||
|
{saveInvoiceMutation.isPending ? 'Wird gespeichert...' : 'Als Rechnung speichern'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight } from 'lucide-react';
|
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight, Receipt } from 'lucide-react';
|
||||||
import Modal from '../ui/Modal';
|
import Modal from '../ui/Modal';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
|
import Input from '../ui/Input';
|
||||||
|
import Select from '../ui/Select';
|
||||||
import { cachedEmailApi, AttachmentTargetSlot, AttachmentEntityWithSlots } from '../../services/api';
|
import { cachedEmailApi, AttachmentTargetSlot, AttachmentEntityWithSlots } from '../../services/api';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import type { InvoiceType } from '../../types';
|
||||||
|
|
||||||
interface SaveEmailAsPdfModalProps {
|
interface SaveEmailAsPdfModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -21,6 +24,8 @@ type SelectedTarget = {
|
|||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SaveMode = 'document' | 'invoice';
|
||||||
|
|
||||||
export default function SaveEmailAsPdfModal({
|
export default function SaveEmailAsPdfModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -29,6 +34,12 @@ export default function SaveEmailAsPdfModal({
|
|||||||
}: SaveEmailAsPdfModalProps) {
|
}: SaveEmailAsPdfModalProps) {
|
||||||
const [selectedTarget, setSelectedTarget] = useState<SelectedTarget | null>(null);
|
const [selectedTarget, setSelectedTarget] = useState<SelectedTarget | null>(null);
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['customer']));
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['customer']));
|
||||||
|
const [saveMode, setSaveMode] = useState<SaveMode>('document');
|
||||||
|
const [invoiceData, setInvoiceData] = useState({
|
||||||
|
invoiceDate: new Date().toISOString().split('T')[0],
|
||||||
|
invoiceType: 'INTERIM' as InvoiceType,
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Ziele laden (gleiche wie bei Anhängen)
|
// Ziele laden (gleiche wie bei Anhängen)
|
||||||
@@ -40,6 +51,9 @@ export default function SaveEmailAsPdfModal({
|
|||||||
|
|
||||||
const targets = targetsData?.data;
|
const targets = targetsData?.data;
|
||||||
|
|
||||||
|
// Prüfen ob es ein Energievertrag ist
|
||||||
|
const isEnergyContract = targets?.contract?.type === 'ELECTRICITY' || targets?.contract?.type === 'GAS';
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
if (!selectedTarget) throw new Error('Kein Ziel ausgewählt');
|
if (!selectedTarget) throw new Error('Kein Ziel ausgewählt');
|
||||||
@@ -71,8 +85,40 @@ export default function SaveEmailAsPdfModal({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveInvoiceMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
return cachedEmailApi.saveEmailAsInvoice(emailId, {
|
||||||
|
invoiceDate: invoiceData.invoiceDate,
|
||||||
|
invoiceType: invoiceData.invoiceType,
|
||||||
|
notes: invoiceData.notes || undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('E-Mail als Rechnung gespeichert');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['attachment-targets', emailId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||||
|
|
||||||
|
if (targets?.contract?.id) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contract', targets.contract.id.toString()] });
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || 'Fehler beim Speichern der Rechnung');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setSelectedTarget(null);
|
setSelectedTarget(null);
|
||||||
|
setSaveMode('document');
|
||||||
|
setInvoiceData({
|
||||||
|
invoiceDate: new Date().toISOString().split('T')[0],
|
||||||
|
invoiceType: 'INTERIM',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,6 +235,8 @@ export default function SaveEmailAsPdfModal({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isPending = saveMutation.isPending || saveInvoiceMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={handleClose} title="E-Mail als PDF speichern" size="lg">
|
<Modal isOpen={isOpen} onClose={handleClose} title="E-Mail als PDF speichern" size="lg">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -213,8 +261,38 @@ export default function SaveEmailAsPdfModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Targets */}
|
|
||||||
{targets && (
|
{targets && (
|
||||||
|
<>
|
||||||
|
{/* Mode Toggle für Energieverträge */}
|
||||||
|
{isEnergyContract && (
|
||||||
|
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setSaveMode('document')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
saveMode === 'document'
|
||||||
|
? 'bg-white text-blue-600 shadow-sm'
|
||||||
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
Als Dokument
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSaveMode('invoice')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
saveMode === 'invoice'
|
||||||
|
? 'bg-white text-green-600 shadow-sm'
|
||||||
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Receipt className="w-4 h-4" />
|
||||||
|
Als Rechnung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Document Mode */}
|
||||||
|
{saveMode === 'document' && (
|
||||||
<div className="space-y-3 max-h-96 overflow-auto">
|
<div className="space-y-3 max-h-96 overflow-auto">
|
||||||
{/* Kunde */}
|
{/* Kunde */}
|
||||||
{renderSection(
|
{renderSection(
|
||||||
@@ -264,8 +342,46 @@ export default function SaveEmailAsPdfModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Invoice Mode */}
|
||||||
|
{saveMode === 'invoice' && isEnergyContract && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-3 bg-green-50 rounded-lg">
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
Die E-Mail wird als Rechnung für den Vertrag <strong>{targets.contract?.contractNumber}</strong> gespeichert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Rechnungsdatum"
|
||||||
|
type="date"
|
||||||
|
value={invoiceData.invoiceDate}
|
||||||
|
onChange={(e) => setInvoiceData({ ...invoiceData, invoiceDate: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Rechnungstyp"
|
||||||
|
value={invoiceData.invoiceType}
|
||||||
|
onChange={(e) => setInvoiceData({ ...invoiceData, invoiceType: e.target.value as InvoiceType })}
|
||||||
|
options={[
|
||||||
|
{ value: 'INTERIM', label: 'Zwischenrechnung' },
|
||||||
|
{ value: 'FINAL', label: 'Schlussrechnung' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Notizen (optional)"
|
||||||
|
value={invoiceData.notes}
|
||||||
|
onChange={(e) => setInvoiceData({ ...invoiceData, notes: e.target.value })}
|
||||||
|
placeholder="Optionale Anmerkungen..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Warning if replacing */}
|
{/* Warning if replacing */}
|
||||||
{selectedTarget?.hasDocument && (
|
{saveMode === 'document' && selectedTarget?.hasDocument && (
|
||||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
|
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
|
||||||
<AlertTriangle className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
<AlertTriangle className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||||
<div className="text-sm text-yellow-800">
|
<div className="text-sm text-yellow-800">
|
||||||
@@ -280,12 +396,21 @@ export default function SaveEmailAsPdfModal({
|
|||||||
<Button variant="secondary" onClick={handleClose}>
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
|
{saveMode === 'document' ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => saveMutation.mutate()}
|
onClick={() => saveMutation.mutate()}
|
||||||
disabled={!selectedTarget || saveMutation.isPending}
|
disabled={!selectedTarget || isPending}
|
||||||
>
|
>
|
||||||
{saveMutation.isPending ? 'Wird erstellt...' : 'Als PDF speichern'}
|
{isPending ? 'Wird erstellt...' : 'Als PDF speichern'}
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => saveInvoiceMutation.mutate()}
|
||||||
|
disabled={!invoiceData.invoiceDate || isPending}
|
||||||
|
>
|
||||||
|
{isPending ? 'Wird erstellt...' : 'Als Rechnung speichern'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi } from '../../services/api';
|
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi } from '../../services/api';
|
||||||
import { ContractEmailsSection } from '../../components/email';
|
import { ContractEmailsSection } from '../../components/email';
|
||||||
import { ContractDetailModal } from '../../components/contracts';
|
import { ContractDetailModal } from '../../components/contracts';
|
||||||
|
import InvoicesSection from '../../components/contracts/InvoicesSection';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import Card from '../../components/ui/Card';
|
import Card from '../../components/ui/Card';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
@@ -11,7 +12,7 @@ import Badge from '../../components/ui/Badge';
|
|||||||
import Input from '../../components/ui/Input';
|
import Input from '../../components/ui/Input';
|
||||||
import Modal from '../../components/ui/Modal';
|
import Modal from '../../components/ui/Modal';
|
||||||
import FileUpload from '../../components/ui/FileUpload';
|
import FileUpload from '../../components/ui/FileUpload';
|
||||||
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator } from 'lucide-react';
|
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X } from 'lucide-react';
|
||||||
import { calculateConsumption, calculateCosts } from '../../utils/energyCalculations';
|
import { calculateConsumption, calculateCosts } from '../../utils/energyCalculations';
|
||||||
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||||
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask } from '../../types';
|
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask } from '../../types';
|
||||||
@@ -45,6 +46,42 @@ const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' |
|
|||||||
DEACTIVATED: 'default',
|
DEACTIVATED: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Status-Erklärungen für Info-Modal
|
||||||
|
const statusDescriptions = [
|
||||||
|
{ status: 'DRAFT', label: 'Entwurf', description: 'Vertrag wird noch vorbereitet', color: 'text-gray-600' },
|
||||||
|
{ status: 'PENDING', label: 'Ausstehend', description: 'Wartet auf Aktivierung', color: 'text-yellow-600' },
|
||||||
|
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
|
||||||
|
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
|
||||||
|
{ status: 'CANCELLED', label: 'Gekündigt', description: 'Aktive Kündigung eingereicht, Vertrag endet', color: 'text-red-600' },
|
||||||
|
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function StatusInfoModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="fixed inset-0 bg-black/20" onClick={onClose} />
|
||||||
|
<div className="relative bg-white rounded-lg shadow-xl p-4 max-w-sm w-full mx-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">Vertragsstatus-Übersicht</h3>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{statusDescriptions.map(({ status, label, description, color }) => (
|
||||||
|
<div key={status} className="flex items-start gap-2">
|
||||||
|
<span className={`font-medium text-sm min-w-[90px] ${color}`}>{label}</span>
|
||||||
|
<span className="text-sm text-gray-600">{description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Prüft ob die Laufzeit als "unbefristet" gilt (≤ 4 Wochen / 1 Monat / 30 Tage)
|
// Prüft ob die Laufzeit als "unbefristet" gilt (≤ 4 Wochen / 1 Monat / 30 Tage)
|
||||||
function isUnlimitedDuration(durationCode: string): boolean {
|
function isUnlimitedDuration(durationCode: string): boolean {
|
||||||
const match = durationCode.match(/^(\d+)([TMWJ])$/);
|
const match = durationCode.match(/^(\d+)([TMWJ])$/);
|
||||||
@@ -1203,6 +1240,9 @@ export default function ContractDetail() {
|
|||||||
// Bestätigungsdialog für Folgevertrag
|
// Bestätigungsdialog für Folgevertrag
|
||||||
const [showFollowUpConfirm, setShowFollowUpConfirm] = useState(false);
|
const [showFollowUpConfirm, setShowFollowUpConfirm] = useState(false);
|
||||||
|
|
||||||
|
// Status-Info Modal
|
||||||
|
const [showStatusInfo, setShowStatusInfo] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['contract', id],
|
queryKey: ['contract', id],
|
||||||
queryFn: () => contractApi.getById(contractId),
|
queryFn: () => contractApi.getById(contractId),
|
||||||
@@ -1399,6 +1439,13 @@ export default function ContractDetail() {
|
|||||||
<h1 className="text-2xl font-bold">{c.contractNumber}</h1>
|
<h1 className="text-2xl font-bold">{c.contractNumber}</h1>
|
||||||
<Badge>{typeLabels[c.type]}</Badge>
|
<Badge>{typeLabels[c.type]}</Badge>
|
||||||
<Badge variant={statusVariants[c.status]}>{statusLabels[c.status]}</Badge>
|
<Badge variant={statusVariants[c.status]}>{statusLabels[c.status]}</Badge>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowStatusInfo(true)}
|
||||||
|
className="text-gray-400 hover:text-blue-600 transition-colors"
|
||||||
|
title="Status-Erklärung"
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{c.customer && (
|
{c.customer && (
|
||||||
<p className="text-gray-500 ml-10">
|
<p className="text-gray-500 ml-10">
|
||||||
@@ -2151,6 +2198,14 @@ export default function ContractDetail() {
|
|||||||
bonus={c.energyDetails.bonus}
|
bonus={c.energyDetails.bonus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Rechnungen */}
|
||||||
|
<InvoicesSection
|
||||||
|
ecdId={c.energyDetails.id}
|
||||||
|
invoices={c.energyDetails.invoices || []}
|
||||||
|
contractId={contractId}
|
||||||
|
canEdit={hasPermission('contracts:update') && !isCustomer}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2589,6 +2644,9 @@ export default function ContractDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Status-Info Modal */}
|
||||||
|
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Button from '../../components/ui/Button';
|
|||||||
import Input from '../../components/ui/Input';
|
import Input from '../../components/ui/Input';
|
||||||
import Select from '../../components/ui/Select';
|
import Select from '../../components/ui/Select';
|
||||||
import type { ContractType } from '../../types';
|
import type { ContractType } from '../../types';
|
||||||
import { Plus, Trash2, Eye, EyeOff } from 'lucide-react';
|
import { Plus, Trash2, Eye, EyeOff, Info, X } from 'lucide-react';
|
||||||
|
|
||||||
// Contract types are now loaded dynamically from the database
|
// Contract types are now loaded dynamically from the database
|
||||||
|
|
||||||
@@ -21,6 +21,42 @@ const statusOptions = [
|
|||||||
{ value: 'DEACTIVATED', label: 'Deaktiviert' },
|
{ value: 'DEACTIVATED', label: 'Deaktiviert' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Status-Erklärungen für Info-Modal
|
||||||
|
const statusDescriptions = [
|
||||||
|
{ status: 'DRAFT', label: 'Entwurf', description: 'Vertrag wird noch vorbereitet', color: 'text-gray-600' },
|
||||||
|
{ status: 'PENDING', label: 'Ausstehend', description: 'Wartet auf Aktivierung', color: 'text-yellow-600' },
|
||||||
|
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
|
||||||
|
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
|
||||||
|
{ status: 'CANCELLED', label: 'Gekündigt', description: 'Aktive Kündigung eingereicht, Vertrag endet', color: 'text-red-600' },
|
||||||
|
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function StatusInfoModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="fixed inset-0 bg-black/20" onClick={onClose} />
|
||||||
|
<div className="relative bg-white rounded-lg shadow-xl p-4 max-w-sm w-full mx-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">Vertragsstatus-Übersicht</h3>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{statusDescriptions.map(({ status, label, description, color }) => (
|
||||||
|
<div key={status} className="flex items-start gap-2">
|
||||||
|
<span className={`font-medium text-sm min-w-[90px] ${color}`}>{label}</span>
|
||||||
|
<span className="text-sm text-gray-600">{description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContractForm() {
|
export default function ContractForm() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -143,6 +179,9 @@ export default function ContractForm() {
|
|||||||
const [showSimPins, setShowSimPins] = useState<Record<number, boolean>>({});
|
const [showSimPins, setShowSimPins] = useState<Record<number, boolean>>({});
|
||||||
const [showSimPuks, setShowSimPuks] = useState<Record<number, boolean>>({});
|
const [showSimPuks, setShowSimPuks] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
// Status-Info Modal
|
||||||
|
const [showStatusInfo, setShowStatusInfo] = useState(false);
|
||||||
|
|
||||||
// For new contracts, mark as "loaded" immediately so provider change detection works
|
// For new contracts, mark as "loaded" immediately so provider change detection works
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEdit) {
|
if (!isEdit) {
|
||||||
@@ -635,11 +674,23 @@ export default function ContractForm() {
|
|||||||
options={typeOptions}
|
options={typeOptions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Status</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowStatusInfo(true)}
|
||||||
|
className="text-gray-400 hover:text-blue-600 transition-colors"
|
||||||
|
title="Status-Erklärung"
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<Select
|
<Select
|
||||||
label="Status"
|
|
||||||
{...register('status')}
|
{...register('status')}
|
||||||
options={statusOptions}
|
options={statusOptions}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
label="Vertriebsplattform"
|
label="Vertriebsplattform"
|
||||||
@@ -1322,6 +1373,9 @@ export default function ContractForm() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Status-Info Modal */}
|
||||||
|
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import Input from '../../components/ui/Input';
|
|||||||
import Select from '../../components/ui/Select';
|
import Select from '../../components/ui/Select';
|
||||||
import Badge from '../../components/ui/Badge';
|
import Badge from '../../components/ui/Badge';
|
||||||
import CopyButton from '../../components/ui/CopyButton';
|
import CopyButton from '../../components/ui/CopyButton';
|
||||||
import { Plus, Search, Eye, Edit, Trash2, User, Users, ChevronDown, ChevronRight } from 'lucide-react';
|
import { Plus, Search, Eye, Edit, Trash2, User, Users, ChevronDown, ChevronRight, Info, X } from 'lucide-react';
|
||||||
import type { Contract, ContractType, ContractStatus } from '../../types';
|
import type { Contract, ContractType, ContractStatus } from '../../types';
|
||||||
|
|
||||||
const typeLabels: Record<ContractType, string> = {
|
const typeLabels: Record<ContractType, string> = {
|
||||||
@@ -41,6 +41,42 @@ const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' |
|
|||||||
DEACTIVATED: 'default',
|
DEACTIVATED: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Status-Erklärungen für Info-Modal
|
||||||
|
const statusDescriptions = [
|
||||||
|
{ status: 'DRAFT', label: 'Entwurf', description: 'Vertrag wird noch vorbereitet', color: 'text-gray-600' },
|
||||||
|
{ status: 'PENDING', label: 'Ausstehend', description: 'Wartet auf Aktivierung', color: 'text-yellow-600' },
|
||||||
|
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
|
||||||
|
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
|
||||||
|
{ status: 'CANCELLED', label: 'Gekündigt', description: 'Aktive Kündigung eingereicht, Vertrag endet', color: 'text-red-600' },
|
||||||
|
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function StatusInfoModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="fixed inset-0 bg-black/20" onClick={onClose} />
|
||||||
|
<div className="relative bg-white rounded-lg shadow-xl p-4 max-w-sm w-full mx-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">Vertragsstatus-Übersicht</h3>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{statusDescriptions.map(({ status, label, description, color }) => (
|
||||||
|
<div key={status} className="flex items-start gap-2">
|
||||||
|
<span className={`font-medium text-sm min-w-[90px] ${color}`}>{label}</span>
|
||||||
|
<span className="text-sm text-gray-600">{description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContractList() {
|
export default function ContractList() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -54,6 +90,9 @@ export default function ContractList() {
|
|||||||
// State für aufgeklappte Verträge (Baumstruktur)
|
// State für aufgeklappte Verträge (Baumstruktur)
|
||||||
const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set());
|
const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// Status-Info Modal
|
||||||
|
const [showStatusInfo, setShowStatusInfo] = useState(false);
|
||||||
|
|
||||||
const { hasPermission, isCustomer, isCustomerPortal, user } = useAuth();
|
const { hasPermission, isCustomer, isCustomerPortal, user } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -358,7 +397,18 @@ export default function ContractList() {
|
|||||||
)}
|
)}
|
||||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Typ</th>
|
<th className="text-left py-3 px-4 font-medium text-gray-600">Typ</th>
|
||||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Anbieter / Tarif</th>
|
<th className="text-left py-3 px-4 font-medium text-gray-600">Anbieter / Tarif</th>
|
||||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Status</th>
|
<th className="text-left py-3 px-4 font-medium text-gray-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
Status
|
||||||
|
<button
|
||||||
|
onClick={() => setShowStatusInfo(true)}
|
||||||
|
className="text-gray-400 hover:text-blue-600 transition-colors"
|
||||||
|
title="Status-Erklärung"
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Beginn</th>
|
<th className="text-left py-3 px-4 font-medium text-gray-600">Beginn</th>
|
||||||
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
|
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -472,6 +522,9 @@ export default function ContractList() {
|
|||||||
<div className="text-center py-8 text-gray-500">Keine Verträge gefunden.</div>
|
<div className="text-center py-8 text-gray-500">Keine Verträge gefunden.</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Status-Info Modal */}
|
||||||
|
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import Modal from '../../components/ui/Modal';
|
|||||||
import Input from '../../components/ui/Input';
|
import Input from '../../components/ui/Input';
|
||||||
import Select from '../../components/ui/Select';
|
import Select from '../../components/ui/Select';
|
||||||
import FileUpload from '../../components/ui/FileUpload';
|
import FileUpload from '../../components/ui/FileUpload';
|
||||||
import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight } from 'lucide-react';
|
import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||||
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||||
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary } from '../../types';
|
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary } from '../../types';
|
||||||
|
|
||||||
@@ -1496,6 +1496,7 @@ function ContractsTab({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set());
|
const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set());
|
||||||
|
const [showStatusInfo, setShowStatusInfo] = useState(false);
|
||||||
|
|
||||||
// Lade Vertragsbaum statt flacher Liste
|
// Lade Vertragsbaum statt flacher Liste
|
||||||
const { data: treeData, isLoading } = useQuery({
|
const { data: treeData, isLoading } = useQuery({
|
||||||
@@ -1537,6 +1538,15 @@ function ContractsTab({
|
|||||||
DEACTIVATED: 'default',
|
DEACTIVATED: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const statusDescriptions = [
|
||||||
|
{ status: 'DRAFT', label: 'Entwurf', description: 'Vertrag wird noch vorbereitet', color: 'text-gray-600' },
|
||||||
|
{ status: 'PENDING', label: 'Ausstehend', description: 'Wartet auf Aktivierung', color: 'text-yellow-600' },
|
||||||
|
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
|
||||||
|
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
|
||||||
|
{ status: 'CANCELLED', label: 'Gekündigt', description: 'Aktive Kündigung eingereicht, Vertrag endet', color: 'text-red-600' },
|
||||||
|
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
|
||||||
|
];
|
||||||
|
|
||||||
const toggleExpand = (contractId: number) => {
|
const toggleExpand = (contractId: number) => {
|
||||||
setExpandedContracts(prev => {
|
setExpandedContracts(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -1597,6 +1607,15 @@ function ContractsTab({
|
|||||||
</span>
|
</span>
|
||||||
<Badge>{typeLabels[contract.type] || contract.type}</Badge>
|
<Badge>{typeLabels[contract.type] || contract.type}</Badge>
|
||||||
<Badge variant={statusVariants[contract.status] || 'default'}>{contract.status}</Badge>
|
<Badge variant={statusVariants[contract.status] || 'default'}>{contract.status}</Badge>
|
||||||
|
{depth === 0 && !isPredecessor && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowStatusInfo(true); }}
|
||||||
|
className="text-gray-400 hover:text-blue-600 transition-colors"
|
||||||
|
title="Status-Erklärung"
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{isPredecessor && (
|
{isPredecessor && (
|
||||||
<span className="text-xs text-gray-500 ml-2">(Vorgänger)</span>
|
<span className="text-xs text-gray-500 ml-2">(Vorgänger)</span>
|
||||||
@@ -1693,6 +1712,29 @@ function ContractsTab({
|
|||||||
) : (
|
) : (
|
||||||
<p className="text-gray-500">Keine Verträge vorhanden.</p>
|
<p className="text-gray-500">Keine Verträge vorhanden.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Status Info Modal */}
|
||||||
|
{showStatusInfo && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="fixed inset-0 bg-black/20" onClick={() => setShowStatusInfo(false)} />
|
||||||
|
<div className="relative bg-white rounded-lg shadow-xl p-4 max-w-sm w-full mx-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">Vertragsstatus-Übersicht</h3>
|
||||||
|
<button onClick={() => setShowStatusInfo(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{statusDescriptions.map(({ status, label, description, color }) => (
|
||||||
|
<div key={status} className="flex items-start gap-2">
|
||||||
|
<span className={`font-medium text-sm min-w-[90px] ${color}`}>{label}</span>
|
||||||
|
<span className="text-sm text-gray-600">{description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Role, PortalSettings, CustomerRepresentative, CustomerSummary } from '../types';
|
import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Invoice, Role, PortalSettings, CustomerRepresentative, CustomerSummary } from '../types';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
@@ -208,6 +208,40 @@ export const meterApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Invoice API
|
||||||
|
export const invoiceApi = {
|
||||||
|
getInvoices: async (ecdId: number) => {
|
||||||
|
const res = await api.get<ApiResponse<Invoice[]>>(`/energy-details/${ecdId}/invoices`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
addInvoice: async (ecdId: number, data: Partial<Invoice>) => {
|
||||||
|
const res = await api.post<ApiResponse<Invoice>>(`/energy-details/${ecdId}/invoices`, data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
updateInvoice: async (ecdId: number, invoiceId: number, data: Partial<Invoice>) => {
|
||||||
|
const res = await api.put<ApiResponse<Invoice>>(`/energy-details/${ecdId}/invoices/${invoiceId}`, data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
deleteInvoice: async (ecdId: number, invoiceId: number) => {
|
||||||
|
const res = await api.delete<ApiResponse<void>>(`/energy-details/${ecdId}/invoices/${invoiceId}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
uploadDocument: async (invoiceId: number, file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('document', file);
|
||||||
|
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(
|
||||||
|
`/upload/invoices/${invoiceId}`,
|
||||||
|
formData,
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
deleteDocument: async (invoiceId: number) => {
|
||||||
|
const res = await api.delete<ApiResponse<void>>(`/upload/invoices/${invoiceId}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Stressfrei-Wechseln E-Mail-Adressen
|
// Stressfrei-Wechseln E-Mail-Adressen
|
||||||
export interface StressfreiEmail {
|
export interface StressfreiEmail {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -320,6 +354,8 @@ export interface AttachmentTargetsResponse {
|
|||||||
contract?: {
|
contract?: {
|
||||||
id: number;
|
id: number;
|
||||||
contractNumber: string;
|
contractNumber: string;
|
||||||
|
type: string;
|
||||||
|
energyDetailsId?: number;
|
||||||
slots: AttachmentTargetSlot[];
|
slots: AttachmentTargetSlot[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -514,6 +550,23 @@ export const cachedEmailApi = {
|
|||||||
);
|
);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
// E-Mail als Rechnung speichern (für Energieverträge)
|
||||||
|
saveEmailAsInvoice: async (emailId: number, params: { invoiceDate: string; invoiceType: string; notes?: string }) => {
|
||||||
|
const res = await api.post<ApiResponse<{ id: number; invoiceDate: string; invoiceType: string; documentPath: string }>>(
|
||||||
|
`/emails/${emailId}/save-as-invoice`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
// Anhang als Rechnung speichern (für Energieverträge)
|
||||||
|
saveAttachmentAsInvoice: async (emailId: number, filename: string, params: { invoiceDate: string; invoiceType: string; notes?: string }) => {
|
||||||
|
const encodedFilename = encodeURIComponent(filename);
|
||||||
|
const res = await api.post<ApiResponse<{ id: number; invoiceDate: string; invoiceType: string; documentPath: string }>>(
|
||||||
|
`/emails/${emailId}/attachments/${encodedFilename}/save-as-invoice`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Contracts - Vertragsbaum für Kundenansicht
|
// Contracts - Vertragsbaum für Kundenansicht
|
||||||
|
|||||||
@@ -152,6 +152,19 @@ export interface MeterReading {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type InvoiceType = 'INTERIM' | 'FINAL' | 'NOT_AVAILABLE';
|
||||||
|
|
||||||
|
export interface Invoice {
|
||||||
|
id: number;
|
||||||
|
energyContractDetailsId: number;
|
||||||
|
invoiceDate: string;
|
||||||
|
invoiceType: InvoiceType;
|
||||||
|
documentPath?: string;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type ContractTaskStatus = 'OPEN' | 'COMPLETED';
|
export type ContractTaskStatus = 'OPEN' | 'COMPLETED';
|
||||||
|
|
||||||
export interface ContractTaskSubtask {
|
export interface ContractTaskSubtask {
|
||||||
@@ -344,6 +357,7 @@ export interface EnergyContractDetails {
|
|||||||
bonus?: number;
|
bonus?: number;
|
||||||
previousProviderName?: string;
|
previousProviderName?: string;
|
||||||
previousCustomerNumber?: string;
|
previousCustomerNumber?: string;
|
||||||
|
invoices?: Invoice[]; // Rechnungen
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InternetContractDetails {
|
export interface InternetContractDetails {
|
||||||
|
|||||||
Reference in New Issue
Block a user