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:
@@ -0,0 +1,14 @@
|
|||||||
|
-- Telefonnummern bekommen eine zusätzliche Spalte `areaCode`, damit
|
||||||
|
-- Wechselauftrag-PDFs die Vorwahl verlässlich befüllen können, ohne
|
||||||
|
-- den heuristischen String-Split, der bei Sonderformaten danebenfasst.
|
||||||
|
--
|
||||||
|
-- `phoneNumber` bleibt unverändert die komplette Nummer (Vorwahl +
|
||||||
|
-- Anschlussnummer), damit Reports/Listen/Suchen weiter funktionieren.
|
||||||
|
-- `areaCode` ist optional, weil Bestandsnummern erst beim nächsten
|
||||||
|
-- Edit nachgepflegt werden.
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand
|
||||||
|
-- schon `prisma db push` gefahren hat.
|
||||||
|
|
||||||
|
ALTER TABLE `PhoneNumber`
|
||||||
|
ADD COLUMN IF NOT EXISTS `areaCode` VARCHAR(191) NULL;
|
||||||
@@ -892,7 +892,12 @@ model PhoneNumber {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
internetContractDetailsId Int
|
internetContractDetailsId Int
|
||||||
internetDetails InternetContractDetails @relation(fields: [internetContractDetailsId], references: [id], onDelete: Cascade)
|
internetDetails InternetContractDetails @relation(fields: [internetContractDetailsId], references: [id], onDelete: Cascade)
|
||||||
|
// phoneNumber bleibt die komplette Nummer (Vorwahl + Anschluss) für
|
||||||
|
// bestehende Reports/Listen. Vorwahl wird zusätzlich getrennt erfasst,
|
||||||
|
// damit Auftragsformulare (Wechselauftrag PDF) sie verlässlich
|
||||||
|
// befüllen können – ohne heuristischen String-Split.
|
||||||
phoneNumber String
|
phoneNumber String
|
||||||
|
areaCode String?
|
||||||
isMain Boolean @default(false)
|
isMain Boolean @default(false)
|
||||||
// SIP-Zugangsdaten
|
// SIP-Zugangsdaten
|
||||||
sipUsername String?
|
sipUsername String?
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ interface ContractCreateData {
|
|||||||
phoneNumbers?: {
|
phoneNumbers?: {
|
||||||
id?: number;
|
id?: number;
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
|
areaCode?: string;
|
||||||
isMain?: boolean;
|
isMain?: boolean;
|
||||||
sipUsername?: string;
|
sipUsername?: string;
|
||||||
sipPassword?: string;
|
sipPassword?: string;
|
||||||
@@ -345,6 +346,7 @@ export async function createContract(data: ContractCreateData) {
|
|||||||
? {
|
? {
|
||||||
create: internetDetails.phoneNumbers.map((pn) => ({
|
create: internetDetails.phoneNumbers.map((pn) => ({
|
||||||
phoneNumber: pn.phoneNumber,
|
phoneNumber: pn.phoneNumber,
|
||||||
|
areaCode: pn.areaCode,
|
||||||
isMain: pn.isMain ?? false,
|
isMain: pn.isMain ?? false,
|
||||||
sipUsername: pn.sipUsername,
|
sipUsername: pn.sipUsername,
|
||||||
sipPasswordEncrypted: pn.sipPassword
|
sipPasswordEncrypted: pn.sipPassword
|
||||||
@@ -542,6 +544,7 @@ export async function updateContract(
|
|||||||
return {
|
return {
|
||||||
internetContractDetailsId: existing.id,
|
internetContractDetailsId: existing.id,
|
||||||
phoneNumber: pn.phoneNumber,
|
phoneNumber: pn.phoneNumber,
|
||||||
|
areaCode: pn.areaCode,
|
||||||
isMain: pn.isMain ?? false,
|
isMain: pn.isMain ?? false,
|
||||||
sipUsername: pn.sipUsername,
|
sipUsername: pn.sipUsername,
|
||||||
// Preserve existing sipPassword if no new value provided
|
// Preserve existing sipPassword if no new value provided
|
||||||
@@ -565,6 +568,7 @@ export async function updateContract(
|
|||||||
? {
|
? {
|
||||||
create: phoneNumbers.map((pn) => ({
|
create: phoneNumbers.map((pn) => ({
|
||||||
phoneNumber: pn.phoneNumber,
|
phoneNumber: pn.phoneNumber,
|
||||||
|
areaCode: pn.areaCode,
|
||||||
isMain: pn.isMain ?? false,
|
isMain: pn.isMain ?? false,
|
||||||
sipUsername: pn.sipUsername,
|
sipUsername: pn.sipUsername,
|
||||||
sipPasswordEncrypted: pn.sipPassword
|
sipPasswordEncrypted: pn.sipPassword
|
||||||
|
|||||||
@@ -561,8 +561,31 @@ export async function generateFilledPdf(
|
|||||||
|
|
||||||
const maxFields = template.maxPhoneFields || 8;
|
const maxFields = template.maxPhoneFields || 8;
|
||||||
for (let i = 0; i < Math.max(maxFields, phoneNumbers.length); i++) {
|
for (let i = 0; i < Math.max(maxFields, phoneNumbers.length); i++) {
|
||||||
const fullNumber = phoneNumbers[i]?.phoneNumber || '';
|
const entry = phoneNumbers[i];
|
||||||
const { areaCode, local } = splitPhoneNumber(fullNumber);
|
const fullNumber = entry?.phoneNumber || '';
|
||||||
|
// Bevorzugt den explizit gepflegten areaCode aus der DB (verlässlich),
|
||||||
|
// fällt sonst auf die Heuristik zurück (Altbestand ohne separates
|
||||||
|
// Vorwahl-Feld). `phoneLocal` analog: aus phoneNumber abgeleitet,
|
||||||
|
// wenn areaCode da → den Vorwahl-Prefix abschneiden, sonst Heuristik.
|
||||||
|
let areaCode = '';
|
||||||
|
let local = '';
|
||||||
|
if (entry?.areaCode) {
|
||||||
|
areaCode = entry.areaCode;
|
||||||
|
const split = splitPhoneNumber(fullNumber);
|
||||||
|
// Wenn der heuristische areaCode mit dem DB-Wert übereinstimmt,
|
||||||
|
// ist der heuristische local-Anteil korrekt – sonst pragmatisch:
|
||||||
|
// alles nach dem areaCode-Prefix bis zum Ende
|
||||||
|
if (split.areaCode === entry.areaCode) {
|
||||||
|
local = split.local;
|
||||||
|
} else {
|
||||||
|
const stripped = fullNumber.replace(new RegExp('^' + entry.areaCode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\-/]*'), '').trim();
|
||||||
|
local = stripped || split.local;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const split = splitPhoneNumber(fullNumber);
|
||||||
|
areaCode = split.areaCode;
|
||||||
|
local = split.local;
|
||||||
|
}
|
||||||
dataContext[`phoneNumbers[${i}]`] = fullNumber;
|
dataContext[`phoneNumbers[${i}]`] = fullNumber;
|
||||||
dataContext[`phoneAreaCode[${i}]`] = areaCode;
|
dataContext[`phoneAreaCode[${i}]`] = areaCode;
|
||||||
dataContext[`phoneLocal[${i}]`] = local;
|
dataContext[`phoneLocal[${i}]`] = local;
|
||||||
|
|||||||
@@ -199,7 +199,11 @@ export default function ContractForm() {
|
|||||||
// Rufnummern State für Internet-Verträge
|
// Rufnummern State für Internet-Verträge
|
||||||
interface PhoneNumberInput {
|
interface PhoneNumberInput {
|
||||||
id?: number;
|
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;
|
sipUsername: string;
|
||||||
sipPassword: string;
|
sipPassword: string;
|
||||||
hasExistingSipPassword?: boolean;
|
hasExistingSipPassword?: boolean;
|
||||||
@@ -388,15 +392,41 @@ export default function ContractForm() {
|
|||||||
|
|
||||||
// Load phoneNumbers if available (Internet contracts)
|
// Load phoneNumbers if available (Internet contracts)
|
||||||
if (c.internetDetails?.phoneNumbers && c.internetDetails.phoneNumbers.length > 0) {
|
if (c.internetDetails?.phoneNumbers && c.internetDetails.phoneNumbers.length > 0) {
|
||||||
setPhoneNumbers(c.internetDetails.phoneNumbers.map(pn => ({
|
setPhoneNumbers(c.internetDetails.phoneNumbers.map(pn => {
|
||||||
id: pn.id,
|
// Wenn areaCode gepflegt ist → 1:1 nutzen, sonst aus phoneNumber
|
||||||
phoneNumber: pn.phoneNumber || '',
|
// heuristisch splitten (dann sollte der User das Ergebnis prüfen
|
||||||
sipUsername: pn.sipUsername || '',
|
// und beim nächsten Speichern fixen).
|
||||||
sipPassword: '', // SIP-Passwort wird nicht im Klartext geladen
|
const splitLegacy = (s: string) => {
|
||||||
hasExistingSipPassword: !!pn.sipPasswordEncrypted,
|
const cleaned = s.replace(/[()]/g, '').trim();
|
||||||
sipServer: pn.sipServer || '',
|
const sep = cleaned.match(/^(\d{2,5})[\/\s\-](.+)$/);
|
||||||
isMain: pn.isMain,
|
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 {
|
} else {
|
||||||
setPhoneNumbers([]);
|
setPhoneNumbers([]);
|
||||||
}
|
}
|
||||||
@@ -581,14 +611,22 @@ export default function ContractForm() {
|
|||||||
// Vodafone DSL/Kabel spezifisch
|
// Vodafone DSL/Kabel spezifisch
|
||||||
activationCode: emptyToNull(data.activationCode),
|
activationCode: emptyToNull(data.activationCode),
|
||||||
// Rufnummern mit SIP-Daten
|
// Rufnummern mit SIP-Daten
|
||||||
phoneNumbers: phoneNumbers.length > 0 ? phoneNumbers.map(pn => ({
|
phoneNumbers: phoneNumbers.length > 0 ? phoneNumbers.map(pn => {
|
||||||
id: pn.id,
|
const ac = (pn.areaCode || '').trim();
|
||||||
phoneNumber: pn.phoneNumber || '',
|
const sub = (pn.subscriber || '').trim();
|
||||||
isMain: pn.isMain ?? false,
|
// phoneNumber bleibt das kombinierte "Vorwahl Anschluss"-Feld
|
||||||
sipUsername: emptyToNull(pn.sipUsername),
|
// für Listen/Suchen, areaCode/subscriber sind die getrennten Werte.
|
||||||
sipPassword: pn.sipPassword || undefined, // Passwort: undefined = nicht ändern
|
const combined = ac && sub ? `${ac} ${sub}` : (sub || ac);
|
||||||
sipServer: emptyToNull(pn.sipServer),
|
return {
|
||||||
})) : undefined,
|
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" />
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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
|
<Input
|
||||||
label="Rufnummer"
|
label="Vorwahl"
|
||||||
value={pn.phoneNumber}
|
value={pn.areaCode}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const updated = [...phoneNumbers];
|
const updated = [...phoneNumbers];
|
||||||
updated[index].phoneNumber = e.target.value;
|
updated[index].areaCode = e.target.value;
|
||||||
setPhoneNumbers(updated);
|
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
|
<Input
|
||||||
label="SIP-Benutzername"
|
label="SIP-Benutzername"
|
||||||
@@ -1393,7 +1441,8 @@ export default function ContractForm() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPhoneNumbers([...phoneNumbers, {
|
setPhoneNumbers([...phoneNumbers, {
|
||||||
phoneNumber: '',
|
areaCode: '',
|
||||||
|
subscriber: '',
|
||||||
sipUsername: '',
|
sipUsername: '',
|
||||||
sipPassword: '',
|
sipPassword: '',
|
||||||
sipServer: '',
|
sipServer: '',
|
||||||
|
|||||||
@@ -540,7 +540,11 @@ export interface InternetContractDetails {
|
|||||||
|
|
||||||
export interface PhoneNumber {
|
export interface PhoneNumber {
|
||||||
id: number;
|
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;
|
phoneNumber: string;
|
||||||
|
areaCode?: string;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
// SIP-Zugangsdaten
|
// SIP-Zugangsdaten
|
||||||
sipUsername?: string;
|
sipUsername?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user