added invoices and status in cockpit, created info button for contract status types

This commit is contained in:
duffyduck 2026-02-08 01:18:12 +01:00
parent 1ad4fe0819
commit aee48a8ccb
45 changed files with 4543 additions and 863 deletions

View File

@ -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 emailProvider_routes_js_1 = __importDefault(require("./routes/emailProvider.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();
const app = (0, express_1.default)();
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/email-providers', emailProvider_routes_js_1.default);
app.use('/api', cachedEmail_routes_js_1.default);
app.use('/api/energy-details', invoice_routes_js_1.default);
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });

View File

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

View File

@ -1 +1 @@
{"version":3,"file":"upload.routes.d.ts","sourceRoot":"","sources":["../../src/routes/upload.routes.ts"],"names":[],"mappings":"AAQA,QAAA,MAAM,MAAM,4CAAW,CAAC;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"}

View File

@ -469,5 +469,77 @@ router.delete('/contracts/:id/cancellation-letter-options', auth_js_1.authentica
// 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.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;
//# sourceMappingURL=upload.routes.js.map

File diff suppressed because one or more lines are too long

View File

@ -242,6 +242,16 @@ export declare function getContractById(id: number, decryptPassword?: boolean):
meterNumber: string;
location: string | 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;
meterId: number | null;
@ -421,6 +431,16 @@ export declare function getContractById(id: number, decryptPassword?: boolean):
meterNumber: string;
location: string | 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;
meterId: number | null;
@ -974,6 +994,16 @@ export declare function updateContract(id: number, data: Partial<ContractCreateD
meterNumber: string;
location: string | 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;
meterId: number | null;
@ -1153,6 +1183,16 @@ export declare function updateContract(id: number, data: Partial<ContractCreateD
meterNumber: string;
location: string | 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;
meterId: number | null;

File diff suppressed because one or more lines are too long

View File

@ -121,14 +121,14 @@ async function getContractById(id, decryptPassword = false) {
contractCategory: true,
previousContract: {
include: {
energyDetails: { include: { meter: { include: { readings: true } } } },
energyDetails: { include: { meter: { include: { readings: true } }, invoices: true } },
internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } },
tvDetails: true,
carInsuranceDetails: true,
},
},
energyDetails: { include: { meter: { include: { readings: true } } } },
energyDetails: { include: { meter: { include: { readings: true } }, invoices: true } },
internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } },
tvDetails: true,

File diff suppressed because one or more lines are too long

View File

@ -40,6 +40,7 @@ export interface CockpitSummary {
contractEnding: number;
missingCredentials: number;
missingData: number;
missingInvoices: number;
openTasks: number;
pendingContracts: number;
};

View File

@ -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"}

View File

@ -105,11 +105,11 @@ async function getCockpitData() {
const criticalDays = parseInt(settings.deadlineCriticalDays) || 14;
const warningDays = parseInt(settings.deadlineWarningDays) || 42;
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({
where: {
status: {
in: ['ACTIVE', 'PENDING', 'DRAFT'],
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED'],
},
},
include: {
@ -145,6 +145,7 @@ async function getCockpitData() {
energyDetails: {
include: {
meter: true,
invoices: true,
},
},
internetDetails: {
@ -186,6 +187,7 @@ async function getCockpitData() {
contractEnding: 0,
missingCredentials: 0,
missingData: 0,
missingInvoices: 0,
openTasks: 0,
pendingContracts: 0,
},
@ -367,6 +369,68 @@ async function getCockpitData() {
});
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
if (issues.length > 0) {
const highestUrgency = getHighestUrgency(issues);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -476,6 +476,17 @@ exports.Prisma.EnergyContractDetailsScalarFieldEnum = {
previousCustomerNumber: 'previousCustomerNumber'
};
exports.Prisma.InvoiceScalarFieldEnum = {
id: 'id',
energyContractDetailsId: 'energyContractDetailsId',
invoiceDate: 'invoiceDate',
invoiceType: 'invoiceType',
documentPath: 'documentPath',
notes: 'notes',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.InternetContractDetailsScalarFieldEnum = {
id: 'id',
contractId: 'contractId',
@ -624,6 +635,12 @@ exports.ContractTaskStatus = exports.$Enums.ContractTaskStatus = {
COMPLETED: 'COMPLETED'
};
exports.InvoiceType = exports.$Enums.InvoiceType = {
INTERIM: 'INTERIM',
FINAL: 'FINAL',
NOT_AVAILABLE: 'NOT_AVAILABLE'
};
exports.InsuranceType = exports.$Enums.InsuranceType = {
LIABILITY: 'LIABILITY',
PARTIAL: 'PARTIAL',
@ -657,6 +674,7 @@ exports.Prisma.ModelName = {
ContractTask: 'ContractTask',
ContractTaskSubtask: 'ContractTaskSubtask',
EnergyContractDetails: 'EnergyContractDetails',
Invoice: 'Invoice',
InternetContractDetails: 'InternetContractDetails',
PhoneNumber: 'PhoneNumber',
MobileContractDetails: 'MobileContractDetails',

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{
"name": "prisma-client-86f9a11dd446473e587830adde1df64246ce246e4602839fa2ced48c6aa2f902",
"name": "prisma-client-a41de88ab23bcfe6707e2a70a25cdd4736b5f2e024b64f8f8760b0e23260313f",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js",

View File

@ -596,12 +596,18 @@ model ContractTaskSubtask {
// ==================== ENERGY CONTRACT DETAILS ====================
enum InvoiceType {
INTERIM // Zwischenrechnung
FINAL // Schlussrechnung
NOT_AVAILABLE // Rechnung nicht mehr zu bekommen
}
model EnergyContractDetails {
id Int @id @default(autoincrement())
contractId Int @unique
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
contractId Int @unique
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
meterId Int?
meter Meter? @relation(fields: [meterId], references: [id])
meter Meter? @relation(fields: [meterId], references: [id])
maloId String? // Marktlokations-ID
annualConsumption Float? // kWh für Strom, m³ für Gas
annualConsumptionKwh Float? // kWh für Gas (zusätzlich zu m³)
@ -610,6 +616,21 @@ model EnergyContractDetails {
bonus Float?
previousProviderName 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 ====================

View File

@ -476,6 +476,17 @@ exports.Prisma.EnergyContractDetailsScalarFieldEnum = {
previousCustomerNumber: 'previousCustomerNumber'
};
exports.Prisma.InvoiceScalarFieldEnum = {
id: 'id',
energyContractDetailsId: 'energyContractDetailsId',
invoiceDate: 'invoiceDate',
invoiceType: 'invoiceType',
documentPath: 'documentPath',
notes: 'notes',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.InternetContractDetailsScalarFieldEnum = {
id: 'id',
contractId: 'contractId',
@ -624,6 +635,12 @@ exports.ContractTaskStatus = exports.$Enums.ContractTaskStatus = {
COMPLETED: 'COMPLETED'
};
exports.InvoiceType = exports.$Enums.InvoiceType = {
INTERIM: 'INTERIM',
FINAL: 'FINAL',
NOT_AVAILABLE: 'NOT_AVAILABLE'
};
exports.InsuranceType = exports.$Enums.InsuranceType = {
LIABILITY: 'LIABILITY',
PARTIAL: 'PARTIAL',
@ -657,6 +674,7 @@ exports.Prisma.ModelName = {
ContractTask: 'ContractTask',
ContractTaskSubtask: 'ContractTaskSubtask',
EnergyContractDetails: 'EnergyContractDetails',
Invoice: 'Invoice',
InternetContractDetails: 'InternetContractDetails',
PhoneNumber: 'PhoneNumber',
MobileContractDetails: 'MobileContractDetails',

View File

@ -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;

View File

@ -596,20 +596,41 @@ model ContractTaskSubtask {
// ==================== ENERGY CONTRACT DETAILS ====================
enum InvoiceType {
INTERIM // Zwischenrechnung
FINAL // Schlussrechnung
NOT_AVAILABLE // Rechnung nicht mehr zu bekommen
}
model EnergyContractDetails {
id Int @id @default(autoincrement())
contractId Int @unique
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
contractId Int @unique
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
meterId Int?
meter Meter? @relation(fields: [meterId], references: [id])
maloId String? // Marktlokations-ID
annualConsumption Float? // kWh für Strom, m³ für Gas
annualConsumptionKwh Float? // kWh für Gas (zusätzlich zu m³)
basePrice Float? // €/Monat
unitPrice Float? // €/kWh (Arbeitspreis)
meter Meter? @relation(fields: [meterId], references: [id])
maloId String? // Marktlokations-ID
annualConsumption Float? // kWh für Strom, m³ für Gas
annualConsumptionKwh Float? // kWh für Gas (zusätzlich zu m³)
basePrice Float? // €/Monat
unitPrice Float? // €/kWh (Arbeitspreis)
bonus Float?
previousProviderName 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 ====================

View File

@ -3,6 +3,7 @@
import { Request, Response } from 'express';
import * as cachedEmailService from '../services/cachedEmail.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 { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
@ -885,6 +886,8 @@ export async function getAttachmentTargets(req: Request, res: Response): Promise
contract?: {
id: number;
contractNumber: string;
type: string;
energyDetailsId?: number;
slots: TargetSlot[];
};
}
@ -954,10 +957,14 @@ export async function getAttachmentTargets(req: Request, res: Response): Promise
select: {
id: true,
contractNumber: true,
type: true,
cancellationLetterPath: true,
cancellationConfirmationPath: true,
cancellationLetterOptionsPath: true,
cancellationConfirmationOptionsPath: true,
energyDetails: {
select: { id: true },
},
},
});
@ -966,6 +973,8 @@ export async function getAttachmentTargets(req: Request, res: Response): Promise
response.contract = {
id: contract.id,
contractNumber: contract.contractNumber,
type: contract.type,
energyDetailsId: contract.energyDetails?.id,
slots: contractTargets.map(target => ({
key: target.key,
label: target.label,
@ -1518,3 +1527,309 @@ export async function saveEmailAsPdf(req: Request, res: Response): Promise<void>
} 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);
}
}

View File

@ -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);
}
}

View File

@ -24,6 +24,7 @@ import contractTaskRoutes from './routes/contractTask.routes.js';
import appSettingRoutes from './routes/appSetting.routes.js';
import emailProviderRoutes from './routes/emailProvider.routes.js';
import cachedEmailRoutes from './routes/cachedEmail.routes.js';
import invoiceRoutes from './routes/invoice.routes.js';
dotenv.config();
@ -59,6 +60,7 @@ app.use('/api', contractTaskRoutes);
app.use('/api/settings', appSettingRoutes);
app.use('/api/email-providers', emailProviderRoutes);
app.use('/api', cachedEmailRoutes);
app.use('/api/energy-details', invoiceRoutes);
// Health check
app.get('/api/health', (req, res) => {

View File

@ -185,6 +185,24 @@ router.post(
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 ====================
// E-Mail Vertrag zuordnen

View File

@ -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;

View File

@ -658,4 +658,99 @@ router.delete(
(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;

View File

@ -124,14 +124,14 @@ export async function getContractById(id: number, decryptPassword = false) {
contractCategory: true,
previousContract: {
include: {
energyDetails: { include: { meter: { include: { readings: true } } } },
energyDetails: { include: { meter: { include: { readings: true } }, invoices: true } },
internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } },
tvDetails: true,
carInsuranceDetails: true,
},
},
energyDetails: { include: { meter: { include: { readings: true } } } },
energyDetails: { include: { meter: { include: { readings: true } }, invoices: true } },
internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } },
tvDetails: true,

View File

@ -49,6 +49,7 @@ export interface CockpitSummary {
contractEnding: number;
missingCredentials: number;
missingData: number;
missingInvoices: number;
openTasks: number;
pendingContracts: number;
};
@ -142,11 +143,11 @@ export async function getCockpitData(): Promise<CockpitResult> {
const warningDays = parseInt(settings.deadlineWarningDays) || 42;
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({
where: {
status: {
in: ['ACTIVE', 'PENDING', 'DRAFT'],
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED'],
},
},
include: {
@ -182,6 +183,7 @@ export async function getCockpitData(): Promise<CockpitResult> {
energyDetails: {
include: {
meter: true,
invoices: true,
},
},
internetDetails: {
@ -224,6 +226,7 @@ export async function getCockpitData(): Promise<CockpitResult> {
contractEnding: 0,
missingCredentials: 0,
missingData: 0,
missingInvoices: 0,
openTasks: 0,
pendingContracts: 0,
},
@ -426,6 +429,75 @@ export async function getCockpitData(): Promise<CockpitResult> {
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
if (issues.length > 0) {
const highestUrgency = getHighestUrgency(issues);

View File

@ -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,
},
});
}

File diff suppressed because one or more lines are too long

710
frontend/dist/assets/index-BZmzqt4I.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenCRM</title>
<script type="module" crossorigin src="/assets/index-CzqYCocn.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-OfL2GqlZ.css">
<script type="module" crossorigin src="/assets/index-BZmzqt4I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BKXieHhr.css">
</head>
<body>
<div id="root"></div>

View File

@ -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>
);
}

View File

@ -1,10 +1,13 @@
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 Button from '../ui/Button';
import Input from '../ui/Input';
import Select from '../ui/Select';
import { cachedEmailApi, AttachmentTargetSlot, AttachmentEntityWithSlots } from '../../services/api';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import type { InvoiceType } from '../../types';
interface SaveAttachmentModalProps {
isOpen: boolean;
@ -22,6 +25,8 @@ type SelectedTarget = {
label: string;
};
type SaveMode = 'document' | 'invoice';
export default function SaveAttachmentModal({
isOpen,
onClose,
@ -31,6 +36,12 @@ export default function SaveAttachmentModal({
}: SaveAttachmentModalProps) {
const [selectedTarget, setSelectedTarget] = useState<SelectedTarget | null>(null);
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();
// Ziele laden
@ -42,6 +53,9 @@ export default function SaveAttachmentModal({
const targets = targetsData?.data;
// Prüfen ob es ein Energievertrag ist
const isEnergyContract = targets?.contract?.type === 'ELECTRICITY' || targets?.contract?.type === 'GAS';
const saveMutation = useMutation({
mutationFn: () => {
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 = () => {
setSelectedTarget(null);
setSaveMode('document');
setInvoiceData({
invoiceDate: new Date().toISOString().split('T')[0],
invoiceType: 'INTERIM',
notes: '',
});
onClose();
};
@ -215,59 +261,127 @@ export default function SaveAttachmentModal({
</div>
)}
{/* Targets */}
{targets && (
<div className="space-y-3 max-h-96 overflow-auto">
{/* Kunde */}
{renderSection(
`Kunde: ${targets.customer.name}`,
'customer',
<User className="w-4 h-4 text-blue-600" />,
renderSlots(targets.customer.slots, 'customer'),
targets.customer.slots.length === 0
)}
{/* Ausweise */}
{renderSection(
'Ausweisdokumente',
'identityDocuments',
<IdCard className="w-4 h-4 text-green-600" />,
targets.identityDocuments.map((doc) =>
renderEntityWithSlots(doc, 'identityDocument')
),
targets.identityDocuments.length === 0
)}
{/* Bankkarten */}
{renderSection(
'Bankkarten',
'bankCards',
<CreditCard className="w-4 h-4 text-purple-600" />,
targets.bankCards.map((card) => renderEntityWithSlots(card, 'bankCard')),
targets.bankCards.length === 0
)}
{/* Vertrag */}
{targets.contract && renderSection(
`Vertrag: ${targets.contract.contractNumber}`,
'contract',
<FileText className="w-4 h-4 text-orange-600" />,
renderSlots(targets.contract.slots, 'contract'),
targets.contract.slots.length === 0
)}
{!targets.contract && (
<div className="p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
<FileText className="w-4 h-4 inline-block mr-2 text-gray-400" />
E-Mail ist keinem Vertrag zugeordnet. Ordnen Sie die E-Mail einem Vertrag zu, um
Vertragsdokumente als Ziel auswählen zu können.
<>
{/* 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>
)}
</div>
{/* Document Mode */}
{saveMode === 'document' && (
<div className="space-y-3 max-h-96 overflow-auto">
{/* Kunde */}
{renderSection(
`Kunde: ${targets.customer.name}`,
'customer',
<User className="w-4 h-4 text-blue-600" />,
renderSlots(targets.customer.slots, 'customer'),
targets.customer.slots.length === 0
)}
{/* Ausweise */}
{renderSection(
'Ausweisdokumente',
'identityDocuments',
<IdCard className="w-4 h-4 text-green-600" />,
targets.identityDocuments.map((doc) =>
renderEntityWithSlots(doc, 'identityDocument')
),
targets.identityDocuments.length === 0
)}
{/* Bankkarten */}
{renderSection(
'Bankkarten',
'bankCards',
<CreditCard className="w-4 h-4 text-purple-600" />,
targets.bankCards.map((card) => renderEntityWithSlots(card, 'bankCard')),
targets.bankCards.length === 0
)}
{/* Vertrag */}
{targets.contract && renderSection(
`Vertrag: ${targets.contract.contractNumber}`,
'contract',
<FileText className="w-4 h-4 text-orange-600" />,
renderSlots(targets.contract.slots, 'contract'),
targets.contract.slots.length === 0
)}
{!targets.contract && (
<div className="p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
<FileText className="w-4 h-4 inline-block mr-2 text-gray-400" />
E-Mail ist keinem Vertrag zugeordnet. Ordnen Sie die E-Mail einem Vertrag zu, um
Vertragsdokumente als Ziel auswählen zu können.
</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 */}
{selectedTarget?.hasDocument && (
{saveMode === 'document' && selectedTarget?.hasDocument && (
<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" />
<div className="text-sm text-yellow-800">
@ -282,12 +396,21 @@ export default function SaveAttachmentModal({
<Button variant="secondary" onClick={handleClose}>
Abbrechen
</Button>
<Button
onClick={() => saveMutation.mutate()}
disabled={!selectedTarget || saveMutation.isPending}
>
{saveMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
</Button>
{saveMode === 'document' ? (
<Button
onClick={() => saveMutation.mutate()}
disabled={!selectedTarget || saveMutation.isPending || saveInvoiceMutation.isPending}
>
{saveMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
</Button>
) : (
<Button
onClick={() => saveInvoiceMutation.mutate()}
disabled={!invoiceData.invoiceDate || saveMutation.isPending || saveInvoiceMutation.isPending}
>
{saveInvoiceMutation.isPending ? 'Wird gespeichert...' : 'Als Rechnung speichern'}
</Button>
)}
</div>
</div>
</Modal>

View File

@ -1,10 +1,13 @@
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 Button from '../ui/Button';
import Input from '../ui/Input';
import Select from '../ui/Select';
import { cachedEmailApi, AttachmentTargetSlot, AttachmentEntityWithSlots } from '../../services/api';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import type { InvoiceType } from '../../types';
interface SaveEmailAsPdfModalProps {
isOpen: boolean;
@ -21,6 +24,8 @@ type SelectedTarget = {
label: string;
};
type SaveMode = 'document' | 'invoice';
export default function SaveEmailAsPdfModal({
isOpen,
onClose,
@ -29,6 +34,12 @@ export default function SaveEmailAsPdfModal({
}: SaveEmailAsPdfModalProps) {
const [selectedTarget, setSelectedTarget] = useState<SelectedTarget | null>(null);
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();
// Ziele laden (gleiche wie bei Anhängen)
@ -40,6 +51,9 @@ export default function SaveEmailAsPdfModal({
const targets = targetsData?.data;
// Prüfen ob es ein Energievertrag ist
const isEnergyContract = targets?.contract?.type === 'ELECTRICITY' || targets?.contract?.type === 'GAS';
const saveMutation = useMutation({
mutationFn: () => {
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 = () => {
setSelectedTarget(null);
setSaveMode('document');
setInvoiceData({
invoiceDate: new Date().toISOString().split('T')[0],
invoiceType: 'INTERIM',
notes: '',
});
onClose();
};
@ -189,6 +235,8 @@ export default function SaveEmailAsPdfModal({
);
};
const isPending = saveMutation.isPending || saveInvoiceMutation.isPending;
return (
<Modal isOpen={isOpen} onClose={handleClose} title="E-Mail als PDF speichern" size="lg">
<div className="space-y-4">
@ -213,59 +261,127 @@ export default function SaveEmailAsPdfModal({
</div>
)}
{/* Targets */}
{targets && (
<div className="space-y-3 max-h-96 overflow-auto">
{/* Kunde */}
{renderSection(
`Kunde: ${targets.customer.name}`,
'customer',
<User className="w-4 h-4 text-blue-600" />,
renderSlots(targets.customer.slots, 'customer'),
targets.customer.slots.length === 0
)}
{/* Ausweise */}
{renderSection(
'Ausweisdokumente',
'identityDocuments',
<IdCard className="w-4 h-4 text-green-600" />,
targets.identityDocuments.map((doc) =>
renderEntityWithSlots(doc, 'identityDocument')
),
targets.identityDocuments.length === 0
)}
{/* Bankkarten */}
{renderSection(
'Bankkarten',
'bankCards',
<CreditCard className="w-4 h-4 text-purple-600" />,
targets.bankCards.map((card) => renderEntityWithSlots(card, 'bankCard')),
targets.bankCards.length === 0
)}
{/* Vertrag */}
{targets.contract && renderSection(
`Vertrag: ${targets.contract.contractNumber}`,
'contract',
<FileText className="w-4 h-4 text-orange-600" />,
renderSlots(targets.contract.slots, 'contract'),
targets.contract.slots.length === 0
)}
{!targets.contract && (
<div className="p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
<FileText className="w-4 h-4 inline-block mr-2 text-gray-400" />
E-Mail ist keinem Vertrag zugeordnet. Ordnen Sie die E-Mail einem Vertrag zu, um
Vertragsdokumente als Ziel auswählen zu können.
<>
{/* 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>
)}
</div>
{/* Document Mode */}
{saveMode === 'document' && (
<div className="space-y-3 max-h-96 overflow-auto">
{/* Kunde */}
{renderSection(
`Kunde: ${targets.customer.name}`,
'customer',
<User className="w-4 h-4 text-blue-600" />,
renderSlots(targets.customer.slots, 'customer'),
targets.customer.slots.length === 0
)}
{/* Ausweise */}
{renderSection(
'Ausweisdokumente',
'identityDocuments',
<IdCard className="w-4 h-4 text-green-600" />,
targets.identityDocuments.map((doc) =>
renderEntityWithSlots(doc, 'identityDocument')
),
targets.identityDocuments.length === 0
)}
{/* Bankkarten */}
{renderSection(
'Bankkarten',
'bankCards',
<CreditCard className="w-4 h-4 text-purple-600" />,
targets.bankCards.map((card) => renderEntityWithSlots(card, 'bankCard')),
targets.bankCards.length === 0
)}
{/* Vertrag */}
{targets.contract && renderSection(
`Vertrag: ${targets.contract.contractNumber}`,
'contract',
<FileText className="w-4 h-4 text-orange-600" />,
renderSlots(targets.contract.slots, 'contract'),
targets.contract.slots.length === 0
)}
{!targets.contract && (
<div className="p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
<FileText className="w-4 h-4 inline-block mr-2 text-gray-400" />
E-Mail ist keinem Vertrag zugeordnet. Ordnen Sie die E-Mail einem Vertrag zu, um
Vertragsdokumente als Ziel auswählen zu können.
</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 */}
{selectedTarget?.hasDocument && (
{saveMode === 'document' && selectedTarget?.hasDocument && (
<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" />
<div className="text-sm text-yellow-800">
@ -280,12 +396,21 @@ export default function SaveEmailAsPdfModal({
<Button variant="secondary" onClick={handleClose}>
Abbrechen
</Button>
<Button
onClick={() => saveMutation.mutate()}
disabled={!selectedTarget || saveMutation.isPending}
>
{saveMutation.isPending ? 'Wird erstellt...' : 'Als PDF speichern'}
</Button>
{saveMode === 'document' ? (
<Button
onClick={() => saveMutation.mutate()}
disabled={!selectedTarget || isPending}
>
{isPending ? 'Wird erstellt...' : 'Als PDF speichern'}
</Button>
) : (
<Button
onClick={() => saveInvoiceMutation.mutate()}
disabled={!invoiceData.invoiceDate || isPending}
>
{isPending ? 'Wird erstellt...' : 'Als Rechnung speichern'}
</Button>
)}
</div>
</div>
</Modal>

View File

@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi } from '../../services/api';
import { ContractEmailsSection } from '../../components/email';
import { ContractDetailModal } from '../../components/contracts';
import InvoicesSection from '../../components/contracts/InvoicesSection';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
@ -11,7 +12,7 @@ import Badge from '../../components/ui/Badge';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import FileUpload from '../../components/ui/FileUpload';
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator } 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 CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask } from '../../types';
@ -45,6 +46,42 @@ const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' |
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)
function isUnlimitedDuration(durationCode: string): boolean {
const match = durationCode.match(/^(\d+)([TMWJ])$/);
@ -1203,6 +1240,9 @@ export default function ContractDetail() {
// Bestätigungsdialog für Folgevertrag
const [showFollowUpConfirm, setShowFollowUpConfirm] = useState(false);
// Status-Info Modal
const [showStatusInfo, setShowStatusInfo] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['contract', id],
queryFn: () => contractApi.getById(contractId),
@ -1399,6 +1439,13 @@ export default function ContractDetail() {
<h1 className="text-2xl font-bold">{c.contractNumber}</h1>
<Badge>{typeLabels[c.type]}</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>
{c.customer && (
<p className="text-gray-500 ml-10">
@ -2151,6 +2198,14 @@ export default function ContractDetail() {
bonus={c.energyDetails.bonus}
/>
)}
{/* Rechnungen */}
<InvoicesSection
ecdId={c.energyDetails.id}
invoices={c.energyDetails.invoices || []}
contractId={contractId}
canEdit={hasPermission('contracts:update') && !isCustomer}
/>
</Card>
)}
@ -2589,6 +2644,9 @@ export default function ContractDetail() {
</div>
</div>
</Modal>
{/* Status-Info Modal */}
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
</div>
);
}

View File

@ -8,7 +8,7 @@ import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
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
@ -21,6 +21,42 @@ const statusOptions = [
{ 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() {
const { id } = useParams();
const [searchParams] = useSearchParams();
@ -143,6 +179,9 @@ export default function ContractForm() {
const [showSimPins, setShowSimPins] = 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
useEffect(() => {
if (!isEdit) {
@ -635,11 +674,23 @@ export default function ContractForm() {
options={typeOptions}
/>
<Select
label="Status"
{...register('status')}
options={statusOptions}
/>
<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
{...register('status')}
options={statusOptions}
/>
</div>
<Select
label="Vertriebsplattform"
@ -1322,6 +1373,9 @@ export default function ContractForm() {
</Button>
</div>
</form>
{/* Status-Info Modal */}
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
</div>
);
}

View File

@ -9,7 +9,7 @@ import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import Badge from '../../components/ui/Badge';
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';
const typeLabels: Record<ContractType, string> = {
@ -41,6 +41,42 @@ const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' |
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() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
@ -54,6 +90,9 @@ export default function ContractList() {
// State für aufgeklappte Verträge (Baumstruktur)
const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set());
// Status-Info Modal
const [showStatusInfo, setShowStatusInfo] = useState(false);
const { hasPermission, isCustomer, isCustomerPortal, user } = useAuth();
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">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-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
</tr>
@ -472,6 +522,9 @@ export default function ContractList() {
<div className="text-center py-8 text-gray-500">Keine Verträge gefunden.</div>
</Card>
)}
{/* Status-Info Modal */}
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
</div>
);
}

View File

@ -12,7 +12,7 @@ import Modal from '../../components/ui/Modal';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
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 type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary } from '../../types';
@ -1496,6 +1496,7 @@ function ContractsTab({
const navigate = useNavigate();
const queryClient = useQueryClient();
const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set());
const [showStatusInfo, setShowStatusInfo] = useState(false);
// Lade Vertragsbaum statt flacher Liste
const { data: treeData, isLoading } = useQuery({
@ -1537,6 +1538,15 @@ function ContractsTab({
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) => {
setExpandedContracts(prev => {
const next = new Set(prev);
@ -1597,6 +1607,15 @@ function ContractsTab({
</span>
<Badge>{typeLabels[contract.type] || contract.type}</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 && (
<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>
)}
{/* 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>
);
}

View File

@ -1,5 +1,5 @@
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({
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
export interface StressfreiEmail {
id: number;
@ -320,6 +354,8 @@ export interface AttachmentTargetsResponse {
contract?: {
id: number;
contractNumber: string;
type: string;
energyDetailsId?: number;
slots: AttachmentTargetSlot[];
};
}
@ -514,6 +550,23 @@ export const cachedEmailApi = {
);
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

View File

@ -152,6 +152,19 @@ export interface MeterReading {
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 interface ContractTaskSubtask {
@ -344,6 +357,7 @@ export interface EnergyContractDetails {
bonus?: number;
previousProviderName?: string;
previousCustomerNumber?: string;
invoices?: Invoice[]; // Rechnungen
}
export interface InternetContractDetails {