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

2785 lines
109 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, gdprApi } from '../../services/api';
import { ContractEmailsSection } from '../../components/email';
import { ContractDetailModal, ContractHistorySection } from '../../components/contracts';
import InvoicesSection from '../../components/contracts/InvoicesSection';
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, Calculator, Info, X, BellOff, Lock, Shield } from 'lucide-react';
import { calculateConsumption, calculateCosts } from '../../utils/energyCalculations';
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',
};
// Status-Erklärungen für Info-Modal
const statusDescriptions = [
{ status: 'DRAFT', label: 'Entwurf', description: 'Vertrag wird noch vorbereitet', color: 'text-gray-600' },
{ status: 'PENDING', label: 'Ausstehend', description: 'Wartet auf Aktivierung', color: 'text-yellow-600' },
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
{ status: 'CANCELLED', label: 'Gekündigt', description: 'Aktive Kündigung eingereicht, Vertrag endet', color: 'text-red-600' },
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
];
function StatusInfoModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/20" onClick={onClose} />
<div className="relative bg-white rounded-lg shadow-xl p-4 max-w-sm w-full mx-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900">Vertragsstatus-Übersicht</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-2">
{statusDescriptions.map(({ status, label, description, color }) => (
<div key={status} className="flex items-start gap-2">
<span className={`font-medium text-sm min-w-[90px] ${color}`}>{label}</span>
<span className="text-sm text-gray-600">{description}</span>
</div>
))}
</div>
</div>
</div>
);
}
// 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>
);
}
// Energy Consumption and Cost Calculation Component
function EnergyConsumptionCalculation({
contractType,
readings,
startDate,
endDate,
basePrice,
unitPrice,
bonus,
}: {
contractType: 'ELECTRICITY' | 'GAS';
readings: MeterReading[];
startDate: string;
endDate: string;
basePrice?: number;
unitPrice?: number;
bonus?: number;
}) {
// Berechnung durchführen
const consumption = calculateConsumption(readings, startDate, endDate, contractType);
const costs = consumption.consumptionKwh > 0
? calculateCosts(consumption.consumptionKwh, basePrice, unitPrice, bonus)
: null;
// Nichts anzeigen wenn keine Daten
if (consumption.type === 'none') return null;
const formatNumber = (num: number, decimals: number = 2) =>
num.toLocaleString('de-DE', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
const formatDate = (dateStr: string) =>
new Date(dateStr).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
return (
<div className="mt-4 pt-4 border-t">
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<Calculator className="w-4 h-4 text-gray-500" />
<h4 className="text-sm font-medium text-gray-700">Verbrauch & Kosten</h4>
{consumption.type === 'exact' && (
<Badge variant="success">Exakt</Badge>
)}
{consumption.type === 'projected' && (
<Badge variant="warning">Hochrechnung</Badge>
)}
</div>
{/* Fall C: Unzureichende Daten */}
{consumption.type === 'insufficient' ? (
<p className="text-sm text-gray-500 italic">{consumption.message}</p>
) : (
<div className="bg-gray-50 rounded-lg p-4 space-y-4">
{/* Verbrauchsanzeige */}
<div>
<h5 className="text-sm font-medium text-gray-600 mb-2">
Berechneter Verbrauch
{consumption.type === 'projected' && ' (hochgerechnet)'}
</h5>
<div className="text-lg font-semibold text-gray-900">
{contractType === 'GAS' ? (
<>
<span className="font-mono">{formatNumber(consumption.consumptionM3 || 0)} m³</span>
<span className="text-gray-500 text-sm ml-2">
= {formatNumber(consumption.consumptionKwh)} kWh
</span>
</>
) : (
<span className="font-mono">{formatNumber(consumption.consumptionKwh)} kWh</span>
)}
</div>
{consumption.startReading && consumption.endReading && (
<p className="text-xs text-gray-400 mt-1">
Basierend auf Zählerständen vom {formatDate(consumption.startReading.readingDate)} bis {formatDate(consumption.endReading.readingDate)}
</p>
)}
</div>
{/* Kostenrechnung */}
{costs && (
<div className="border-t border-gray-200 pt-4">
<h5 className="text-sm font-medium text-gray-600 mb-3">Kostenvorschau</h5>
<div className="space-y-2 text-sm">
{/* Grundpreis */}
{basePrice != null && basePrice > 0 && (
<div className="flex justify-between">
<span className="text-gray-600">
Grundpreis: {formatNumber(basePrice)} /Mon × 12
</span>
<span className="font-mono">{formatNumber(costs.annualBaseCost)} </span>
</div>
)}
{/* Arbeitspreis */}
{unitPrice != null && unitPrice > 0 && (
<div className="flex justify-between">
<span className="text-gray-600">
Arbeitspreis: {formatNumber(consumption.consumptionKwh)} kWh × {formatNumber(unitPrice, 4)}
</span>
<span className="font-mono">{formatNumber(costs.annualConsumptionCost)} </span>
</div>
)}
{/* Trennlinie */}
<div className="border-t border-gray-300 pt-2">
<div className="flex justify-between font-medium">
<span className="text-gray-700">Jahreskosten</span>
<span className="font-mono">{formatNumber(costs.annualTotalCost)} </span>
</div>
</div>
{/* Bonus */}
{costs.bonus != null && costs.bonus > 0 && (
<>
<div className="flex justify-between text-green-600">
<span>Bonus</span>
<span className="font-mono">- {formatNumber(costs.bonus)} </span>
</div>
<div className="border-t border-gray-300 pt-2">
<div className="flex justify-between font-semibold">
<span className="text-gray-800">Effektive Jahreskosten</span>
<span className="font-mono">{formatNumber(costs.effectiveAnnualCost)} </span>
</div>
</div>
</>
)}
{/* Monatlicher Abschlag */}
<div className="border-t border-gray-300 pt-2 mt-2">
<div className="flex justify-between text-blue-700 font-semibold">
<span>Monatlicher Abschlag</span>
<span className="font-mono">{formatNumber(costs.monthlyPayment)} </span>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}
// 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>
);
}
export default function ContractDetail() {
const { id } = useParams();
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const backTo = (location.state as any)?.from as string | undefined;
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);
// Status-Info Modal
const [showStatusInfo, setShowStatusInfo] = useState(false);
// Un-Snooze Bestätigungsmodal
const [showUnsnoozeConfirm, setShowUnsnoozeConfirm] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['contract', id],
queryFn: () => contractApi.getById(contractId),
});
// Consent-Check für den Kunden des Vertrags (nur für Mitarbeiter relevant)
const contractCustomerId = data?.data?.customerId;
const { data: consentStatusData } = useQuery({
queryKey: ['consent-status', contractCustomerId],
queryFn: () => gdprApi.checkConsentStatus(contractCustomerId!),
enabled: !!contractCustomerId && !isCustomerPortal,
});
const hasConsentApproval = isCustomerPortal || (consentStatusData?.data?.hasConsent ?? true);
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'}`);
},
});
// Un-Snooze Mutation
const unsnoozeMutation = useMutation({
mutationFn: () => contractApi.snooze(contractId, {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract', id] });
queryClient.invalidateQueries({ queryKey: ['contract-cockpit'] });
setShowUnsnoozeConfirm(false);
},
onError: (error) => {
console.error('Un-Snooze Fehler:', error);
alert(`Fehler beim Aufheben der Zurückstellung: ${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;
// Consent-Sperrung: Vertrag nicht anzeigen wenn Kunde keine Einwilligung hat
if (!hasConsentApproval) {
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" size="sm" onClick={() => navigate(backTo || (isCustomerPortal ? '/contracts' : (c.customer ? `/customers/${c.customer.id}?tab=contracts` : '/contracts')))}>
<ArrowLeft className="w-4 h-4" />
</Button>
<h1 className="text-2xl font-bold">Vertrag {c.contractNumber}</h1>
</div>
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="bg-amber-50 border border-amber-200 rounded-full p-4 mb-4">
<Lock className="w-8 h-8 text-amber-500" />
</div>
<h2 className="text-lg font-semibold text-gray-800 mb-2">
Datenschutz-Einwilligung erforderlich
</h2>
<p className="text-sm text-gray-600 mb-6 max-w-md">
Die Vertragsdaten können nicht angezeigt werden, da der Kunde der Datenschutzerklärung noch nicht zugestimmt hat.
</p>
<Link
to={`/customers/${c.customerId}?tab=consents`}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
<Shield className="w-4 h-4" />
Zum Kunden: Einwilligungen / Datenschutz
</Link>
</div>
</div>
);
}
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={() => navigate(backTo || (isCustomerPortal ? '/contracts' : (c.customer ? `/customers/${c.customer.id}?tab=contracts` : '/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>
<button
onClick={() => setShowStatusInfo(true)}
className="text-gray-400 hover:text-blue-600 transition-colors"
title="Status-Erklärung"
>
<Info className="w-4 h-4" />
</button>
{/* Snooze-Hinweis wenn nextReviewDate in der Zukunft */}
{c.nextReviewDate && new Date(c.nextReviewDate) > new Date() && (
<div className="flex items-center gap-1 px-2 py-1 bg-amber-100 text-amber-800 rounded-full text-xs">
<BellOff className="w-3 h-3" />
<span>Zurückgestellt bis {new Date(c.nextReviewDate).toLocaleDateString('de-DE')}</span>
{hasPermission('contracts:update') && (
<button
onClick={() => setShowUnsnoozeConfirm(true)}
className="ml-1 p-0.5 hover:bg-amber-200 rounded"
title="Zurückstellung aufheben"
>
<X className="w-3 h-3" />
</button>
)}
</div>
)}
</div>
{c.customer && (
<p className="text-gray-500 ml-10">
Kunde:{' '}
<Link to={`/customers/${c.customer.id}`} state={{ from: `/contracts/${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`} state={{ from: `/contracts/${id}` }}>
<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.contractNumberAtProvider && (
<div>
<dt className="text-sm text-gray-500">Vertragsnummer</dt>
<dd className="font-mono">{c.previousContract.contractNumberAtProvider}</dd>
</div>
)}
{c.previousContract.portalUsername && (
<div>
<dt className="text-sm text-gray-500">Zugangsdaten</dt>
<dd>{c.previousContract.portalUsername}</dd>
</div>
)}
</div>
</Card>
)}
{/* Altanbieter-Info (nur wenn KEIN Vorgängervertrag aber Altanbieter-Daten vorhanden) */}
{!c.previousContract && (c.previousProvider || c.previousCustomerNumber || c.previousContractNumber) && (
<Card className="mb-6 border-l-4 border-l-gray-400" title="Altanbieter">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{c.previousProvider && (
<div>
<dt className="text-sm text-gray-500">Anbieter</dt>
<dd>{c.previousProvider.name}</dd>
</div>
)}
{c.previousCustomerNumber && (
<div>
<dt className="text-sm text-gray-500">Kundennummer</dt>
<dd className="font-mono flex items-center gap-1">
{c.previousCustomerNumber}
<CopyButton value={c.previousCustomerNumber} />
</dd>
</div>
)}
{c.previousContractNumber && (
<div>
<dt className="text-sm text-gray-500">Vertragsnummer</dt>
<dd className="font-mono flex items-center gap-1">
{c.previousContractNumber}
<CopyButton value={c.previousContractNumber} />
</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.contractNumberAtProvider && (
<div>
<dt className="text-sm text-gray-500">Vertragsnummer</dt>
<dd className="font-mono flex items-center gap-1">
{c.contractNumberAtProvider}
<CopyButton value={c.contractNumberAtProvider} />
</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}
/>
)}
{/* Verbrauchsberechnung & Kostenvorschau */}
{c.energyDetails.meter && c.startDate && c.endDate && (
<EnergyConsumptionCalculation
contractType={c.type as 'ELECTRICITY' | 'GAS'}
readings={c.energyDetails.meter.readings || []}
startDate={c.startDate}
endDate={c.endDate}
basePrice={c.energyDetails.basePrice}
unitPrice={c.energyDetails.unitPrice}
bonus={c.energyDetails.bonus}
/>
)}
{/* Rechnungen */}
<InvoicesSection
ecdId={c.energyDetails.id}
invoices={c.energyDetails.invoices || []}
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>
)}
{/* Vertragshistorie (nur für Mitarbeiter) */}
{!isCustomerPortal && hasPermission('contracts:read') && (
<ContractHistorySection
contractId={contractId}
canEdit={hasPermission('contracts:update')}
/>
)}
{/* 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>
{/* Status-Info Modal */}
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
{/* Un-Snooze Bestätigungsmodal */}
<Modal
isOpen={showUnsnoozeConfirm}
onClose={() => setShowUnsnoozeConfirm(false)}
title="Zurückstellung aufheben?"
>
<div className="space-y-4">
<p className="text-gray-700">
Möchten Sie die Zurückstellung für diesen Vertrag wirklich aufheben?
</p>
<p className="text-sm text-gray-500">
Der Vertrag wird danach wieder im Cockpit angezeigt, wenn Fristen anstehen oder abgelaufen sind.
</p>
<div className="flex justify-end gap-3 pt-4">
<Button variant="secondary" onClick={() => setShowUnsnoozeConfirm(false)}>
Abbrechen
</Button>
<Button
variant="danger"
onClick={() => unsnoozeMutation.mutate()}
disabled={unsnoozeMutation.isPending}
>
{unsnoozeMutation.isPending ? 'Wird aufgehoben...' : 'Ja, aufheben'}
</Button>
</div>
</div>
</Modal>
</div>
);
}