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>
This commit is contained in:
2026-05-30 14:33:11 +02:00
parent e527aebb84
commit 3a9cece929
2 changed files with 58 additions and 3 deletions
@@ -1,4 +1,5 @@
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';
@@ -11,11 +12,57 @@ 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);
@@ -84,7 +131,7 @@ export default function ContractHistorySection({
})}
</span>
{' - '}
{sortedEntries[0].title}
{renderTextWithContractLinks(sortedEntries[0].title, knownContracts)}
</div>
)}
@@ -99,7 +146,7 @@ export default function ContractHistorySection({
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-800">
{entry.title}
{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">
@@ -115,7 +162,7 @@ export default function ContractHistorySection({
</div>
{entry.description && (
<p className="text-sm text-gray-600 whitespace-pre-wrap mb-1">
{entry.description}
{renderTextWithContractLinks(entry.description, knownContracts)}
</p>
)}
<div className="flex items-center gap-3 text-xs text-gray-400">
@@ -3224,6 +3224,14 @@ export default function ContractDetail() {
<ContractHistorySection
contractId={contractId}
canEdit={hasPermission('contracts:update')}
knownContracts={{
...(c.previousContract?.contractNumber
? { [c.previousContract.contractNumber]: c.previousContract.id }
: {}),
...(c.followUpContract?.contractNumber
? { [c.followUpContract.contractNumber]: c.followUpContract.id }
: {}),
}}
/>
)}