Files
opencrm/frontend/src/pages/contracts/ContractList.tsx
T
duffyduckandClaude Opus 4.8 fc3131059b Neuer Vertragsstatus "Gekuendigt / bestaetigt" + Kuendigungs-Workflow
Bisheriges "Gekuendigt" (CANCELLED) umbenannt in "Gekuendigt / Bestaetigung
abwarten" und wird jetzt automatisch gesetzt, sobald ein Kuendigungsschreiben
hochgeladen wird. Neuer Status CANCELLED_CONFIRMED ("Gekuendigt / bestaetigt")
wird automatisch gesetzt, sobald ein Kuendigungsbestaetigungsdatum vorliegt
(Dokument fuellt das Datum oder manuell) + Vertragsende = Kuendigungsdatum.

Schema: Enum-Wert CANCELLED_CONFIRMED + Migration (idempotentes MODIFY COLUMN);
Daten-Migration hebt bestehende CANCELLED (alte Logik: nur bei Bestaetigung
gesetzt) auf CANCELLED_CONFIRMED.

Backend: neue Trigger maybeMarkAwaitingConfirmationOnLetter (Schreiben->CANCELLED)
im Upload-Handler; maybeCancelOnCancellationConfirmation setzt jetzt
CANCELLED_CONFIRMED (auch aus CANCELLED). Cockpit-Semantik mitgewandert
(Fristen-Skip/"beendet" fuer CANCELLED_CONFIRMED; Ladeliste + Kuendigungs-
bestaetigungs-Filter erweitert).

Frontend: Labels/Farben/Status-Erklaerungen + Status-Dropdown in ContractList,
ContractDetail, ContractForm, ContractDetailModal, CustomerDetail
(CANCELLED orange "abwarten", CANCELLED_CONFIRMED rot).

Verifiziert: tsc+build gruen; Schreiben->CANCELLED, Bestaetigung->
CANCELLED_CONFIRMED+Enddatum; Daten-Migration idempotent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 18:14:34 +02:00

639 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query';
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
import { pushHistory } from '../../utils/navigation';
import { contractApi, ContractTreeNode } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import Badge from '../../components/ui/Badge';
import CopyButton from '../../components/ui/CopyButton';
import { Plus, Search, Eye, EyeOff, Edit, Trash2, User, Users, ChevronDown, ChevronRight, Info, X, ShieldAlert } from 'lucide-react';
import { gdprApi } from '../../services/api';
import { formatDate } from '../../utils/dateFormat';
import { getContractTypeInfo } from '../../utils/contractInfo';
import type { Contract, ContractType, ContractStatus } 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 / Bestätigung abwarten',
CANCELLED_CONFIRMED: 'Gekündigt / bestätigt',
EXPIRED: 'Abgelaufen',
DEACTIVATED: 'Deaktiviert',
ONGOING: 'Läuft weiter (unbestimmt)',
};
const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' | 'default'> = {
ACTIVE: 'success',
PENDING: 'warning',
CANCELLED: 'warning',
CANCELLED_CONFIRMED: 'danger',
EXPIRED: 'danger',
DRAFT: 'default',
DEACTIVATED: 'default',
ONGOING: 'success',
};
// 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: 'ONGOING', label: 'Läuft weiter (unbestimmt)', description: 'Vertrag läuft unbefristet alle Fristen im Cockpit werden ignoriert', color: 'text-green-700' },
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
{ status: 'CANCELLED', label: 'Gekündigt / Bestätigung abwarten', description: 'Kündigungsschreiben raus Bestätigung des Anbieters steht noch aus', color: 'text-orange-600' },
{ status: 'CANCELLED_CONFIRMED', label: 'Gekündigt / bestätigt', description: 'Kündigung vom Anbieter bestätigt, 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>
);
}
export default function ContractList() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
// Filter-Werte aus URL-Parametern lesen
const [search, setSearch] = useState(searchParams.get('search') || '');
const [type, setType] = useState(searchParams.get('type') || '');
const [status, setStatus] = useState(searchParams.get('status') || '');
const [page, setPage] = useState(parseInt(searchParams.get('page') || '1', 10));
// State für aufgeklappte Verträge (Baumstruktur)
const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set());
// Status-Info Modal
const [showStatusInfo, setShowStatusInfo] = useState(false);
// Toggle: deaktivierte Verträge im Kundenportal-Baum mit anzeigen
const [showDeactivated, setShowDeactivated] = useState(false);
const { hasPermission, isCustomer, isCustomerPortal, user } = useAuth();
const queryClient = useQueryClient();
// URL-Parameter aktualisieren wenn sich Filter ändern
useEffect(() => {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (type) params.set('type', type);
if (status) params.set('status', status);
if (page > 1) params.set('page', page.toString());
setSearchParams(params, { replace: true });
}, [search, type, status, page, setSearchParams]);
const deleteMutation = useMutation({
mutationFn: contractApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contracts'] });
},
});
const { data, isLoading } = useQuery({
queryKey: ['contracts', search, type, status, page, isCustomer ? user?.customerId : null],
queryFn: () =>
contractApi.getAll({
search: search || undefined,
type: type || undefined,
status: status || undefined,
page,
limit: 20,
customerId: isCustomer ? user?.customerId : undefined,
}),
});
// Alle Kunden-IDs ermitteln für Baumstruktur-Queries (Kundenportal)
const allCustomerIds = useMemo(() => {
if (!isCustomerPortal || !data?.data) return [];
const ids = new Set<number>();
// Eigene customerId hinzufügen
if (user?.customerId) ids.add(user.customerId);
// Freigegebene Kunden hinzufügen
data.data.forEach(c => ids.add(c.customerId));
return [...ids];
}, [data?.data, isCustomerPortal, user?.customerId]);
// Vollmacht-Status für vertretene Kunden (Portal)
const { data: authStatusData } = useQuery({
queryKey: ['my-authorization-status'],
queryFn: () => gdprApi.getMyAuthorizationStatus(),
enabled: isCustomerPortal,
});
const unauthorizedCustomers = useMemo(() => {
if (!isCustomerPortal || !authStatusData?.data || !user?.representedCustomers) return [];
return authStatusData.data
.filter((s) => !s.hasAuthorization)
.map((s) => {
const cust = user.representedCustomers?.find((c) => c.id === s.customerId);
return {
customerId: s.customerId,
customerName: cust ? `${cust.firstName} ${cust.lastName}` : `Kunde ${s.customerId}`,
};
});
}, [authStatusData?.data, isCustomerPortal, user?.representedCustomers]);
// Baumstruktur für alle Kunden laden (Kundenportal)
const treeQueries = useQueries({
queries: allCustomerIds.map(customerId => ({
queryKey: ['contract-tree', customerId, showDeactivated],
queryFn: () => contractApi.getTreeForCustomer(customerId, showDeactivated),
enabled: isCustomerPortal,
})),
});
// Map von customerId zu Baum erstellen
const contractTrees = useMemo(() => {
const map = new Map<number, ContractTreeNode[]>();
allCustomerIds.forEach((customerId, index) => {
const queryResult = treeQueries[index];
if (queryResult?.data?.data) {
map.set(customerId, queryResult.data.data);
}
});
return map;
}, [allCustomerIds, treeQueries]);
// Für Kundenportal: Verträge nach Kunde gruppieren mit Baumstruktur
const groupedContracts = useMemo(() => {
if (!isCustomerPortal || !data?.data) return null;
const groups: Record<number, {
customerId: number;
customerName: string;
isOwn: boolean;
contracts: Contract[];
tree: ContractTreeNode[];
}> = {};
for (const contract of data.data) {
const customerId = contract.customerId;
if (!groups[customerId]) {
const customerName = contract.customer
? (contract.customer.companyName || `${contract.customer.firstName} ${contract.customer.lastName}`)
: `Kunde ${customerId}`;
groups[customerId] = {
customerId,
customerName,
isOwn: customerId === user?.customerId,
contracts: [],
tree: contractTrees.get(customerId) || [],
};
}
groups[customerId].contracts.push(contract);
}
// Eigene Verträge zuerst, dann alphabetisch nach Name
return Object.values(groups).sort((a, b) => {
if (a.isOwn && !b.isOwn) return -1;
if (!a.isOwn && b.isOwn) return 1;
return a.customerName.localeCompare(b.customerName);
});
}, [data?.data, isCustomerPortal, user?.customerId, contractTrees]);
// Toggle für Aufklappen/Zuklappen (Baumstruktur)
const toggleExpand = (contractId: number) => {
setExpandedContracts(prev => {
const next = new Set(prev);
if (next.has(contractId)) {
next.delete(contractId);
} else {
next.add(contractId);
}
return next;
});
};
// Rekursive Rendering-Funktion für Vorgänger (Kundenportal)
const renderPredecessors = (predecessors: ContractTreeNode[], depth: number): React.ReactNode => {
return predecessors.map(node => (
<div key={node.contract.id}>
{renderContractNode(node, depth)}
</div>
));
};
// Einzelnen Vertragsknoten rendern (Kundenportal)
const renderContractNode = (node: ContractTreeNode, depth: number = 0): React.ReactNode => {
const { contract, predecessors, hasHistory } = node;
const isExpanded = expandedContracts.has(contract.id);
const isPredecessor = depth > 0;
return (
<div key={contract.id}>
<div
className={`
border rounded-lg p-4 transition-colors
${isPredecessor ? 'ml-6 border-l-4 border-l-gray-300 bg-gray-50' : 'hover:bg-gray-50'}
`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{/* Aufklapp-Button nur bei Wurzelknoten mit Historie */}
{!isPredecessor && hasHistory ? (
<button
onClick={() => toggleExpand(contract.id)}
className="p-1 hover:bg-gray-200 rounded transition-colors"
title={isExpanded ? 'Einklappen' : 'Vorgänger anzeigen'}
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
</button>
) : !isPredecessor ? (
<div className="w-6" /> // Platzhalter für Ausrichtung
) : null}
<Link to={`/contracts/${contract.id}`} state={pushHistory('/contracts')} className="font-mono flex items-center gap-1 hover:text-blue-600 hover:underline">
{contract.contractNumber}
<CopyButton value={contract.contractNumber} />
</Link>
<Badge>{typeLabels[contract.type as ContractType] || contract.type}</Badge>
<Badge variant={statusVariants[contract.status as ContractStatus] || 'default'}>
{statusLabels[contract.status as ContractStatus] || contract.status}
</Badge>
{isPredecessor && (
<span className="text-xs text-gray-500 ml-2">(Vorgänger)</span>
)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/contracts/${contract.id}`, {
state: { from: 'contracts' }
})}
title="Ansehen"
>
<Eye className="w-4 h-4" />
</Button>
</div>
</div>
{(contract.providerName || contract.provider?.name) && (
<p className={`flex items-center gap-1 ${isPredecessor ? 'ml-6' : ''}`}>
{contract.providerName || contract.provider?.name}
{(contract.tariffName || contract.tariff?.name) && ` - ${contract.tariffName || contract.tariff?.name}`}
<CopyButton value={(contract.providerName || contract.provider?.name || '') + ((contract.tariffName || contract.tariff?.name) ? ` - ${contract.tariffName || contract.tariff?.name}` : '')} />
</p>
)}
{(() => {
const typeInfo = getContractTypeInfo(contract as any);
return typeInfo ? (
<p className={`text-sm text-gray-600 ${isPredecessor ? 'ml-6' : ''}`}>
<span className="font-medium text-gray-700">{typeInfo.label}:</span> {typeInfo.value}
{typeInfo.cardUser && (
<> · <span className="font-medium text-gray-700">Karteninhaber:</span> {typeInfo.cardUser}</>
)}
{typeInfo.network && (
<> · <span className="font-medium text-gray-700">Netz:</span> {typeInfo.network}</>
)}
</p>
) : null;
})()}
{contract.startDate && (
<p className={`text-sm text-gray-500 ${isPredecessor ? 'ml-6' : ''}`}>
Beginn: {formatDate(contract.startDate)}
{contract.endDate &&
` | Ende: ${formatDate(contract.endDate)}`}
</p>
)}
{contract.cancellationConfirmationDate && (
<p className={`text-sm font-semibold text-red-600 ${isPredecessor ? 'ml-6' : ''}`}>
Kündigungsbestätigung zum {formatDate(contract.cancellationConfirmationDate)}
</p>
)}
</div>
{/* Vorgänger rekursiv rendern - für Wurzel nur wenn aufgeklappt, für Vorgänger immer */}
{((depth === 0 && isExpanded) || depth > 0) && predecessors.length > 0 && (
<div className="mt-2">
{renderPredecessors(predecessors, depth + 1)}
</div>
)}
</div>
);
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Verträge</h1>
{hasPermission('contracts:create') && !isCustomer && (
<Link to="/contracts/new">
<Button>
<Plus className="w-4 h-4 mr-2" />
Neuer Vertrag
</Button>
</Link>
)}
</div>
<Card className="mb-6">
<div className="flex gap-4 flex-wrap">
<div className="flex-1 min-w-[200px]">
<Input
placeholder="Suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Select
value={type}
onChange={(e) => setType(e.target.value)}
options={Object.entries(typeLabels).map(([value, label]) => ({ value, label }))}
className="w-48"
/>
<Select
value={status}
onChange={(e) => setStatus(e.target.value)}
options={Object.entries(statusLabels).map(([value, label]) => ({ value, label }))}
className="w-48"
/>
<Button variant="secondary">
<Search className="w-4 h-4" />
</Button>
</div>
</Card>
{isLoading ? (
<Card>
<div className="text-center py-8 text-gray-500">Laden...</div>
</Card>
) : data?.data && data.data.length > 0 ? (
<>
{/* Kundenportal: Gruppierte Ansicht mit Baumstruktur */}
{isCustomerPortal && groupedContracts ? (
<div className="space-y-6">
{groupedContracts.map((group) => (
<Card key={group.isOwn ? 'own' : group.customerName}>
{/* Gruppen-Header */}
<div className="flex items-center justify-between gap-3 mb-4 pb-3 border-b">
<div className="flex items-center gap-3">
{group.isOwn ? (
<>
<User className="w-5 h-5 text-blue-600" />
<h2 className="text-lg font-semibold text-gray-900">Meine Verträge</h2>
<Badge variant="default">{group.contracts.length}</Badge>
</>
) : (
<>
<Users className="w-5 h-5 text-purple-600" />
<h2 className="text-lg font-semibold text-gray-900">
Verträge von {group.customerName}
</h2>
<Badge variant="default">{group.contracts.length}</Badge>
</>
)}
</div>
{/* Toggle rechtsbündig auf Höhe von "Meine Verträge" */}
{group.isOwn && (
<Button
size="sm"
variant={showDeactivated ? 'secondary' : 'ghost'}
onClick={() => setShowDeactivated((v) => !v)}
title="Deaktivierte Verträge ein-/ausblenden"
>
{showDeactivated ? <EyeOff className="w-4 h-4 mr-2" /> : <Eye className="w-4 h-4 mr-2" />}
{showDeactivated ? 'Deaktivierte ausblenden' : 'Deaktivierte anzeigen'}
</Button>
)}
</div>
{/* Baumstruktur */}
{group.tree.length > 0 ? (
<div className="space-y-4">
{group.tree.map(node => renderContractNode(node, 0))}
</div>
) : (
<p className="text-gray-500">Keine Verträge vorhanden.</p>
)}
</Card>
))}
{/* Kunden ohne Vollmacht */}
{unauthorizedCustomers.map((uc) => (
<Card key={`no-auth-${uc.customerId}`}>
<div className="flex items-center gap-3 mb-2 pb-3 border-b">
<ShieldAlert className="w-5 h-5 text-amber-500" />
<h2 className="text-lg font-semibold text-gray-900">
Verträge von {uc.customerName}
</h2>
</div>
<div className="flex items-center gap-3 py-6 text-center justify-center">
<ShieldAlert className="w-5 h-5 text-amber-500" />
<p className="text-sm text-gray-600">
Einwilligung / Vollmacht fehlt. {uc.customerName} muss Ihnen zuerst eine Vollmacht erteilen.
</p>
</div>
</Card>
))}
</div>
) : (
/* Standard-Ansicht für Mitarbeiter */
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 font-medium text-gray-600">Vertragsnr.</th>
{!isCustomer && (
<th className="text-left py-3 px-4 font-medium text-gray-600">Kunde</th>
)}
<th className="text-left py-3 px-4 font-medium text-gray-600">Typ</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Anbieter / Tarif</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">
<span className="flex items-center gap-1">
Status
<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>
</span>
</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Beginn</th>
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody>
{data.data.map((contract) => {
const typeInfo = getContractTypeInfo(contract as any);
return (
<tr key={contract.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-mono text-sm">
<Link to={`/contracts/${contract.id}`} state={pushHistory('/contracts')} className="text-blue-600 hover:underline">
{contract.contractNumber}
</Link>
{typeInfo && (
<div className="text-xs text-gray-500 font-sans mt-0.5">
<span className="font-medium text-gray-600">{typeInfo.label}:</span> {typeInfo.value}
{typeInfo.cardUser && (
<> · <span className="font-medium text-gray-600">Karteninhaber:</span> {typeInfo.cardUser}</>
)}
{typeInfo.network && (
<> · <span className="font-medium text-gray-600">Netz:</span> {typeInfo.network}</>
)}
</div>
)}
{contract.cancellationConfirmationDate && (
<div className="text-xs font-semibold text-red-600 font-sans mt-0.5">
Kündigungsbestätigung zum {formatDate(contract.cancellationConfirmationDate)}
</div>
)}
</td>
{!isCustomer && (
<td className="py-3 px-4">
{contract.customer && (
<Link
to={`/customers/${contract.customer.id}`}
state={pushHistory('/contracts')}
className="text-blue-600 hover:underline"
>
{contract.customer.companyName ||
`${contract.customer.firstName} ${contract.customer.lastName}`}
</Link>
)}
</td>
)}
<td className="py-3 px-4">
<Badge>{typeLabels[contract.type]}</Badge>
</td>
<td className="py-3 px-4">
{contract.providerName || '-'}
{contract.tariffName && (
<span className="text-gray-500"> / {contract.tariffName}</span>
)}
</td>
<td className="py-3 px-4">
<Badge variant={statusVariants[contract.status]}>
{statusLabels[contract.status]}
</Badge>
</td>
<td className="py-3 px-4">
{contract.startDate
? formatDate(contract.startDate)
: '-'}
</td>
<td className="py-3 px-4 text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/contracts/${contract.id}`, {
state: { from: 'contracts' }
})}
>
<Eye className="w-4 h-4" />
</Button>
{hasPermission('contracts:update') && !isCustomer && (
<Link to={`/contracts/${contract.id}/edit`} state={pushHistory('/contracts')}>
<Button variant="ghost" size="sm">
<Edit className="w-4 h-4" />
</Button>
</Link>
)}
{hasPermission('contracts:delete') && !isCustomer && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Vertrag wirklich löschen?')) {
deleteMutation.mutate(contract.id);
}
}}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{data.pagination && data.pagination.totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-500">
Seite {data.pagination.page} von {data.pagination.totalPages} (
{data.pagination.total} Einträge)
</p>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Zurück
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={page >= data.pagination.totalPages}
>
Weiter
</Button>
</div>
</div>
)}
</Card>
)}
</>
) : (
<Card>
<div className="text-center py-8 text-gray-500">Keine Verträge gefunden.</div>
</Card>
)}
{/* Status-Info Modal */}
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
</div>
);
}