added contract history
This commit is contained in:
parent
ee4f1aacdd
commit
e348e86c60
|
|
@ -5,7 +5,7 @@ export declare function getContract(req: AuthRequest, res: Response): Promise<vo
|
|||
export declare function createContract(req: Request, res: Response): Promise<void>;
|
||||
export declare function updateContract(req: Request, res: Response): Promise<void>;
|
||||
export declare function deleteContract(req: Request, res: Response): Promise<void>;
|
||||
export declare function createFollowUp(req: Request, res: Response): Promise<void>;
|
||||
export declare function createFollowUp(req: AuthRequest, res: Response): Promise<void>;
|
||||
export declare function getContractPassword(req: Request, res: Response): Promise<void>;
|
||||
export declare function getSimCardCredentials(req: Request, res: Response): Promise<void>;
|
||||
export declare function getInternetCredentials(req: Request, res: Response): Promise<void>;
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"contract.controller.d.ts","sourceRoot":"","sources":["../../src/controllers/contract.controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAI5C,OAAO,EAAe,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAI7D,wBAAsB,YAAY,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAwCjF;AAED,wBAAsB,WAAW,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BhF;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBpF;AAED,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUtF;AAED,wBAAsB,sBAAsB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUvF;AAED,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUlF;AAID,wBAAsB,UAAU,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/E;AAID,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAyC/E"}
|
||||
{"version":3,"file":"contract.controller.d.ts","sourceRoot":"","sources":["../../src/controllers/contract.controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAK5C,OAAO,EAAe,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAI7D,wBAAsB,YAAY,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAwCjF;AAED,wBAAsB,WAAW,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BhF;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAuCnF;AAED,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBpF;AAED,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUtF;AAED,wBAAsB,sBAAsB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUvF;AAED,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUlF;AAID,wBAAsB,UAAU,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/E;AAID,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAyC/E"}
|
||||
|
|
@ -48,6 +48,7 @@ exports.snoozeContract = snoozeContract;
|
|||
const client_1 = require("@prisma/client");
|
||||
const contractService = __importStar(require("../services/contract.service.js"));
|
||||
const contractCockpitService = __importStar(require("../services/contractCockpit.service.js"));
|
||||
const contractHistoryService = __importStar(require("../services/contractHistory.service.js"));
|
||||
const prisma = new client_1.PrismaClient();
|
||||
async function getContracts(req, res) {
|
||||
try {
|
||||
|
|
@ -154,7 +155,22 @@ async function deleteContract(req, res) {
|
|||
}
|
||||
async function createFollowUp(req, res) {
|
||||
try {
|
||||
const contract = await contractService.createFollowUpContract(parseInt(req.params.id));
|
||||
const previousContractId = parseInt(req.params.id);
|
||||
// Vorgängervertrag laden für Vertragsnummer
|
||||
const previousContract = await prisma.contract.findUnique({
|
||||
where: { id: previousContractId },
|
||||
select: { contractNumber: true },
|
||||
});
|
||||
if (!previousContract) {
|
||||
res.status(404).json({ success: false, error: 'Vorgängervertrag nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
const contract = await contractService.createFollowUpContract(previousContractId);
|
||||
const createdBy = req.user?.email || 'unbekannt';
|
||||
// Historie-Eintrag für den Vorgängervertrag erstellen
|
||||
await contractHistoryService.createFollowUpHistoryEntry(previousContractId, contract.contractNumber, createdBy);
|
||||
// Historie-Eintrag für den neuen Folgevertrag erstellen
|
||||
await contractHistoryService.createNewContractFromPredecessorEntry(contract.id, previousContract.contractNumber, createdBy);
|
||||
res.status(201).json({ success: true, data: contract });
|
||||
}
|
||||
catch (error) {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -29,6 +29,7 @@ const appSetting_routes_js_1 = __importDefault(require("./routes/appSetting.rout
|
|||
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"));
|
||||
const contractHistory_routes_js_1 = __importDefault(require("./routes/contractHistory.routes.js"));
|
||||
dotenv_1.default.config();
|
||||
const app = (0, express_1.default)();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
|
@ -60,6 +61,7 @@ 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);
|
||||
app.use('/api', contractHistory_routes_js_1.default);
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
|
|
|
|||
|
|
@ -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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"}
|
||||
|
|
@ -467,7 +467,7 @@ async function createFollowUpContract(previousContractId) {
|
|||
// Explicitly NOT copying: providerName, tariffName, portalUsername, portalPassword, price fields
|
||||
cancellationPeriodId: previousContract.cancellationPeriodId ?? undefined,
|
||||
contractDurationId: previousContract.contractDurationId ?? undefined,
|
||||
notes: `Folgevertrag zu ${previousContract.contractNumber}`,
|
||||
// notes nicht mehr automatisch setzen - wird jetzt über Historie-Eintrag dokumentiert
|
||||
};
|
||||
// Copy type-specific details (without credentials)
|
||||
if (previousContract.energyDetails) {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -443,6 +443,16 @@ exports.Prisma.ContractScalarFieldEnum = {
|
|||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.ContractHistoryEntryScalarFieldEnum = {
|
||||
id: 'id',
|
||||
contractId: 'contractId',
|
||||
title: 'title',
|
||||
description: 'description',
|
||||
isAutomatic: 'isAutomatic',
|
||||
createdBy: 'createdBy',
|
||||
createdAt: 'createdAt'
|
||||
};
|
||||
|
||||
exports.Prisma.ContractTaskScalarFieldEnum = {
|
||||
id: 'id',
|
||||
contractId: 'contractId',
|
||||
|
|
@ -676,6 +686,7 @@ exports.Prisma.ModelName = {
|
|||
Tariff: 'Tariff',
|
||||
ContractCategory: 'ContractCategory',
|
||||
Contract: 'Contract',
|
||||
ContractHistoryEntry: 'ContractHistoryEntry',
|
||||
ContractTask: 'ContractTask',
|
||||
ContractTaskSubtask: 'ContractTaskSubtask',
|
||||
EnergyContractDetails: 'EnergyContractDetails',
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "prisma-client-ab03eeebd49b41f4edbc7df102e0a7779d814508dbccb860745760460e9e271f",
|
||||
"name": "prisma-client-652f85dbf9d7be282ff4b16714e4689fe4701aade21c76f6bcc5db624157e639",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "index-browser.js",
|
||||
|
|
|
|||
|
|
@ -566,11 +566,25 @@ model Contract {
|
|||
|
||||
tasks ContractTask[]
|
||||
assignedEmails CachedEmail[] // Zugeordnete E-Mails aus dem E-Mail-Client
|
||||
historyEntries ContractHistoryEntry[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ==================== CONTRACT HISTORY ====================
|
||||
|
||||
model ContractHistoryEntry {
|
||||
id Int @id @default(autoincrement())
|
||||
contractId Int
|
||||
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
title String // Kurzbeschreibung (z.B. "Folgevertrag erstellt", "kWh auf 18000 erhöht")
|
||||
description String? @db.Text // Längere Beschreibung (optional)
|
||||
isAutomatic Boolean @default(false) // true = automatisch erstellt, false = manuell
|
||||
createdBy String // E-Mail des Erstellers
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// ==================== CONTRACT TASKS ====================
|
||||
|
||||
enum ContractTaskStatus {
|
||||
|
|
|
|||
|
|
@ -443,6 +443,16 @@ exports.Prisma.ContractScalarFieldEnum = {
|
|||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.ContractHistoryEntryScalarFieldEnum = {
|
||||
id: 'id',
|
||||
contractId: 'contractId',
|
||||
title: 'title',
|
||||
description: 'description',
|
||||
isAutomatic: 'isAutomatic',
|
||||
createdBy: 'createdBy',
|
||||
createdAt: 'createdAt'
|
||||
};
|
||||
|
||||
exports.Prisma.ContractTaskScalarFieldEnum = {
|
||||
id: 'id',
|
||||
contractId: 'contractId',
|
||||
|
|
@ -676,6 +686,7 @@ exports.Prisma.ModelName = {
|
|||
Tariff: 'Tariff',
|
||||
ContractCategory: 'ContractCategory',
|
||||
Contract: 'Contract',
|
||||
ContractHistoryEntry: 'ContractHistoryEntry',
|
||||
ContractTask: 'ContractTask',
|
||||
ContractTaskSubtask: 'ContractTaskSubtask',
|
||||
EnergyContractDetails: 'EnergyContractDetails',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE `ContractHistoryEntry` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`contractId` INTEGER NOT NULL,
|
||||
`title` VARCHAR(191) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`isAutomatic` BOOLEAN NOT NULL DEFAULT false,
|
||||
`createdBy` VARCHAR(191) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `ContractHistoryEntry` ADD CONSTRAINT `ContractHistoryEntry_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -566,11 +566,25 @@ model Contract {
|
|||
|
||||
tasks ContractTask[]
|
||||
assignedEmails CachedEmail[] // Zugeordnete E-Mails aus dem E-Mail-Client
|
||||
historyEntries ContractHistoryEntry[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ==================== CONTRACT HISTORY ====================
|
||||
|
||||
model ContractHistoryEntry {
|
||||
id Int @id @default(autoincrement())
|
||||
contractId Int
|
||||
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
title String // Kurzbeschreibung (z.B. "Folgevertrag erstellt", "kWh auf 18000 erhöht")
|
||||
description String? @db.Text // Längere Beschreibung (optional)
|
||||
isAutomatic Boolean @default(false) // true = automatisch erstellt, false = manuell
|
||||
createdBy String // E-Mail des Erstellers
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// ==================== CONTRACT TASKS ====================
|
||||
|
||||
enum ContractTaskStatus {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Request, Response } from 'express';
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import * as contractService from '../services/contract.service.js';
|
||||
import * as contractCockpitService from '../services/contractCockpit.service.js';
|
||||
import * as contractHistoryService from '../services/contractHistory.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
|
@ -116,9 +117,38 @@ export async function deleteContract(req: Request, res: Response): Promise<void>
|
|||
}
|
||||
}
|
||||
|
||||
export async function createFollowUp(req: Request, res: Response): Promise<void> {
|
||||
export async function createFollowUp(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contract = await contractService.createFollowUpContract(parseInt(req.params.id));
|
||||
const previousContractId = parseInt(req.params.id);
|
||||
|
||||
// Vorgängervertrag laden für Vertragsnummer
|
||||
const previousContract = await prisma.contract.findUnique({
|
||||
where: { id: previousContractId },
|
||||
select: { contractNumber: true },
|
||||
});
|
||||
|
||||
if (!previousContract) {
|
||||
res.status(404).json({ success: false, error: 'Vorgängervertrag nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const contract = await contractService.createFollowUpContract(previousContractId);
|
||||
const createdBy = req.user?.email || 'unbekannt';
|
||||
|
||||
// Historie-Eintrag für den Vorgängervertrag erstellen
|
||||
await contractHistoryService.createFollowUpHistoryEntry(
|
||||
previousContractId,
|
||||
contract.contractNumber,
|
||||
createdBy
|
||||
);
|
||||
|
||||
// Historie-Eintrag für den neuen Folgevertrag erstellen
|
||||
await contractHistoryService.createNewContractFromPredecessorEntry(
|
||||
contract.id,
|
||||
previousContract.contractNumber,
|
||||
createdBy
|
||||
);
|
||||
|
||||
res.status(201).json({ success: true, data: contract } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import { Request, Response } from 'express';
|
||||
import * as contractHistoryService from '../services/contractHistory.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
|
||||
export async function getHistoryEntries(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
const entries = await contractHistoryService.getHistoryEntries(contractId);
|
||||
res.json({ success: true, data: entries } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Laden der Historie',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
const { title, description } = req.body;
|
||||
|
||||
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Titel ist erforderlich',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = await contractHistoryService.createHistoryEntry(contractId, {
|
||||
title: title.trim(),
|
||||
description: description?.trim() || undefined,
|
||||
isAutomatic: false,
|
||||
createdBy: req.user?.email || 'unbekannt',
|
||||
});
|
||||
|
||||
res.status(201).json({ success: true, data: entry } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Eintrags',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
const entryId = parseInt(req.params.entryId);
|
||||
const { title, description } = req.body;
|
||||
|
||||
const entry = await contractHistoryService.updateHistoryEntry(contractId, entryId, {
|
||||
title: title?.trim(),
|
||||
description: description?.trim(),
|
||||
});
|
||||
|
||||
res.json({ success: true, data: entry } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Eintrags',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
const entryId = parseInt(req.params.entryId);
|
||||
|
||||
await contractHistoryService.deleteHistoryEntry(contractId, entryId);
|
||||
|
||||
res.json({ success: true, message: 'Eintrag gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Eintrags',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ 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';
|
||||
import contractHistoryRoutes from './routes/contractHistory.routes.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
|
|
@ -61,6 +62,7 @@ app.use('/api/settings', appSettingRoutes);
|
|||
app.use('/api/email-providers', emailProviderRoutes);
|
||||
app.use('/api', cachedEmailRoutes);
|
||||
app.use('/api/energy-details', invoiceRoutes);
|
||||
app.use('/api', contractHistoryRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { Router } from 'express';
|
||||
import * as contractHistoryController from '../controllers/contractHistory.controller.js';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Alle Einträge für einen Vertrag
|
||||
router.get(
|
||||
'/contracts/:contractId/history',
|
||||
authenticate,
|
||||
requirePermission('contracts:read'),
|
||||
contractHistoryController.getHistoryEntries
|
||||
);
|
||||
|
||||
// Neuen Eintrag erstellen
|
||||
router.post(
|
||||
'/contracts/:contractId/history',
|
||||
authenticate,
|
||||
requirePermission('contracts:update'),
|
||||
contractHistoryController.createHistoryEntry
|
||||
);
|
||||
|
||||
// Eintrag aktualisieren
|
||||
router.put(
|
||||
'/contracts/:contractId/history/:entryId',
|
||||
authenticate,
|
||||
requirePermission('contracts:update'),
|
||||
contractHistoryController.updateHistoryEntry
|
||||
);
|
||||
|
||||
// Eintrag löschen
|
||||
router.delete(
|
||||
'/contracts/:contractId/history/:entryId',
|
||||
authenticate,
|
||||
requirePermission('contracts:update'),
|
||||
contractHistoryController.deleteHistoryEntry
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
@ -626,7 +626,7 @@ export async function createFollowUpContract(previousContractId: number) {
|
|||
// Explicitly NOT copying: providerName, tariffName, portalUsername, portalPassword, price fields
|
||||
cancellationPeriodId: previousContract.cancellationPeriodId ?? undefined,
|
||||
contractDurationId: previousContract.contractDurationId ?? undefined,
|
||||
notes: `Folgevertrag zu ${previousContract.contractNumber}`,
|
||||
// notes nicht mehr automatisch setzen - wird jetzt über Historie-Eintrag dokumentiert
|
||||
};
|
||||
|
||||
// Copy type-specific details (without credentials)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export interface CreateHistoryEntryData {
|
||||
title: string;
|
||||
description?: string;
|
||||
isAutomatic?: boolean;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Historie-Einträge für einen Vertrag abrufen
|
||||
*/
|
||||
export async function getHistoryEntries(contractId: number) {
|
||||
return prisma.contractHistoryEntry.findMany({
|
||||
where: { contractId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelnen Historie-Eintrag abrufen
|
||||
*/
|
||||
export async function getHistoryEntry(contractId: number, entryId: number) {
|
||||
return prisma.contractHistoryEntry.findFirst({
|
||||
where: { id: entryId, contractId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuen Historie-Eintrag erstellen
|
||||
*/
|
||||
export async function createHistoryEntry(contractId: number, data: CreateHistoryEntryData) {
|
||||
// Prüfen ob Vertrag existiert
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id: contractId },
|
||||
});
|
||||
|
||||
if (!contract) {
|
||||
throw new Error('Vertrag nicht gefunden');
|
||||
}
|
||||
|
||||
return prisma.contractHistoryEntry.create({
|
||||
data: {
|
||||
contractId,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
isAutomatic: data.isAutomatic ?? false,
|
||||
createdBy: data.createdBy,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Historie-Eintrag aktualisieren (nur manuelle Einträge)
|
||||
*/
|
||||
export async function updateHistoryEntry(
|
||||
contractId: number,
|
||||
entryId: number,
|
||||
data: { title?: string; description?: string }
|
||||
) {
|
||||
const entry = await prisma.contractHistoryEntry.findFirst({
|
||||
where: { id: entryId, contractId },
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
throw new Error('Historie-Eintrag nicht gefunden');
|
||||
}
|
||||
|
||||
if (entry.isAutomatic) {
|
||||
throw new Error('Automatische Einträge können nicht bearbeitet werden');
|
||||
}
|
||||
|
||||
return prisma.contractHistoryEntry.update({
|
||||
where: { id: entryId },
|
||||
data: {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Historie-Eintrag löschen (nur manuelle Einträge)
|
||||
*/
|
||||
export async function deleteHistoryEntry(contractId: number, entryId: number) {
|
||||
const entry = await prisma.contractHistoryEntry.findFirst({
|
||||
where: { id: entryId, contractId },
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
throw new Error('Historie-Eintrag nicht gefunden');
|
||||
}
|
||||
|
||||
if (entry.isAutomatic) {
|
||||
throw new Error('Automatische Einträge können nicht gelöscht werden');
|
||||
}
|
||||
|
||||
return prisma.contractHistoryEntry.delete({ where: { id: entryId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatischen Historie-Eintrag für Folgevertrag erstellen (im Vorgängervertrag)
|
||||
*/
|
||||
export async function createFollowUpHistoryEntry(
|
||||
previousContractId: number,
|
||||
newContractNumber: string,
|
||||
createdBy: string
|
||||
) {
|
||||
return createHistoryEntry(previousContractId, {
|
||||
title: `Folgevertrag erstellt: ${newContractNumber}`,
|
||||
description: `Ein neuer Folgevertrag (${newContractNumber}) wurde aus diesem Vertrag erstellt.`,
|
||||
isAutomatic: true,
|
||||
createdBy,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatischen Historie-Eintrag für neuen Folgevertrag erstellen (im neuen Vertrag selbst)
|
||||
*/
|
||||
export async function createNewContractFromPredecessorEntry(
|
||||
newContractId: number,
|
||||
previousContractNumber: string,
|
||||
createdBy: string
|
||||
) {
|
||||
return createHistoryEntry(newContractId, {
|
||||
title: `Folgevertrag zu ${previousContractNumber}`,
|
||||
description: `Dieser Vertrag wurde als Folgevertrag zu ${previousContractNumber} erstellt.`,
|
||||
isAutomatic: true,
|
||||
createdBy,
|
||||
});
|
||||
}
|
||||
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
File diff suppressed because one or more lines are too long
|
|
@ -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-XQdvYOWp.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B06MVODt.css">
|
||||
<script type="module" crossorigin src="/assets/index-Cdzd21Iz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-b8RXSgxB.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,292 @@
|
|||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit, Trash2, ChevronDown, ChevronUp, History, Clock, Bot, User } from 'lucide-react';
|
||||
import Modal from '../ui/Modal';
|
||||
import Button from '../ui/Button';
|
||||
import Input from '../ui/Input';
|
||||
import Badge from '../ui/Badge';
|
||||
import { contractHistoryApi } from '../../services/api';
|
||||
import type { ContractHistoryEntry } from '../../types';
|
||||
|
||||
interface ContractHistorySectionProps {
|
||||
contractId: number;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export default function ContractHistorySection({
|
||||
contractId,
|
||||
canEdit,
|
||||
}: ContractHistorySectionProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingEntry, setEditingEntry] = useState<ContractHistoryEntry | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['contract-history', contractId],
|
||||
queryFn: () => contractHistoryApi.getByContract(contractId),
|
||||
});
|
||||
|
||||
const deleteEntryMutation = useMutation({
|
||||
mutationFn: (entryId: number) => contractHistoryApi.delete(contractId, entryId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-history', contractId] });
|
||||
},
|
||||
});
|
||||
|
||||
const entries = data?.data || [];
|
||||
|
||||
// Sort entries by date (newest first)
|
||||
const sortedEntries = [...entries].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="w-4 h-4 text-gray-500" />
|
||||
<h4 className="text-sm font-medium text-gray-700">Vertragshistorie</h4>
|
||||
<Badge variant="default">{entries.length}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowAddModal(true)}>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{entries.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>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<p className="text-sm text-gray-500">Laden...</p>
|
||||
)}
|
||||
|
||||
{/* Collapsed view - show latest entry */}
|
||||
{!isExpanded && !isLoading && sortedEntries.length > 0 && (
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">
|
||||
{new Date(sortedEntries[0].createdAt).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
{' - '}
|
||||
{sortedEntries[0].title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded view */}
|
||||
{isExpanded && !isLoading && sortedEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{sortedEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-start justify-between p-3 bg-gray-50 rounded-lg group"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-gray-800">
|
||||
{entry.title}
|
||||
</span>
|
||||
{entry.isAutomatic ? (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 text-xs rounded bg-blue-100 text-blue-700" title="Automatisch erstellt">
|
||||
<Bot className="w-3 h-3" />
|
||||
Auto
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-600" title="Manuell erstellt">
|
||||
<User className="w-3 h-3" />
|
||||
Manuell
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.description && (
|
||||
<p className="text-sm text-gray-600 whitespace-pre-wrap mb-1">
|
||||
{entry.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(entry.createdAt).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<span>von {entry.createdBy}</span>
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && !entry.isAutomatic && (
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 ml-3">
|
||||
<button
|
||||
onClick={() => setEditingEntry(entry)}
|
||||
className="text-gray-500 hover:text-blue-600"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Eintrag wirklich löschen?')) {
|
||||
deleteEntryMutation.mutate(entry.id);
|
||||
}
|
||||
}}
|
||||
className="text-gray-500 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpanded && !isLoading && sortedEntries.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic">Keine Historie vorhanden.</p>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
{(showAddModal || editingEntry) && (
|
||||
<HistoryEntryModal
|
||||
isOpen={true}
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
setEditingEntry(null);
|
||||
}}
|
||||
contractId={contractId}
|
||||
entry={editingEntry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// History Entry Modal Component
|
||||
function HistoryEntryModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
contractId,
|
||||
entry,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
contractId: number;
|
||||
entry?: ContractHistoryEntry | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!entry;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: entry?.title || '',
|
||||
description: entry?.description || '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
contractHistoryApi.create(contractId, {
|
||||
title: formData.title,
|
||||
description: formData.description || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-history', contractId] });
|
||||
onClose();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
contractHistoryApi.update(contractId, entry!.id, {
|
||||
title: formData.title,
|
||||
description: formData.description || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-history', contractId] });
|
||||
onClose();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
setError('Titel ist erforderlich');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
updateMutation.mutate();
|
||||
} else {
|
||||
createMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Eintrag bearbeiten' : 'Historie-Eintrag 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="Titel *"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="z.B. kWh auf 18000 erhöht"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Beschreibung (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Weitere Details..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Wird gespeichert...' : isEditing ? 'Speichern' : 'Hinzufügen'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export { default as ContractDetailModal } from './ContractDetailModal';
|
||||
export { default as ContractHistorySection } from './ContractHistorySection';
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
|
|||
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 { ContractDetailModal, ContractHistorySection } from '../../components/contracts';
|
||||
import InvoicesSection from '../../components/contracts/InvoicesSection';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
|
|
@ -2683,6 +2683,14 @@ export default function ContractDetail() {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Vertragshistorie (nur für Mitarbeiter) */}
|
||||
{!isCustomerPortal && hasPermission('contracts:read') && (
|
||||
<ContractHistorySection
|
||||
contractId={contractId}
|
||||
canEdit={hasPermission('contracts:update')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Vorgängervertrag Modal */}
|
||||
{showPredecessorModal && c.previousContract && (
|
||||
<ContractDetailModal
|
||||
|
|
|
|||
|
|
@ -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, Invoice, 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, ContractHistoryEntry } from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
|
|
@ -648,6 +648,26 @@ export const contractApi = {
|
|||
},
|
||||
};
|
||||
|
||||
// Contract History (Vertragshistorie - nur intern)
|
||||
export const contractHistoryApi = {
|
||||
getByContract: async (contractId: number) => {
|
||||
const res = await api.get<ApiResponse<ContractHistoryEntry[]>>(`/contracts/${contractId}/history`);
|
||||
return res.data;
|
||||
},
|
||||
create: async (contractId: number, data: { title: string; description?: string }) => {
|
||||
const res = await api.post<ApiResponse<ContractHistoryEntry>>(`/contracts/${contractId}/history`, data);
|
||||
return res.data;
|
||||
},
|
||||
update: async (contractId: number, entryId: number, data: { title?: string; description?: string }) => {
|
||||
const res = await api.put<ApiResponse<ContractHistoryEntry>>(`/contracts/${contractId}/history/${entryId}`, data);
|
||||
return res.data;
|
||||
},
|
||||
delete: async (contractId: number, entryId: number) => {
|
||||
const res = await api.delete<ApiResponse<void>>(`/contracts/${contractId}/history/${entryId}`);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Contract Tasks (Aufgaben)
|
||||
export const contractTaskApi = {
|
||||
// Alle Tasks über alle Verträge (für Task-Liste & Dashboard)
|
||||
|
|
|
|||
|
|
@ -216,6 +216,16 @@ export interface ContractTask {
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ContractHistoryEntry {
|
||||
id: number;
|
||||
contractId: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
isAutomatic: boolean;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SalesPlatform {
|
||||
id: number;
|
||||
name: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue