first commit

This commit is contained in:
Stefan Hacker
2026-01-29 01:16:54 +01:00
commit e209e9bbca
12105 changed files with 2480672 additions and 0 deletions
@@ -0,0 +1,421 @@
import { useState, useMemo, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom';
import { contractApi } from '../../services/api';
import Card from '../../components/ui/Card';
import Badge from '../../components/ui/Badge';
import Select from '../../components/ui/Select';
import {
AlertCircle,
AlertTriangle,
CheckCircle,
Clock,
Eye,
Calendar,
Key,
FileText,
ClipboardList,
ChevronDown,
ChevronRight,
Zap,
Wifi,
Smartphone,
Tv,
Car,
Flame,
} from 'lucide-react';
import type { CockpitContract, CockpitUrgencyLevel, ContractType } from '../../types';
const typeIcons: Record<ContractType, typeof Zap> = {
ELECTRICITY: Zap,
GAS: Flame,
DSL: Wifi,
CABLE: Wifi,
FIBER: Wifi,
MOBILE: Smartphone,
TV: Tv,
CAR_INSURANCE: Car,
};
const typeLabels: Record<ContractType, string> = {
ELECTRICITY: 'Strom',
GAS: 'Gas',
DSL: 'DSL',
CABLE: 'Kabel',
FIBER: 'Glasfaser',
MOBILE: 'Mobilfunk',
TV: 'TV',
CAR_INSURANCE: 'KFZ',
};
const urgencyColors: Record<CockpitUrgencyLevel, string> = {
critical: 'bg-red-100 border-red-300 text-red-800',
warning: 'bg-yellow-100 border-yellow-300 text-yellow-800',
ok: 'bg-green-100 border-green-300 text-green-800',
none: 'bg-gray-100 border-gray-300 text-gray-800',
};
const urgencyBadgeVariants: Record<CockpitUrgencyLevel, 'danger' | 'warning' | 'success' | 'default'> = {
critical: 'danger',
warning: 'warning',
ok: 'success',
none: 'default',
};
const issueTypeIcons: Record<string, typeof Calendar> = {
cancellation_deadline: Calendar,
contract_ending: Clock,
missing_cancellation_letter: FileText,
missing_cancellation_confirmation: FileText,
missing_portal_credentials: Key,
missing_customer_number: FileText,
missing_provider: FileText,
missing_address: FileText,
missing_bank: FileText,
missing_meter: Zap,
missing_sim: Smartphone,
open_tasks: ClipboardList,
pending_status: Clock,
draft_status: FileText,
};
const categoryLabels: Record<string, string> = {
cancellationDeadlines: 'Kündigungsfristen',
contractEnding: 'Vertragsenden',
missingCredentials: 'Fehlende Zugangsdaten',
missingData: 'Fehlende Daten',
openTasks: 'Offene Aufgaben',
pendingContracts: 'Wartende Verträge',
};
type FilterType = 'all' | 'critical' | 'warning' | 'ok' | 'deadlines' | 'credentials' | 'data' | 'tasks';
export default function ContractCockpit() {
const [searchParams, setSearchParams] = useSearchParams();
const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set());
// Filter aus URL-Parameter initialisieren
const urlFilter = searchParams.get('filter') as FilterType | null;
const [filter, setFilter] = useState<FilterType>(urlFilter || 'all');
// URL-Parameter bei Filter-Änderung aktualisieren
useEffect(() => {
if (filter === 'all') {
searchParams.delete('filter');
} else {
searchParams.set('filter', filter);
}
setSearchParams(searchParams, { replace: true });
}, [filter, searchParams, setSearchParams]);
const { data: cockpitData, isLoading, error } = useQuery({
queryKey: ['contract-cockpit'],
queryFn: () => contractApi.getCockpit(),
staleTime: 0,
});
const toggleExpanded = (contractId: number) => {
setExpandedContracts(prev => {
const next = new Set(prev);
if (next.has(contractId)) {
next.delete(contractId);
} else {
next.add(contractId);
}
return next;
});
};
// Filter contracts
const filteredContracts = useMemo(() => {
if (!cockpitData?.data?.contracts) return [];
const contracts = cockpitData.data.contracts;
switch (filter) {
case 'critical':
return contracts.filter(c => c.highestUrgency === 'critical');
case 'warning':
return contracts.filter(c => c.highestUrgency === 'warning');
case 'ok':
return contracts.filter(c => c.highestUrgency === 'ok');
case 'deadlines':
return contracts.filter(c =>
c.issues.some(i => ['cancellation_deadline', 'contract_ending'].includes(i.type))
);
case 'credentials':
return contracts.filter(c =>
c.issues.some(i => i.type.includes('credentials'))
);
case 'data':
return contracts.filter(c =>
c.issues.some(i => i.type.startsWith('missing_') && !i.type.includes('credentials'))
);
case 'tasks':
return contracts.filter(c =>
c.issues.some(i => ['open_tasks', 'pending_status', 'draft_status'].includes(i.type))
);
default:
return contracts;
}
}, [cockpitData?.data?.contracts, filter]);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">Laden...</div>
</div>
);
}
if (error || !cockpitData?.data) {
return (
<div className="text-center py-12">
<p className="text-red-500">Fehler beim Laden des Cockpits</p>
</div>
);
}
const { summary, thresholds } = cockpitData.data;
const renderContract = (contract: CockpitContract) => {
const isExpanded = expandedContracts.has(contract.id);
const TypeIcon = typeIcons[contract.type] || FileText;
return (
<div
key={contract.id}
className={`border rounded-lg mb-2 ${urgencyColors[contract.highestUrgency]}`}
>
{/* Contract Header */}
<div
className="flex items-center p-4 cursor-pointer hover:bg-opacity-50"
onClick={() => toggleExpanded(contract.id)}
>
{/* Expand Icon */}
<div className="w-6 mr-2">
{isExpanded ? (
<ChevronDown className="w-5 h-5" />
) : (
<ChevronRight className="w-5 h-5" />
)}
</div>
{/* Type Icon */}
<TypeIcon className="w-5 h-5 mr-3" />
{/* Contract Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Link
to={`/contracts/${contract.id}`}
state={{ from: 'cockpit', filter: filter !== 'all' ? filter : undefined }}
className="font-medium hover:underline"
onClick={(e) => e.stopPropagation()}
>
{contract.contractNumber}
</Link>
<Badge variant={urgencyBadgeVariants[contract.highestUrgency]}>
{contract.issues.length} {contract.highestUrgency === 'ok'
? (contract.issues.length === 1 ? 'Hinweis' : 'Hinweise')
: (contract.issues.length === 1 ? 'Problem' : 'Probleme')}
</Badge>
<span className="text-sm">
{typeLabels[contract.type]}
</span>
</div>
<div className="text-sm mt-1">
<Link
to={`/customers/${contract.customer.id}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{contract.customer.customerNumber} - {contract.customer.name}
</Link>
{(contract.provider?.name || contract.providerName) && (
<span className="ml-2">
| {contract.provider?.name || contract.providerName}
{(contract.tariff?.name || contract.tariffName) &&
` - ${contract.tariff?.name || contract.tariffName}`}
</span>
)}
</div>
</div>
{/* Actions */}
<Link
to={`/contracts/${contract.id}`}
state={{ from: 'cockpit', filter: filter !== 'all' ? filter : undefined }}
className="ml-4 p-2 hover:bg-white hover:bg-opacity-50 rounded"
onClick={(e) => e.stopPropagation()}
title="Zum Vertrag"
>
<Eye className="w-4 h-4" />
</Link>
</div>
{/* Expanded: Issues */}
{isExpanded && (
<div className="border-t px-4 py-3 bg-white bg-opacity-50">
<div className="space-y-2">
{contract.issues.map((issue, idx) => {
const IssueIcon = issueTypeIcons[issue.type] || AlertCircle;
const UrgencyIcon = issue.urgency === 'critical' ? AlertCircle :
issue.urgency === 'warning' ? AlertTriangle :
issue.urgency === 'ok' ? CheckCircle : Clock;
return (
<div
key={idx}
className="flex items-start gap-3 text-sm"
>
<UrgencyIcon className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
issue.urgency === 'critical' ? 'text-red-500' :
issue.urgency === 'warning' ? 'text-yellow-500' :
issue.urgency === 'ok' ? 'text-green-500' : 'text-gray-500'
}`} />
<IssueIcon className="w-4 h-4 mt-0.5 flex-shrink-0 text-gray-500" />
<div>
<span className="font-medium">{issue.label}</span>
{issue.details && (
<span className="text-gray-600 ml-2">{issue.details}</span>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<AlertCircle className="w-6 h-6 text-red-500" />
<h1 className="text-2xl font-bold">Vertrags-Cockpit</h1>
</div>
<Link
to="/settings/deadlines"
className="text-sm text-blue-600 hover:underline"
>
Fristenschwellen anpassen
</Link>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="!p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-100 rounded-lg">
<AlertCircle className="w-6 h-6 text-red-500" />
</div>
<div>
<p className="text-2xl font-bold text-red-600">{summary.criticalCount}</p>
<p className="text-sm text-gray-500">Kritisch (&lt;{thresholds.criticalDays} Tage)</p>
</div>
</div>
</Card>
<Card className="!p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-100 rounded-lg">
<AlertTriangle className="w-6 h-6 text-yellow-500" />
</div>
<div>
<p className="text-2xl font-bold text-yellow-600">{summary.warningCount}</p>
<p className="text-sm text-gray-500">Warnung (&lt;{thresholds.warningDays} Tage)</p>
</div>
</div>
</Card>
<Card className="!p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg">
<CheckCircle className="w-6 h-6 text-green-500" />
</div>
<div>
<p className="text-2xl font-bold text-green-600">{summary.okCount}</p>
<p className="text-sm text-gray-500">OK (&lt;{thresholds.okDays} Tage)</p>
</div>
</div>
</Card>
<Card className="!p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 rounded-lg">
<FileText className="w-6 h-6 text-gray-500" />
</div>
<div>
<p className="text-2xl font-bold text-gray-600">{summary.totalContracts}</p>
<p className="text-sm text-gray-500">Verträge mit Handlungsbedarf</p>
</div>
</div>
</Card>
</div>
{/* Category Summary */}
<Card className="mb-6">
<div className="flex flex-wrap gap-4">
{Object.entries(summary.byCategory).map(([key, count]) => (
count > 0 && (
<div key={key} className="flex items-center gap-2 text-sm">
<span className="font-medium">{categoryLabels[key] || key}:</span>
<Badge variant="default">{count}</Badge>
</div>
)
))}
</div>
</Card>
{/* Filter */}
<Card className="mb-6">
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">Filter:</span>
<Select
value={filter}
onChange={(e) => setFilter(e.target.value as FilterType)}
options={[
{ value: 'all', label: `Alle (${cockpitData.data.contracts.length})` },
{ value: 'critical', label: `Kritisch (${summary.criticalCount})` },
{ value: 'warning', label: `Warnung (${summary.warningCount})` },
{ value: 'ok', label: `OK (${summary.okCount})` },
{ value: 'deadlines', label: `Fristen (${summary.byCategory.cancellationDeadlines + summary.byCategory.contractEnding})` },
{ value: 'credentials', label: `Zugangsdaten (${summary.byCategory.missingCredentials})` },
{ value: 'data', label: `Fehlende Daten (${summary.byCategory.missingData})` },
{ value: 'tasks', label: `Aufgaben/Status (${summary.byCategory.openTasks + summary.byCategory.pendingContracts})` },
]}
className="w-64"
/>
<span className="text-sm text-gray-500">
{filteredContracts.length} Verträge angezeigt
</span>
</div>
</Card>
{/* Contract List */}
{filteredContracts.length === 0 ? (
<Card>
<div className="text-center py-8 text-gray-500">
{filter === 'all' ? (
<>
<CheckCircle className="w-12 h-12 mx-auto mb-4 text-green-500" />
<p className="text-lg font-medium">Alles in Ordnung!</p>
<p>Keine Verträge mit Handlungsbedarf gefunden.</p>
</>
) : (
<p>Keine Verträge für diesen Filter gefunden.</p>
)}
</div>
</Card>
) : (
<div>
{filteredContracts.map(renderContract)}
</div>
)}
</div>
);
}