added new view in contracts customer and contracts

This commit is contained in:
2026-02-04 00:52:04 +01:00
parent 97b4670643
commit 2b23ed64c4
12 changed files with 1022 additions and 758 deletions
@@ -25,7 +25,7 @@ export default function EmailDetail({
onReply,
onAssignContract,
onDeleted,
isSentFolder = false,
isSentFolder: _isSentFolder = false,
isContractView = false,
isTrashView = false,
onRestored,
@@ -35,6 +35,7 @@ export default function ContractForm() {
customerId: preselectedCustomerId || '',
type: 'ELECTRICITY',
status: 'DRAFT',
previousContractId: '',
},
});
@@ -61,6 +62,13 @@ export default function ContractForm() {
enabled: !!customerId,
});
// Fetch contracts for same customer (for predecessor selection)
const { data: customerContractsData } = useQuery({
queryKey: ['customer-contracts-for-predecessor', customerId],
queryFn: () => contractApi.getAll({ customerId: parseInt(customerId), limit: 1000 }),
enabled: !!customerId,
});
// Fetch platforms
const { data: platformsData } = useQuery({
queryKey: ['platforms'],
@@ -256,6 +264,8 @@ export default function ContractForm() {
cancellationConfirmationDate: c.cancellationConfirmationDate ? c.cancellationConfirmationDate.split('T')[0] : '',
cancellationConfirmationOptionsDate: c.cancellationConfirmationOptionsDate ? c.cancellationConfirmationOptionsDate.split('T')[0] : '',
wasSpecialCancellation: c.wasSpecialCancellation || false,
// Vorgänger-Vertrag
previousContractId: c.previousContractId?.toString() || '',
});
// Load simCards if available
@@ -427,6 +437,7 @@ export default function ContractForm() {
cancellationConfirmationDate: data.cancellationConfirmationDate ? new Date(data.cancellationConfirmationDate) : null,
cancellationConfirmationOptionsDate: data.cancellationConfirmationOptionsDate ? new Date(data.cancellationConfirmationOptionsDate) : null,
wasSpecialCancellation: data.wasSpecialCancellation || false,
previousContractId: safeParseInt(data.previousContractId) ?? null,
};
// Add type-specific details
@@ -540,6 +551,11 @@ export default function ContractForm() {
const contractCategories = contractCategoriesData?.data?.filter(c => c.isActive).sort((a, b) => a.sortOrder - b.sortOrder) || [];
const typeOptions = contractCategories.map(c => ({ value: c.code, label: c.name }));
// Available predecessor contracts (same customer, excluding current contract if editing)
const predecessorContracts = (customerContractsData?.data || [])
.filter(c => !isEdit || c.id !== parseInt(id!))
.sort((a, b) => new Date(b.startDate || 0).getTime() - new Date(a.startDate || 0).getTime());
// Get tariffs for selected provider
const selectedProvider = providers.find(p => p.id === parseInt(selectedProviderId || '0'));
const availableTariffs = selectedProvider?.tariffs?.filter(t => t.isActive) || [];
@@ -615,6 +631,19 @@ export default function ContractForm() {
{...register('salesPlatformId')}
options={platforms.map((p) => ({ value: p.id, label: p.name }))}
/>
{/* Vorgänger-Vertrag auswählen (nur wenn Kunde gewählt) */}
{customerId && (
<Select
label="Vorgänger-Vertrag"
{...register('previousContractId')}
options={predecessorContracts.map((c) => ({
value: c.id,
label: `${c.contractNumber} (${c.type}${c.startDate ? ` - ${new Date(c.startDate).toLocaleDateString('de-DE')}` : ''})`,
}))}
placeholder="Keinen Vorgänger auswählen"
/>
)}
</div>
</Card>
+150 -73
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, StressfreiEmail } from '../../services/api';
import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, StressfreiEmail, ContractTreeNode } from '../../services/api';
import { EmailClientTab } from '../../components/email';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
@@ -12,7 +12,7 @@ import Modal from '../../components/ui/Modal';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import FileUpload from '../../components/ui/FileUpload';
import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check } from 'lucide-react';
import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight } from 'lucide-react';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary } from '../../types';
@@ -146,7 +146,6 @@ export default function CustomerDetail() {
content: (
<ContractsTab
customerId={customerId}
contracts={c.contracts || []}
/>
),
},
@@ -1488,14 +1487,21 @@ function MetersTab({
function ContractsTab({
customerId,
contracts,
}: {
customerId: number;
contracts: any[];
}) {
const { hasPermission } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set());
// Lade Vertragsbaum statt flacher Liste
const { data: treeData, isLoading } = useQuery({
queryKey: ['contract-tree', customerId],
queryFn: () => contractApi.getTreeForCustomer(customerId),
});
const contractTree = treeData?.data || [];
const deleteMutation = useMutation({
mutationFn: contractApi.delete,
@@ -1503,6 +1509,7 @@ function ContractsTab({
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customers'] });
queryClient.invalidateQueries({ queryKey: ['contracts'] });
queryClient.invalidateQueries({ queryKey: ['contract-tree', customerId] });
},
onError: (error: any) => {
alert(error?.message || 'Fehler beim Löschen des Vertrags');
@@ -1528,6 +1535,142 @@ function ContractsTab({
DEACTIVATED: 'default',
};
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
const renderPredecessors = (predecessors: ContractTreeNode[], depth: number): React.ReactNode => {
return predecessors.map(node => (
<div key={node.contract.id}>
{renderContractNode(node, depth)}
</div>
));
};
// Einzelnen Vertragsknoten rendern
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}
<span className="font-mono flex items-center gap-1">
{contract.contractNumber}
<CopyButton value={contract.contractNumber} />
</span>
<Badge>{typeLabels[contract.type] || contract.type}</Badge>
<Badge variant={statusVariants[contract.status] || 'default'}>{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: 'customer', customerId: customerId.toString() }
})}
title="Ansehen"
>
<Eye className="w-4 h-4" />
</Button>
{hasPermission('contracts:update') && (
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/contracts/${contract.id}/edit`)}
title="Bearbeiten"
>
<Edit className="w-4 h-4" />
</Button>
)}
{hasPermission('contracts:delete') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Vertrag wirklich löschen?')) {
deleteMutation.mutate(contract.id);
}
}}
title="Löschen"
>
<Trash2 className="w-4 h-4 text-red-500" />
</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>
)}
{contract.startDate && (
<p className={`text-sm text-gray-500 ${isPredecessor ? 'ml-6' : ''}`}>
Beginn: {new Date(contract.startDate).toLocaleDateString('de-DE')}
{contract.endDate &&
` | Ende: ${new Date(contract.endDate).toLocaleDateString('de-DE')}`}
</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>
);
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div>
{hasPermission('contracts:create') && (
@@ -1541,75 +1684,9 @@ function ContractsTab({
</div>
)}
{contracts.length > 0 ? (
{contractTree.length > 0 ? (
<div className="space-y-4">
{contracts.map((contract) => (
<div
key={contract.id}
className="border rounded-lg p-4 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-mono flex items-center gap-1">
{contract.contractNumber}
<CopyButton value={contract.contractNumber} />
</span>
<Badge>{typeLabels[contract.type]}</Badge>
<Badge variant={statusVariants[contract.status]}>{contract.status}</Badge>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/contracts/${contract.id}`, {
state: { from: 'customer', customerId: customerId.toString() }
})}
title="Ansehen"
>
<Eye className="w-4 h-4" />
</Button>
{hasPermission('contracts:update') && (
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/contracts/${contract.id}/edit`)}
title="Bearbeiten"
>
<Edit className="w-4 h-4" />
</Button>
)}
{hasPermission('contracts:delete') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Vertrag wirklich löschen?')) {
deleteMutation.mutate(contract.id);
}
}}
title="Löschen"
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
</div>
{contract.providerName && (
<p className="flex items-center gap-1">
{contract.providerName}
{contract.tariffName && ` - ${contract.tariffName}`}
<CopyButton value={contract.providerName + (contract.tariffName ? ` - ${contract.tariffName}` : '')} />
</p>
)}
{contract.startDate && (
<p className="text-sm text-gray-500">
Beginn: {new Date(contract.startDate).toLocaleDateString('de-DE')}
{contract.endDate &&
` | Ende: ${new Date(contract.endDate).toLocaleDateString('de-DE')}`}
</p>
)}
</div>
))}
{contractTree.map(node => renderContractNode(node, 0))}
</div>
) : (
<p className="text-gray-500">Keine Verträge vorhanden.</p>
+26 -1
View File
@@ -508,12 +508,37 @@ export const cachedEmailApi = {
},
};
// Contracts
// Contracts - Vertragsbaum für Kundenansicht
export interface ContractTreeNodeContract {
id: number;
contractNumber: string;
type: string;
status: string;
startDate: string | null;
endDate: string | null;
providerName: string | null;
tariffName: string | null;
previousContractId: number | null;
provider?: { id: number; name: string } | null;
tariff?: { id: number; name: string } | null;
contractCategory?: { id: number; name: string } | null;
}
export interface ContractTreeNode {
contract: ContractTreeNodeContract;
predecessors: ContractTreeNode[];
hasHistory: boolean;
}
export const contractApi = {
getAll: async (params?: { customerId?: number; type?: string; status?: string; search?: string; page?: number; limit?: number }) => {
const res = await api.get<ApiResponse<Contract[]>>('/contracts', { params });
return res.data;
},
getTreeForCustomer: async (customerId: number) => {
const res = await api.get<ApiResponse<ContractTreeNode[]>>('/contracts', { params: { customerId, tree: 'true' } });
return res.data;
},
getById: async (id: number) => {
const res = await api.get<ApiResponse<Contract>>(`/contracts/${id}`);
return res.data;