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