Compare commits
5 Commits
7c18343a95
...
2fee13d09e
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fee13d09e | |||
| 84cbf01706 | |||
| fcc3b04725 | |||
| 101369c205 | |||
| e792fe4185 |
@@ -0,0 +1,7 @@
|
|||||||
|
-- Vertrieb-/Provider-Trennung: viele Plattformen vergeben eigene Kunden-/
|
||||||
|
-- Vertragsnummern, die nicht mit denen beim Endanbieter identisch sind.
|
||||||
|
-- Zwei neue optionale Felder unter "Anbieter & Tarif".
|
||||||
|
|
||||||
|
ALTER TABLE `Contract`
|
||||||
|
ADD COLUMN IF NOT EXISTS `customerNumberAtSalesPlatform` VARCHAR(191) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS `contractNumberAtSalesPlatform` VARCHAR(191) NULL;
|
||||||
@@ -687,6 +687,8 @@ model Contract {
|
|||||||
tariffName String?
|
tariffName String?
|
||||||
customerNumberAtProvider String?
|
customerNumberAtProvider String?
|
||||||
contractNumberAtProvider String? // Vertragsnummer beim Anbieter
|
contractNumberAtProvider String? // Vertragsnummer beim Anbieter
|
||||||
|
customerNumberAtSalesPlatform String? // Kundennummer bei der Vertriebsplattform
|
||||||
|
contractNumberAtSalesPlatform String? // Vertragsnummer bei der Vertriebsplattform
|
||||||
priceFirst12Months String? // Preis erste 12 Monate
|
priceFirst12Months String? // Preis erste 12 Monate
|
||||||
priceFrom13Months String? // Preis ab 13. Monat
|
priceFrom13Months String? // Preis ab 13. Monat
|
||||||
priceAfter24Months String? // Preis nach 24 Monaten
|
priceAfter24Months String? // Preis nach 24 Monaten
|
||||||
|
|||||||
@@ -203,6 +203,9 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
|||||||
const fieldLabels: Record<string, string> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
status: 'Status', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
|
status: 'Status', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
|
||||||
portalUsername: 'Portal-Benutzername', customerNumberAtProvider: 'Kundennummer beim Anbieter',
|
portalUsername: 'Portal-Benutzername', customerNumberAtProvider: 'Kundennummer beim Anbieter',
|
||||||
|
contractNumberAtProvider: 'Vertragsnummer beim Anbieter',
|
||||||
|
customerNumberAtSalesPlatform: 'Kundennummer bei Vertriebsplattform',
|
||||||
|
contractNumberAtSalesPlatform: 'Vertragsnummer bei Vertriebsplattform',
|
||||||
providerId: 'Anbieter', tariffId: 'Tarif', cancellationPeriodId: 'Kündigungsfrist',
|
providerId: 'Anbieter', tariffId: 'Tarif', cancellationPeriodId: 'Kündigungsfrist',
|
||||||
contractDurationId: 'Vertragslaufzeit', platformId: 'Vertriebsplattform',
|
contractDurationId: 'Vertragslaufzeit', platformId: 'Vertriebsplattform',
|
||||||
cancellationDate: 'Kündigungsdatum', cancellationSentDate: 'Kündigung gesendet am',
|
cancellationDate: 'Kündigungsdatum', cancellationSentDate: 'Kündigung gesendet am',
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ interface ContractCreateData {
|
|||||||
providerName?: string;
|
providerName?: string;
|
||||||
tariffName?: string;
|
tariffName?: string;
|
||||||
customerNumberAtProvider?: string;
|
customerNumberAtProvider?: string;
|
||||||
|
customerNumberAtSalesPlatform?: string;
|
||||||
|
contractNumberAtSalesPlatform?: string;
|
||||||
priceFirst12Months?: string;
|
priceFirst12Months?: string;
|
||||||
priceFrom13Months?: string;
|
priceFrom13Months?: string;
|
||||||
priceAfter24Months?: string;
|
priceAfter24Months?: string;
|
||||||
@@ -896,6 +898,9 @@ export async function createRenewalContract(previousContractId: number) {
|
|||||||
providerName: previousContract.providerName,
|
providerName: previousContract.providerName,
|
||||||
tariffName: previousContract.tariffName,
|
tariffName: previousContract.tariffName,
|
||||||
customerNumberAtProvider: previousContract.customerNumberAtProvider,
|
customerNumberAtProvider: previousContract.customerNumberAtProvider,
|
||||||
|
contractNumberAtProvider: previousContract.contractNumberAtProvider,
|
||||||
|
customerNumberAtSalesPlatform: previousContract.customerNumberAtSalesPlatform,
|
||||||
|
contractNumberAtSalesPlatform: previousContract.contractNumberAtSalesPlatform,
|
||||||
portalUsername: previousContract.portalUsername,
|
portalUsername: previousContract.portalUsername,
|
||||||
portalPasswordEncrypted: previousContract.portalPasswordEncrypted,
|
portalPasswordEncrypted: previousContract.portalPasswordEncrypted,
|
||||||
commission: previousContract.commission,
|
commission: previousContract.commission,
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ const CONTRACT_DISPLAY_STRING_FIELDS = [
|
|||||||
'tariffName',
|
'tariffName',
|
||||||
'customerNumberAtProvider',
|
'customerNumberAtProvider',
|
||||||
'contractNumberAtProvider',
|
'contractNumberAtProvider',
|
||||||
|
'customerNumberAtSalesPlatform',
|
||||||
|
'contractNumberAtSalesPlatform',
|
||||||
'portalUsername',
|
'portalUsername',
|
||||||
'previousProviderName',
|
'previousProviderName',
|
||||||
'previousCustomerNumber',
|
'previousCustomerNumber',
|
||||||
@@ -276,9 +278,14 @@ export function assertSafePdf(buf: Buffer): void {
|
|||||||
if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
||||||
return; // keine PDF → andere Validatoren zuständig
|
return; // keine PDF → andere Validatoren zuständig
|
||||||
}
|
}
|
||||||
const content = buf.toString('latin1');
|
// Stream-Inhalte (Bilder/Fonts/Komprimiertes) aus dem Scan rausnehmen.
|
||||||
|
// Jpeg-Bytes können zufällig "/JavaScript" enthalten → false-positive
|
||||||
|
// bei jsPDF-generierten PDFs mit eingebetteten Fotos (stage-Bug
|
||||||
|
// 2026-06-03). Echte aktive PDF-Inhalte stehen IMMER im PDF-
|
||||||
|
// Object-Stream (außerhalb von `stream..endstream`-Blöcken).
|
||||||
|
const scanTarget = buf.toString('latin1').replace(/stream\s[\s\S]*?endstream/g, '');
|
||||||
for (const { pattern, label } of PDF_DANGER_PATTERNS) {
|
for (const { pattern, label } of PDF_DANGER_PATTERNS) {
|
||||||
if (pattern.test(content)) {
|
if (pattern.test(scanTarget)) {
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
415,
|
415,
|
||||||
`PDF enthält nicht erlaubte aktive Inhalte (${label}). Bitte ohne JavaScript / Auto-Actions / eingebettete Dateien hochladen.`,
|
`PDF enthält nicht erlaubte aktive Inhalte (${label}). Bitte ohne JavaScript / Auto-Actions / eingebettete Dateien hochladen.`,
|
||||||
|
|||||||
@@ -97,6 +97,47 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🆕 Kunden-Detail-Tabs: Pro-Tab-Link „in neuem Tab öffnen"**
|
||||||
|
- `Tabs`-Komponente um optionalen Prop `tabHrefBuilder(tabId)` erweitert.
|
||||||
|
Wenn gesetzt, erscheint neben jedem Tab-Label ein kleines
|
||||||
|
`ExternalLink`-Icon. CustomerDetail übergibt den Builder mit
|
||||||
|
`?tab=<id>`-Query-Param, der eh schon vom URL-Sync genutzt wird.
|
||||||
|
- `target="_blank"` + `rel="noopener noreferrer"` + Klick-stopPropagation,
|
||||||
|
damit der Tab-Wechsel nicht parallel zur Tab-Aktivierung passiert.
|
||||||
|
|
||||||
|
- [x] **🆕 Vertrag: Kunden-/Vertragsnummer bei Vertriebsplattform**
|
||||||
|
- Zwei neue optionale Felder
|
||||||
|
`Contract.customerNumberAtSalesPlatform` +
|
||||||
|
`contractNumberAtSalesPlatform`, Migration
|
||||||
|
`20260603150000_contract_sales_platform_numbers` mit
|
||||||
|
`IF NOT EXISTS`.
|
||||||
|
- Im ContractForm direkt unter „Kundennummer/Vertragsnummer beim
|
||||||
|
Anbieter" angeordnet. ContractDetail zeigt sie als eigene Zeilen
|
||||||
|
mit Copy-Button. Audit-Log-Mapping + Renewal-Copy + XSS-Strip
|
||||||
|
(CONTRACT_DISPLAY_STRING_FIELDS) mitgezogen.
|
||||||
|
- Bonus: das fehlende `contractNumberAtProvider` im Renewal-Copy
|
||||||
|
und Audit-Label-Mapping ist gleich mit drin – wurde bisher
|
||||||
|
nicht in VVL-Folgeverträge kopiert.
|
||||||
|
|
||||||
|
- [x] **🆕 Email-Links öffnen im neuen Tab**
|
||||||
|
- In `EmailDetail` nach der DOMPurify-Sanitize jedes `<a>`-Element
|
||||||
|
auf `target="_blank"` + `rel="noopener noreferrer"` gesetzt. Letzteres
|
||||||
|
verhindert window.opener-Tab-Hijacking. Sanitize + DOM-Walk laufen
|
||||||
|
in einem `useMemo`, das nur bei Wechsel der Email neu rechnet.
|
||||||
|
|
||||||
|
- [x] **🐞 assertSafePdf: jspdf-PDFs mit JPEGs fälschlich als „JavaScript" blockiert**
|
||||||
|
- Stage-Bug: User lädt Ausweis als „JPGs → PDF" hoch → 415 mit
|
||||||
|
Meldung „PDF enthält JavaScript-Action". Backend hat den jspdf-
|
||||||
|
Output korrekt strukturell, aber die JPEG-Bytes im Image-Stream
|
||||||
|
enthielten zufällig die Byte-Folge „/JavaScript" → Pattern-Match.
|
||||||
|
- Fix: vor dem Pattern-Scan `stream..endstream`-Blöcke aus dem
|
||||||
|
PDF-Text rausnehmen. Echte aktive Inhalte stehen IMMER außerhalb
|
||||||
|
von Streams (in PDF-Object-Dictionaries) – Binär-Streams enthalten
|
||||||
|
Bilder/Fonts/Komprimiertes und werden jetzt zu Recht ignoriert.
|
||||||
|
- Smoke-Test: jspdf-Style-PDF mit `/JavaScript`-Bytes im Stream
|
||||||
|
durchgewinkt, echte `/OpenAction /S /JavaScript` weiterhin
|
||||||
|
blockiert, clean PDF weiterhin OK.
|
||||||
|
|
||||||
- [x] **🐞 AddressModal: Straße-Feld ließ sich nicht editieren**
|
- [x] **🐞 AddressModal: Straße-Feld ließ sich nicht editieren**
|
||||||
- `setFormData` wurde unbedingt im Render-Body aufgerufen, wenn
|
- `setFormData` wurde unbedingt im Render-Body aufgerufen, wenn
|
||||||
`formData.street !== address.street`. Jeder Tastendruck löste neu
|
`formData.street !== address.street`. Jeder Tastendruck löste neu
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Reply, Forward, RotateCcw, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
|
import { Reply, Forward, RotateCcw, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { CachedEmail, cachedEmailApi } from '../../services/api';
|
import { CachedEmail, cachedEmailApi } from '../../services/api';
|
||||||
@@ -52,6 +52,24 @@ export default function EmailDetail({
|
|||||||
setLocalStarred(email.isStarred);
|
setLocalStarred(email.isStarred);
|
||||||
}, [email.id, email.isStarred]);
|
}, [email.id, email.isStarred]);
|
||||||
|
|
||||||
|
// Email-Body sanitizen + alle <a>-Links auf neuen Tab umstellen.
|
||||||
|
// rel="noopener noreferrer" verhindert window.opener-Tab-Hijacking.
|
||||||
|
const safeHtmlBody = useMemo(() => {
|
||||||
|
if (!email.htmlBody) return '';
|
||||||
|
const sanitized = DOMPurify.sanitize(email.htmlBody, {
|
||||||
|
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
|
||||||
|
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus'],
|
||||||
|
ADD_ATTR: ['target'],
|
||||||
|
});
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.innerHTML = sanitized;
|
||||||
|
wrapper.querySelectorAll('a').forEach((a) => {
|
||||||
|
a.setAttribute('target', '_blank');
|
||||||
|
a.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
});
|
||||||
|
return wrapper.innerHTML;
|
||||||
|
}, [email.htmlBody]);
|
||||||
|
|
||||||
const toggleStarMutation = useMutation({
|
const toggleStarMutation = useMutation({
|
||||||
mutationFn: () => cachedEmailApi.toggleStar(email.id),
|
mutationFn: () => cachedEmailApi.toggleStar(email.id),
|
||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
@@ -312,6 +330,16 @@ export default function EmailDetail({
|
|||||||
{email.contract.contractNumber}
|
{email.contract.contractNumber}
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
|
<a
|
||||||
|
href={`/contracts/${email.contract.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-1 text-green-600 hover:text-green-800"
|
||||||
|
title="Vertrag in neuem Tab öffnen"
|
||||||
|
aria-label="Vertrag in neuem Tab öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
{/* X-Button nur für manuell zugeordnete E-Mails (nicht für automatisch zugeordnete aus Vertrag gesendete) */}
|
{/* X-Button nur für manuell zugeordnete E-Mails (nicht für automatisch zugeordnete aus Vertrag gesendete) */}
|
||||||
{!email.isAutoAssigned && (
|
{!email.isAutoAssigned && (
|
||||||
<button
|
<button
|
||||||
@@ -411,16 +439,7 @@ export default function EmailDetail({
|
|||||||
{showHtml && email.htmlBody ? (
|
{showHtml && email.htmlBody ? (
|
||||||
<div
|
<div
|
||||||
className="prose prose-sm max-w-none"
|
className="prose prose-sm max-w-none"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{ __html: safeHtmlBody }}
|
||||||
__html: DOMPurify.sanitize(email.htmlBody, {
|
|
||||||
// Scripte, Inline-Handler, Form-Elemente, externe Referenzen verbieten.
|
|
||||||
// Bilder + Links mit target=_blank bleiben zugelassen.
|
|
||||||
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
|
|
||||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus'],
|
|
||||||
// Links in neuen Tabs öffnen (verhindert window.opener-Angriffe)
|
|
||||||
ADD_ATTR: ['target'],
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<pre className="whitespace-pre-wrap text-sm text-gray-700 font-sans">
|
<pre className="whitespace-pre-wrap text-sm text-gray-700 font-sans">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ReactNode, useState, useEffect } from 'react';
|
import { ReactNode, useState, useEffect } from 'react';
|
||||||
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,9 +12,21 @@ interface TabsProps {
|
|||||||
defaultTab?: string;
|
defaultTab?: string;
|
||||||
activeTab?: string;
|
activeTab?: string;
|
||||||
onTabChange?: (tabId: string) => void;
|
onTabChange?: (tabId: string) => void;
|
||||||
|
/**
|
||||||
|
* Optional: liefert die URL, unter der ein einzelner Tab in einem
|
||||||
|
* neuen Tab geöffnet werden kann. Wenn gesetzt, erscheint neben jedem
|
||||||
|
* Tab-Label ein kleines „im neuen Tab öffnen"-Icon.
|
||||||
|
*/
|
||||||
|
tabHrefBuilder?: (tabId: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Tabs({ tabs, defaultTab, activeTab: controlledTab, onTabChange }: TabsProps) {
|
export default function Tabs({
|
||||||
|
tabs,
|
||||||
|
defaultTab,
|
||||||
|
activeTab: controlledTab,
|
||||||
|
onTabChange,
|
||||||
|
tabHrefBuilder,
|
||||||
|
}: TabsProps) {
|
||||||
const [internalTab, setInternalTab] = useState(defaultTab || tabs[0]?.id);
|
const [internalTab, setInternalTab] = useState(defaultTab || tabs[0]?.id);
|
||||||
const activeTab = controlledTab ?? internalTab;
|
const activeTab = controlledTab ?? internalTab;
|
||||||
|
|
||||||
@@ -31,19 +44,33 @@ export default function Tabs({ tabs, defaultTab, activeTab: controlledTab, onTab
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<nav className="flex -mb-px space-x-8">
|
<nav className="flex -mb-px space-x-6">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<div key={tab.id} className="flex items-center gap-1">
|
||||||
key={tab.id}
|
<button
|
||||||
onClick={() => handleTabChange(tab.id)}
|
onClick={() => handleTabChange(tab.id)}
|
||||||
className={`py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap ${
|
className={`py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-blue-500 text-blue-600'
|
? 'border-blue-500 text-blue-600'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
|
{tabHrefBuilder && (
|
||||||
|
<a
|
||||||
|
href={tabHrefBuilder(tab.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-0.5"
|
||||||
|
title={`${tab.label} in neuem Tab öffnen`}
|
||||||
|
aria-label={`${tab.label} in neuem Tab öffnen`}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2080,6 +2080,24 @@ export default function ContractDetail() {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{c.customerNumberAtSalesPlatform && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">Kundennr. Vertriebsplattform</dt>
|
||||||
|
<dd className="font-mono flex items-center gap-1">
|
||||||
|
{c.customerNumberAtSalesPlatform}
|
||||||
|
<CopyButton value={c.customerNumberAtSalesPlatform} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{c.contractNumberAtSalesPlatform && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">Vertragsnr. Vertriebsplattform</dt>
|
||||||
|
<dd className="font-mono flex items-center gap-1">
|
||||||
|
{c.contractNumberAtSalesPlatform}
|
||||||
|
<CopyButton value={c.contractNumberAtSalesPlatform} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{c.salesPlatform && (
|
{c.salesPlatform && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-gray-500">Vertriebsplattform</dt>
|
<dt className="text-sm text-gray-500">Vertriebsplattform</dt>
|
||||||
|
|||||||
@@ -302,6 +302,8 @@ export default function ContractForm() {
|
|||||||
tariffName: c.tariffName || '',
|
tariffName: c.tariffName || '',
|
||||||
customerNumberAtProvider: c.customerNumberAtProvider || '',
|
customerNumberAtProvider: c.customerNumberAtProvider || '',
|
||||||
contractNumberAtProvider: c.contractNumberAtProvider || '',
|
contractNumberAtProvider: c.contractNumberAtProvider || '',
|
||||||
|
customerNumberAtSalesPlatform: c.customerNumberAtSalesPlatform || '',
|
||||||
|
contractNumberAtSalesPlatform: c.contractNumberAtSalesPlatform || '',
|
||||||
priceFirst12Months: c.priceFirst12Months || '',
|
priceFirst12Months: c.priceFirst12Months || '',
|
||||||
priceFrom13Months: c.priceFrom13Months || '',
|
priceFrom13Months: c.priceFrom13Months || '',
|
||||||
priceAfter24Months: c.priceAfter24Months || '',
|
priceAfter24Months: c.priceAfter24Months || '',
|
||||||
@@ -556,6 +558,8 @@ export default function ContractForm() {
|
|||||||
tariffName: emptyToNull(data.tariffName),
|
tariffName: emptyToNull(data.tariffName),
|
||||||
customerNumberAtProvider: emptyToNull(data.customerNumberAtProvider),
|
customerNumberAtProvider: emptyToNull(data.customerNumberAtProvider),
|
||||||
contractNumberAtProvider: emptyToNull(data.contractNumberAtProvider),
|
contractNumberAtProvider: emptyToNull(data.contractNumberAtProvider),
|
||||||
|
customerNumberAtSalesPlatform: emptyToNull(data.customerNumberAtSalesPlatform),
|
||||||
|
contractNumberAtSalesPlatform: emptyToNull(data.contractNumberAtSalesPlatform),
|
||||||
priceFirst12Months: emptyToNull(data.priceFirst12Months),
|
priceFirst12Months: emptyToNull(data.priceFirst12Months),
|
||||||
priceFrom13Months: emptyToNull(data.priceFrom13Months),
|
priceFrom13Months: emptyToNull(data.priceFrom13Months),
|
||||||
priceAfter24Months: emptyToNull(data.priceAfter24Months),
|
priceAfter24Months: emptyToNull(data.priceAfter24Months),
|
||||||
@@ -952,6 +956,8 @@ export default function ContractForm() {
|
|||||||
/>
|
/>
|
||||||
<Input label="Kundennummer beim Anbieter" {...register('customerNumberAtProvider')} />
|
<Input label="Kundennummer beim Anbieter" {...register('customerNumberAtProvider')} />
|
||||||
<Input label="Vertragsnummer beim Anbieter" {...register('contractNumberAtProvider')} />
|
<Input label="Vertragsnummer beim Anbieter" {...register('contractNumberAtProvider')} />
|
||||||
|
<Input label="Kundennummer bei Vertriebsplattform" {...register('customerNumberAtSalesPlatform')} />
|
||||||
|
<Input label="Vertragsnummer bei Vertriebsplattform" {...register('contractNumberAtSalesPlatform')} />
|
||||||
<Input label="Provision (€)" type="number" step="0.01" {...register('commission')} />
|
<Input label="Provision (€)" type="number" step="0.01" {...register('commission')} />
|
||||||
<Input label="Preis erste 12 Monate" {...register('priceFirst12Months')} placeholder="z.B. 29,99 €/Monat" />
|
<Input label="Preis erste 12 Monate" {...register('priceFirst12Months')} placeholder="z.B. 29,99 €/Monat" />
|
||||||
<Input label="Preis ab 13. Monat" {...register('priceFrom13Months')} placeholder="z.B. 39,99 €/Monat" />
|
<Input label="Preis ab 13. Monat" {...register('priceFrom13Months')} placeholder="z.B. 39,99 €/Monat" />
|
||||||
|
|||||||
@@ -411,7 +411,13 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Tabs tabs={tabs} defaultTab={defaultTab} activeTab={activeTab} onTabChange={handleTabChange} />
|
<Tabs
|
||||||
|
tabs={tabs}
|
||||||
|
defaultTab={defaultTab}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
tabHrefBuilder={(tabId) => `${location.pathname}?tab=${tabId}`}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<AddressModal
|
<AddressModal
|
||||||
|
|||||||
@@ -454,6 +454,8 @@ export interface Contract {
|
|||||||
tariffName?: string;
|
tariffName?: string;
|
||||||
customerNumberAtProvider?: string;
|
customerNumberAtProvider?: string;
|
||||||
contractNumberAtProvider?: string;
|
contractNumberAtProvider?: string;
|
||||||
|
customerNumberAtSalesPlatform?: string;
|
||||||
|
contractNumberAtSalesPlatform?: string;
|
||||||
priceFirst12Months?: string;
|
priceFirst12Months?: string;
|
||||||
priceFrom13Months?: string;
|
priceFrom13Months?: string;
|
||||||
priceAfter24Months?: string;
|
priceAfter24Months?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user