383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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
|
|||
|
|
}
|