added new view in contracts customer and contracts
This commit is contained in:
parent
97b4670643
commit
2b23ed64c4
20
README.md
20
README.md
|
|
@ -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 = true`: E-Mail wurde direkt aus dem Vertragskontext gesendet → X-Button ausgeblendet
|
||||||
> - `isAutoAssigned = false`: E-Mail wurde manuell dem Vertrag zugeordnet → X-Button sichtbar
|
> - `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
|
## Lizenz
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,16 @@ import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
|
|
||||||
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
|
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
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
|
// Für Kundenportal-Benutzer: nur eigene + vertretene Kunden-Verträge anzeigen
|
||||||
let customerIds: number[] | undefined;
|
let customerIds: number[] | undefined;
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export async function getAllContracts(filters: ContractFilters) {
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
orderBy: [{ startDate: 'desc' }, { createdAt: 'desc' }],
|
orderBy: [{ createdAt: 'desc' }],
|
||||||
include: {
|
include: {
|
||||||
customer: {
|
customer: {
|
||||||
select: {
|
select: {
|
||||||
|
|
@ -778,3 +778,96 @@ export async function getSipCredentials(phoneNumberId: number): Promise<{ passwo
|
||||||
return { password: null };
|
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
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
|
|
@ -5,8 +5,8 @@
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>OpenCRM</title>
|
<title>OpenCRM</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DxzmsVZ0.js"></script>
|
<script type="module" crossorigin src="/assets/index-CooZFd_R.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CUVVQncv.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DWDTTlpk.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export default function EmailDetail({
|
||||||
onReply,
|
onReply,
|
||||||
onAssignContract,
|
onAssignContract,
|
||||||
onDeleted,
|
onDeleted,
|
||||||
isSentFolder = false,
|
isSentFolder: _isSentFolder = false,
|
||||||
isContractView = false,
|
isContractView = false,
|
||||||
isTrashView = false,
|
isTrashView = false,
|
||||||
onRestored,
|
onRestored,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export default function ContractForm() {
|
||||||
customerId: preselectedCustomerId || '',
|
customerId: preselectedCustomerId || '',
|
||||||
type: 'ELECTRICITY',
|
type: 'ELECTRICITY',
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
|
previousContractId: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -61,6 +62,13 @@ export default function ContractForm() {
|
||||||
enabled: !!customerId,
|
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
|
// Fetch platforms
|
||||||
const { data: platformsData } = useQuery({
|
const { data: platformsData } = useQuery({
|
||||||
queryKey: ['platforms'],
|
queryKey: ['platforms'],
|
||||||
|
|
@ -256,6 +264,8 @@ export default function ContractForm() {
|
||||||
cancellationConfirmationDate: c.cancellationConfirmationDate ? c.cancellationConfirmationDate.split('T')[0] : '',
|
cancellationConfirmationDate: c.cancellationConfirmationDate ? c.cancellationConfirmationDate.split('T')[0] : '',
|
||||||
cancellationConfirmationOptionsDate: c.cancellationConfirmationOptionsDate ? c.cancellationConfirmationOptionsDate.split('T')[0] : '',
|
cancellationConfirmationOptionsDate: c.cancellationConfirmationOptionsDate ? c.cancellationConfirmationOptionsDate.split('T')[0] : '',
|
||||||
wasSpecialCancellation: c.wasSpecialCancellation || false,
|
wasSpecialCancellation: c.wasSpecialCancellation || false,
|
||||||
|
// Vorgänger-Vertrag
|
||||||
|
previousContractId: c.previousContractId?.toString() || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load simCards if available
|
// Load simCards if available
|
||||||
|
|
@ -427,6 +437,7 @@ export default function ContractForm() {
|
||||||
cancellationConfirmationDate: data.cancellationConfirmationDate ? new Date(data.cancellationConfirmationDate) : null,
|
cancellationConfirmationDate: data.cancellationConfirmationDate ? new Date(data.cancellationConfirmationDate) : null,
|
||||||
cancellationConfirmationOptionsDate: data.cancellationConfirmationOptionsDate ? new Date(data.cancellationConfirmationOptionsDate) : null,
|
cancellationConfirmationOptionsDate: data.cancellationConfirmationOptionsDate ? new Date(data.cancellationConfirmationOptionsDate) : null,
|
||||||
wasSpecialCancellation: data.wasSpecialCancellation || false,
|
wasSpecialCancellation: data.wasSpecialCancellation || false,
|
||||||
|
previousContractId: safeParseInt(data.previousContractId) ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add type-specific details
|
// 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 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 }));
|
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
|
// Get tariffs for selected provider
|
||||||
const selectedProvider = providers.find(p => p.id === parseInt(selectedProviderId || '0'));
|
const selectedProvider = providers.find(p => p.id === parseInt(selectedProviderId || '0'));
|
||||||
const availableTariffs = selectedProvider?.tariffs?.filter(t => t.isActive) || [];
|
const availableTariffs = selectedProvider?.tariffs?.filter(t => t.isActive) || [];
|
||||||
|
|
@ -615,6 +631,19 @@ export default function ContractForm() {
|
||||||
{...register('salesPlatformId')}
|
{...register('salesPlatformId')}
|
||||||
options={platforms.map((p) => ({ value: p.id, label: p.name }))}
|
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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { EmailClientTab } from '../../components/email';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import Card from '../../components/ui/Card';
|
import Card from '../../components/ui/Card';
|
||||||
|
|
@ -12,7 +12,7 @@ import Modal from '../../components/ui/Modal';
|
||||||
import Input from '../../components/ui/Input';
|
import Input from '../../components/ui/Input';
|
||||||
import Select from '../../components/ui/Select';
|
import Select from '../../components/ui/Select';
|
||||||
import FileUpload from '../../components/ui/FileUpload';
|
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 CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||||
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary } from '../../types';
|
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary } from '../../types';
|
||||||
|
|
||||||
|
|
@ -146,7 +146,6 @@ export default function CustomerDetail() {
|
||||||
content: (
|
content: (
|
||||||
<ContractsTab
|
<ContractsTab
|
||||||
customerId={customerId}
|
customerId={customerId}
|
||||||
contracts={c.contracts || []}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -1488,14 +1487,21 @@ function MetersTab({
|
||||||
|
|
||||||
function ContractsTab({
|
function ContractsTab({
|
||||||
customerId,
|
customerId,
|
||||||
contracts,
|
|
||||||
}: {
|
}: {
|
||||||
customerId: number;
|
customerId: number;
|
||||||
contracts: any[];
|
|
||||||
}) {
|
}) {
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: contractApi.delete,
|
mutationFn: contractApi.delete,
|
||||||
|
|
@ -1503,6 +1509,7 @@ function ContractsTab({
|
||||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contract-tree', customerId] });
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
alert(error?.message || 'Fehler beim Löschen des Vertrags');
|
alert(error?.message || 'Fehler beim Löschen des Vertrags');
|
||||||
|
|
@ -1528,34 +1535,70 @@ function ContractsTab({
|
||||||
DEACTIVATED: 'default',
|
DEACTIVATED: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const toggleExpand = (contractId: number) => {
|
||||||
<div>
|
setExpandedContracts(prev => {
|
||||||
{hasPermission('contracts:create') && (
|
const next = new Set(prev);
|
||||||
<div className="mb-4">
|
if (next.has(contractId)) {
|
||||||
<Link to={`/contracts/new?customerId=${customerId}`}>
|
next.delete(contractId);
|
||||||
<Button size="sm">
|
} else {
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
next.add(contractId);
|
||||||
Vertrag anlegen
|
}
|
||||||
</Button>
|
return next;
|
||||||
</Link>
|
});
|
||||||
</div>
|
};
|
||||||
)}
|
|
||||||
|
|
||||||
{contracts.length > 0 ? (
|
// Rekursive Rendering-Funktion für Vorgänger
|
||||||
<div className="space-y-4">
|
const renderPredecessors = (predecessors: ContractTreeNode[], depth: number): React.ReactNode => {
|
||||||
{contracts.map((contract) => (
|
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
|
<div
|
||||||
key={contract.id}
|
className={`
|
||||||
className="border rounded-lg p-4 hover:bg-gray-50 transition-colors"
|
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 justify-between mb-2">
|
||||||
<div className="flex items-center gap-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">
|
<span className="font-mono flex items-center gap-1">
|
||||||
{contract.contractNumber}
|
{contract.contractNumber}
|
||||||
<CopyButton value={contract.contractNumber} />
|
<CopyButton value={contract.contractNumber} />
|
||||||
</span>
|
</span>
|
||||||
<Badge>{typeLabels[contract.type]}</Badge>
|
<Badge>{typeLabels[contract.type] || contract.type}</Badge>
|
||||||
<Badge variant={statusVariants[contract.status]}>{contract.status}</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>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1594,22 +1637,56 @@ function ContractsTab({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{contract.providerName && (
|
{(contract.providerName || contract.provider?.name) && (
|
||||||
<p className="flex items-center gap-1">
|
<p className={`flex items-center gap-1 ${isPredecessor ? 'ml-6' : ''}`}>
|
||||||
{contract.providerName}
|
{contract.providerName || contract.provider?.name}
|
||||||
{contract.tariffName && ` - ${contract.tariffName}`}
|
{(contract.tariffName || contract.tariff?.name) && ` - ${contract.tariffName || contract.tariff?.name}`}
|
||||||
<CopyButton value={contract.providerName + (contract.tariffName ? ` - ${contract.tariffName}` : '')} />
|
<CopyButton value={(contract.providerName || contract.provider?.name || '') + ((contract.tariffName || contract.tariff?.name) ? ` - ${contract.tariffName || contract.tariff?.name}` : '')} />
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{contract.startDate && (
|
{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')}
|
Beginn: {new Date(contract.startDate).toLocaleDateString('de-DE')}
|
||||||
{contract.endDate &&
|
{contract.endDate &&
|
||||||
` | Ende: ${new Date(contract.endDate).toLocaleDateString('de-DE')}`}
|
` | Ende: ${new Date(contract.endDate).toLocaleDateString('de-DE')}`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-500">Keine Verträge vorhanden.</p>
|
<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 = {
|
export const contractApi = {
|
||||||
getAll: async (params?: { customerId?: number; type?: string; status?: string; search?: string; page?: number; limit?: number }) => {
|
getAll: async (params?: { customerId?: number; type?: string; status?: string; search?: string; page?: number; limit?: number }) => {
|
||||||
const res = await api.get<ApiResponse<Contract[]>>('/contracts', { params });
|
const res = await api.get<ApiResponse<Contract[]>>('/contracts', { params });
|
||||||
return res.data;
|
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) => {
|
getById: async (id: number) => {
|
||||||
const res = await api.get<ApiResponse<Contract>>(`/contracts/${id}`);
|
const res = await api.get<ApiResponse<Contract>>(`/contracts/${id}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue