Vertragsauswahl: Kundennr./Zählernr./Telefonnr. in allen Pickern
Zentraler Helper contractLabel.ts liefert je nach Vertragstyp die sinnvollen Zusatzinfos (nur wenn Daten vorhanden): - Kdnr. <customer.customerNumber> (immer) - Anbieter-Vtr. <contractNumberAtProvider> - Zähler <meterNumber> (Strom/Gas) - Mobil <main-SIM-Nummer> (Mobilfunk) - Festnetz <main-phone> (Internet) Backend contractApi.getAll include um energyDetails.meter und internetDetails.phoneNumbers erweitert – vorher fehlten die im Response. Angewendet auf 5 Call-Sites: - AssignToContractModal (Email-zu-Vertrag zuordnen) - TaskList (Mitarbeiter- und Kundenportal-"Neue Aufgabe/Anfrage") - Dashboard (Support-Anfrage-Modal) - PdfTemplates (Test-Vorlage-Kontext-Auswahl) - ContractForm (Vorgänger-Vertrag-Select) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -102,6 +102,22 @@ export async function getAllContracts(filters: ContractFilters) {
|
|||||||
simCards: { select: { phoneNumber: true, isMain: true } },
|
simCards: { select: { phoneNumber: true, isMain: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// 2026-07-04: für Vertragsauswahl-Labels brauchen wir überall
|
||||||
|
// die Zählernummer (Strom/Gas) und die erste Festnetznummer
|
||||||
|
// (Internet). Beides light-select, damit das Payload klein bleibt.
|
||||||
|
energyDetails: {
|
||||||
|
select: {
|
||||||
|
meter: { select: { id: true, meterNumber: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
internetDetails: {
|
||||||
|
select: {
|
||||||
|
phoneNumbers: {
|
||||||
|
select: { phoneNumber: true, isMain: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
carInsuranceDetails: { select: { licensePlate: true } },
|
carInsuranceDetails: { select: { licensePlate: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Modal from '../ui/Modal';
|
|||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import { contractApi, cachedEmailApi, CachedEmail } from '../../services/api';
|
import { contractApi, cachedEmailApi, CachedEmail } from '../../services/api';
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
|
import { buildContractLabelParts } from '../../utils/contractLabel';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
interface AssignToContractModalProps {
|
interface AssignToContractModalProps {
|
||||||
@@ -147,6 +148,14 @@ export default function AssignToContractModal({
|
|||||||
{contract.contractCategory?.name}
|
{contract.contractCategory?.name}
|
||||||
{contract.provider && ` - ${contract.provider.name}`}
|
{contract.provider && ` - ${contract.provider.name}`}
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const parts = buildContractLabelParts(contract);
|
||||||
|
return parts.extras.length > 0 ? (
|
||||||
|
<div className="text-xs text-gray-500 truncate">
|
||||||
|
{parts.extras.join(' · ')}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
Start: {formatDateOrDash(contract.startDate)}
|
Start: {formatDateOrDash(contract.startDate)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { customerApi, contractApi, contractTaskApi, appSettingsApi } from '../services/api';
|
import { customerApi, contractApi, contractTaskApi, appSettingsApi } from '../services/api';
|
||||||
|
import { buildContractLabelParts } from '../utils/contractLabel';
|
||||||
import Card from '../components/ui/Card';
|
import Card from '../components/ui/Card';
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
import Input from '../components/ui/Input';
|
import Input from '../components/ui/Input';
|
||||||
@@ -572,6 +573,14 @@ function CreateSupportTicketModal({
|
|||||||
{contract.providerName || 'Kein Anbieter'}
|
{contract.providerName || 'Kein Anbieter'}
|
||||||
{contract.tariffName && ` - ${contract.tariffName}`}
|
{contract.tariffName && ` - ${contract.tariffName}`}
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const parts = buildContractLabelParts(contract);
|
||||||
|
return parts.extras.length > 0 ? (
|
||||||
|
<div className="text-xs text-gray-400 truncate">
|
||||||
|
{parts.extras.join(' · ')}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Input from '../../components/ui/Input';
|
|||||||
import Select from '../../components/ui/Select';
|
import Select from '../../components/ui/Select';
|
||||||
import CopyButton from '../../components/ui/CopyButton';
|
import CopyButton from '../../components/ui/CopyButton';
|
||||||
import CustomerInfoModal from '../../components/contracts/CustomerInfoModal';
|
import CustomerInfoModal from '../../components/contracts/CustomerInfoModal';
|
||||||
|
import { buildContractLabelParts } from '../../utils/contractLabel';
|
||||||
import type { ContractType } from '../../types';
|
import type { ContractType } from '../../types';
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||||
@@ -878,10 +879,15 @@ export default function ContractForm() {
|
|||||||
<Select
|
<Select
|
||||||
label="Vorgänger-Vertrag"
|
label="Vorgänger-Vertrag"
|
||||||
{...register('previousContractId')}
|
{...register('previousContractId')}
|
||||||
options={predecessorContracts.map((c) => ({
|
options={predecessorContracts.map((c) => {
|
||||||
value: c.id,
|
const parts = buildContractLabelParts(c);
|
||||||
label: `${c.contractNumber} (${c.type}${c.startDate ? ` - ${formatDate(c.startDate)}` : ''})`,
|
const dateHint = c.startDate ? ` – ${formatDate(c.startDate)}` : '';
|
||||||
}))}
|
const suffix = parts.extras.length > 0 ? ` · ${parts.extras.join(' · ')}` : '';
|
||||||
|
return {
|
||||||
|
value: c.id,
|
||||||
|
label: `${c.contractNumber} (${c.type}${dateHint})${suffix}`,
|
||||||
|
};
|
||||||
|
})}
|
||||||
placeholder="Keinen Vorgänger auswählen"
|
placeholder="Keinen Vorgänger auswählen"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { pdfTemplateApi, contractApi, authApi } from '../../services/api';
|
import { pdfTemplateApi, contractApi, authApi } from '../../services/api';
|
||||||
import type { PdfTemplate, CrmField, Contract } from '../../types';
|
import type { PdfTemplate, CrmField, Contract } from '../../types';
|
||||||
|
import { buildContractLabelParts } from '../../utils/contractLabel';
|
||||||
import Card from '../../components/ui/Card';
|
import Card from '../../components/ui/Card';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
import Input from '../../components/ui/Input';
|
import Input from '../../components/ui/Input';
|
||||||
@@ -475,13 +476,23 @@ function TestPreviewModal({ template, onClose }: { template: PdfTemplate; onClos
|
|||||||
selectedContractId === c.id ? 'bg-blue-50 border-blue-200' : ''
|
selectedContractId === c.id ? 'bg-blue-50 border-blue-200' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<span className="font-mono font-medium">{c.contractNumber}</span>
|
<div>
|
||||||
{c.customer && (
|
<span className="font-mono font-medium">{c.contractNumber}</span>
|
||||||
<span className="text-gray-500 ml-2">
|
{c.customer && (
|
||||||
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
|
<span className="text-gray-500 ml-2">
|
||||||
</span>
|
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const parts = buildContractLabelParts(c);
|
||||||
|
return parts.extras.length > 0 ? (
|
||||||
|
<div className="text-xs text-gray-400 truncate">
|
||||||
|
{parts.extras.join(' · ')}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="default">{c.type}</Badge>
|
<Badge variant="default">{c.type}</Badge>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { ContractTask, ContractTaskStatus, Contract, Customer, ContractTaskSubtask } from '../../types';
|
import type { ContractTask, ContractTaskStatus, Contract, Customer, ContractTaskSubtask } from '../../types';
|
||||||
|
import { buildContractLabelParts } from '../../utils/contractLabel';
|
||||||
|
|
||||||
const statusLabels: Record<ContractTaskStatus, string> = {
|
const statusLabels: Record<ContractTaskStatus, string> = {
|
||||||
OPEN: 'Offen',
|
OPEN: 'Offen',
|
||||||
@@ -687,6 +688,14 @@ function CreateSupportTicketModal({
|
|||||||
{contract.providerName || 'Kein Anbieter'}
|
{contract.providerName || 'Kein Anbieter'}
|
||||||
{contract.tariffName && ` - ${contract.tariffName}`}
|
{contract.tariffName && ` - ${contract.tariffName}`}
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const parts = buildContractLabelParts(contract);
|
||||||
|
return parts.extras.length > 0 ? (
|
||||||
|
<div className="text-xs text-gray-400 truncate">
|
||||||
|
{parts.extras.join(' · ')}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -915,6 +924,14 @@ function CreateTaskModal({
|
|||||||
{contract.providerName || 'Kein Anbieter'}
|
{contract.providerName || 'Kein Anbieter'}
|
||||||
{contract.tariffName && ` - ${contract.tariffName}`}
|
{contract.tariffName && ` - ${contract.tariffName}`}
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const parts = buildContractLabelParts(contract);
|
||||||
|
return parts.extras.length > 0 ? (
|
||||||
|
<div className="text-xs text-gray-400 truncate">
|
||||||
|
{parts.extras.join(' · ')}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// Einheitliches Label für Vertragsauswahl-Listen (Assign-Modals, Neue-
|
||||||
|
// Aufgabe, Neue-Support-Anfrage, Vorgänger-Vertrag, PDF-Vorlage-Test).
|
||||||
|
//
|
||||||
|
// User-Wunsch 2026-07-04: überall, wo man einen Vertrag aus einer Liste
|
||||||
|
// wählt, sollen zusätzlich zur Vertragsnummer folgende Infos sichtbar
|
||||||
|
// sein, sofern vorhanden:
|
||||||
|
// - Kundennummer (interne CRM-Nummer des Kunden)
|
||||||
|
// - Vertragsnummer beim Anbieter (contractNumberAtProvider)
|
||||||
|
// - Zählernummer (Strom/Gas, aus energyDetails.meter)
|
||||||
|
// - Mobilfunknummer (main SIM) bei Mobilfunk
|
||||||
|
// - Erste Festnetznummer bei Internet-Verträgen
|
||||||
|
//
|
||||||
|
// Der Helper liefert entweder eine flache Textzeile
|
||||||
|
// (`formatContractLabel`) oder ein strukturiertes Objekt mit Bestandteilen
|
||||||
|
// (`buildContractLabelParts`), damit UI-Views mit mehreren Zeilen /
|
||||||
|
// Badges das gleiche Modell teilen.
|
||||||
|
|
||||||
|
import type { Contract } from '../types';
|
||||||
|
|
||||||
|
export interface ContractLabelParts {
|
||||||
|
/** Interne Vertragsnummer (immer vorhanden). */
|
||||||
|
contractNumber: string;
|
||||||
|
/** Anbietername oder null, wenn keiner gesetzt ist. */
|
||||||
|
providerName: string | null;
|
||||||
|
/** Tarifname oder null. */
|
||||||
|
tariffName: string | null;
|
||||||
|
/**
|
||||||
|
* Kurze Zusatzinfos, jede nur wenn Daten existieren – z.B.
|
||||||
|
* `Kdnr. 12345`, `Anbieter-Vtr. VG-2026`, `Zähler 1EMH…`,
|
||||||
|
* `Mobil +49 …`, `Festnetz +49 …`.
|
||||||
|
*/
|
||||||
|
extras: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fischt die relevanten Zusatzfelder aus dem Contract raus. Fehlende
|
||||||
|
* Felder werden übersprungen – das Ergebnis passt sich automatisch dem
|
||||||
|
* Vertragstyp an (Zählernummer nur bei Strom/Gas, Mobilfunknr nur bei
|
||||||
|
* MOBILE, Festnetznr nur bei INTERNET etc.).
|
||||||
|
*/
|
||||||
|
export function buildContractLabelParts(contract: Contract): ContractLabelParts {
|
||||||
|
const providerName =
|
||||||
|
contract.provider?.name ??
|
||||||
|
(contract.providerName ? contract.providerName : null);
|
||||||
|
const tariffName =
|
||||||
|
contract.tariff?.name ??
|
||||||
|
(contract.tariffName ? contract.tariffName : null);
|
||||||
|
|
||||||
|
const extras: string[] = [];
|
||||||
|
|
||||||
|
const customerNumber = contract.customer?.customerNumber;
|
||||||
|
if (customerNumber) {
|
||||||
|
extras.push(`Kdnr. ${customerNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerContractNumber = contract.contractNumberAtProvider;
|
||||||
|
if (providerContractNumber) {
|
||||||
|
extras.push(`Anbieter-Vtr. ${providerContractNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meterNumber = contract.energyDetails?.meter?.meterNumber;
|
||||||
|
if (meterNumber) {
|
||||||
|
extras.push(`Zähler ${meterNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobilfunk: Main-SIM-Nummer bevorzugt, sonst erste vorhandene.
|
||||||
|
const simCards = contract.mobileDetails?.simCards ?? [];
|
||||||
|
if (simCards.length > 0) {
|
||||||
|
const mainSim = simCards.find((s) => s.isMain && s.phoneNumber) ?? simCards.find((s) => !!s.phoneNumber);
|
||||||
|
if (mainSim?.phoneNumber) {
|
||||||
|
extras.push(`Mobil ${mainSim.phoneNumber}`);
|
||||||
|
} else if (contract.mobileDetails?.phoneNumber) {
|
||||||
|
extras.push(`Mobil ${contract.mobileDetails.phoneNumber}`);
|
||||||
|
}
|
||||||
|
} else if (contract.mobileDetails?.phoneNumber) {
|
||||||
|
extras.push(`Mobil ${contract.mobileDetails.phoneNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Festnetz: Main-Nummer bevorzugt, sonst erste vorhandene.
|
||||||
|
const phones = contract.internetDetails?.phoneNumbers ?? [];
|
||||||
|
if (phones.length > 0) {
|
||||||
|
const mainPhone = phones.find((p) => p.isMain && p.phoneNumber) ?? phones.find((p) => !!p.phoneNumber);
|
||||||
|
if (mainPhone?.phoneNumber) {
|
||||||
|
extras.push(`Festnetz ${mainPhone.phoneNumber}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contractNumber: contract.contractNumber,
|
||||||
|
providerName,
|
||||||
|
tariffName,
|
||||||
|
extras,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flach-String-Version für <Select>-Options:
|
||||||
|
* `GAS-XYZ · Anbieter · Tarif · Kdnr. 123 · Zähler 1EMH…`
|
||||||
|
* Trenner: ` · ` (schmale Bullets ohne Sonderzeichen im URL/DOM).
|
||||||
|
*/
|
||||||
|
export function formatContractLabel(contract: Contract): string {
|
||||||
|
const parts = buildContractLabelParts(contract);
|
||||||
|
const chunks: string[] = [parts.contractNumber];
|
||||||
|
if (parts.providerName) chunks.push(parts.providerName);
|
||||||
|
if (parts.tariffName) chunks.push(parts.tariffName);
|
||||||
|
chunks.push(...parts.extras);
|
||||||
|
return chunks.join(' · ');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user