2785 lines
109 KiB
TypeScript
2785 lines
109 KiB
TypeScript
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>
|
||
);
|
||
}
|