first commit

This commit is contained in:
Stefan Hacker
2026-01-29 01:16:54 +01:00
commit 31f807fbd0
12106 changed files with 2480685 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>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,370 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
import { contractApi } 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 { Plus, Search, Eye, Edit, Trash2, User, Users } from 'lucide-react';
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',
EXPIRED: 'Abgelaufen',
DEACTIVATED: 'Deaktiviert',
};
const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' | 'default'> = {
ACTIVE: 'success',
PENDING: 'warning',
CANCELLED: 'danger',
EXPIRED: 'danger',
DRAFT: 'default',
DEACTIVATED: 'default',
};
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));
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,
}),
});
// Für Kundenportal: Verträge nach Kunde gruppieren
const groupedContracts = useMemo(() => {
if (!isCustomerPortal || !data?.data) return null;
const groups: Record<number, { customerName: string; isOwn: boolean; contracts: Contract[] }> = {};
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] = {
customerName,
isOwn: customerId === user?.customerId,
contracts: [],
};
}
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]);
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 */}
{isCustomerPortal && groupedContracts ? (
<div className="space-y-6">
{groupedContracts.map((group) => (
<Card key={group.isOwn ? 'own' : group.customerName}>
{/* Gruppen-Header */}
<div className="flex items-center gap-3 mb-4 pb-3 border-b">
{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>
{/* Vertrags-Tabelle */}
<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>
<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">Status</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>
{group.contracts.map((contract) => (
<tr key={contract.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-mono text-sm">{contract.contractNumber}</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
? new Date(contract.startDate).toLocaleDateString('de-DE')
: '-'}
</td>
<td className="py-3 px-4 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/contracts/${contract.id}`, {
state: { from: 'contracts' }
})}
>
<Eye className="w-4 h-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</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">Status</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) => (
<tr key={contract.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-mono text-sm">{contract.contractNumber}</td>
{!isCustomer && (
<td className="py-3 px-4">
{contract.customer && (
<Link
to={`/customers/${contract.customer.id}`}
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
? new Date(contract.startDate).toLocaleDateString('de-DE')
: '-'}
</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`}>
<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>
)}
</div>
);
}