opencrm/frontend/src/pages/contracts/ContractDetail.tsx

2443 lines
94 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
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 { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Badge from '../../components/ui/Badge';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import FileUpload from '../../components/ui/FileUpload';
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare } from 'lucide-react';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask } from '../../types';
const typeLabels: Record<ContractType, string> = {
ELECTRICITY: 'Strom',
GAS: 'Gas',
DSL: 'DSL',
CABLE: 'Kabelinternet',
FIBER: 'Glasfaser',
MOBILE: 'Mobilfunk',
TV: 'TV',
CAR_INSURANCE: 'KFZ-Versicherung',
};
const statusLabels: Record<ContractStatus, string> = {
DRAFT: 'Entwurf',
PENDING: 'Ausstehend',
ACTIVE: 'Aktiv',
CANCELLED: 'Gekündigt',
EXPIRED: 'Abgelaufen',
DEACTIVATED: 'Deaktiviert',
};
const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' | 'default'> = {
ACTIVE: 'success',
PENDING: 'warning',
CANCELLED: 'danger',
EXPIRED: 'danger',
DRAFT: 'default',
DEACTIVATED: 'default',
};
// Prüft ob die Laufzeit als "unbefristet" gilt (≤ 4 Wochen / 1 Monat / 30 Tage)
function isUnlimitedDuration(durationCode: string): boolean {
const match = durationCode.match(/^(\d+)([TMWJ])$/);
if (!match) return false;
const value = parseInt(match[1]);
const unit = match[2];
// Alles in Tage umrechnen (1 Monat ≈ 30 Tage, 1 Woche = 7 Tage)
let days = 0;
if (unit === 'T') days = value;
else if (unit === 'W') days = value * 7;
else if (unit === 'M') days = value * 30;
else if (unit === 'J') days = value * 365;
// ≤ 30 Tage (entspricht 4 Wochen = 28 Tage, 1 Monat = 30 Tage)
return days <= 30;
}
// SimCard Display Component with PIN/PUK reveal
function SimCardDisplay({ simCard }: { simCard: SimCard }) {
const [showCredentials, setShowCredentials] = useState(false);
const [credentials, setCredentials] = useState<{ pin: string | null; puk: string | null } | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleShowCredentials = async () => {
if (showCredentials) {
setShowCredentials(false);
setCredentials(null);
} else {
setIsLoading(true);
try {
const res = await contractApi.getSimCardCredentials(simCard.id);
if (res.data) {
setCredentials(res.data);
setShowCredentials(true);
}
} catch (err) {
alert('PIN/PUK konnte nicht geladen werden');
} finally {
setIsLoading(false);
}
}
};
return (
<div className="p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center gap-2 mb-2">
{simCard.isMain && <Badge variant="success">Hauptkarte</Badge>}
{simCard.isMultisim && <Badge variant="warning">Multisim</Badge>}
</div>
<dl className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
{simCard.phoneNumber && (
<div>
<dt className="text-gray-500">Rufnummer</dt>
<dd className="font-mono flex items-center gap-1">
{simCard.phoneNumber}
<CopyButton value={simCard.phoneNumber} />
</dd>
</div>
)}
{simCard.simCardNumber && (
<div>
<dt className="text-gray-500">SIM-Nr.</dt>
<dd className="font-mono text-xs flex items-center gap-1">
{simCard.simCardNumber}
<CopyButton value={simCard.simCardNumber} />
</dd>
</div>
)}
<div>
<dt className="text-gray-500">PIN</dt>
<dd className="font-mono flex items-center gap-1">
{showCredentials && credentials?.pin ? (
<>
{credentials.pin}
<CopyButton value={credentials.pin} />
</>
) : '••••'}
</dd>
</div>
<div>
<dt className="text-gray-500">PUK</dt>
<dd className="font-mono flex items-center gap-1">
{showCredentials && credentials?.puk ? (
<>
{credentials.puk}
<CopyButton value={credentials.puk} />
</>
) : '••••••••'}
</dd>
</div>
</dl>
<div className="mt-2">
<Button
variant="ghost"
size="sm"
onClick={handleShowCredentials}
disabled={isLoading}
>
{isLoading ? 'Laden...' : showCredentials ? (
<><EyeOff className="w-4 h-4 mr-1" /> PIN/PUK verbergen</>
) : (
<><Eye className="w-4 h-4 mr-1" /> PIN/PUK anzeigen</>
)}
</Button>
</div>
</div>
);
}
// Meter Readings Section Component
function MeterReadingsSection({
meterId,
meterType,
readings,
contractId,
canEdit,
}: {
meterId: number;
meterType: 'ELECTRICITY' | 'GAS';
readings: MeterReading[];
contractId: number;
canEdit: boolean;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
const [editingReading, setEditingReading] = useState<MeterReading | null>(null);
const queryClient = useQueryClient();
const deleteReadingMutation = useMutation({
mutationFn: (readingId: number) => meterApi.deleteReading(meterId, readingId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
},
});
// Sort readings by date (newest first)
const sortedReadings = [...readings].sort(
(a, b) => new Date(b.readingDate).getTime() - new Date(a.readingDate).getTime()
);
const defaultUnit = meterType === 'ELECTRICITY' ? 'kWh' : 'm³';
return (
<div className="mt-4 pt-4 border-t">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Gauge className="w-4 h-4 text-gray-500" />
<h4 className="text-sm font-medium text-gray-700">Zählerstände</h4>
<Badge variant="default">{readings.length}</Badge>
</div>
<div className="flex items-center gap-2">
{canEdit && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowAddModal(true)}
title="Zählerstand erfassen"
>
<Plus className="w-4 h-4" />
</Button>
)}
{readings.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</Button>
)}
</div>
</div>
{isExpanded && readings.length > 0 && (
<div className="space-y-2 bg-gray-50 rounded-lg p-3">
{sortedReadings.map((reading) => (
<div
key={reading.id}
className="flex justify-between items-center text-sm group py-1 border-b border-gray-200 last:border-0"
>
<span className="text-gray-500 flex items-center gap-1">
{new Date(reading.readingDate).toLocaleDateString('de-DE')}
<CopyButton value={new Date(reading.readingDate).toLocaleDateString('de-DE')} />
</span>
<div className="flex items-center gap-2">
<span className="font-mono flex items-center gap-1">
{reading.value.toLocaleString('de-DE')} {reading.unit}
<CopyButton value={reading.value.toString()} title="Nur Wert kopieren" />
</span>
{canEdit && (
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
<button
onClick={() => setEditingReading(reading)}
className="text-gray-400 hover:text-blue-600"
title="Bearbeiten"
>
<Edit className="w-3 h-3" />
</button>
<button
onClick={() => {
if (confirm('Zählerstand wirklich löschen?')) {
deleteReadingMutation.mutate(reading.id);
}
}}
className="text-gray-400 hover:text-red-600"
title="Löschen"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
{!isExpanded && readings.length > 0 && (
<p className="text-sm text-gray-500">
Letzter Stand: {sortedReadings[0].value.toLocaleString('de-DE')} {sortedReadings[0].unit} ({new Date(sortedReadings[0].readingDate).toLocaleDateString('de-DE')})
</p>
)}
{readings.length === 0 && (
<p className="text-sm text-gray-500">Keine Zählerstände vorhanden.</p>
)}
{/* Add/Edit Reading Modal */}
{(showAddModal || editingReading) && (
<MeterReadingModal
isOpen={true}
onClose={() => {
setShowAddModal(false);
setEditingReading(null);
}}
meterId={meterId}
contractId={contractId}
reading={editingReading}
defaultUnit={defaultUnit}
/>
)}
</div>
);
}
// Meter Reading Modal Component
function MeterReadingModal({
isOpen,
onClose,
meterId,
contractId,
reading,
defaultUnit,
}: {
isOpen: boolean;
onClose: () => void;
meterId: number;
contractId: number;
reading?: MeterReading | null;
defaultUnit: string;
}) {
const queryClient = useQueryClient();
const isEditing = !!reading;
const [formData, setFormData] = useState({
readingDate: reading?.readingDate
? new Date(reading.readingDate).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0],
value: reading?.value?.toString() || '',
notes: reading?.notes || '',
});
const createMutation = useMutation({
mutationFn: (data: Partial<MeterReading>) => meterApi.addReading(meterId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
onClose();
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<MeterReading>) => meterApi.updateReading(meterId, reading!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
onClose();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data = {
readingDate: new Date(formData.readingDate),
value: parseFloat(formData.value),
unit: defaultUnit,
notes: formData.notes || undefined,
};
if (isEditing) {
updateMutation.mutate(data as unknown as Partial<MeterReading>);
} else {
createMutation.mutate(data as unknown as Partial<MeterReading>);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Zählerstand bearbeiten' : 'Zählerstand erfassen'}>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Ablesedatum"
type="date"
value={formData.readingDate}
onChange={(e) => setFormData({ ...formData, readingDate: e.target.value })}
required
/>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<Input
label="Zählerstand"
type="number"
step="0.01"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Einheit</label>
<div className="h-10 flex items-center px-3 bg-gray-100 border border-gray-300 rounded-md text-gray-700">
{defaultUnit}
</div>
</div>
</div>
<Input
label="Notizen (optional)"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
/>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending || !formData.value}>
{isPending ? 'Speichern...' : isEditing ? 'Speichern' : 'Erfassen'}
</Button>
</div>
</form>
</Modal>
);
}
// Contract Task Item Component (handles subtasks)
function ContractTaskItem({
task,
contractId,
canEdit,
isCustomerPortal,
isCompleted,
onEdit,
}: {
task: ContractTask;
contractId: number;
canEdit: boolean;
isCustomerPortal: boolean;
isCompleted: boolean;
onEdit: () => void;
}) {
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
const [showSubtaskInput, setShowSubtaskInput] = useState(false);
const [editingSubtaskId, setEditingSubtaskId] = useState<number | null>(null);
const [editSubtaskTitle, setEditSubtaskTitle] = useState('');
const queryClient = useQueryClient();
const completeMutation = useMutation({
mutationFn: (taskId: number) => contractTaskApi.complete(taskId),
onSuccess: async () => {
await queryClient.refetchQueries({ queryKey: ['contract-tasks', contractId] });
},
});
const reopenMutation = useMutation({
mutationFn: (taskId: number) => contractTaskApi.reopen(taskId),
onSuccess: async () => {
await queryClient.refetchQueries({ queryKey: ['contract-tasks', contractId] });
},
});
const deleteMutation = useMutation({
mutationFn: (taskId: number) => contractTaskApi.delete(taskId),
onSuccess: async () => {
await queryClient.refetchQueries({ queryKey: ['contract-tasks', contractId] });
},
});
const createSubtaskMutation = useMutation({
mutationFn: (title: string) => contractTaskApi.createSubtask(task.id, title),
onSuccess: async () => {
await queryClient.refetchQueries({ queryKey: ['contract-tasks', contractId] });
setNewSubtaskTitle('');
setShowSubtaskInput(false);
},
onError: (error) => {
console.error('Fehler beim Erstellen der Unteraufgabe:', error);
alert('Fehler beim Erstellen der Unteraufgabe. Bitte versuchen Sie es erneut.');
},
});
// Kundenportal: Antwort auf eigenes Ticket
const createReplyMutation = useMutation({
mutationFn: (title: string) => contractTaskApi.createReply(task.id, title),
onSuccess: async () => {
await queryClient.refetchQueries({ queryKey: ['contract-tasks', contractId] });
setNewSubtaskTitle('');
setShowSubtaskInput(false);
},
onError: (error) => {
console.error('Fehler beim Erstellen der Antwort:', error);
alert('Fehler beim Erstellen der Antwort. Bitte versuchen Sie es erneut.');
},
});
const updateSubtaskMutation = useMutation({
mutationFn: ({ id, title }: { id: number; title: string }) => contractTaskApi.updateSubtask(id, title),
onSuccess: async () => {
await queryClient.refetchQueries({ queryKey: ['contract-tasks', contractId] });
setEditingSubtaskId(null);
setEditSubtaskTitle('');
},
});
const completeSubtaskMutation = useMutation({
mutationFn: (subtaskId: number) => contractTaskApi.completeSubtask(subtaskId),
onSuccess: async () => {
await queryClient.refetchQueries({ queryKey: ['contract-tasks', contractId] });
},
});
const reopenSubtaskMutation = useMutation({
mutationFn: (subtaskId: number) => contractTaskApi.reopenSubtask(subtaskId),
onSuccess: async () => {
await queryClient.refetchQueries({ queryKey: ['contract-tasks', contractId] });
},
});
const deleteSubtaskMutation = useMutation({
mutationFn: (subtaskId: number) => contractTaskApi.deleteSubtask(subtaskId),
onSuccess: async () => {
await queryClient.refetchQueries({ queryKey: ['contract-tasks', contractId] });
},
});
const handleAddSubtask = (e: React.FormEvent) => {
e.preventDefault();
if (newSubtaskTitle.trim()) {
if (isCustomerPortal) {
createReplyMutation.mutate(newSubtaskTitle.trim());
} else {
createSubtaskMutation.mutate(newSubtaskTitle.trim());
}
}
};
const handleEditSubtask = (e: React.FormEvent) => {
e.preventDefault();
if (editSubtaskTitle.trim() && editingSubtaskId) {
updateSubtaskMutation.mutate({ id: editingSubtaskId, title: editSubtaskTitle.trim() });
}
};
const startEditingSubtask = (subtaskId: number, currentTitle: string) => {
setEditingSubtaskId(subtaskId);
setEditSubtaskTitle(currentTitle);
};
const cancelEditingSubtask = () => {
setEditingSubtaskId(null);
setEditSubtaskTitle('');
};
const subtasks = task.subtasks || [];
const openSubtasks = subtasks.filter(s => s.status === 'OPEN');
const completedSubtasks = subtasks.filter(s => s.status === 'COMPLETED');
// Labels für Subtasks (Antwort im Portal, Unteraufgabe für Mitarbeiter)
const subtaskLabels = isCustomerPortal
? { singular: 'Antwort', placeholder: 'Antwort...', deleteConfirm: 'Antwort löschen?' }
: { singular: 'Unteraufgabe', placeholder: 'Unteraufgabe...', deleteConfirm: 'Unteraufgabe löschen?' };
// Render a single subtask row
const renderSubtask = (subtask: ContractTaskSubtask, isSubtaskCompleted: boolean) => {
const isEditing = editingSubtaskId === subtask.id;
if (isEditing) {
return (
<div key={subtask.id} className="py-1">
<form onSubmit={handleEditSubtask} className="flex items-center gap-2">
<Circle className="w-4 h-4 text-gray-300 flex-shrink-0" />
<input
type="text"
value={editSubtaskTitle}
onChange={(e) => setEditSubtaskTitle(e.target.value)}
className="flex-1 text-sm px-2 py-1 border rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
autoFocus
/>
<Button type="submit" size="sm" disabled={!editSubtaskTitle.trim() || updateSubtaskMutation.isPending}>
</Button>
<Button type="button" variant="ghost" size="sm" onClick={cancelEditingSubtask}>
×
</Button>
</form>
</div>
);
}
return (
<div key={subtask.id} className={`py-1 group/subtask ${isSubtaskCompleted ? 'opacity-60' : ''}`}>
<div className="flex items-start gap-2">
<button
onClick={() => isSubtaskCompleted
? reopenSubtaskMutation.mutate(subtask.id)
: completeSubtaskMutation.mutate(subtask.id)
}
disabled={completeSubtaskMutation.isPending || reopenSubtaskMutation.isPending || isCustomerPortal}
className={`flex-shrink-0 mt-0.5 ${isCustomerPortal ? 'cursor-default' : isSubtaskCompleted ? 'hover:text-yellow-600' : 'hover:text-green-600'}`}
>
{isSubtaskCompleted ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<Circle className="w-4 h-4 text-gray-400" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
<span className={`text-sm ${isSubtaskCompleted ? 'line-through text-gray-500' : ''}`}>
{subtask.title}
</span>
{canEdit && !isCustomerPortal && !isSubtaskCompleted && (
<div className="flex items-center gap-0.5 opacity-0 group-hover/subtask:opacity-100">
<button
onClick={() => startEditingSubtask(subtask.id, subtask.title)}
className="text-gray-400 hover:text-blue-600 p-0.5"
title="Bearbeiten"
>
<Edit className="w-3 h-3" />
</button>
<button
onClick={() => {
if (confirm(subtaskLabels.deleteConfirm)) {
deleteSubtaskMutation.mutate(subtask.id);
}
}}
className="text-gray-400 hover:text-red-600 p-0.5"
title="Löschen"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
{canEdit && !isCustomerPortal && isSubtaskCompleted && (
<button
onClick={() => {
if (confirm(subtaskLabels.deleteConfirm)) {
deleteSubtaskMutation.mutate(subtask.id);
}
}}
className="text-gray-400 hover:text-red-600 p-0.5 opacity-0 group-hover/subtask:opacity-100"
title="Löschen"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
<p className="text-xs text-gray-400">
{subtask.createdBy && `${subtask.createdBy}`}
{isSubtaskCompleted
? `Erledigt am ${subtask.completedAt ? new Date(subtask.completedAt).toLocaleDateString('de-DE') : new Date(subtask.updatedAt).toLocaleDateString('de-DE')}`
: new Date(subtask.createdAt).toLocaleDateString('de-DE')}
</p>
</div>
</div>
</div>
);
};
return (
<div className={`p-3 bg-gray-50 rounded-lg group ${isCompleted ? 'bg-gray-50/50 opacity-70' : ''}`}>
<div className="flex items-start gap-3">
<button
onClick={() => isCompleted ? reopenMutation.mutate(task.id) : completeMutation.mutate(task.id)}
disabled={completeMutation.isPending || reopenMutation.isPending || isCustomerPortal}
className={`mt-0.5 flex-shrink-0 ${isCustomerPortal ? 'cursor-default' : isCompleted ? 'hover:text-yellow-600' : 'hover:text-green-600'}`}
title={isCustomerPortal ? undefined : isCompleted ? 'Wieder öffnen' : 'Als erledigt markieren'}
>
{isCompleted ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<Circle className="w-5 h-5 text-gray-400" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`font-medium ${isCompleted ? 'line-through text-gray-500' : ''}`}>{task.title}</span>
{task.visibleInPortal && (
<Badge variant="default" className="text-xs">Portal</Badge>
)}
{subtasks.length > 0 && (
<span className="text-xs text-gray-400">
({completedSubtasks.length}/{subtasks.length})
</span>
)}
</div>
{task.description && (
<p className={`text-sm mt-1 whitespace-pre-wrap ${isCompleted ? 'text-gray-500' : 'text-gray-600'}`}>
{task.description}
</p>
)}
<p className="text-xs text-gray-400 mt-1">
{task.createdBy && `${task.createdBy}`}
{isCompleted
? `Erledigt am ${task.completedAt ? new Date(task.completedAt).toLocaleDateString('de-DE') : '-'}`
: new Date(task.createdAt).toLocaleDateString('de-DE')}
</p>
{/* Subtasks */}
{subtasks.length > 0 && (
<div className="mt-3 ml-2 space-y-0 border-l-2 border-gray-200 pl-3">
{openSubtasks.map((subtask) => renderSubtask(subtask, false))}
{completedSubtasks.map((subtask) => renderSubtask(subtask, true))}
</div>
)}
{/* Add Subtask */}
{/* Subtask/Antwort hinzufügen: Mitarbeiter mit Berechtigung ODER Kundenportal */}
{!isCompleted && ((canEdit && !isCustomerPortal) || isCustomerPortal) && (
<div className="mt-2 ml-2">
{showSubtaskInput ? (
<form onSubmit={handleAddSubtask} className="flex items-center gap-2">
<input
type="text"
value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)}
placeholder={subtaskLabels.placeholder}
className="flex-1 text-sm px-2 py-1 border rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
autoFocus
/>
<Button type="submit" size="sm" disabled={!newSubtaskTitle.trim() || createSubtaskMutation.isPending || createReplyMutation.isPending}>
<Plus className="w-3 h-3" />
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => { setShowSubtaskInput(false); setNewSubtaskTitle(''); }}>
×
</Button>
</form>
) : (
<button
onClick={() => setShowSubtaskInput(true)}
className="text-xs text-gray-400 hover:text-blue-600 flex items-center gap-1"
>
<Plus className="w-3 h-3" />
{subtaskLabels.singular}
</button>
)}
</div>
)}
</div>
{canEdit && !isCustomerPortal && (
<div className="flex gap-1 opacity-0 group-hover:opacity-100">
{!isCompleted && (
<button
onClick={onEdit}
className="text-gray-400 hover:text-blue-600 p-1"
title="Bearbeiten"
>
<Edit className="w-4 h-4" />
</button>
)}
<button
onClick={() => {
if (confirm('Aufgabe wirklich löschen?')) {
deleteMutation.mutate(task.id);
}
}}
className="text-gray-400 hover:text-red-600 p-1"
title="Löschen"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
);
}
// Contract Tasks Section Component
function ContractTasksSection({
contractId,
canEdit,
isCustomerPortal,
}: {
contractId: number;
canEdit: boolean;
isCustomerPortal: boolean;
}) {
const [showAddModal, setShowAddModal] = useState(false);
const [editingTask, setEditingTask] = useState<ContractTask | null>(null);
const { data: tasksData, isLoading } = useQuery({
queryKey: ['contract-tasks', contractId],
queryFn: () => contractTaskApi.getByContract(contractId),
staleTime: 0, // Immer als "stale" behandeln
gcTime: 0, // Kein Caching
refetchOnMount: 'always', // Immer neu laden beim Mounten
});
// Lade öffentliche Einstellungen (für Kundenportal)
const { data: settingsData, isLoading: isSettingsLoading } = useQuery({
queryKey: ['app-settings-public'],
queryFn: () => appSettingsApi.getPublic(),
enabled: isCustomerPortal,
staleTime: 0, // Immer neu laden, damit Einstellungsänderungen sofort wirken
});
// Wichtig: Nur true wenn explizit aktiviert UND geladen
const supportTicketsEnabled = !isSettingsLoading && settingsData?.data?.customerSupportTicketsEnabled === 'true';
const tasks = tasksData?.data || [];
const openTasks = tasks.filter(t => t.status === 'OPEN');
const completedTasks = tasks.filter(t => t.status === 'COMPLETED');
// Labels je nach Portal/Mitarbeiter
const labels = isCustomerPortal
? { title: 'Support-Anfragen', singular: 'Support-Anfrage', button: 'Anfrage erstellen', empty: 'Keine Support-Anfragen vorhanden.' }
: { title: 'Aufgaben', singular: 'Aufgabe', button: 'Aufgabe', empty: 'Keine Aufgaben vorhanden.' };
const IconComponent = isCustomerPortal ? MessageSquare : ClipboardList;
// Warte auf beide Queries bevor gerändert wird
const stillLoading = isLoading || (isCustomerPortal && isSettingsLoading);
if (stillLoading) {
return (
<Card className="mb-6" title={labels.title}>
<div className="text-center py-4 text-gray-500">Laden...</div>
</Card>
);
}
// Zeige Button wenn: Mitarbeiter mit Berechtigung ODER Kundenportal mit aktivierten Support-Tickets
const canCreateTask = (canEdit && !isCustomerPortal) || (isCustomerPortal && supportTicketsEnabled);
return (
<Card className="mb-6" title={labels.title}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<IconComponent className="w-5 h-5 text-gray-500" />
<span className="text-sm text-gray-600">
{openTasks.length} offen, {completedTasks.length} erledigt
</span>
</div>
{canCreateTask && (
<Button size="sm" onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4 mr-1" />
{labels.button}
</Button>
)}
</div>
{tasks.length === 0 ? (
<p className="text-center py-4 text-gray-500">{labels.empty}</p>
) : (
<div className="space-y-2">
{/* Open Tasks first */}
{openTasks.map((task) => (
<ContractTaskItem
key={task.id}
task={task}
contractId={contractId}
canEdit={canEdit}
isCustomerPortal={isCustomerPortal}
isCompleted={false}
onEdit={() => setEditingTask(task)}
/>
))}
{/* Completed Tasks */}
{completedTasks.length > 0 && openTasks.length > 0 && (
<div className="border-t my-3" />
)}
{completedTasks.map((task) => (
<ContractTaskItem
key={task.id}
task={task}
contractId={contractId}
canEdit={canEdit}
isCustomerPortal={isCustomerPortal}
isCompleted={true}
onEdit={() => {}}
/>
))}
</div>
)}
{/* Add/Edit Task Modal */}
{(showAddModal || editingTask) && (
<ContractTaskModal
isOpen={true}
onClose={() => {
setShowAddModal(false);
setEditingTask(null);
}}
contractId={contractId}
task={editingTask}
isCustomerPortal={isCustomerPortal}
/>
)}
</Card>
);
}
// Contract Task Modal Component
function ContractTaskModal({
isOpen,
onClose,
contractId,
task,
isCustomerPortal = false,
}: {
isOpen: boolean;
onClose: () => void;
contractId: number;
task?: ContractTask | null;
isCustomerPortal?: boolean;
}) {
const queryClient = useQueryClient();
const isEditing = !!task;
const [formData, setFormData] = useState({
title: task?.title || '',
description: task?.description || '',
visibleInPortal: task?.visibleInPortal || false,
});
// Reset formData when modal opens or task changes
useEffect(() => {
if (isOpen) {
setFormData({
title: task?.title || '',
description: task?.description || '',
visibleInPortal: task?.visibleInPortal || false,
});
}
}, [isOpen, task]);
// Für Mitarbeiter: normale Task-Erstellung
const createMutation = useMutation({
mutationFn: (data: { title: string; description?: string; visibleInPortal?: boolean }) =>
contractTaskApi.create(contractId, data),
onSuccess: async () => {
await queryClient.refetchQueries({ queryKey: ['contract-tasks', contractId] });
onClose();
},
});
// Für Kundenportal: Support-Ticket-Erstellung
const createSupportTicketMutation = useMutation({
mutationFn: (data: { title: string; description?: string }) =>
contractTaskApi.createSupportTicket(contractId, data),
onSuccess: async () => {
await queryClient.refetchQueries({ queryKey: ['contract-tasks', contractId] });
onClose();
},
});
const updateMutation = useMutation({
mutationFn: (data: { title?: string; description?: string; visibleInPortal?: boolean }) =>
contractTaskApi.update(task!.id, data),
onSuccess: async () => {
await queryClient.refetchQueries({ queryKey: ['contract-tasks', contractId] });
onClose();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isEditing) {
updateMutation.mutate({
title: formData.title,
description: formData.description || undefined,
visibleInPortal: formData.visibleInPortal,
});
} else if (isCustomerPortal) {
// Kundenportal: Support-Ticket erstellen
createSupportTicketMutation.mutate({
title: formData.title,
description: formData.description || undefined,
});
} else {
// Mitarbeiter: normale Aufgabe erstellen
createMutation.mutate({
title: formData.title,
description: formData.description || undefined,
visibleInPortal: formData.visibleInPortal,
});
}
};
const isPending = createMutation.isPending || createSupportTicketMutation.isPending || updateMutation.isPending;
// Labels für Kundenportal vs. Mitarbeiter
const labels = isCustomerPortal
? {
modalTitle: isEditing ? 'Anfrage bearbeiten' : 'Neue Support-Anfrage',
titleLabel: 'Betreff',
titlePlaceholder: 'Kurze Beschreibung Ihrer Anfrage',
descLabel: 'Ihre Nachricht',
descPlaceholder: 'Beschreiben Sie Ihr Anliegen...',
submitBtn: isEditing ? 'Speichern' : 'Anfrage senden',
}
: {
modalTitle: isEditing ? 'Aufgabe bearbeiten' : 'Neue Aufgabe',
titleLabel: 'Titel',
titlePlaceholder: 'Kurze Beschreibung der Aufgabe',
descLabel: 'Beschreibung (optional)',
descPlaceholder: 'Details zur Aufgabe...',
submitBtn: isEditing ? 'Speichern' : 'Erstellen',
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={labels.modalTitle}>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label={labels.titleLabel}
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
placeholder={labels.titlePlaceholder}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{labels.descLabel}
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={isCustomerPortal ? 5 : 3}
placeholder={labels.descPlaceholder}
/>
</div>
{/* Portal-Sichtbarkeit nur für Mitarbeiter anzeigen */}
{!isCustomerPortal && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.visibleInPortal}
onChange={(e) => setFormData({ ...formData, visibleInPortal: e.target.checked })}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">Im Kundenportal sichtbar</span>
</label>
)}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending || !formData.title.trim()}>
{isPending ? 'Speichern...' : labels.submitBtn}
</Button>
</div>
</form>
</Modal>
);
}
interface LocationState {
from?: 'customer' | 'contracts' | 'cockpit';
customerId?: string;
filter?: string; // Für Cockpit-Filter
}
export default function ContractDetail() {
const { id } = useParams();
const navigate = useNavigate();
const location = useLocation();
const locationState = location.state as LocationState | null;
const queryClient = useQueryClient();
const { hasPermission, isCustomer, isCustomerPortal } = useAuth();
const contractId = parseInt(id!);
const [showPassword, setShowPassword] = useState(false);
const [decryptedPassword, setDecryptedPassword] = useState<string | null>(null);
const [isAutoLoginLoading, setIsAutoLoginLoading] = useState(false);
// Internet-Zugangsdaten
const [showInternetPassword, setShowInternetPassword] = useState(false);
const [decryptedInternetPassword, setDecryptedInternetPassword] = useState<string | null>(null);
// SIP-Passwörter (Map mit phoneNumberId als Key)
const [showSipPasswords, setShowSipPasswords] = useState<Record<number, boolean>>({});
const [decryptedSipPasswords, setDecryptedSipPasswords] = useState<Record<number, string | null>>({});
// Modal für Vorgängervertrag
const [showPredecessorModal, setShowPredecessorModal] = useState(false);
// Bestätigungsdialog für Folgevertrag
const [showFollowUpConfirm, setShowFollowUpConfirm] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['contract', id],
queryFn: () => contractApi.getById(contractId),
});
const deleteMutation = useMutation({
mutationFn: () => contractApi.delete(contractId),
onSuccess: () => {
navigate('/contracts');
},
});
const followUpMutation = useMutation({
mutationFn: () => contractApi.createFollowUp(contractId),
onSuccess: (data) => {
if (data.data) {
navigate(`/contracts/${data.data.id}/edit`);
} else {
alert('Folgevertrag wurde erstellt, aber keine ID zurückgegeben');
}
},
onError: (error) => {
console.error('Folgevertrag Fehler:', error);
alert(`Fehler beim Erstellen des Folgevertrags: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
},
});
// Mutation für Kündigungsbestätigungsdatum
const updateCancellationDateMutation = useMutation({
mutationFn: (date: string | null) => {
// Datum in ISO-Format konvertieren für Backend
const isoDate = date ? new Date(date).toISOString() : null;
const payload: Record<string, unknown> = { cancellationConfirmationDate: isoDate };
return contractApi.update(contractId, payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract', id] });
queryClient.invalidateQueries({ queryKey: ['contract-cockpit'] });
},
onError: (error) => {
console.error('Fehler beim Speichern des Datums:', error);
alert('Fehler beim Speichern des Datums');
},
});
// Mutation für Kündigungsbestätigungsoptionendatum
const updateCancellationOptionsDateMutation = useMutation({
mutationFn: (date: string | null) => {
// Datum in ISO-Format konvertieren für Backend
const isoDate = date ? new Date(date).toISOString() : null;
const payload: Record<string, unknown> = { cancellationConfirmationOptionsDate: isoDate };
return contractApi.update(contractId, payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract', id] });
queryClient.invalidateQueries({ queryKey: ['contract-cockpit'] });
},
onError: (error) => {
console.error('Fehler beim Speichern des Datums:', error);
alert('Fehler beim Speichern des Datums');
},
});
const handleShowPassword = async () => {
if (showPassword) {
setShowPassword(false);
setDecryptedPassword(null);
} else {
try {
const res = await contractApi.getPassword(contractId);
if (res.data?.password) {
setDecryptedPassword(res.data.password);
setShowPassword(true);
}
} catch (err) {
alert('Passwort konnte nicht entschlüsselt werden');
}
}
};
const handleShowInternetPassword = async () => {
if (showInternetPassword) {
setShowInternetPassword(false);
setDecryptedInternetPassword(null);
} else {
try {
const res = await contractApi.getInternetCredentials(contractId);
if (res.data?.password) {
setDecryptedInternetPassword(res.data.password);
setShowInternetPassword(true);
}
} catch (err) {
alert('Internet-Passwort konnte nicht entschlüsselt werden');
}
}
};
const handleShowSipPassword = async (phoneNumberId: number) => {
if (showSipPasswords[phoneNumberId]) {
setShowSipPasswords(prev => ({ ...prev, [phoneNumberId]: false }));
setDecryptedSipPasswords(prev => ({ ...prev, [phoneNumberId]: null }));
} else {
try {
const res = await contractApi.getSipCredentials(phoneNumberId);
const password = res.data?.password;
if (password) {
setDecryptedSipPasswords(prev => ({ ...prev, [phoneNumberId]: password }));
setShowSipPasswords(prev => ({ ...prev, [phoneNumberId]: true }));
}
} catch (err) {
alert('SIP-Passwort konnte nicht entschlüsselt werden');
}
}
};
const handleAutoLogin = async () => {
const contract = data?.data;
// Get username from stressfreiEmail or portalUsername
const username = contract?.stressfreiEmail?.email || contract?.portalUsername;
if (!contract?.provider?.portalUrl || !username) {
alert('Portal-URL oder Benutzername fehlt');
return;
}
setIsAutoLoginLoading(true);
try {
// Get decrypted password
const res = await contractApi.getPassword(contractId);
if (!res.data?.password) {
alert('Passwort konnte nicht entschlüsselt werden');
return;
}
const provider = contract.provider;
const baseUrl = provider.portalUrl!; // Already validated above
const usernameField = provider.usernameFieldName || 'username';
const passwordField = provider.passwordFieldName || 'password';
// Build URL with query parameters
const url = new URL(baseUrl);
url.searchParams.set(usernameField, username);
url.searchParams.set(passwordField, res.data.password);
// Open in new tab
window.open(url.toString(), '_blank');
} catch (err) {
alert('Fehler beim Auto-Login');
} finally {
setIsAutoLoginLoading(false);
}
};
if (isLoading) {
return <div className="text-center py-8">Laden...</div>;
}
if (!data?.data) {
return <div className="text-center py-8 text-red-600">Vertrag nicht gefunden</div>;
}
const c = data.data;
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center gap-4 mb-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
// Zurück zur Herkunftsseite navigieren
if (locationState?.from === 'customer' && locationState?.customerId) {
// Kam von Kundendetail -> zurück zum Kunden mit Verträge-Tab
navigate(`/customers/${locationState.customerId}?tab=contracts`);
} else if (locationState?.from === 'cockpit') {
// Kam vom Cockpit -> zurück zum Cockpit (mit Filter falls vorhanden)
const filterParam = locationState.filter ? `?filter=${locationState.filter}` : '';
navigate(`/contracts/cockpit${filterParam}`);
} else if (locationState?.from === 'contracts') {
// Kam von Vertragsliste -> zurück zur Vertragsliste (URL-Parameter bleiben erhalten)
navigate('/contracts');
} else if (c.customer) {
// Fallback: Wenn Kunde vorhanden, zum Kunden
navigate(`/customers/${c.customer.id}?tab=contracts`);
} else {
// Fallback: Zur Vertragsliste
navigate('/contracts');
}
}}
>
<ArrowLeft className="w-4 h-4" />
</Button>
<h1 className="text-2xl font-bold">{c.contractNumber}</h1>
<Badge>{typeLabels[c.type]}</Badge>
<Badge variant={statusVariants[c.status]}>{statusLabels[c.status]}</Badge>
</div>
{c.customer && (
<p className="text-gray-500 ml-10">
Kunde:{' '}
<Link to={`/customers/${c.customer.id}`} className="text-blue-600 hover:underline">
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
</Link>
</p>
)}
</div>
{!isCustomer && (
<div className="flex gap-2">
{c.previousContract && (
<Link to={`/contracts/${c.previousContract.id}`}>
<Button variant="secondary">
<ArrowLeft className="w-4 h-4 mr-2" />
Vorgängervertrag
</Button>
</Link>
)}
{hasPermission('contracts:create') && !c.followUpContract && (
<Button
variant="secondary"
onClick={() => setShowFollowUpConfirm(true)}
disabled={followUpMutation.isPending}
>
<Copy className="w-4 h-4 mr-2" />
{followUpMutation.isPending ? 'Erstelle...' : 'Folgevertrag anlegen'}
</Button>
)}
{c.followUpContract && (
<Link to={`/contracts/${c.followUpContract.id}`}>
<Button variant="secondary">
<ArrowRight className="w-4 h-4 mr-2" />
Folgevertrag anzeigen
</Button>
</Link>
)}
{hasPermission('contracts:update') && (
<Link to={`/contracts/${id}/edit`}>
<Button variant="secondary">
<Edit className="w-4 h-4 mr-2" />
Bearbeiten
</Button>
</Link>
)}
{hasPermission('contracts:delete') && (
<Button
variant="danger"
onClick={() => {
if (confirm('Vertrag wirklich löschen?')) {
deleteMutation.mutate();
}
}}
>
<Trash2 className="w-4 h-4 mr-2" />
Löschen
</Button>
)}
</div>
)}
</div>
{/* Previous Contract Info */}
{c.previousContract && (
<Card className="mb-6 border-l-4 border-l-blue-500" title="Vorgängervertrag">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<dt className="text-sm text-gray-500">Vertragsnummer</dt>
<dd>
<button
onClick={() => setShowPredecessorModal(true)}
className="text-blue-600 hover:underline"
>
{c.previousContract.contractNumber}
</button>
</dd>
</div>
{c.previousContract.providerName && (
<div>
<dt className="text-sm text-gray-500">Anbieter</dt>
<dd>{c.previousContract.providerName}</dd>
</div>
)}
{c.previousContract.customerNumberAtProvider && (
<div>
<dt className="text-sm text-gray-500">Kundennummer</dt>
<dd className="font-mono">{c.previousContract.customerNumberAtProvider}</dd>
</div>
)}
{c.previousContract.portalUsername && (
<div>
<dt className="text-sm text-gray-500">Zugangsdaten</dt>
<dd>{c.previousContract.portalUsername}</dd>
</div>
)}
</div>
</Card>
)}
{/* Cancellation Confirmation Warning */}
{c.cancellationConfirmationDate && (
<div className="mb-6 p-4 bg-red-50 border-2 border-red-400 rounded-lg flex items-start gap-3">
<span className="text-red-600 text-xl font-bold">!</span>
<div>
<p className="font-semibold text-red-800">Kündigungsbestätigung vorhanden</p>
<p className="text-sm text-red-700 mt-1">
Dieser Vertrag hat eine Kündigungsbestätigung vom{' '}
<strong>{new Date(c.cancellationConfirmationDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}</strong>.
{c.cancellationConfirmationOptionsDate && (
<> Optionen-Bestätigung: <strong>{new Date(c.cancellationConfirmationOptionsDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}</strong>.</>
)}
</p>
</div>
</div>
)}
{/* Multisim Warning */}
{c.type === 'MOBILE' && c.mobileDetails?.requiresMultisim && (
<div className="mb-6 p-4 bg-amber-50 border border-amber-300 rounded-lg flex items-start gap-3">
<span className="text-amber-600 text-xl font-bold">!</span>
<div>
<p className="font-semibold text-amber-800">Multisim erforderlich</p>
<p className="text-sm text-amber-700 mt-1">
Dieser Kunde benötigt eine Multisim-Karte. Multisim ist bei Klarmobil, Congstar und Otelo nicht buchbar.
Bitte einen Anbieter wie Freenet oder vergleichbar wählen.
</p>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* Provider & Tariff */}
<Card title="Anbieter & Tarif">
<dl className="space-y-3">
{(c.provider || c.providerName) && (
<div>
<dt className="text-sm text-gray-500">Anbieter</dt>
<dd className="font-medium">{c.provider?.name || c.providerName}</dd>
</div>
)}
{(c.tariff || c.tariffName) && (
<div>
<dt className="text-sm text-gray-500">Tarif</dt>
<dd>{c.tariff?.name || c.tariffName}</dd>
</div>
)}
{c.customerNumberAtProvider && (
<div>
<dt className="text-sm text-gray-500">Kundennummer</dt>
<dd className="font-mono flex items-center gap-1">
{c.customerNumberAtProvider}
<CopyButton value={c.customerNumberAtProvider} />
</dd>
</div>
)}
{c.salesPlatform && (
<div>
<dt className="text-sm text-gray-500">Vertriebsplattform</dt>
<dd>{c.salesPlatform.name}</dd>
</div>
)}
{c.commission !== null && c.commission !== undefined && (
<div>
<dt className="text-sm text-gray-500">Provision</dt>
<dd>{c.commission.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</dd>
</div>
)}
{c.priceFirst12Months && (
<div>
<dt className="text-sm text-gray-500">Preis erste 12 Monate</dt>
<dd>{c.priceFirst12Months}</dd>
</div>
)}
{c.priceFrom13Months && (
<div>
<dt className="text-sm text-gray-500">Preis ab 13. Monat</dt>
<dd>{c.priceFrom13Months}</dd>
</div>
)}
{c.priceAfter24Months && (
<div>
<dt className="text-sm text-gray-500">Preis nach 24 Monaten</dt>
<dd>{c.priceAfter24Months}</dd>
</div>
)}
</dl>
</Card>
{/* Duration & Cancellation */}
<Card title="Laufzeit und Kündigung" className={c.cancellationConfirmationDate ? 'border-2 border-red-400' : ''}>
{c.contractDuration && isUnlimitedDuration(c.contractDuration.code) && (
<p className="text-sm text-gray-500 mb-4 bg-blue-50 border border-blue-200 rounded-lg p-3">
<strong>Hinweis:</strong> Dieser Vertrag gilt als unbefristet mit der jeweiligen Kündigungsfrist.
</p>
)}
<dl className="space-y-3">
{c.startDate && (
<div>
<dt className="text-sm text-gray-500">Vertragsbeginn</dt>
<dd>{new Date(c.startDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}</dd>
</div>
)}
{c.endDate && (
<div>
<dt className="text-sm text-gray-500">Vertragsende</dt>
<dd>{new Date(c.endDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}</dd>
</div>
)}
{c.contractDuration && (
<div>
<dt className="text-sm text-gray-500">Vertragslaufzeit</dt>
<dd>{c.contractDuration.description}</dd>
</div>
)}
{c.cancellationPeriod && (
<div>
<dt className="text-sm text-gray-500">Kündigungsfrist</dt>
<dd>{c.cancellationPeriod.description}</dd>
</div>
)}
{c.cancellationConfirmationDate && (
<div>
<dt className="text-sm text-gray-500">Kündigungsbestätigungsdatum</dt>
<dd>{new Date(c.cancellationConfirmationDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}</dd>
</div>
)}
{c.cancellationConfirmationOptionsDate && (
<div>
<dt className="text-sm text-gray-500">Kündigungsbestätigungsoptionendatum</dt>
<dd>{new Date(c.cancellationConfirmationOptionsDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}</dd>
</div>
)}
{c.wasSpecialCancellation && (
<div>
<dt className="text-sm text-gray-500">Sonderkündigung</dt>
<dd><Badge variant="warning">Ja</Badge></dd>
</div>
)}
</dl>
{/* Kündigungsdokumente */}
{hasPermission('contracts:update') && (
<div className="mt-6 pt-6 border-t">
<h4 className="font-medium mb-4">Kündigungsdokumente</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Kündigungsschreiben */}
<div>
<dt className="text-sm text-gray-500 mb-1">Kündigungsschreiben</dt>
{c.cancellationLetterPath ? (
<div className="flex items-center gap-3 flex-wrap">
<a
href={`/api${c.cancellationLetterPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Eye className="w-4 h-4" />
Anzeigen
</a>
<a
href={`/api${c.cancellationLetterPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Download className="w-4 h-4" />
Download
</a>
<FileUpload
onUpload={async (file) => {
await uploadApi.uploadCancellationLetter(contractId, file);
queryClient.invalidateQueries({ queryKey: ['contract', id] });
}}
existingFile={c.cancellationLetterPath}
accept=".pdf"
label="Ersetzen"
/>
<button
onClick={async () => {
if (confirm('Dokument wirklich löschen?')) {
await uploadApi.deleteCancellationLetter(contractId);
queryClient.invalidateQueries({ queryKey: ['contract', id] });
}
}}
className="text-red-600 hover:underline text-sm flex items-center gap-1"
>
<Trash2 className="w-4 h-4" />
Löschen
</button>
</div>
) : (
<FileUpload
onUpload={async (file) => {
await uploadApi.uploadCancellationLetter(contractId, file);
queryClient.invalidateQueries({ queryKey: ['contract', id] });
}}
accept=".pdf"
label="PDF hochladen"
/>
)}
</div>
{/* Kündigungsbestätigung */}
<div>
<dt className="text-sm text-gray-500 mb-1">Kündigungsbestätigung</dt>
{c.cancellationConfirmationPath ? (
<>
<div className="flex items-center gap-3 flex-wrap">
<a
href={`/api${c.cancellationConfirmationPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Eye className="w-4 h-4" />
Anzeigen
</a>
<a
href={`/api${c.cancellationConfirmationPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Download className="w-4 h-4" />
Download
</a>
<FileUpload
onUpload={async (file) => {
await uploadApi.uploadCancellationConfirmation(contractId, file);
queryClient.invalidateQueries({ queryKey: ['contract', id] });
}}
existingFile={c.cancellationConfirmationPath}
accept=".pdf"
label="Ersetzen"
/>
<button
onClick={async () => {
if (confirm('Dokument wirklich löschen?')) {
await uploadApi.deleteCancellationConfirmation(contractId);
queryClient.invalidateQueries({ queryKey: ['contract', id] });
}
}}
className="text-red-600 hover:underline text-sm flex items-center gap-1"
>
<Trash2 className="w-4 h-4" />
Löschen
</button>
</div>
{/* Datum der Kündigungsbestätigung */}
<div className="mt-2">
<label className="text-xs text-gray-500 block mb-1">Bestätigung erhalten am</label>
<div className="flex items-center gap-2">
<input
type="date"
value={c.cancellationConfirmationDate ? c.cancellationConfirmationDate.split('T')[0] : ''}
onChange={(e) => {
const value = e.target.value || null;
updateCancellationDateMutation.mutate(value);
}}
className="block w-full max-w-[180px] px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{c.cancellationConfirmationDate && (
<button
onClick={() => updateCancellationDateMutation.mutate(null)}
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded"
title="Datum löschen"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</>
) : (
<FileUpload
onUpload={async (file) => {
await uploadApi.uploadCancellationConfirmation(contractId, file);
queryClient.invalidateQueries({ queryKey: ['contract', id] });
}}
accept=".pdf"
label="PDF hochladen"
/>
)}
</div>
{/* Kündigungsschreiben Optionen */}
<div>
<dt className="text-sm text-gray-500 mb-1">Kündigungsschreiben Optionen</dt>
{c.cancellationLetterOptionsPath ? (
<div className="flex items-center gap-3 flex-wrap">
<a
href={`/api${c.cancellationLetterOptionsPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Eye className="w-4 h-4" />
Anzeigen
</a>
<a
href={`/api${c.cancellationLetterOptionsPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Download className="w-4 h-4" />
Download
</a>
<FileUpload
onUpload={async (file) => {
await uploadApi.uploadCancellationLetterOptions(contractId, file);
queryClient.invalidateQueries({ queryKey: ['contract', id] });
}}
existingFile={c.cancellationLetterOptionsPath}
accept=".pdf"
label="Ersetzen"
/>
<button
onClick={async () => {
if (confirm('Dokument wirklich löschen?')) {
await uploadApi.deleteCancellationLetterOptions(contractId);
queryClient.invalidateQueries({ queryKey: ['contract', id] });
}
}}
className="text-red-600 hover:underline text-sm flex items-center gap-1"
>
<Trash2 className="w-4 h-4" />
Löschen
</button>
</div>
) : (
<FileUpload
onUpload={async (file) => {
await uploadApi.uploadCancellationLetterOptions(contractId, file);
queryClient.invalidateQueries({ queryKey: ['contract', id] });
}}
accept=".pdf"
label="PDF hochladen"
/>
)}
</div>
{/* Kündigungsbestätigung Optionen */}
<div>
<dt className="text-sm text-gray-500 mb-1">Kündigungsbestätigung Optionen</dt>
{c.cancellationConfirmationOptionsPath ? (
<>
<div className="flex items-center gap-3 flex-wrap">
<a
href={`/api${c.cancellationConfirmationOptionsPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Eye className="w-4 h-4" />
Anzeigen
</a>
<a
href={`/api${c.cancellationConfirmationOptionsPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Download className="w-4 h-4" />
Download
</a>
<FileUpload
onUpload={async (file) => {
await uploadApi.uploadCancellationConfirmationOptions(contractId, file);
queryClient.invalidateQueries({ queryKey: ['contract', id] });
}}
existingFile={c.cancellationConfirmationOptionsPath}
accept=".pdf"
label="Ersetzen"
/>
<button
onClick={async () => {
if (confirm('Dokument wirklich löschen?')) {
await uploadApi.deleteCancellationConfirmationOptions(contractId);
queryClient.invalidateQueries({ queryKey: ['contract', id] });
}
}}
className="text-red-600 hover:underline text-sm flex items-center gap-1"
>
<Trash2 className="w-4 h-4" />
Löschen
</button>
</div>
{/* Datum der Kündigungsbestätigung Optionen */}
<div className="mt-2">
<label className="text-xs text-gray-500 block mb-1">Bestätigung erhalten am</label>
<div className="flex items-center gap-2">
<input
type="date"
value={c.cancellationConfirmationOptionsDate ? c.cancellationConfirmationOptionsDate.split('T')[0] : ''}
onChange={(e) => {
const value = e.target.value || null;
updateCancellationOptionsDateMutation.mutate(value);
}}
className="block w-full max-w-[180px] px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{c.cancellationConfirmationOptionsDate && (
<button
onClick={() => updateCancellationOptionsDateMutation.mutate(null)}
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded"
title="Datum löschen"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</>
) : (
<FileUpload
onUpload={async (file) => {
await uploadApi.uploadCancellationConfirmationOptions(contractId, file);
queryClient.invalidateQueries({ queryKey: ['contract', id] });
}}
accept=".pdf"
label="PDF hochladen"
/>
)}
</div>
</div>
</div>
)}
</Card>
</div>
{/* Portal Credentials */}
{(c.portalUsername || c.stressfreiEmail || c.portalPasswordEncrypted) && (
<Card className="mb-6" title="Zugangsdaten">
<dl className="grid grid-cols-2 gap-4">
{(c.portalUsername || c.stressfreiEmail) && (
<div>
<dt className="text-sm text-gray-500">
Benutzername
{c.stressfreiEmail && (
<span className="ml-2 text-xs text-blue-600">(Stressfrei-Wechseln)</span>
)}
</dt>
<dd className="font-mono flex items-center gap-1">
{c.stressfreiEmail?.email || c.portalUsername}
<CopyButton value={c.stressfreiEmail?.email || c.portalUsername || ''} />
</dd>
</div>
)}
{c.portalPasswordEncrypted && (
<div>
<dt className="text-sm text-gray-500">Passwort</dt>
<dd className="flex items-center gap-2">
<span className="font-mono">
{showPassword && decryptedPassword ? decryptedPassword : '••••••••'}
</span>
{showPassword && decryptedPassword && (
<CopyButton value={decryptedPassword} />
)}
<Button variant="ghost" size="sm" onClick={handleShowPassword}>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</dd>
</div>
)}
</dl>
{/* Auto-Login Button */}
{c.provider?.portalUrl && (c.portalUsername || c.stressfreiEmail) && c.portalPasswordEncrypted && (
<div className="mt-4 pt-4 border-t">
<Button
onClick={handleAutoLogin}
disabled={isAutoLoginLoading}
className="w-full sm:w-auto"
>
<ExternalLink className="w-4 h-4 mr-2" />
{isAutoLoginLoading ? 'Wird geöffnet...' : 'Zum Kundenportal (Auto-Login)'}
</Button>
<p className="text-xs text-gray-500 mt-2">
Öffnet das Portal mit vorausgefüllten Zugangsdaten
</p>
</div>
)}
</Card>
)}
{/* Linked Data */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 mb-6">
{c.address && (
<Card title="Lieferadresse">
<CopyableBlock
values={[
`${c.address.street} ${c.address.houseNumber}`,
`${c.address.postalCode} ${c.address.city}`,
c.address.country
]}
>
<p>
{c.address.street} {c.address.houseNumber}
</p>
<p>
{c.address.postalCode} {c.address.city}
</p>
<p className="text-gray-500">{c.address.country}</p>
</CopyableBlock>
</Card>
)}
{/* Rechnungsadresse: Falls nicht gesetzt, Lieferadresse anzeigen */}
{(c.billingAddress || c.address) && (
<Card title="Rechnungsadresse">
{(() => {
const addr = c.billingAddress || c.address;
if (!addr) return null;
return (
<CopyableBlock
values={[
`${addr.street} ${addr.houseNumber}`,
`${addr.postalCode} ${addr.city}`,
addr.country
]}
>
<p>
{addr.street} {addr.houseNumber}
</p>
<p>
{addr.postalCode} {addr.city}
</p>
<p className="text-gray-500">{addr.country}</p>
{!c.billingAddress && c.address && (
<p className="text-xs text-gray-400 mt-1">(wie Lieferadresse)</p>
)}
</CopyableBlock>
);
})()}
</Card>
)}
{c.bankCard && (
<Card title="Bankkarte">
<p className="font-medium">{c.bankCard.accountHolder}</p>
<p className="font-mono flex items-center gap-1">
{c.bankCard.iban}
<CopyButton value={c.bankCard.iban} />
</p>
{c.bankCard.bankName && <p className="text-gray-500">{c.bankCard.bankName}</p>}
</Card>
)}
{c.identityDocument && (
<Card title="Ausweis">
<p className="font-mono flex items-center gap-1">
{c.identityDocument.documentNumber}
<CopyButton value={c.identityDocument.documentNumber} />
</p>
<p className="text-gray-500">{c.identityDocument.type}</p>
</Card>
)}
</div>
{/* Type-specific details */}
{c.energyDetails && (
<Card className="mb-6" title={c.type === 'ELECTRICITY' ? 'Strom-Details' : 'Gas-Details'}>
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
{c.energyDetails.meter && (
<div>
<dt className="text-sm text-gray-500">Zählernummer</dt>
<dd className="font-mono flex items-center gap-1">
{c.energyDetails.meter.meterNumber}
<CopyButton value={c.energyDetails.meter.meterNumber} />
</dd>
</div>
)}
{c.energyDetails.maloId && (
<div>
<dt className="text-sm text-gray-500">MaLo-ID</dt>
<dd className="font-mono flex items-center gap-1">
{c.energyDetails.maloId}
<CopyButton value={c.energyDetails.maloId} />
</dd>
</div>
)}
{c.energyDetails.annualConsumption && (
<div>
<dt className="text-sm text-gray-500">
Jahresverbrauch {c.type === 'ELECTRICITY' ? '' : '(m³)'}
</dt>
<dd>
{c.energyDetails.annualConsumption.toLocaleString('de-DE')}{' '}
{c.type === 'ELECTRICITY' ? 'kWh' : 'm³'}
</dd>
</div>
)}
{c.type === 'GAS' && c.energyDetails.annualConsumptionKwh && (
<div>
<dt className="text-sm text-gray-500">Jahresverbrauch (kWh)</dt>
<dd>{c.energyDetails.annualConsumptionKwh.toLocaleString('de-DE')} kWh</dd>
</div>
)}
{c.energyDetails.basePrice != null && (
<div>
<dt className="text-sm text-gray-500">Grundpreis</dt>
<dd>{c.energyDetails.basePrice.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 10 })} /Monat</dd>
</div>
)}
{c.energyDetails.unitPrice != null && (
<div>
<dt className="text-sm text-gray-500">Arbeitspreis</dt>
<dd>
{c.energyDetails.unitPrice.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 10 })} /kWh
</dd>
</div>
)}
{c.energyDetails.bonus && (
<div>
<dt className="text-sm text-gray-500">Bonus</dt>
<dd>{c.energyDetails.bonus.toLocaleString('de-DE')} </dd>
</div>
)}
{c.energyDetails.previousProviderName && (
<div>
<dt className="text-sm text-gray-500">Vorversorger</dt>
<dd>{c.energyDetails.previousProviderName}</dd>
</div>
)}
{c.energyDetails.previousCustomerNumber && (
<div>
<dt className="text-sm text-gray-500">Vorherige Kundennr.</dt>
<dd className="font-mono flex items-center gap-1">
{c.energyDetails.previousCustomerNumber}
<CopyButton value={c.energyDetails.previousCustomerNumber} />
</dd>
</div>
)}
</dl>
{/* Zählerstände */}
{c.energyDetails.meter && (
<MeterReadingsSection
meterId={c.energyDetails.meter.id}
meterType={c.energyDetails.meter.type}
readings={c.energyDetails.meter.readings || []}
contractId={contractId}
canEdit={hasPermission('contracts:update') && !isCustomer}
/>
)}
</Card>
)}
{c.internetDetails && (
<Card className="mb-6" title={
c.type === 'DSL' ? 'DSL-Details' :
c.type === 'CABLE' ? 'Kabelinternet-Details' :
'Glasfaser-Details'
}>
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
{c.internetDetails.downloadSpeed && (
<div>
<dt className="text-sm text-gray-500">Download</dt>
<dd>{c.internetDetails.downloadSpeed} Mbit/s</dd>
</div>
)}
{c.internetDetails.uploadSpeed && (
<div>
<dt className="text-sm text-gray-500">Upload</dt>
<dd>{c.internetDetails.uploadSpeed} Mbit/s</dd>
</div>
)}
{c.internetDetails.routerModel && (
<div>
<dt className="text-sm text-gray-500">Router</dt>
<dd>{c.internetDetails.routerModel}</dd>
</div>
)}
{c.internetDetails.routerSerialNumber && (
<div>
<dt className="text-sm text-gray-500">Router S/N</dt>
<dd className="font-mono flex items-center gap-1">
{c.internetDetails.routerSerialNumber}
<CopyButton value={c.internetDetails.routerSerialNumber} />
</dd>
</div>
)}
{c.internetDetails.installationDate && (
<div>
<dt className="text-sm text-gray-500">Installation</dt>
<dd>{new Date(c.internetDetails.installationDate).toLocaleDateString('de-DE')}</dd>
</div>
)}
{c.internetDetails.homeId && (
<div>
<dt className="text-sm text-gray-500">Home-ID</dt>
<dd className="font-mono flex items-center gap-1">
{c.internetDetails.homeId}
<CopyButton value={c.internetDetails.homeId} />
</dd>
</div>
)}
{c.internetDetails.activationCode && (
<div>
<dt className="text-sm text-gray-500">Aktivierungscode</dt>
<dd className="font-mono flex items-center gap-1">
{c.internetDetails.activationCode}
<CopyButton value={c.internetDetails.activationCode} />
</dd>
</div>
)}
</dl>
{/* Internet-Zugangsdaten */}
{(c.internetDetails.internetUsername || c.internetDetails.internetPasswordEncrypted) && (
<div className="mt-4 pt-4 border-t">
<h4 className="text-sm font-medium text-gray-700 mb-3">Internet-Zugangsdaten</h4>
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
{c.internetDetails.internetUsername && (
<div>
<dt className="text-sm text-gray-500">Benutzername</dt>
<dd className="font-mono flex items-center gap-1">
{c.internetDetails.internetUsername}
<CopyButton value={c.internetDetails.internetUsername} />
</dd>
</div>
)}
{c.internetDetails.internetPasswordEncrypted && (
<div>
<dt className="text-sm text-gray-500">Passwort</dt>
<dd className="flex items-center gap-2">
<span className="font-mono">
{showInternetPassword && decryptedInternetPassword
? decryptedInternetPassword
: '••••••••'}
</span>
{showInternetPassword && decryptedInternetPassword && (
<CopyButton value={decryptedInternetPassword} />
)}
<Button variant="ghost" size="sm" onClick={handleShowInternetPassword}>
{showInternetPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</dd>
</div>
)}
</dl>
</div>
)}
{/* Rufnummern mit SIP-Daten */}
{c.internetDetails.phoneNumbers && c.internetDetails.phoneNumbers.length > 0 && (
<div className="mt-4 pt-4 border-t">
<h4 className="text-sm font-medium text-gray-700 mb-3">Rufnummern & SIP-Zugangsdaten</h4>
<div className="space-y-3">
{c.internetDetails.phoneNumbers.map((pn) => (
<div key={pn.id} className="p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="font-mono font-medium flex items-center gap-1">
{pn.phoneNumber}
<CopyButton value={pn.phoneNumber} />
</span>
{pn.isMain && <Badge variant="success">Hauptnummer</Badge>}
</div>
{(pn.sipUsername || pn.sipPasswordEncrypted || pn.sipServer) && (
<dl className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
{pn.sipUsername && (
<div>
<dt className="text-gray-500">SIP-Benutzer</dt>
<dd className="font-mono flex items-center gap-1">
{pn.sipUsername}
<CopyButton value={pn.sipUsername} />
</dd>
</div>
)}
{pn.sipPasswordEncrypted && (
<div>
<dt className="text-gray-500">SIP-Passwort</dt>
<dd className="flex items-center gap-2">
<span className="font-mono">
{showSipPasswords[pn.id] && decryptedSipPasswords[pn.id]
? decryptedSipPasswords[pn.id]
: '••••••••'}
</span>
{showSipPasswords[pn.id] && decryptedSipPasswords[pn.id] && (
<CopyButton value={decryptedSipPasswords[pn.id]!} />
)}
<Button variant="ghost" size="sm" onClick={() => handleShowSipPassword(pn.id)}>
{showSipPasswords[pn.id] ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
</Button>
</dd>
</div>
)}
{pn.sipServer && (
<div>
<dt className="text-gray-500">SIP-Server</dt>
<dd className="font-mono flex items-center gap-1">
{pn.sipServer}
<CopyButton value={pn.sipServer} />
</dd>
</div>
)}
</dl>
)}
</div>
))}
</div>
</div>
)}
</Card>
)}
{c.mobileDetails && (
<Card className="mb-6" title="Mobilfunk-Details">
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
{c.mobileDetails.dataVolume && (
<div>
<dt className="text-sm text-gray-500">Datenvolumen</dt>
<dd>{c.mobileDetails.dataVolume} GB</dd>
</div>
)}
{c.mobileDetails.includedMinutes && (
<div>
<dt className="text-sm text-gray-500">Inklusiv-Minuten</dt>
<dd>{c.mobileDetails.includedMinutes}</dd>
</div>
)}
{c.mobileDetails.includedSMS && (
<div>
<dt className="text-sm text-gray-500">Inklusiv-SMS</dt>
<dd>{c.mobileDetails.includedSMS}</dd>
</div>
)}
{c.mobileDetails.deviceModel && (
<div>
<dt className="text-sm text-gray-500">Gerät</dt>
<dd>{c.mobileDetails.deviceModel}</dd>
</div>
)}
{c.mobileDetails.deviceImei && (
<div>
<dt className="text-sm text-gray-500">IMEI</dt>
<dd className="font-mono flex items-center gap-1">
{c.mobileDetails.deviceImei}
<CopyButton value={c.mobileDetails.deviceImei} />
</dd>
</div>
)}
{c.mobileDetails.requiresMultisim && (
<div>
<dt className="text-sm text-gray-500">Multisim</dt>
<dd><Badge variant="warning">Erforderlich</Badge></dd>
</div>
)}
</dl>
{/* SIM-Karten */}
{c.mobileDetails.simCards && c.mobileDetails.simCards.length > 0 && (
<div className="mt-6 pt-6 border-t">
<h4 className="font-medium mb-4">SIM-Karten</h4>
<div className="space-y-3">
{c.mobileDetails.simCards.map((simCard) => (
<SimCardDisplay key={simCard.id} simCard={simCard} />
))}
</div>
</div>
)}
{/* Legacy: Alte Felder falls keine simCards vorhanden */}
{(!c.mobileDetails.simCards || c.mobileDetails.simCards.length === 0) &&
(c.mobileDetails.phoneNumber || c.mobileDetails.simCardNumber) && (
<div className="mt-6 pt-6 border-t">
<h4 className="font-medium mb-4">SIM-Karte (Legacy)</h4>
<dl className="grid grid-cols-2 gap-4">
{c.mobileDetails.phoneNumber && (
<div>
<dt className="text-sm text-gray-500">Rufnummer</dt>
<dd className="font-mono flex items-center gap-1">
{c.mobileDetails.phoneNumber}
<CopyButton value={c.mobileDetails.phoneNumber} />
</dd>
</div>
)}
{c.mobileDetails.simCardNumber && (
<div>
<dt className="text-sm text-gray-500">SIM-Kartennummer</dt>
<dd className="font-mono flex items-center gap-1">
{c.mobileDetails.simCardNumber}
<CopyButton value={c.mobileDetails.simCardNumber} />
</dd>
</div>
)}
</dl>
</div>
)}
</Card>
)}
{c.tvDetails && (
<Card className="mb-6" title="TV-Details">
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
{c.tvDetails.receiverModel && (
<div>
<dt className="text-sm text-gray-500">Receiver</dt>
<dd>{c.tvDetails.receiverModel}</dd>
</div>
)}
{c.tvDetails.smartcardNumber && (
<div>
<dt className="text-sm text-gray-500">Smartcard</dt>
<dd className="font-mono flex items-center gap-1">
{c.tvDetails.smartcardNumber}
<CopyButton value={c.tvDetails.smartcardNumber} />
</dd>
</div>
)}
{c.tvDetails.package && (
<div>
<dt className="text-sm text-gray-500">Paket</dt>
<dd>{c.tvDetails.package}</dd>
</div>
)}
</dl>
</Card>
)}
{c.carInsuranceDetails && (
<Card className="mb-6" title="KFZ-Versicherung Details">
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
{c.carInsuranceDetails.licensePlate && (
<div>
<dt className="text-sm text-gray-500">Kennzeichen</dt>
<dd className="font-mono font-bold flex items-center gap-1">
{c.carInsuranceDetails.licensePlate}
<CopyButton value={c.carInsuranceDetails.licensePlate} />
</dd>
</div>
)}
{c.carInsuranceDetails.vehicleType && (
<div>
<dt className="text-sm text-gray-500">Fahrzeug</dt>
<dd>{c.carInsuranceDetails.vehicleType}</dd>
</div>
)}
{c.carInsuranceDetails.hsn && (
<div>
<dt className="text-sm text-gray-500">HSN/TSN</dt>
<dd className="font-mono flex items-center gap-1">
{c.carInsuranceDetails.hsn}/{c.carInsuranceDetails.tsn}
<CopyButton value={`${c.carInsuranceDetails.hsn}/${c.carInsuranceDetails.tsn}`} />
</dd>
</div>
)}
{c.carInsuranceDetails.vin && (
<div>
<dt className="text-sm text-gray-500">FIN</dt>
<dd className="font-mono text-sm flex items-center gap-1">
{c.carInsuranceDetails.vin}
<CopyButton value={c.carInsuranceDetails.vin} />
</dd>
</div>
)}
{c.carInsuranceDetails.firstRegistration && (
<div>
<dt className="text-sm text-gray-500">Erstzulassung</dt>
<dd>
{new Date(c.carInsuranceDetails.firstRegistration).toLocaleDateString('de-DE')}
</dd>
</div>
)}
{c.carInsuranceDetails.noClaimsClass && (
<div>
<dt className="text-sm text-gray-500">SF-Klasse</dt>
<dd>{c.carInsuranceDetails.noClaimsClass}</dd>
</div>
)}
<div>
<dt className="text-sm text-gray-500">Versicherungsart</dt>
<dd>
<Badge
variant={
c.carInsuranceDetails.insuranceType === 'FULL'
? 'success'
: c.carInsuranceDetails.insuranceType === 'PARTIAL'
? 'warning'
: 'default'
}
>
{c.carInsuranceDetails.insuranceType === 'FULL'
? 'Vollkasko'
: c.carInsuranceDetails.insuranceType === 'PARTIAL'
? 'Teilkasko'
: 'Haftpflicht'}
</Badge>
</dd>
</div>
{c.carInsuranceDetails.deductiblePartial && (
<div>
<dt className="text-sm text-gray-500">SB Teilkasko</dt>
<dd>{c.carInsuranceDetails.deductiblePartial} </dd>
</div>
)}
{c.carInsuranceDetails.deductibleFull && (
<div>
<dt className="text-sm text-gray-500">SB Vollkasko</dt>
<dd>{c.carInsuranceDetails.deductibleFull} </dd>
</div>
)}
{c.carInsuranceDetails.policyNumber && (
<div>
<dt className="text-sm text-gray-500">Versicherungsschein-Nr.</dt>
<dd className="font-mono flex items-center gap-1">
{c.carInsuranceDetails.policyNumber}
<CopyButton value={c.carInsuranceDetails.policyNumber} />
</dd>
</div>
)}
{c.carInsuranceDetails.previousInsurer && (
<div>
<dt className="text-sm text-gray-500">Vorversicherer</dt>
<dd>{c.carInsuranceDetails.previousInsurer}</dd>
</div>
)}
</dl>
</Card>
)}
{/* Contract Tasks */}
<ContractTasksSection
contractId={contractId}
canEdit={hasPermission('contracts:update')}
isCustomerPortal={isCustomerPortal}
/>
{/* Zugeordnete E-Mails */}
{!isCustomerPortal && hasPermission('contracts:read') && c.customerId && (
<ContractEmailsSection
contractId={contractId}
customerId={c.customerId}
/>
)}
{c.notes && (
<Card title="Notizen">
<p className="whitespace-pre-wrap">{c.notes}</p>
</Card>
)}
{/* Vorgängervertrag Modal */}
{showPredecessorModal && c.previousContract && (
<ContractDetailModal
contractId={c.previousContract.id}
isOpen={true}
onClose={() => setShowPredecessorModal(false)}
/>
)}
{/* Folgevertrag Bestätigung */}
<Modal
isOpen={showFollowUpConfirm}
onClose={() => setShowFollowUpConfirm(false)}
title="Folgevertrag anlegen"
size="sm"
>
<div className="space-y-4">
<p className="text-gray-700">
Möchten Sie wirklich einen Folgevertrag für diesen Vertrag anlegen?
</p>
<p className="text-sm text-gray-500">
Die Daten des aktuellen Vertrags werden als Vorlage übernommen.
</p>
<div className="flex justify-end gap-3 pt-2">
<Button
variant="secondary"
onClick={() => setShowFollowUpConfirm(false)}
>
Nein
</Button>
<Button
onClick={() => {
setShowFollowUpConfirm(false);
followUpMutation.mutate();
}}
>
Ja, anlegen
</Button>
</div>
</div>
</Modal>
</div>
);
}