Rufnummern: Vorwahl als eigenes Feld – verlässliche PDF-Befüllung

Bisher steht in PhoneNumber.phoneNumber die kombinierte Nummer
("04264 836975"). Die Wechselauftrag-PDFs splittten heuristisch
auf Vorwahl/Anschluss, was bei Sonderformaten daneben ging.

Schema: PhoneNumber.areaCode String? (optional, Bestandsdaten
werden beim nächsten Edit nachgepflegt). Migration
20260601200000_phone_area_code mit IF NOT EXISTS.

ContractForm: aus "Rufnummer" werden zwei Felder – "Vorwahl" und
"Rufnummer". Beim Speichern sendet das Frontend areaCode separat
UND die kombinierte phoneNumber (für Listen/Suchen weiter
unverändert). Beim Edit-Load wird areaCode bevorzugt; falls leer,
splittet die UI heuristisch und prefillt beides – User kann
korrigieren und beim Speichern wird der saubere Wert persistiert.

PDF-Template-Service: phoneAreaCode[N] und phoneLocal[N]
verwenden jetzt primär den gespeicherten areaCode aus der DB
(verlässlich), Heuristik nur als Fallback für Altbestand. Die
Template-Variablen-Liste war bereits korrekt definiert, jetzt
ist die Datenquelle solide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 14:10:25 +02:00
parent 57eb29c2a6
commit 61daff8df9
6 changed files with 125 additions and 26 deletions
+73 -24
View File
@@ -199,7 +199,11 @@ export default function ContractForm() {
// Rufnummern State für Internet-Verträge
interface PhoneNumberInput {
id?: number;
phoneNumber: string;
// areaCode + subscriber sind die getrennten Eingabefelder.
// phoneNumber-Feld in der DB ist immer die zusammengesetzte
// "Vorwahl Anschluss"-Form, wird im Submit kombiniert.
areaCode: string;
subscriber: string;
sipUsername: string;
sipPassword: string;
hasExistingSipPassword?: boolean;
@@ -388,15 +392,41 @@ export default function ContractForm() {
// Load phoneNumbers if available (Internet contracts)
if (c.internetDetails?.phoneNumbers && c.internetDetails.phoneNumbers.length > 0) {
setPhoneNumbers(c.internetDetails.phoneNumbers.map(pn => ({
id: pn.id,
phoneNumber: pn.phoneNumber || '',
sipUsername: pn.sipUsername || '',
sipPassword: '', // SIP-Passwort wird nicht im Klartext geladen
hasExistingSipPassword: !!pn.sipPasswordEncrypted,
sipServer: pn.sipServer || '',
isMain: pn.isMain,
})));
setPhoneNumbers(c.internetDetails.phoneNumbers.map(pn => {
// Wenn areaCode gepflegt ist → 1:1 nutzen, sonst aus phoneNumber
// heuristisch splitten (dann sollte der User das Ergebnis prüfen
// und beim nächsten Speichern fixen).
const splitLegacy = (s: string) => {
const cleaned = s.replace(/[()]/g, '').trim();
const sep = cleaned.match(/^(\d{2,5})[\/\s\-](.+)$/);
if (sep) return { ac: sep[1], rest: sep[2].replace(/[\s\-/]/g, '') };
const noSep = cleaned.match(/^(0\d{1,4})(\d{3,})$/);
if (noSep) return { ac: noSep[1], rest: noSep[2] };
return { ac: '', rest: cleaned };
};
const full = pn.phoneNumber || '';
let ac = pn.areaCode || '';
let rest = '';
if (ac && full.startsWith(ac)) {
rest = full.slice(ac.length).replace(/^[\s\-/]+/, '').trim();
} else if (ac) {
rest = full.replace(ac, '').replace(/^[\s\-/]+/, '').trim();
} else {
const s = splitLegacy(full);
ac = s.ac;
rest = s.rest;
}
return {
id: pn.id,
areaCode: ac,
subscriber: rest,
sipUsername: pn.sipUsername || '',
sipPassword: '', // SIP-Passwort wird nicht im Klartext geladen
hasExistingSipPassword: !!pn.sipPasswordEncrypted,
sipServer: pn.sipServer || '',
isMain: pn.isMain,
};
}));
} else {
setPhoneNumbers([]);
}
@@ -581,14 +611,22 @@ export default function ContractForm() {
// Vodafone DSL/Kabel spezifisch
activationCode: emptyToNull(data.activationCode),
// Rufnummern mit SIP-Daten
phoneNumbers: phoneNumbers.length > 0 ? phoneNumbers.map(pn => ({
id: pn.id,
phoneNumber: pn.phoneNumber || '',
isMain: pn.isMain ?? false,
sipUsername: emptyToNull(pn.sipUsername),
sipPassword: pn.sipPassword || undefined, // Passwort: undefined = nicht ändern
sipServer: emptyToNull(pn.sipServer),
})) : undefined,
phoneNumbers: phoneNumbers.length > 0 ? phoneNumbers.map(pn => {
const ac = (pn.areaCode || '').trim();
const sub = (pn.subscriber || '').trim();
// phoneNumber bleibt das kombinierte "Vorwahl Anschluss"-Feld
// für Listen/Suchen, areaCode/subscriber sind die getrennten Werte.
const combined = ac && sub ? `${ac} ${sub}` : (sub || ac);
return {
id: pn.id,
phoneNumber: combined,
areaCode: ac || undefined,
isMain: pn.isMain ?? false,
sipUsername: emptyToNull(pn.sipUsername),
sipPassword: pn.sipPassword || undefined, // Passwort: undefined = nicht ändern
sipServer: emptyToNull(pn.sipServer),
};
}) : undefined,
};
}
@@ -1327,16 +1365,26 @@ export default function ContractForm() {
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
<Input
label="Rufnummer"
value={pn.phoneNumber}
label="Vorwahl"
value={pn.areaCode}
onChange={(e) => {
const updated = [...phoneNumbers];
updated[index].phoneNumber = e.target.value;
updated[index].areaCode = e.target.value;
setPhoneNumbers(updated);
}}
placeholder="z.B. 030 123456"
placeholder="z.B. 030"
/>
<Input
label="Rufnummer"
value={pn.subscriber}
onChange={(e) => {
const updated = [...phoneNumbers];
updated[index].subscriber = e.target.value;
setPhoneNumbers(updated);
}}
placeholder="z.B. 123456"
/>
<Input
label="SIP-Benutzername"
@@ -1393,7 +1441,8 @@ export default function ContractForm() {
variant="secondary"
onClick={() => {
setPhoneNumbers([...phoneNumbers, {
phoneNumber: '',
areaCode: '',
subscriber: '',
sipUsername: '',
sipPassword: '',
sipServer: '',
+4
View File
@@ -540,7 +540,11 @@ export interface InternetContractDetails {
export interface PhoneNumber {
id: number;
// Komplette Nummer (Vorwahl + Anschluss). Bleibt für bestehende
// Listen/Suchen die Quelle der Wahrheit. Vorwahl zusätzlich separat
// gepflegt, damit Wechselauftrag-PDFs sie verlässlich befüllen.
phoneNumber: string;
areaCode?: string;
isMain: boolean;
// SIP-Zugangsdaten
sipUsername?: string;