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>
340 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|