Files
opencrm/frontend/src/pages/contracts/ContractCockpit.tsx
T
duffyduckandClaude Opus 4.6 018784cca6 Geburtstagskalender + Geburtstagsgruß-Modal im Kundenportal
Admin (Vertrags-Cockpit):
- Neue Section "Geburtstage" zeigt Kunden mit Geburtstag
- Fenster: -7 bis +30 Tage um heute
- Farbcodierung: heute (pink), vergangen (amber), bevorstehend (grau)
- Anzeige: Name, Kundennummer, Geburtsdatum, Alter, "Heute!" / "In X Tagen" / "Vor X Tagen"

Portal (Kundenportal):
- Modal mit Geburtstagsgruß wenn Geburtstag heute oder in den letzten 7 Tagen war
- Unterscheidet zwischen aktuellem Geburtstag und nachträglichen Glückwünschen
- Schönes Gradient-Design mit Konfetti-Emojis
- Wird pro Jahr nur einmal angezeigt (Customer.lastBirthdayGreetingYear)
- Bestätigung speichert das aktuelle Jahr

Backend:
- Neues Feld Customer.lastBirthdayGreetingYear (Int?)
- Service birthday.service.ts mit Fenster-Logik + Alter-Berechnung
- Endpoints /api/birthdays/upcoming (Admin),
  /api/birthdays/my-birthday (Portal GET + POST /acknowledge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:51:20 +02:00

777 lines
30 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, useMemo, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom';
import { pushHistory } from '../../utils/navigation';
import { contractApi, meterApi, birthdayApi } from '../../services/api';
import Card from '../../components/ui/Card';
import Badge from '../../components/ui/Badge';
import Select from '../../components/ui/Select';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import {
AlertCircle,
AlertTriangle,
CheckCircle,
Clock,
Eye,
Calendar,
Key,
FileText,
ClipboardList,
ChevronDown,
ChevronRight,
Zap,
Wifi,
Smartphone,
Tv,
Car,
Flame,
BellOff,
RotateCcw,
Receipt,
ShieldAlert,
ShieldX,
CreditCard,
Gauge,
ExternalLink,
CheckCircle2,
Cake,
} from 'lucide-react';
import { formatDate } from '../../utils/dateFormat';
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,
review_due: RotateCcw,
missing_invoice: Receipt,
missing_identity_document: CreditCard,
identity_document_expired: CreditCard,
identity_document_expiring: CreditCard,
missing_consents: ShieldAlert,
consent_withdrawn: ShieldX,
};
const categoryLabels: Record<string, string> = {
cancellationDeadlines: 'Kündigungsfristen',
contractEnding: 'Vertragsenden',
missingCredentials: 'Fehlende Zugangsdaten',
missingData: 'Fehlende Daten',
openTasks: 'Offene Aufgaben',
pendingContracts: 'Wartende Verträge',
missingInvoices: 'Fehlende Rechnungen',
reviewDue: 'Erneute Prüfung fällig',
missingConsents: 'Fehlende Einwilligungen',
};
type FilterType = 'all' | 'critical' | 'warning' | 'ok' | 'deadlines' | 'credentials' | 'data' | 'tasks' | 'review' | 'invoices' | 'consents';
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,
});
// Geburtstage (kommende + vergangene der letzten 7 Tage)
const { data: birthdaysData } = useQuery({
queryKey: ['upcoming-birthdays'],
queryFn: () => birthdayApi.getUpcoming(7, 30),
staleTime: 5 * 60_000,
});
const queryClient = useQueryClient();
const [snoozeContractId, setSnoozeContractId] = useState<number | null>(null);
const [customDate, setCustomDate] = useState('');
const snoozeDropdownRef = useRef<HTMLDivElement>(null);
// Close snooze dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (snoozeDropdownRef.current && !snoozeDropdownRef.current.contains(event.target as Node)) {
setSnoozeContractId(null);
setCustomDate('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const snoozeMutation = useMutation({
mutationFn: ({ contractId, data }: { contractId: number; data: { months?: number; nextReviewDate?: string } }) =>
contractApi.snooze(contractId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-cockpit'] });
setSnoozeContractId(null);
setCustomDate('');
},
});
const handleSnooze = (contractId: number, months?: number) => {
if (months) {
snoozeMutation.mutate({ contractId, data: { months } });
} else if (customDate) {
snoozeMutation.mutate({ contractId, data: { nextReviewDate: customDate } });
}
};
const handleUnsnooze = (contractId: number) => {
// Snooze aufheben: Leeres Objekt senden → nextReviewDate wird auf null gesetzt
snoozeMutation.mutate({ contractId, data: {} });
};
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))
);
case 'review':
return contracts.filter(c =>
c.issues.some(i => i.type === 'review_due')
);
case 'invoices':
return contracts.filter(c =>
c.issues.some(i => i.type.includes('invoice'))
);
case 'consents':
return contracts.filter(c =>
c.issues.some(i => ['missing_consents', 'consent_withdrawn'].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={pushHistory('/contracts/cockpit')}
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 */}
<div className="flex items-center gap-1 ml-4">
{/* Snooze Button */}
<div className="relative" ref={snoozeContractId === contract.id ? snoozeDropdownRef : undefined}>
<button
onClick={(e) => {
e.stopPropagation();
setSnoozeContractId(snoozeContractId === contract.id ? null : contract.id);
setCustomDate('');
}}
className="p-2 hover:bg-white hover:bg-opacity-50 rounded"
title="Zurückstellen"
>
<BellOff className="w-4 h-4" />
</button>
{/* Snooze Dropdown */}
{snoozeContractId === contract.id && (
<div
className="absolute right-0 top-full mt-1 w-56 bg-white border rounded-lg shadow-lg z-50 p-3"
onClick={(e) => e.stopPropagation()}
>
<div className="text-sm font-medium mb-2">Zurückstellen</div>
<div className="space-y-1">
<button
onClick={() => handleSnooze(contract.id, 3)}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 rounded"
disabled={snoozeMutation.isPending}
>
+3 Monate
</button>
<button
onClick={() => handleSnooze(contract.id, 6)}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 rounded bg-blue-50 border-blue-200"
disabled={snoozeMutation.isPending}
>
+6 Monate <span className="text-xs text-gray-500">(Empfohlen)</span>
</button>
<button
onClick={() => handleSnooze(contract.id, 12)}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 rounded"
disabled={snoozeMutation.isPending}
>
+12 Monate
</button>
</div>
<div className="border-t mt-2 pt-2">
<label className="text-xs text-gray-500 block mb-1">Eigenes Datum:</label>
<div className="flex gap-2">
<Input
type="date"
value={customDate}
onChange={(e) => setCustomDate(e.target.value)}
className="flex-1 text-sm"
min={new Date().toISOString().split('T')[0]}
/>
<Button
size="sm"
onClick={() => handleSnooze(contract.id)}
disabled={!customDate || snoozeMutation.isPending}
>
OK
</Button>
</div>
</div>
{/* Snooze aufheben - zeige nur wenn review_due Issue existiert */}
{contract.issues.some(i => i.type === 'review_due') && (
<div className="border-t mt-2 pt-2">
<button
onClick={() => handleUnsnooze(contract.id)}
className="w-full text-left px-3 py-2 text-sm hover:bg-red-50 text-red-600 rounded flex items-center gap-2"
disabled={snoozeMutation.isPending}
>
<RotateCcw className="w-4 h-4" />
Snooze aufheben
</button>
</div>
)}
</div>
)}
</div>
<Link
to={`/contracts/${contract.id}`}
state={pushHistory('/contracts/cockpit')}
className="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>
</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>
{/* Ausweis-Warnungen (vertragsunabhängig) */}
{cockpitData.data.documentAlerts && cockpitData.data.documentAlerts.length > 0 && (
<Card className="mb-6">
<div className="flex items-center gap-2 mb-3">
<CreditCard className="w-5 h-5 text-amber-500" />
<h3 className="font-medium">Ablaufende Ausweise</h3>
<Badge variant="warning">{cockpitData.data.documentAlerts.length}</Badge>
</div>
<div className="space-y-2">
{cockpitData.data.documentAlerts.map((alert) => (
<div
key={alert.id}
className={`flex items-center justify-between p-3 rounded-lg border ${
alert.urgency === 'critical' ? 'bg-red-50 border-red-200' : 'bg-amber-50 border-amber-200'
}`}
>
<div className="flex items-center gap-3">
<CreditCard className={`w-4 h-4 ${alert.urgency === 'critical' ? 'text-red-500' : 'text-amber-500'}`} />
<div>
<Link
to={`/customers/${alert.customer.id}?tab=documents`}
className="text-sm font-medium hover:underline"
>
{alert.customer.name}
</Link>
<span className="text-xs text-gray-500 ml-2">({alert.customer.customerNumber})</span>
<p className="text-xs text-gray-600">
{alert.type === 'ID_CARD' ? 'Personalausweis' :
alert.type === 'PASSPORT' ? 'Reisepass' :
alert.type === 'DRIVERS_LICENSE' ? 'Führerschein' : 'Ausweis'}{' '}
{alert.documentNumber}
</p>
</div>
</div>
<div className="text-right">
{alert.daysUntilExpiry < 0 ? (
<Badge variant="danger">Seit {Math.abs(alert.daysUntilExpiry)} Tagen abgelaufen</Badge>
) : (
<Badge variant={alert.daysUntilExpiry <= 30 ? 'danger' : 'warning'}>
Noch {alert.daysUntilExpiry} Tage
</Badge>
)}
<p className="text-xs text-gray-500 mt-1">
{formatDate(alert.expiryDate)}
</p>
</div>
</div>
))}
</div>
</Card>
)}
{/* Gemeldete Zählerstände */}
{cockpitData.data.reportedReadings && cockpitData.data.reportedReadings.length > 0 && (
<Card className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Gauge className="w-5 h-5 text-blue-500" />
<h3 className="font-medium">Gemeldete Zählerstände</h3>
<Badge variant="warning">{cockpitData.data.reportedReadings.length}</Badge>
</div>
<p className="text-sm text-gray-500 mb-3">
Von Kunden gemeldete Zählerstände bitte an den jeweiligen Anbieter übertragen.
</p>
<div className="space-y-2">
{cockpitData.data.reportedReadings.map((reading) => (
<div
key={reading.id}
className="flex items-center justify-between p-3 rounded-lg border bg-blue-50 border-blue-200"
>
<div className="flex items-center gap-3">
{reading.meter.type === 'ELECTRICITY' ? (
<Zap className="w-4 h-4 text-yellow-500" />
) : (
<Flame className="w-4 h-4 text-blue-500" />
)}
<div>
<div className="flex items-center gap-2">
<Link
to={`/customers/${reading.customer.id}?tab=meters`}
className="text-sm font-medium hover:underline"
>
{reading.customer.name}
</Link>
<span className="text-xs text-gray-500">({reading.customer.customerNumber})</span>
{reading.contract && (
<Link
to={`/contracts/${reading.contract.id}`}
state={pushHistory('/contracts/cockpit')}
className="text-xs text-blue-600 hover:underline"
>
{reading.contract.contractNumber}
</Link>
)}
</div>
<p className="text-xs text-gray-600">
Zähler {reading.meter.meterNumber} <strong>{reading.value} {reading.unit}</strong> am{' '}
{formatDate(reading.readingDate)}
{reading.notes && ` ${reading.notes}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{reading.providerPortal && (
<a
href={reading.providerPortal.portalUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs px-2 py-1 bg-white border rounded hover:bg-gray-50"
title={reading.providerPortal.portalUsername ? `Login: ${reading.providerPortal.portalUsername}` : undefined}
>
<ExternalLink className="w-3 h-3" />
{reading.providerPortal.providerName}
</a>
)}
<Button
variant="secondary"
size="sm"
onClick={async () => {
if (!confirm(`Zählerstand ${reading.value} ${reading.unit} (Zähler ${reading.meter.meterNumber}) als übertragen markieren?`)) return;
await meterApi.markTransferred(reading.meter.id, reading.id);
queryClient.invalidateQueries({ queryKey: ['contract-cockpit'] });
}}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
Übertragen
</Button>
</div>
</div>
))}
</div>
</Card>
)}
{/* Geburtstage */}
{birthdaysData?.data && birthdaysData.data.length > 0 && (
<Card className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Cake className="w-5 h-5 text-pink-500" />
<h3 className="font-medium">Geburtstage</h3>
<Badge variant="default">{birthdaysData.data.length}</Badge>
</div>
<p className="text-sm text-gray-500 mb-3">
Kunden mit Geburtstag in den nächsten 30 Tagen oder den letzten 7 Tagen.
</p>
<div className="space-y-2">
{birthdaysData.data.map((b) => (
<div
key={b.customerId}
className={`flex items-center justify-between p-3 rounded-lg border ${
b.isToday
? 'bg-pink-50 border-pink-300'
: b.isPast
? 'bg-amber-50 border-amber-200'
: 'bg-gray-50 border-gray-200'
}`}
>
<div className="flex items-center gap-3">
<Cake className={`w-4 h-4 ${b.isToday ? 'text-pink-500' : b.isPast ? 'text-amber-500' : 'text-gray-400'}`} />
<div>
<Link
to={`/customers/${b.customerId}`}
state={pushHistory('/contracts/cockpit')}
className="text-sm font-medium hover:underline"
>
{b.name}
</Link>
<span className="text-xs text-gray-500 ml-2">({b.customerNumber})</span>
<p className="text-xs text-gray-600">
{formatDate(b.birthDate)} wird {b.age} Jahre
</p>
</div>
</div>
<div className="text-right">
{b.isToday ? (
<Badge variant="warning">🎉 Heute!</Badge>
) : b.isPast ? (
<Badge variant="default">Vor {Math.abs(b.daysUntil)} Tag{Math.abs(b.daysUntil) === 1 ? '' : 'en'}</Badge>
) : (
<Badge variant="default">In {b.daysUntil} Tag{b.daysUntil === 1 ? '' : 'en'}</Badge>
)}
</div>
</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})` },
{ value: 'review', label: `Erneute Prüfung (${summary.byCategory.reviewDue || 0})` },
{ value: 'invoices', label: `Fehlende Rechnungen (${summary.byCategory.missingInvoices || 0})` },
{ value: 'consents', label: `Einwilligungen (${summary.byCategory.missingConsents || 0})` },
]}
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>
);
}