/** * 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(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, Record> = { 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 | undefined let countryRequest: Promise> | 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>() 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 row?: { data?: Record } }) => { 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() values.set(dataField, e?.value) broadcastValues.set(formElement, values) } ;(e?.element as HTMLElement | undefined)?.dispatchEvent( new CustomEvent(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() const attachRowCountry = (editor: { editorOptions: Record row?: { data?: Record } }) => { // 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() /** * 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).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 | 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 = >(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 }