added contract history

This commit is contained in:
2026-02-08 19:24:37 +01:00
parent ee4f1aacdd
commit e348e86c60
33 changed files with 3200 additions and 743 deletions
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
+2 -2
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-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
+21 -1
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, 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)
+10
View File
@@ -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;