added new view in contracts customer and contracts
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user