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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenCRM</title>
<script type="module" crossorigin src="/assets/index-DxzmsVZ0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CUVVQncv.css">
<script type="module" crossorigin src="/assets/index-CooZFd_R.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DWDTTlpk.css">
</head>
<body>
<div id="root"></div>
@@ -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;