Files
opencrm/frontend/src/components/contracts/ContractHistorySection.tsx
T
duffyduckandClaude Opus 4.7 3a9cece929 Vertragshistorie: Vertragsnummern als Link in neuem Tab
Erwähnte Vertragsnummern (Pattern PREFIX-RANDOM) in Title und
Description werden gegen previousContract + followUpContract des
aktuellen Vertrags aufgelöst und als Link mit target="_blank"
gerendert. Nicht aufgelöste Nummern bleiben als Text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:33:11 +02:00

340 lines
11 KiB
TypeScript

import { useState } from 'react';
import { Link } from 'react-router-dom';
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;
// Map: contractNumber → contractId. Wird genutzt um in title/description
// erwähnte Vertragsnummern als Link auf den jeweiligen Vertrag zu rendern.
// Aufgebaut aus previousContract + followUpContract des aktuellen Vertrags.
knownContracts?: Record<string, number>;
}
// Vertragsnummer-Pattern: 3 Großbuchstaben + Bindestrich + Alphanumerisch
// (siehe backend/src/utils/helpers.ts generateContractNumber).
const CONTRACT_NUMBER_REGEX = /\b([A-Z]{3}-[A-Z0-9]{6,})\b/g;
// Rendert einen Text und ersetzt enthaltene Vertragsnummern durch Links,
// falls sie in der knownContracts-Map auflösbar sind. Nicht aufgelöste Nummern
// bleiben als normaler Text.
function renderTextWithContractLinks(
text: string,
knownContracts?: Record<string, number>,
): React.ReactNode {
if (!knownContracts || Object.keys(knownContracts).length === 0) return text;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
CONTRACT_NUMBER_REGEX.lastIndex = 0;
while ((match = CONTRACT_NUMBER_REGEX.exec(text)) !== null) {
const num = match[1];
const id = knownContracts[num];
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
if (id) {
parts.push(
<Link
key={`${match.index}-${num}`}
to={`/contracts/${id}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline font-mono"
>
{num}
</Link>,
);
} else {
parts.push(num);
}
lastIndex = match.index + num.length;
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
return parts.length > 0 ? <>{parts}</> : text;
}
export default function ContractHistorySection({
contractId,
canEdit,
knownContracts,
}: 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>
{' - '}
{renderTextWithContractLinks(sortedEntries[0].title, knownContracts)}
</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">
{renderTextWithContractLinks(entry.title, knownContracts)}
</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">
{renderTextWithContractLinks(entry.description, knownContracts)}
</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>
);
}