sozsoft-platform/ui/src/utils/phoneFormat.ts
2026-10-06 15:36:01 +03:00

382 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Telefon alanlarının `editorOptions.format` token'ları.
*
* Değer her biçimde yalnızca rakam olarak saklanır (`useMaskedValue: false`); biçim
* editörde maskeyle, listede görüntü metniyle uygulanır. Ülkeye bağlı biçimler
* `Sas_H_Country` satırındaki `PhoneCode` / `PhoneNumberFormat` / `PhoneNumber{Min,Max}Length`
* değerlerini kullanır.
*/
import Form from 'devextreme/ui/form'
import { getCountries, type CountryDto } from '@/services/location.service'
export const PhoneFormats = {
/** 10 haneli yerel numara: `(555) 123-4567`. */
Global: 'phoneGlobal',
/** Türkiye: `(532) 123 45 67`. */
Turkey: 'phoneTR',
/** Ülke koduyla uluslararası numara (E.164, en çok 15 hane): `+49 301 234 567`. */
International: 'phoneIntl',
/** Formdaki ülke alanına göre maske (ülke yoksa Türkiye); ülke kodu değere yazılmaz. */
Country: 'phoneCountry',
} as const
export type PhoneFormat = (typeof PhoneFormats)[keyof typeof PhoneFormats]
const PHONE_FORMAT_VALUES = new Set<string>(Object.values(PhoneFormats))
export const isPhoneFormat = (format: unknown): format is PhoneFormat =>
typeof format === 'string' && PHONE_FORMAT_VALUES.has(format)
/** `phoneCountry` biçiminde ülkenin okunduğu alan; `phoneCountryField` verilmezse bu kullanılır. */
export const DEFAULT_PHONE_COUNTRY_FIELD = 'Country'
const GLOBAL_PATTERN = '(000) 000-0000'
const TURKEY_PATTERN = '(000) 000 00 00'
const TURKEY_DIAL_CODE = 90
const MAX_PHONE_DIGITS = 15
/** Ülkesi bilinmeyen numara: tek zorunlu, kalanı isteğe bağlı 15 hane. */
const INTERNATIONAL_MASK = '+0' + '9'.repeat(MAX_PHONE_DIGITS - 1)
const STATIC_EDITOR_OPTIONS: Record<Exclude<PhoneFormat, 'phoneCountry'>, Record<string, any>> = {
phoneGlobal: { mask: GLOBAL_PATTERN, placeholder: '(555) 123-4567' },
phoneTR: { mask: TURKEY_PATTERN, placeholder: '(532) 123 45 67' },
phoneIntl: { mask: INTERNATIONAL_MASK, placeholder: '+90 532 123 45 67' },
}
// ---------------------------------------------------------------------------
// Ülke önbelleği: grid hücresi biçimlendirmesi eşzamanlıdır, liste bir kez yüklenir.
// ---------------------------------------------------------------------------
let countryCache: Map<string, CountryDto> | undefined
let countryRequest: Promise<Map<string, CountryDto>> | undefined
export const loadPhoneCountries = () => {
countryRequest ??= getCountries()
.then((response) => {
countryCache = new Map(response.data.map((country) => [country.id, country]))
return countryCache
})
.catch((error) => {
// Başarısız istek önbelleğe yazılmaz; sonraki çağrı yeniden dener.
countryRequest = undefined
throw error
})
return countryRequest
}
export const arePhoneCountriesLoaded = () => countryCache !== undefined
const getCountry = (countryId: unknown) =>
countryId == null || countryId === '' ? undefined : countryCache?.get(String(countryId))
/** Ülke tablosu yüklenmeden de maskenin Türkiye biçiminde açılabilmesi için. */
const FALLBACK_COUNTRY: CountryDto = {
id: 'Türkiye',
name: 'Türkiye',
phoneCode: TURKEY_DIAL_CODE,
phoneNumberMinLength: 10,
phoneNumberMaxLength: 10,
phoneNumberFormat: GLOBAL_PATTERN,
}
/** Ülke seçilmemişse ya da bulunamazsa Türkiye (tablodaki satırı, yoksa sabit karşılığı). */
const getCountryOrDefault = (countryId: unknown) =>
getCountry(countryId) ??
[...(countryCache?.values() ?? [])].find((country) => country.phoneCode === TURKEY_DIAL_CODE) ??
FALLBACK_COUNTRY
/** Rakamlarla başlayan en uzun ülke kodu (1 ile 1268 gibi çakışan kodlarda doğru ülke). */
const findCountryByDialCode = (digits: string) => {
let match: CountryDto | undefined
countryCache?.forEach((country) => {
const code = String(country.phoneCode ?? '')
if (code && digits.startsWith(code) && code.length > String(match?.phoneCode ?? '').length) {
match = country
}
})
return match
}
// ---------------------------------------------------------------------------
// Biçim
// ---------------------------------------------------------------------------
const countSlots = (pattern: string) => pattern.replace(/[^0]/g, '').length
/**
* Ülkenin ulusal numara kalıbı; kalıp en çok hane sayısına göre kısaltılır ya da uzatılır,
* böylece maske ile görüntü aynı hane sayısını taşır.
*/
const getNationalPattern = (country: CountryDto) => {
let pattern =
country.phoneNumberFormat ||
(country.phoneCode === TURKEY_DIAL_CODE ? GLOBAL_PATTERN : '000 000 000 000 000')
const slots = countSlots(pattern)
const maxLength = country.phoneNumberMaxLength ?? slots
if (maxLength > slots) {
pattern += ' ' + '0'.repeat(maxLength - slots)
} else if (maxLength < slots) {
let seen = 0
pattern = [...pattern]
.filter((character) => (character === '0' ? ++seen <= maxLength : seen < maxLength))
.join('')
}
return pattern
}
/** Rakamları kalıbın `0` yerlerine dizer; rakam bittiğinde sondaki ayraçlar yazılmaz. */
const applyPattern = (digits: string, pattern: string) => {
let index = 0
let result = ''
for (const character of pattern) {
if (index >= digits.length) break
if (character === '0') {
result += digits[index++]
} else {
result += character
}
}
// Kalıptan uzun numara kesilmez; fazlası sona eklenir.
return result + digits.slice(index)
}
const onlyDigits = (value: unknown) => String(value ?? '').replace(/\D/g, '')
/** Değerin başında ülke kodu ya da yurt içi `0` öneki varsa ulusal kısmı döner. */
const toNationalDigits = (value: unknown, country: CountryDto) => {
const digits = onlyDigits(value)
const code = String(country.phoneCode)
const maxLength = country.phoneNumberMaxLength ?? countSlots(getNationalPattern(country))
if (digits.length > maxLength && digits.startsWith(code)) return digits.slice(code.length)
if (digits.length > maxLength && digits.startsWith('0')) return digits.slice(1)
return digits
}
const formatWithCountry = (value: unknown, country: CountryDto) => {
const national = toNationalDigits(value, country)
return national
? `+${country.phoneCode} ${applyPattern(national, getNationalPattern(country))}`
: ''
}
/** Yerel 10 haneli biçimler: ülke kodu / `0` öneki atılır, son 10 hane kalıba dizilir. */
const formatLocal = (value: unknown, pattern: string) => {
let digits = onlyDigits(value)
if (digits.length > 10) digits = digits.slice(-10)
return digits.length === 10 ? applyPattern(digits, pattern) : String(value ?? '')
}
type PhoneDisplayOptions = {
format: PhoneFormat
/** `phoneCountry` için satırdaki ülke değeri. */
countryId?: unknown
}
/** Liste/kart hücresinde gösterilecek metin. */
export const formatPhone = (value: unknown, { format, countryId }: PhoneDisplayOptions) => {
if (value == null || value === '') return ''
switch (format) {
case PhoneFormats.Turkey:
return formatLocal(value, TURKEY_PATTERN)
case PhoneFormats.International: {
const digits = onlyDigits(value)
const country = findCountryByDialCode(digits)
return country
? formatWithCountry(digits.slice(String(country.phoneCode).length), country)
: `+${digits}`
}
case PhoneFormats.Country: {
return formatWithCountry(value, getCountryOrDefault(countryId))
}
default:
return formatLocal(value, GLOBAL_PATTERN)
}
}
// ---------------------------------------------------------------------------
// Editör
// ---------------------------------------------------------------------------
/** Maske kuralı olan karakterler kalıpta sabit metin olarak kaçışlanır. */
const escapeMaskLiteral = (text: string) =>
text.replace(/[09#LlCcAa]/g, (character) => `\\${character}`)
/** Ülkenin maskesi: kod sabit önek, en az hane zorunlu, kalanı isteğe bağlı. */
export const getCountryPhoneEditorOptions = (country: CountryDto) => {
const pattern = getNationalPattern(country)
const minLength = country.phoneNumberMinLength ?? countSlots(pattern)
let slot = 0
const nationalMask = [...pattern]
.map((character) => {
if (character === '0') return slot++ < minLength ? '0' : '9'
return escapeMaskLiteral(character)
})
.join('')
const prefix = `+${country.phoneCode} `
return {
mask: escapeMaskLiteral(prefix) + nationalMask,
placeholder: prefix + pattern,
}
}
/** Form alanı değer değişikliği; editörden forma doğru kabarcıklanır. */
const FIELD_VALUE_CHANGED_EVENT = 'platform:fieldvaluechanged'
type FieldValueChangedDetail = { dataField: string; value: unknown }
/**
* Yayınlanan son değerler, form elemanı başına. DataGrid bir alan değişince (ör. cascade alt
* alanları temizlerken) form editörlerini yeniden oluşturur; yeni editör formun `formData`'sına
* bakarsa düzenleme öncesi değeri görür, çünkü DataGrid değişikliği satıra yazar, forma değil.
*/
const broadcastValues = new WeakMap<HTMLElement, Map<string, unknown>>()
const getFormElement = (element: unknown) =>
(element as HTMLElement | undefined)?.closest?.('.dx-form') as HTMLElement | null | undefined
/**
* Grid/Tree düzenleme formunda editörleri DataGrid kendisi oluşturur ve değeri satıra yazar;
* formun `fieldDataChanged` olayı tetiklenmez. `onEditorPreparing` bu fonksiyonla editörün
* değişikliğini DOM olayı olarak yayınlar. `editorOptions.onValueChanged`'a yazılmaz: editör
* fabrikası onu kendi değer yazan işleyicisinin üstüne birleştirir.
*/
export const attachFieldValueBroadcast = (editor: {
dataField?: string
editorOptions: Record<string, any>
row?: { data?: Record<string, any> }
}) => {
const { dataField } = editor
if (!dataField) return
attachRowCountry(editor)
const previousValueChanged = editor.editorOptions.onValueChanged
editor.editorOptions.onValueChanged = (e: any) => {
previousValueChanged?.(e)
const formElement = getFormElement(e?.element)
if (formElement) {
const values = broadcastValues.get(formElement) ?? new Map<string, unknown>()
values.set(dataField, e?.value)
broadcastValues.set(formElement, values)
}
;(e?.element as HTMLElement | undefined)?.dispatchEvent(
new CustomEvent<FieldValueChangedDetail>(FIELD_VALUE_CHANGED_EVENT, {
bubbles: true,
detail: { dataField, value: e?.value },
}),
)
}
}
/**
* Düzenlenen satırın ülkesi. DataGrid formunun `formData`'sı satır değerini taşımaz; ülke
* `onEditorPreparing`'in verdiği satırdan okunur ve editör oluşunca bağlamaya aktarılır.
*/
const rowCountries = new WeakMap<object, unknown>()
const attachRowCountry = (editor: {
editorOptions: Record<string, any>
row?: { data?: Record<string, any> }
}) => {
// Grid/Tree ekranın ham editorOptions'ını onEditorPreparing'de yeniden birleştirdiği için token burada da görünür.
if (editor.editorOptions.format !== PhoneFormats.Country || !editor.row?.data) return
const countryField = editor.editorOptions.phoneCountryField || DEFAULT_PHONE_COUNTRY_FIELD
const countryId = editor.row.data[countryField]
if (arePhoneCountriesLoaded()) {
Object.assign(
editor.editorOptions,
getCountryPhoneEditorOptions(getCountryOrDefault(countryId)),
)
}
const previousContentReady = editor.editorOptions.onContentReady
editor.editorOptions.onContentReady = (e: any) => {
if (e?.component) rowCountries.set(e.component, countryId)
previousContentReady?.(e)
}
}
const boundEditors = new WeakSet<object>()
/**
* Editörü içinde bulunduğu formun ülke alanına bağlar: ilk değer formda son yayınlanan
* değerden, o yoksa düzenlenen satırdan (Grid, Tree), o da yoksa form verisinden; sonraki
* değişiklikler formun `fieldDataChanged` olayından (Card, Scheduler, Form sayfası) ya da
* `attachFieldValueBroadcast` olayından (Grid, Tree) okunur. Form yoksa (satır içi düzenleme)
* Türkiye maskesinde kalır.
*/
const bindCountryMask = (editor: any, element: HTMLElement, countryField: string) => {
if (boundEditors.has(editor)) return
boundEditors.add(editor)
// contentReady anında editör forma henüz yerleşmemiş olabilir.
setTimeout(() => {
const formElement = getFormElement(element)
if (!formElement) return
let disposed = false
const apply = (countryId: unknown) => {
loadPhoneCountries()
.then(() => {
if (!disposed) editor.option(getCountryPhoneEditorOptions(getCountryOrDefault(countryId)))
})
.catch((error) => console.error('Telefon ülke listesi yüklenemedi', error))
}
const onFieldValueChanged = (event: Event) => {
const { dataField, value } = (event as CustomEvent<FieldValueChangedDetail>).detail ?? {}
if (dataField === countryField) apply(value)
}
const onFormFieldDataChanged = (e: { dataField?: string; value?: unknown }) => {
if (e.dataField === countryField) apply(e.value)
}
const form = Form.getInstance(formElement) as Form | undefined
const broadcast = broadcastValues.get(formElement)
if (broadcast?.has(countryField)) apply(broadcast.get(countryField))
else if (rowCountries.has(editor)) apply(rowCountries.get(editor))
else apply((form?.option('formData') as Record<string, unknown> | undefined)?.[countryField])
formElement.addEventListener(FIELD_VALUE_CHANGED_EVENT, onFieldValueChanged)
form?.on('fieldDataChanged', onFormFieldDataChanged)
editor.on('disposing', () => {
disposed = true
formElement.removeEventListener(FIELD_VALUE_CHANGED_EVENT, onFieldValueChanged)
form?.off('fieldDataChanged', onFormFieldDataChanged)
})
}, 0)
}
/**
* Telefon token'ı taşıyan editorOptions'a biçimin maskesini serer; yapılandırmada açıkça
* yazılmış seçenek (maske dahil) varsayılanı ezer. `phoneCountry` maskesi forma göre
* çalışma anında değiştiği için yapılandırmadaki maske yok sayılır.
*/
export const withPhoneEditorOptions = <T extends Record<string, any>>(editorOptions: T): T => {
const { format, phoneCountryField, ...options } = editorOptions
if (!isPhoneFormat(format)) return editorOptions
if (format !== PhoneFormats.Country) {
return { useMaskedValue: false, ...STATIC_EDITOR_OPTIONS[format], ...options } as unknown as T
}
const countryField = phoneCountryField || DEFAULT_PHONE_COUNTRY_FIELD
const previousContentReady = options.onContentReady
return {
...options,
...getCountryPhoneEditorOptions(getCountryOrDefault(undefined)),
useMaskedValue: false,
onContentReady: (e: any) => {
if (typeof previousContentReady === 'function') previousContentReady(e)
bindCountryMask(e.component, e.element, countryField)
},
} as unknown as T
}