added new view in contracts customer and contracts

This commit is contained in:
dufyfduck 2026-02-04 00:52:04 +01:00
parent 97b4670643
commit 2b23ed64c4
12 changed files with 1022 additions and 758 deletions

View File

@ -558,6 +558,26 @@ Der X-Button zum Aufheben der Vertragszuordnung erscheint nur bei **manuell zuge
> - `isAutoAssigned = true`: E-Mail wurde direkt aus dem Vertragskontext gesendet → X-Button ausgeblendet
> - `isAutoAssigned = false`: E-Mail wurde manuell dem Vertrag zugeordnet → X-Button sichtbar
### Vertragsbaum in Kundenansicht
In der Kundendetailansicht werden Verträge als **Baumstruktur** mit Vorgänger-Verknüpfung dargestellt:
```
▼ GAS-ML781A4FYXU │ Gas │ ACTIVE │ 01.01.2025 - 31.12.2026
└─ GAS-ML24GKR...│ Gas │ EXPIRED│ 05.05.2023 - 05.05.2025 (Vorgänger)
└─ GAS-OLD123 │ Gas │ EXPIRED│ 01.01.2021 - 04.05.2023 (Vorgänger)
▶ MOB-ML77W560A73 │ Mobil│ DRAFT │ 02.01.2024 - 02.01.2026
```
**Funktionsweise:**
- **Aktuellste Verträge oben** - Verträge ohne Nachfolger werden als Wurzelknoten angezeigt
- **Standardmäßig eingeklappt** - Klick auf ▶ zeigt die Vorgängerkette
- **Vorgänger eingerückt** - Mit grauem Rand und "(Vorgänger)" Label
- **Verknüpfung über `previousContractId`** - Wird beim Erstellen eines Folgevertrags automatisch gesetzt
> **Hinweis:** In der Hauptvertragsliste (`/contracts`) wird weiterhin die flache Ansicht ohne Baumstruktur verwendet.
## Lizenz
MIT

View File

@ -5,7 +5,16 @@ import { ApiResponse, AuthRequest } from '../types/index.js';
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
try {
const { customerId, type, status, search, page, limit } = req.query;
const { customerId, type, status, search, page, limit, tree } = req.query;
// Baumstruktur für Kundenansicht
if (tree === 'true' && customerId) {
const treeData = await contractService.getContractTreeForCustomer(
parseInt(customerId as string)
);
res.json({ success: true, data: treeData } as ApiResponse);
return;
}
// Für Kundenportal-Benutzer: nur eigene + vertretene Kunden-Verträge anzeigen
let customerIds: number[] | undefined;

View File

@ -77,7 +77,7 @@ export async function getAllContracts(filters: ContractFilters) {
where,
skip,
take,
orderBy: [{ startDate: 'desc' }, { createdAt: 'desc' }],
orderBy: [{ createdAt: 'desc' }],
include: {
customer: {
select: {
@ -778,3 +778,96 @@ export async function getSipCredentials(phoneNumberId: number): Promise<{ passwo
return { password: null };
}
}
// ==================== VERTRAGSBAUM FÜR KUNDENANSICHT ====================
export interface ContractTreeNode {
contract: {
id: number;
contractNumber: string;
type: ContractType;
status: ContractStatus;
startDate: Date | null;
endDate: Date | 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;
};
predecessors: ContractTreeNode[];
hasHistory: boolean;
}
/**
* Verträge eines Kunden als Baumstruktur abrufen.
* Wurzelknoten = Verträge ohne Nachfolger (aktuellste Verträge)
* Vorgänger werden rekursiv eingebettet.
*/
export async function getContractTreeForCustomer(customerId: number): Promise<ContractTreeNode[]> {
// Alle Verträge des Kunden laden (außer DEACTIVATED)
const allContracts = await prisma.contract.findMany({
where: {
customerId,
status: { not: ContractStatus.DEACTIVATED },
},
select: {
id: true,
contractNumber: true,
type: true,
status: true,
startDate: true,
endDate: true,
providerName: true,
tariffName: true,
previousContractId: true,
provider: { select: { id: true, name: true } },
tariff: { select: { id: true, name: true } },
contractCategory: { select: { id: true, name: true } },
},
orderBy: [{ startDate: 'desc' }, { createdAt: 'desc' }],
});
// Map für schnellen Zugriff: contractId -> contract
const contractMap = new Map(allContracts.map(c => [c.id, c]));
// Set der IDs die als Vorgänger referenziert werden
const predecessorIds = new Set(
allContracts
.filter(c => c.previousContractId !== null)
.map(c => c.previousContractId!)
);
// Wurzelverträge = Verträge die keinen Nachfolger haben
// (werden von keinem anderen Vertrag als previousContractId referenziert)
const rootContracts = allContracts.filter(c => !predecessorIds.has(c.id));
// Rekursive Funktion um Vorgängerkette aufzubauen
function buildPredecessorChain(contractId: number | null): ContractTreeNode[] {
if (contractId === null) return [];
const contract = contractMap.get(contractId);
if (!contract) return [];
const predecessors = buildPredecessorChain(contract.previousContractId);
return [{
contract,
predecessors,
hasHistory: predecessors.length > 0,
}];
}
// Baumstruktur für jeden Wurzelvertrag aufbauen
const tree: ContractTreeNode[] = rootContracts.map(contract => {
const predecessors = buildPredecessorChain(contract.previousContractId);
return {
contract,
predecessors,
hasHistory: predecessors.length > 0,
};
});
return tree;
}

File diff suppressed because one or more lines are too long

689
frontend/dist/assets/index-CooZFd_R.js vendored Normal file

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

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>

View File

@ -25,7 +25,7 @@ export default function EmailDetail({
onReply,
onAssignContract,
onDeleted,
isSentFolder = false,
isSentFolder: _isSentFolder = false,
isContractView = false,
isTrashView = false,
onRestored,

View File

@ -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>

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,34 +1535,70 @@ function ContractsTab({
DEACTIVATED: 'default',
};
return (
<div>
{hasPermission('contracts:create') && (
<div className="mb-4">
<Link to={`/contracts/new?customerId=${customerId}`}>
<Button size="sm">
<Plus className="w-4 h-4 mr-2" />
Vertrag anlegen
</Button>
</Link>
</div>
)}
const toggleExpand = (contractId: number) => {
setExpandedContracts(prev => {
const next = new Set(prev);
if (next.has(contractId)) {
next.delete(contractId);
} else {
next.add(contractId);
}
return next;
});
};
{contracts.length > 0 ? (
<div className="space-y-4">
{contracts.map((contract) => (
// 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
key={contract.id}
className="border rounded-lg p-4 hover:bg-gray-50 transition-colors"
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]}</Badge>
<Badge variant={statusVariants[contract.status]}>{contract.status}</Badge>
<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
@ -1594,22 +1637,56 @@ function ContractsTab({
)}
</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}` : '')} />
{(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">
<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') && (
<div className="mb-4">
<Link to={`/contracts/new?customerId=${customerId}`}>
<Button size="sm">
<Plus className="w-4 h-4 mr-2" />
Vertrag anlegen
</Button>
</Link>
</div>
)}
{contractTree.length > 0 ? (
<div className="space-y-4">
{contractTree.map(node => renderContractNode(node, 0))}
</div>
) : (
<p className="text-gray-500">Keine Verträge vorhanden.</p>

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;