Ülkeye göre Phone Mask ayarlandı

This commit is contained in:
Sedat ÖZTÜRK 2026-10-06 15:36:01 +03:00
parent 35118ab2d9
commit c1454ab818
24 changed files with 950 additions and 116 deletions

View file

@ -2809,6 +2809,7 @@ Preset mevcut JSON ile **birleşir**, diğer ayarları silmez; aynı anahtar var
| 2 ondalık · Artırmalı sayı · Para | `format.fixedPoint(2)` · + `useMaskBehavior` + `showSpinButtons` · `format.currency TRY` | | 2 ondalık · Artırmalı sayı · Para | `format.fixedPoint(2)` · + `useMaskBehavior` + `showSpinButtons` · `format.currency TRY` |
| Tarih · Tarih-saat · Saat | `type`+`displayFormat`+`dateSerializationFormat` üçlüsü (saatte ayrıca `pickerType:list`, `interval`, `width:100%`) | | Tarih · Tarih-saat · Saat | `type`+`displayFormat`+`dateSerializationFormat` üçlüsü (saatte ayrıca `pickerType:list`, `interval`, `width:100%`) |
| Telefon maskesi | `mask`, `maskRules.X`, `maskInvalidMessage`, `placeholder` | | Telefon maskesi | `mask`, `maskRules.X`, `maskInvalidMessage`, `placeholder` |
| Telefon (Türkiye) · (uluslararası) · (ülkeye göre) | `{"format":"phoneTR"}` · `{"format":"phoneIntl"}` · `{"format":"phoneCountry","phoneCountryField":"Country"}` — ayrıntı §6.4.1 |
| Otomatik büyüyen metin | `autoResizeEnabled`, `minHeight`, `maxHeight` | | Otomatik büyüyen metin | `autoResizeEnabled`, `minHeight`, `maxHeight` |
| Aranabilir liste | `searchEnabled`, `searchMode:contains`, `searchTimeout`, `showClearButton` | | Aranabilir liste | `searchEnabled`, `searchMode:contains`, `searchTimeout`, `showClearButton` |
| TagBox anında seçim | `showSelectionControls`, `applyValueMode:instantly`, `searchEnabled` | | TagBox anında seçim | `showSelectionControls`, `applyValueMode:instantly`, `searchEnabled` |
@ -2819,6 +2820,37 @@ Preset mevcut JSON ile **birleşir**, diğer ayarları silmez; aynı anahtar var
| Barkod okutma butonu | `buttons[]` — kamera ikonlu buton; `onClick` = `"@SCAN_BARCODE"` token'ı. Yalnız `dxTextBox`. Token'ı `editingForm.ts` kamera diyaloğuna bağlar, okunan kod alana yazılır. HTTPS şart. | | Barkod okutma butonu | `buttons[]` — kamera ikonlu buton; `onClick` = `"@SCAN_BARCODE"` token'ı. Yalnız `dxTextBox`. Token'ı `editingForm.ts` kamera diyaloğuna bağlar, okunan kod alana yazılır. HTTPS şart. |
| Kamerayla foto çek | `{"accept":"image/*","capture":"environment"}` — yalnız `dxImageUpload`. Mobilde dosya seçici yerine **yalnızca** kamera açılır (`user` = ön kamera); masaüstünde yok sayılır. Ayarlanmazsa dokunmatik cihazda dosya seçicinin yanına ayrı bir kamera butonu gelir, yani ikisi birden kullanılabilir — bu kalıp kamerayı **zorunlu** kılmak içindir. | | Kamerayla foto çek | `{"accept":"image/*","capture":"environment"}` — yalnız `dxImageUpload`. Mobilde dosya seçici yerine **yalnızca** kamera açılır (`user` = ön kamera); masaüstünde yok sayılır. Ayarlanmazsa dokunmatik cihazda dosya seçicinin yanına ayrı bir kamera butonu gelir, yani ikisi birden kullanılabilir — bu kalıp kamerayı **zorunlu** kılmak içindir. |
#### 6.4.1 Telefon biçimleri (`format` token'ı)
Telefon alanı (`dxTextBox`) biçimini `editorOptions.format` token'ından alır; maske, örnek ve liste/kart
görüntüsü `ui/src/utils/phoneFormat.ts`'te üretilir (form editörü `withEditorTypeDefaults`, kolon
`useListFormColumns` üzerinden — Grid, Tree, Card, Scheduler ve Form sayfası aynı yoldan geçer). Değer
her biçimde **yalnızca rakam** saklanır (`useMaskedValue: false`); yapılandırmada açıkça yazılan seçenek
varsayılanı ezer.
| Token | Editör | Saklanan | Listede |
| --- | --- | --- | --- |
| `phoneGlobal` | `(000) 000-0000` | 10 hane | `(555) 123-4567` |
| `phoneTR` | `(000) 000 00 00` | 10 hane | `(532) 123 45 67` |
| `phoneIntl` | `+` + en çok 15 hane | ülke kodu + numara | ülke kodu `Sas_H_Country.PhoneCode`'dan (en uzun önek) bulunur: `+49 301 234 567` |
| `phoneCountry` | formdaki `phoneCountryField` (varsayılan `Country`) alanının ülkesine göre: kod sabit önek, `PhoneNumberFormat` kalıbı, `PhoneNumberMinLength` kadar hane zorunlu, `PhoneNumberMaxLength`'e kadar isteğe bağlı; ülke seçilmemişse **Türkiye** (`+90 (000) 000-0000`) | ulusal numara (kodsuz) | satırın ülkesiyle `+49 301 234 567`; ülkesiz satır Türkiye biçiminde |
- **Ülke ve telefon alanı olan her ekran `phoneCountry` kullanır** (Kiracılar, Şubeler, Satış Siparişleri,
Kontaklar, Adaylar). Ülke alanı olmayan ekran `phoneGlobal`/`phoneTR`/`phoneIntl`'den birini seçer.
- Formda telefon alanları adres zincirinden (Ülke › Şehir › İlçe › varsa Mahalle/Posta Kodu) **sonra** gelir
(`EditGroupOrderNo` + `EditOrderNo`): kullanıcı önce ülkeyi seçer, maske ona göre açılır.
- Ülke alanı olan her ekranda yeni kayıt Türkiye ile açılır: `FormFieldsDefaultValueJson` →
`{ FieldName: "Country", FieldDbType: String, Value: "Türkiye", CustomValueType: Value }`.
- Ülke alanı değişince maske yenilenir: Card/Scheduler/Form sayfasında formun `fieldDataChanged` olayıyla,
Grid/Tree'de `onEditorPreparing`'deki `attachFieldValueBroadcast` olayıyla (DataGrid formu `fieldDataChanged`
yayınlamaz; `editorOptions.onValueChanged` da kullanılamaz — editör fabrikası onu kendi değer yazan
işleyicisinin üstüne birleştirir). Satır içi düzenlemede (form yok) maske satırın ülkesiyle açılır, değişikliği izlemez.
- `phoneCountry` ekranında sabit uzunluk `pattern`'ı (`^[0-9]{10}$`) yazılmaz; uzunluğu maske denetler,
gerekirse `^[0-9]{4,15}$`.
- Ülke listesi `GET /api/app/public/country`'den bir kez yüklenir ve önbellekte kalır.
- Dinamik raporlar (Grid/Tree/Form) aynı biçimi sunucuda `DynamicReportPhoneFormatter` ile basar; `phoneCountry`
kolonu satırın ülke kolonunu biçim ifadesine beşinci operand olarak verir. Kural `phoneFormat.ts` ile birebir aynıdır.
### 6.5 Seeder tarafı (C#) ### 6.5 Seeder tarafı (C#)
| İhtiyaç | C# | Ürettiği JSON (özet) | | İhtiyaç | C# | Ürettiği JSON (özet) |
@ -2830,6 +2862,7 @@ Preset mevcut JSON ile **birleşir**, diğer ayarları silmez; aynı anahtar var
| Yüzde | `EditorOptions.Percent()` | | | Yüzde | `EditorOptions.Percent()` | |
| Tarih / tarih-saat / saat | `EditorOptions.Date()` · `DateTime()` · `Time(15)` | | | Tarih / tarih-saat / saat | `EditorOptions.Date()` · `DateTime()` · `Time(15)` | |
| Telefon | `EditorOptions.Phone()` | global format + maske + örnek | | Telefon | `EditorOptions.Phone()` | global format + maske + örnek |
| Telefon (ülkeye göre) | `EditorOptions.PhoneByCountry("Country")` | `{"format":"phoneCountry","phoneCountryField":"Country","showClearButton":true}` |
| Kaydırıcı | `EditorOptions.Slider()` | `{"tooltip":{"enabled":true}}` | | Kaydırıcı | `EditorOptions.Slider()` | `{"tooltip":{"enabled":true}}` |
| Görsel yükleme | `EditorOptions.ImageUpload(multiple: true, 80, 80)` | | | Görsel yükleme | `EditorOptions.ImageUpload(multiple: true, 80, 80)` | |
| Zengin metin | `EditorOptions.Html(240)` | `htmlEditorOptions.ts` varsayılanıyla aynı bloklar (tam araç çubuğu, `imageUpload`, `mediaResizing`, `tableResizing`, `tableContextMenu`; biri değişirse diğeri de değişir) + `encodeHtml` + `elementAttr` `min-height:240px` (sabit yükseklik değil; içerikle büyür) | | Zengin metin | `EditorOptions.Html(240)` | `htmlEditorOptions.ts` varsayılanıyla aynı bloklar (tam araç çubuğu, `imageUpload`, `mediaResizing`, `tableResizing`, `tableContextMenu`; biri değişirse diğeri de değişir) + `encodeHtml` + `elementAttr` `min-height:240px` (sabit yükseklik değil; içerikle büyür) |

View file

@ -259,7 +259,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Country", FieldName = "Country",
EditGroupOrderNo = 1, EditGroupOrderNo = 1,
EditOrderNo = 13, EditOrderNo = 10,
EditorType2 = EditorTypes.dxSelectBox, EditorType2 = EditorTypes.dxSelectBox,
ColSpan = 1, ColSpan = 1,
EditorOptions = EditorOptions.ShowClearButton(), EditorOptions = EditorOptions.ShowClearButton(),
@ -289,7 +289,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "City", FieldName = "City",
EditGroupOrderNo = 1, EditGroupOrderNo = 1,
EditOrderNo = 14, EditOrderNo = 11,
EditorType2 = EditorTypes.dxSelectBox, EditorType2 = EditorTypes.dxSelectBox,
ColSpan = 1, ColSpan = 1,
EditorOptions = EditorOptions.ShowClearButton(), EditorOptions = EditorOptions.ShowClearButton(),
@ -322,7 +322,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "District", FieldName = "District",
EditGroupOrderNo = 1, EditGroupOrderNo = 1,
EditOrderNo = 15, EditOrderNo = 12,
EditorType2 = EditorTypes.dxSelectBox, EditorType2 = EditorTypes.dxSelectBox,
ColSpan = 1, ColSpan = 1,
EditorOptions = EditorOptions.ShowClearButton(), EditorOptions = EditorOptions.ShowClearButton(),
@ -355,7 +355,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Township", FieldName = "Township",
EditGroupOrderNo = 1, EditGroupOrderNo = 1,
EditOrderNo = 16, EditOrderNo = 13,
EditorType2 = EditorTypes.dxSelectBox, EditorType2 = EditorTypes.dxSelectBox,
ColSpan = 1, ColSpan = 1,
EditorOptions = EditorOptions.ShowClearButton(), EditorOptions = EditorOptions.ShowClearButton(),
@ -429,7 +429,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "PostalCode", FieldName = "PostalCode",
EditGroupOrderNo = 1, EditGroupOrderNo = 1,
EditOrderNo = 17, EditOrderNo = 14,
EditorType2 = EditorTypes.dxTextBox, EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1, ColSpan = 1,
EditorOptions = EditorOptions.ShowClearButton(), EditorOptions = EditorOptions.ShowClearButton(),
@ -450,7 +450,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Email", FieldName = "Email",
EditGroupOrderNo = 1, EditGroupOrderNo = 1,
EditOrderNo = 10, EditOrderNo = 7,
EditorType2 = EditorTypes.dxTextBox, EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1, ColSpan = 1,
CaptionName = "App.Listform.ListformField.Email", CaptionName = "App.Listform.ListformField.Email",
@ -471,7 +471,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Website", FieldName = "Website",
EditGroupOrderNo = 1, EditGroupOrderNo = 1,
EditOrderNo = 11, EditOrderNo = 8,
EditorType2 = EditorTypes.dxTextBox, EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1, ColSpan = 1,
CaptionName = "App.Listform.ListformField.Website", CaptionName = "App.Listform.ListformField.Website",
@ -492,10 +492,10 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "PhoneNumber", FieldName = "PhoneNumber",
EditGroupOrderNo = 1, EditGroupOrderNo = 1,
EditOrderNo = 7, EditOrderNo = 16,
EditorType2 = EditorTypes.dxTextBox, EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1, ColSpan = 1,
EditorOptions = EditorOptions.Phone(), EditorOptions = EditorOptions.PhoneByCountry(),
CaptionName = "Abp.Identity.User.UserInformation.PhoneNumber", CaptionName = "Abp.Identity.User.UserInformation.PhoneNumber",
Width = 0, Width = 0,
ListOrderNo = 16, ListOrderNo = 16,
@ -514,10 +514,10 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "MobileNumber", FieldName = "MobileNumber",
EditGroupOrderNo = 1, EditGroupOrderNo = 1,
EditOrderNo = 6, EditOrderNo = 15,
EditorType2 = EditorTypes.dxTextBox, EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1, ColSpan = 1,
EditorOptions = EditorOptions.Phone(), EditorOptions = EditorOptions.PhoneByCountry(),
CaptionName = "App.Listform.ListformField.MobileNumber", CaptionName = "App.Listform.ListformField.MobileNumber",
Width = 0, Width = 0,
ListOrderNo = 17, ListOrderNo = 17,
@ -535,10 +535,10 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.Int64, SourceDbType = DbType.Int64,
FieldName = "FaxNumber", FieldName = "FaxNumber",
EditGroupOrderNo = 1, EditGroupOrderNo = 1,
EditOrderNo = 8, EditOrderNo = 17,
EditorType2 = EditorTypes.dxTextBox, EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1, ColSpan = 1,
EditorOptions = EditorOptions.Phone(), EditorOptions = EditorOptions.PhoneByCountry(),
CaptionName = "App.Listform.ListformField.FaxNumber", CaptionName = "App.Listform.ListformField.FaxNumber",
Width = 0, Width = 0,
ListOrderNo = 18, ListOrderNo = 18,
@ -556,7 +556,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.Boolean, SourceDbType = DbType.Boolean,
FieldName = "IsActive", FieldName = "IsActive",
EditGroupOrderNo = 1, EditGroupOrderNo = 1,
EditOrderNo = 12, EditOrderNo = 9,
EditorType2 = EditorTypes.dxCheckBox, EditorType2 = EditorTypes.dxCheckBox,
ColSpan = 1, ColSpan = 1,
CaptionName = "App.Listform.ListformField.IsActive", CaptionName = "App.Listform.ListformField.IsActive",
@ -575,7 +575,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Modules", FieldName = "Modules",
EditGroupOrderNo = 1, EditGroupOrderNo = 1,
EditOrderNo = 9, EditOrderNo = 6,
EditorType2 = EditorTypes.dxTagBox, EditorType2 = EditorTypes.dxTagBox,
ColSpan = 1, ColSpan = 1,
CaptionName = "App.Listform.ListformField.Modules", CaptionName = "App.Listform.ListformField.Modules",
@ -932,7 +932,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Address1", FieldName = "Address1",
EditGroupOrderNo = 2, EditGroupOrderNo = 2,
EditOrderNo = 7, EditOrderNo = 11,
EditorType2 = EditorTypes.dxTextArea, EditorType2 = EditorTypes.dxTextArea,
ColSpan = 3, ColSpan = 3,
CaptionName = "App.Listform.ListformField.Address1", CaptionName = "App.Listform.ListformField.Address1",
@ -953,7 +953,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Address2", FieldName = "Address2",
EditGroupOrderNo = 2, EditGroupOrderNo = 2,
EditOrderNo = 8, EditOrderNo = 12,
EditorType2 = EditorTypes.dxTextArea, EditorType2 = EditorTypes.dxTextArea,
ColSpan = 3, ColSpan = 3,
CaptionName = "App.Listform.ListformField.Address2", CaptionName = "App.Listform.ListformField.Address2",
@ -973,8 +973,8 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "PostalCode", FieldName = "PostalCode",
EditGroupOrderNo = 1, EditGroupOrderNo = 2,
EditOrderNo = 9, EditOrderNo = 5,
EditorType2 = EditorTypes.dxTextBox, EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1, ColSpan = 1,
CaptionName = "App.Listform.ListformField.PostalCode", CaptionName = "App.Listform.ListformField.PostalCode",
@ -995,7 +995,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Email", FieldName = "Email",
EditGroupOrderNo = 2, EditGroupOrderNo = 2,
EditOrderNo = 5, EditOrderNo = 9,
EditorType2 = EditorTypes.dxTextBox, EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1, ColSpan = 1,
CaptionName = "App.Listform.ListformField.Email", CaptionName = "App.Listform.ListformField.Email",
@ -1017,7 +1017,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Website", FieldName = "Website",
EditGroupOrderNo = 2, EditGroupOrderNo = 2,
EditOrderNo = 6, EditOrderNo = 10,
EditorType2 = EditorTypes.dxTextBox, EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1, ColSpan = 1,
CaptionName = "App.Listform.ListformField.Website", CaptionName = "App.Listform.ListformField.Website",
@ -1038,11 +1038,11 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "PhoneNumber", FieldName = "PhoneNumber",
EditGroupOrderNo = 1, EditGroupOrderNo = 2,
EditOrderNo = 7, EditOrderNo = 7,
EditorType2 = EditorTypes.dxTextBox, EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1, ColSpan = 1,
EditorOptions = EditorOptions.Phone(), EditorOptions = EditorOptions.PhoneByCountry(),
CaptionName = "Abp.Identity.User.UserInformation.PhoneNumber", CaptionName = "Abp.Identity.User.UserInformation.PhoneNumber",
Width = 0, Width = 0,
ListOrderNo = 15, ListOrderNo = 15,
@ -1061,11 +1061,11 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "MobileNumber", FieldName = "MobileNumber",
EditGroupOrderNo = 1, EditGroupOrderNo = 2,
EditOrderNo = 6, EditOrderNo = 6,
EditorType2 = EditorTypes.dxTextBox, EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1, ColSpan = 1,
EditorOptions = EditorOptions.Phone(), EditorOptions = EditorOptions.PhoneByCountry(),
CaptionName = "App.Listform.ListformField.MobileNumber", CaptionName = "App.Listform.ListformField.MobileNumber",
Width = 0, Width = 0,
ListOrderNo = 16, ListOrderNo = 16,
@ -1083,11 +1083,11 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.Int64, SourceDbType = DbType.Int64,
FieldName = "FaxNumber", FieldName = "FaxNumber",
EditGroupOrderNo = 1, EditGroupOrderNo = 2,
EditOrderNo = 8, EditOrderNo = 8,
EditorType2 = EditorTypes.dxTextBox, EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1, ColSpan = 1,
EditorOptions = EditorOptions.Phone(), EditorOptions = EditorOptions.PhoneByCountry(),
CaptionName = "App.Listform.ListformField.FaxNumber", CaptionName = "App.Listform.ListformField.FaxNumber",
Width = 0, Width = 0,
ListOrderNo = 17, ListOrderNo = 17,
@ -1106,7 +1106,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SourceDbType = DbType.Boolean, SourceDbType = DbType.Boolean,
FieldName = "IsActive", FieldName = "IsActive",
EditGroupOrderNo = 1, EditGroupOrderNo = 1,
EditOrderNo = 10, EditOrderNo = 6,
EditorType2 = EditorTypes.dxCheckBox, EditorType2 = EditorTypes.dxCheckBox,
ColSpan = 1, ColSpan = 1,
CaptionName = "App.Listform.ListformField.IsActive", CaptionName = "App.Listform.ListformField.IsActive",
@ -7904,7 +7904,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
EditOrderNo = 14, EditOrderNo = 14,
EditorType2 = EditorTypes.dxTextBox, EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1, ColSpan = 1,
EditorOptions = EditorOptions.Phone(), EditorOptions = EditorOptions.PhoneByCountry(),
CaptionName = "Abp.Identity.User.UserInformation.PhoneNumber", CaptionName = "Abp.Identity.User.UserInformation.PhoneNumber",
Width = 0, Width = 0,
ListOrderNo = 18, ListOrderNo = 18,
@ -7927,7 +7927,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
EditOrderNo = 15, EditOrderNo = 15,
EditorType2 = EditorTypes.dxTextBox, EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1, ColSpan = 1,
EditorOptions = EditorOptions.Phone(), EditorOptions = EditorOptions.PhoneByCountry(),
CaptionName = "App.Listform.ListformField.MobileNumber", CaptionName = "App.Listform.ListformField.MobileNumber",
Width = 0, Width = 0,
ListOrderNo = 19, ListOrderNo = 19,
@ -7949,7 +7949,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
EditOrderNo = 16, EditOrderNo = 16,
EditorType2 = EditorTypes.dxTextBox, EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1, ColSpan = 1,
EditorOptions = EditorOptions.Phone(), EditorOptions = EditorOptions.PhoneByCountry(),
CaptionName = "App.Listform.ListformField.FaxNumber", CaptionName = "App.Listform.ListformField.FaxNumber",
Width = 0, Width = 0,
ListOrderNo = 20, ListOrderNo = 20,

View file

@ -68,6 +68,13 @@ public static class EditorOptions
.Raw("maskRules", "{\"X\":\"[0-9]\"}") .Raw("maskRules", "{\"X\":\"[0-9]\"}")
.Placeholder("(555) 123-4567"); .Placeholder("(555) 123-4567");
/// <summary>
/// Maskesi formdaki ulke alaninin <c>Sas_H_Country</c> satirindan (kod, bicim, en az/en cok hane)
/// gelen telefon; deger ulke kodu olmadan saklanir.
/// </summary>
public static EditorOptionsBuilder PhoneByCountry(string countryField = "Country")
=> New().Format("phoneCountry").Text("phoneCountryField", countryField).ShowClearButton();
/// <summary>Kaydirma cubugu; degeri ipucu olarak gosterir.</summary> /// <summary>Kaydirma cubugu; degeri ipucu olarak gosterir.</summary>
public static EditorOptionsBuilder Slider() public static EditorOptionsBuilder Slider()
=> New().Raw("tooltip", "{\"enabled\":true}"); => New().Raw("tooltip", "{\"enabled\":true}");

View file

@ -158,6 +158,7 @@ public class DynamicFormReport : XtraReport
.OrderBy(x => x.ListOrderNo ?? int.MaxValue) .OrderBy(x => x.ListOrderNo ?? int.MaxValue)
.ThenBy(x => x.CaptionName) .ThenBy(x => x.CaptionName)
.ToList(); .ToList();
await DynamicReportImageHelper.EnsurePhoneCountriesAsync(serviceProvider, fields);
var customizations = await customizationManager.GetUsersServerCustomizations(listFormCode); var customizations = await customizationManager.GetUsersServerCustomizations(listFormCode);
var (dynamicDataRepository, connectionString, dataSourceType) = await dynamicDataManager var (dynamicDataRepository, connectionString, dataSourceType) = await dynamicDataManager
.GetAsync(listForm.IsTenant, listForm.DataSourceCode); .GetAsync(listForm.IsTenant, listForm.DataSourceCode);
@ -524,7 +525,11 @@ public class DynamicFormReport : XtraReport
return true; return true;
} }
if (DynamicReportImageHelper.TryFormatReportValue(field.Field, rawValue, field.EditorType, out var formattedValue)) var phoneCountryField = DynamicReportImageHelper.GetPhoneCountryFieldName(field.Field);
var phoneCountry = phoneCountryField != null && DynamicReportImageHelper.TryGetRowValue(row, phoneCountryField, out var country)
? country
: null;
if (DynamicReportImageHelper.TryFormatReportValue(field.Field, rawValue, field.EditorType, phoneCountry, out var formattedValue))
{ {
text = formattedValue; text = formattedValue;
return true; return true;

View file

@ -89,6 +89,7 @@ public class DynamicGridReport : XtraReport
.OrderBy(x => x.ListOrderNo ?? int.MaxValue) .OrderBy(x => x.ListOrderNo ?? int.MaxValue)
.ThenBy(x => x.CaptionName) .ThenBy(x => x.CaptionName)
.ToList(); .ToList();
await DynamicReportImageHelper.EnsurePhoneCountriesAsync(scope.ServiceProvider, fields);
var customizations = await customizationManager.GetUsersServerCustomizations(listFormCode); var customizations = await customizationManager.GetUsersServerCustomizations(listFormCode);
var (dynamicDataRepository, connectionString, dataSourceType) = await dynamicDataManager.GetAsync(listForm.IsTenant, listForm.DataSourceCode); var (dynamicDataRepository, connectionString, dataSourceType) = await dynamicDataManager.GetAsync(listForm.IsTenant, listForm.DataSourceCode);
@ -202,7 +203,7 @@ public class DynamicGridReport : XtraReport
editorTypesByField.TryGetValue(field.FieldName, out var editorType); editorTypesByField.TryGetValue(field.FieldName, out var editorType);
return DynamicReportColumnFactory.Create( return DynamicReportColumnFactory.Create(
field, field,
selectFieldsByName[field.FieldName], selectFieldsByName,
defaultValueHelper, defaultValueHelper,
editorType, editorType,
localizer); localizer);
@ -429,7 +430,8 @@ public class DynamicGridReport : XtraReport
DynamicReportImageHelper.CreateFormatValueExpression( DynamicReportImageHelper.CreateFormatValueExpression(
$"[{groupField.BindingName}]", $"[{groupField.BindingName}]",
groupField.Field, groupField.Field,
groupField.EditorType))); groupField.EditorType,
groupField.PhoneCountryExpression)));
band.Controls.AddRange([captionLabel, valueLabel]); band.Controls.AddRange([captionLabel, valueLabel]);
bands.Add(band); bands.Add(band);

View file

@ -25,6 +25,13 @@ internal sealed record DynamicReportColumn(
{ {
public float ImageWidth => DynamicReportImageHelper.GetGridImageColumnWidth(ImageCount); public float ImageWidth => DynamicReportImageHelper.GetGridImageColumnWidth(ImageCount);
/// <summary><c>phoneCountry</c> biçimli telefon kolonunda satırın ülke değerini taşıyan kolon.</summary>
public string PhoneCountryBindingName { get; init; }
/// <summary>Biçim ifadesine verilecek ülke operandı; telefon kolonu değilse <c>null</c>.</summary>
public string PhoneCountryExpression =>
string.IsNullOrWhiteSpace(PhoneCountryBindingName) ? null : $"[{PhoneCountryBindingName}]";
public bool HasBindingName => public bool HasBindingName =>
!string.IsNullOrWhiteSpace(BindingName) && BindingName != "undefined"; !string.IsNullOrWhiteSpace(BindingName) && BindingName != "undefined";
} }
@ -32,6 +39,21 @@ internal sealed record DynamicReportColumn(
internal static class DynamicReportColumnFactory internal static class DynamicReportColumnFactory
{ {
public static DynamicReportColumn Create( public static DynamicReportColumn Create(
ListFormField field,
IReadOnlyDictionary<string, SelectField> selectFieldsByName,
DefaultValueHelper defaultValueHelper,
string editorType,
IStringLocalizer<PlatformResource> localizer)
{
var column = CreateColumn(field, selectFieldsByName[field.FieldName], defaultValueHelper, editorType, localizer);
var phoneCountryField = DynamicReportImageHelper.GetPhoneCountryFieldName(field);
return phoneCountryField != null && selectFieldsByName.TryGetValue(phoneCountryField, out var countrySelectField)
? column with { PhoneCountryBindingName = DynamicReportImageHelper.GetReportFieldName(countrySelectField) }
: column;
}
private static DynamicReportColumn CreateColumn(
ListFormField field, ListFormField field,
SelectField selectField, SelectField selectField,
DefaultValueHelper defaultValueHelper, DefaultValueHelper defaultValueHelper,

View file

@ -531,17 +531,42 @@ internal static class DynamicReportImageHelper
ListFormField field, ListFormField field,
object value, object value,
string editorType, string editorType,
object phoneCountry,
out string text) out string text)
{ {
return TryFormatReportValue(value, field.SourceDbType, GetEffectiveFormat(field), editorType, out text); return TryFormatReportValue(value, field.SourceDbType, GetEffectiveFormat(field), editorType, phoneCountry, out text);
} }
/// <param name="phoneCountryExpression">
/// <c>phoneCountry</c> biçimli telefon kolonunda satırın ülke değeri; diğer kolonlarda <c>null</c>.
/// </param>
public static string CreateFormatValueExpression( public static string CreateFormatValueExpression(
string valueExpression, string valueExpression,
ListFormField field, ListFormField field,
string editorType) string editorType,
string phoneCountryExpression = null)
{ {
return $"{FormatValueFunctionName}({valueExpression}, '{field.SourceDbType}', '{EscapeExpressionString(GetEffectiveFormat(field))}', '{EscapeExpressionString(editorType)}')"; var countryOperand = string.IsNullOrWhiteSpace(phoneCountryExpression) ? string.Empty : $", {phoneCountryExpression}";
return $"{FormatValueFunctionName}({valueExpression}, '{field.SourceDbType}', '{EscapeExpressionString(GetEffectiveFormat(field))}', '{EscapeExpressionString(editorType)}'{countryOperand})";
}
/// <summary>
/// <c>phoneCountry</c> biçimli telefon alanının ülkeyi okuduğu alan (<c>phoneCountryField</c>,
/// varsayılan <c>Country</c>); başka biçimde <c>null</c>.
/// </summary>
public static string GetPhoneCountryFieldName(ListFormField field)
{
return GetEffectiveFormat(field) == DynamicReportPhoneFormatter.ByCountry
? TryGetFormatOption(field.EditorOptions, "phoneCountryField") ?? DynamicReportPhoneFormatter.DefaultCountryField
: null;
}
/// <summary>Ülke tablosu gereken telefon biçimi varsa tabloyu yükler; rapor ifadesi eşzamanlı çalışır.</summary>
public static Task EnsurePhoneCountriesAsync(IServiceProvider serviceProvider, IEnumerable<ListFormField> fields)
{
return fields.Any(x => DynamicReportPhoneFormatter.NeedsCountries(GetEffectiveFormat(x)))
? DynamicReportPhoneFormatter.LoadCountriesAsync(serviceProvider)
: Task.CompletedTask;
} }
public static string CreateFontFamilyExpression(string valueExpression) public static string CreateFontFamilyExpression(string valueExpression)
@ -560,6 +585,7 @@ internal static class DynamicReportImageHelper
DbType dbType, DbType dbType,
string format, string format,
string editorType, string editorType,
object phoneCountry,
out string text) out string text)
{ {
text = null; text = null;
@ -576,7 +602,7 @@ internal static class DynamicReportImageHelper
} }
var formatOptions = ReportFormatOptions.Parse(format); var formatOptions = ReportFormatOptions.Parse(format);
return TryFormatNamedTextValue(formatOptions, value, out text) || return TryFormatNamedTextValue(formatOptions, value, phoneCountry, out text) ||
TryFormatDateValue(dbType, formatOptions, value, out text) || TryFormatDateValue(dbType, formatOptions, value, out text) ||
TryFormatNumericValue(dbType, format, value, out text); TryFormatNumericValue(dbType, format, value, out text);
} }
@ -669,35 +695,16 @@ internal static class DynamicReportImageHelper
private static bool TryFormatNamedTextValue( private static bool TryFormatNamedTextValue(
ReportFormatOptions formatOptions, ReportFormatOptions formatOptions,
object value, object value,
object phoneCountry,
out string text) out string text)
{ {
text = null; text = null;
if (!string.Equals(formatOptions.Type, "phoneGlobal", StringComparison.OrdinalIgnoreCase)) if (!DynamicReportPhoneFormatter.IsPhoneFormat(formatOptions.Type))
{ {
return false; return false;
} }
var digits = Regex.Replace( text = DynamicReportPhoneFormatter.Format(value, formatOptions.Type, phoneCountry);
Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty,
@"\D",
string.Empty);
if (digits.StartsWith("90", StringComparison.Ordinal) && digits.Length > 10)
{
digits = digits[^10..];
}
if (digits.StartsWith("0", StringComparison.Ordinal) && digits.Length > 10)
{
digits = digits[^10..];
}
if (digits.Length > 10)
{
digits = digits[^10..];
}
text = digits.Length == 10
? $"({digits[..3]}) {digits.Substring(3, 3)}-{digits[6..]}"
: Convert.ToString(value, CultureInfo.CurrentCulture) ?? string.Empty;
return true; return true;
} }
@ -1223,7 +1230,10 @@ internal static class DynamicReportImageHelper
PlatformConsts.GridColumnOptions.ColumnValueFormatMinute or PlatformConsts.GridColumnOptions.ColumnValueFormatMinute or
PlatformConsts.GridColumnOptions.ColumnValueFormatSecond or PlatformConsts.GridColumnOptions.ColumnValueFormatSecond or
PlatformConsts.GridColumnOptions.ColumnValueFormatShortDateShortTime or PlatformConsts.GridColumnOptions.ColumnValueFormatShortDateShortTime or
"phoneGlobal"; DynamicReportPhoneFormatter.Global or
DynamicReportPhoneFormatter.Turkey or
DynamicReportPhoneFormatter.International or
DynamicReportPhoneFormatter.ByCountry;
} }
private static bool IsDateField(DbType dbType) private static bool IsDateField(DbType dbType)
@ -1893,7 +1903,7 @@ internal static class DynamicReportImageHelper
{ {
public string Name => FormatValueFunctionName; public string Name => FormatValueFunctionName;
public int MinOperandCount => 4; public int MinOperandCount => 4;
public int MaxOperandCount => 4; public int MaxOperandCount => 5;
public FunctionCategory Category => FunctionCategory.Text; public FunctionCategory Category => FunctionCategory.Text;
public string Description => "Formats a report value using the active culture."; public string Description => "Formats a report value using the active culture.";
@ -1903,8 +1913,9 @@ internal static class DynamicReportImageHelper
var dbType = ToDbType(Convert.ToString(operands.ElementAtOrDefault(1), CultureInfo.InvariantCulture)); var dbType = ToDbType(Convert.ToString(operands.ElementAtOrDefault(1), CultureInfo.InvariantCulture));
var format = Convert.ToString(operands.ElementAtOrDefault(2), CultureInfo.InvariantCulture); var format = Convert.ToString(operands.ElementAtOrDefault(2), CultureInfo.InvariantCulture);
var editorType = Convert.ToString(operands.ElementAtOrDefault(3), CultureInfo.InvariantCulture); var editorType = Convert.ToString(operands.ElementAtOrDefault(3), CultureInfo.InvariantCulture);
var phoneCountry = operands.ElementAtOrDefault(4);
return TryFormatReportValue(value, dbType, format, editorType, out var text) return TryFormatReportValue(value, dbType, format, editorType, phoneCountry, out var text)
? text ? text
: Convert.ToString(value, CultureInfo.CurrentCulture) ?? string.Empty; : Convert.ToString(value, CultureInfo.CurrentCulture) ?? string.Empty;
} }
@ -1916,7 +1927,7 @@ internal static class DynamicReportImageHelper
public bool IsValidOperandCount(int count) public bool IsValidOperandCount(int count)
{ {
return count == 4; return count is 4 or 5;
} }
public bool IsValidOperandType(int operandIndex, int operandCount, Type type) public bool IsValidOperandType(int operandIndex, int operandCount, Type type)

View file

@ -115,7 +115,8 @@ internal static class DynamicReportLayoutHelper
var valueExpression = DynamicReportImageHelper.CreateFormatValueExpression( var valueExpression = DynamicReportImageHelper.CreateFormatValueExpression(
$"[{column.BindingName}]", $"[{column.BindingName}]",
column.Field, column.Field,
column.EditorType); column.EditorType,
column.PhoneCountryExpression);
var markupExpression = CreateColorMarkupExpression(column); var markupExpression = CreateColorMarkupExpression(column);
cell.AllowMarkupText = markupExpression != null; cell.AllowMarkupText = markupExpression != null;
var displayExpression = markupExpression ?? valueExpression; var displayExpression = markupExpression ?? valueExpression;

View file

@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Sozsoft.Platform.Entities;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Uow;
namespace Sozsoft.Reports.PredefinedReports;
/// <summary>
/// Telefon alanlarının <c>editorOptions.format</c> token'larını raporda listedeki gibi gösterir.
/// Kural UI'daki <c>ui/src/utils/phoneFormat.ts</c> ile birebir aynıdır; biri değişirse diğeri de değişir,
/// yoksa liste ile rapor aynı numarayı farklı yazar.
/// </summary>
internal static class DynamicReportPhoneFormatter
{
public const string Global = "phoneGlobal";
public const string Turkey = "phoneTR";
public const string International = "phoneIntl";
public const string ByCountry = "phoneCountry";
public const string DefaultCountryField = "Country";
private const string GlobalPattern = "(000) 000-0000";
private const string TurkeyPattern = "(000) 000 00 00";
private const string WideNationalPattern = "000 000 000 000 000";
private const int TurkeyDialCode = 90;
private sealed record PhoneCountry(string Id, int PhoneCode, int? MinLength, int? MaxLength, string Pattern);
private static readonly PhoneCountry FallbackCountry = new("Türkiye", TurkeyDialCode, 10, 10, GlobalPattern);
// Ülke tablosu host'a aittir ve kiracıya göre değişmez; rapor ifadesi eşzamanlı çalıştığı için
// liste raporu kurulurken yüklenip süreç genelinde tutulur.
private static IReadOnlyDictionary<string, PhoneCountry> _countries =
new Dictionary<string, PhoneCountry>(StringComparer.OrdinalIgnoreCase);
public static bool IsPhoneFormat(string format) =>
format is Global or Turkey or International or ByCountry;
public static bool NeedsCountries(string format) => format is International or ByCountry;
public static async Task LoadCountriesAsync(IServiceProvider serviceProvider)
{
var unitOfWorkManager = serviceProvider.GetRequiredService<IUnitOfWorkManager>();
var repository = serviceProvider.GetRequiredService<IReadOnlyRepository<Country, string>>();
using var unitOfWork = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false);
var countries = await repository.GetListAsync();
await unitOfWork.CompleteAsync();
_countries = countries
.Select(x => new PhoneCountry(x.Id, x.PhoneCode, x.PhoneNumberMinLength, x.PhoneNumberMaxLength, x.PhoneNumberFormat))
.ToDictionary(x => x.Id, StringComparer.OrdinalIgnoreCase);
}
public static string Format(object value, string format, object countryId)
{
var text = Convert.ToString(value, CultureInfo.InvariantCulture);
if (string.IsNullOrEmpty(text))
{
return string.Empty;
}
switch (format)
{
case Turkey:
return FormatLocal(text, TurkeyPattern);
case International:
{
var digits = OnlyDigits(text);
var country = FindCountryByDialCode(digits);
return country == null
? "+" + digits
: FormatWithCountry(digits[country.PhoneCode.ToString(CultureInfo.InvariantCulture).Length..], country);
}
case ByCountry:
return FormatWithCountry(text, GetCountryOrDefault(countryId));
default:
return FormatLocal(text, GlobalPattern);
}
}
private static PhoneCountry GetCountryOrDefault(object countryId)
{
var id = Convert.ToString(countryId, CultureInfo.InvariantCulture);
if (!string.IsNullOrEmpty(id) && _countries.TryGetValue(id, out var country))
{
return country;
}
return _countries.Values.FirstOrDefault(x => x.PhoneCode == TurkeyDialCode) ?? FallbackCountry;
}
/// <summary>Rakamlarla başlayan en uzun ülke kodu (1 ile 1268 gibi çakışan kodlarda doğru ülke).</summary>
private static PhoneCountry FindCountryByDialCode(string digits) =>
_countries.Values
.Where(x => digits.StartsWith(x.PhoneCode.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal))
.OrderByDescending(x => x.PhoneCode.ToString(CultureInfo.InvariantCulture).Length)
.FirstOrDefault();
private static string OnlyDigits(string value) => Regex.Replace(value, @"\D", string.Empty);
private static int CountSlots(string pattern) => pattern.Count(x => x == '0');
/// <summary>Kalıp en çok hane sayısına göre kısaltılır ya da uzatılır; maske ile görüntü aynı haneyi taşır.</summary>
private static string GetNationalPattern(PhoneCountry country)
{
var pattern = !string.IsNullOrEmpty(country.Pattern)
? country.Pattern
: country.PhoneCode == TurkeyDialCode ? GlobalPattern : WideNationalPattern;
var slots = CountSlots(pattern);
var maxLength = country.MaxLength ?? slots;
if (maxLength > slots)
{
return pattern + " " + new string('0', maxLength - slots);
}
if (maxLength < slots)
{
var seen = 0;
var builder = new StringBuilder();
foreach (var character in pattern)
{
if (character == '0' ? ++seen <= maxLength : seen < maxLength)
{
builder.Append(character);
}
}
return builder.ToString();
}
return pattern;
}
/// <summary>Rakamları kalıbın <c>0</c> yerlerine dizer; rakam bitince sondaki ayraçlar yazılmaz.</summary>
private static string ApplyPattern(string digits, string pattern)
{
var index = 0;
var builder = new StringBuilder();
foreach (var character in pattern)
{
if (index >= digits.Length)
{
break;
}
builder.Append(character == '0' ? digits[index++] : character);
}
// Kalıptan uzun numara kesilmez; fazlası sona eklenir.
return builder.Append(digits[index..]).ToString();
}
/// <summary>Değerin başında ülke kodu ya da yurt içi <c>0</c> öneki varsa ulusal kısmı döner.</summary>
private static string ToNationalDigits(string value, PhoneCountry country)
{
var digits = OnlyDigits(value);
var code = country.PhoneCode.ToString(CultureInfo.InvariantCulture);
var maxLength = country.MaxLength ?? CountSlots(GetNationalPattern(country));
if (digits.Length > maxLength && digits.StartsWith(code, StringComparison.Ordinal))
{
return digits[code.Length..];
}
return digits.Length > maxLength && digits.StartsWith('0') ? digits[1..] : digits;
}
private static string FormatWithCountry(string value, PhoneCountry country)
{
var national = ToNationalDigits(value, country);
return national.Length == 0
? string.Empty
: $"+{country.PhoneCode} {ApplyPattern(national, GetNationalPattern(country))}";
}
/// <summary>Yerel 10 haneli biçimler: ülke kodu / <c>0</c> öneki atılır, son 10 hane kalıba dizilir.</summary>
private static string FormatLocal(string value, string pattern)
{
var digits = OnlyDigits(value);
if (digits.Length > 10)
{
digits = digits[^10..];
}
return digits.Length == 10 ? ApplyPattern(digits, pattern) : value;
}
}

View file

@ -95,6 +95,7 @@ public class DynamicTreeReport : XtraReport
.OrderBy(x => x.ListOrderNo ?? int.MaxValue) .OrderBy(x => x.ListOrderNo ?? int.MaxValue)
.ThenBy(x => x.CaptionName) .ThenBy(x => x.CaptionName)
.ToList(); .ToList();
await DynamicReportImageHelper.EnsurePhoneCountriesAsync(scope.ServiceProvider, fields);
var customizations = await customizationManager.GetUsersServerCustomizations(listFormCode); var customizations = await customizationManager.GetUsersServerCustomizations(listFormCode);
var (dynamicDataRepository, connectionString, dataSourceType) = await dynamicDataManager.GetAsync(listForm.IsTenant, listForm.DataSourceCode); var (dynamicDataRepository, connectionString, dataSourceType) = await dynamicDataManager.GetAsync(listForm.IsTenant, listForm.DataSourceCode);
@ -140,7 +141,7 @@ public class DynamicTreeReport : XtraReport
editorTypesByField.TryGetValue(field.FieldName, out var editorType); editorTypesByField.TryGetValue(field.FieldName, out var editorType);
return DynamicReportColumnFactory.Create( return DynamicReportColumnFactory.Create(
field, field,
selectFieldsByName[field.FieldName], selectFieldsByName,
defaultValueHelper, defaultValueHelper,
editorType, editorType,
localizer); localizer);

View file

@ -224,6 +224,9 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
- **`scriptRecipes.ts` (TS) ↔ `Domain.Shared/Editors/*.cs`** — biri değişirse diğeri de değişir. - **`scriptRecipes.ts` (TS) ↔ `Domain.Shared/Editors/*.cs`** — biri değişirse diğeri de değişir.
İkisinin çıktısı **birebir aynı** olmak zorundadır; aksi hâlde seeder ile basılan script İkisinin çıktısı **birebir aynı** olmak zorundadır; aksi hâlde seeder ile basılan script
dialogda "elle düzenlenmiş" sayılır ve kural editörü kapanır. dialogda "elle düzenlenmiş" sayılır ve kural editörü kapanır.
- **`phoneFormat.ts` ↔ `DynamicReportPhoneFormatter.cs`** — telefon token'larının (`phoneGlobal`, `phoneTR`,
`phoneIntl`, `phoneCountry`) görüntü kuralı iki yerde yazılır: liste/kart istemcide, dinamik rapor sunucuda.
Biri değişirse diğeri de değişir; yoksa liste ile rapor aynı numarayı farklı yazar.
- **`htmlEditorOptions.ts` ↔ `EditorOptions.Html`** — HtmlEditor varsayılanı iki yerde yazılır ve - **`htmlEditorOptions.ts` ↔ `EditorOptions.Html`** — HtmlEditor varsayılanı iki yerde yazılır ve
bloklar birebir aynıdır. İstemci iç içe blokları tek seviye birleştirdiği için seed edilen bloklar birebir aynıdır. İstemci iç içe blokları tek seviye birleştirdiği için seed edilen
`toolbar` varsayılanın yerine geçer; biri değişip diğeri değişmezse seed'li ekranlar eski araç `toolbar` varsayılanın yerine geçer; biri değişip diğeri değişmezse seed'li ekranlar eski araç

View file

@ -54898,6 +54898,90 @@
"Key": "App.PresetPhoneMask.PhoneMaskDescription", "Key": "App.PresetPhoneMask.PhoneMaskDescription",
"Value": "Maske, yer tutucu ve hata mesajı." "Value": "Maske, yer tutucu ve hata mesajı."
}, },
{
"Id": "060c38f8-fd4f-5d57-9c42-4ba5cb0ff106",
"CultureName": "en",
"ResourceName": "Platform",
"Key": "App.PresetPhoneTr.PhoneTrLabel",
"Value": "Phone (Türkiye)"
},
{
"Id": "6cb54be6-fd5d-5e78-b7ec-69076b77f358",
"CultureName": "tr",
"ResourceName": "Platform",
"Key": "App.PresetPhoneTr.PhoneTrLabel",
"Value": "Telefon (Türkiye)"
},
{
"Id": "478644a2-4072-5eba-80d7-a585afa37e61",
"CultureName": "en",
"ResourceName": "Platform",
"Key": "App.PresetPhoneTr.PhoneTrDescription",
"Value": "(532) 123 45 67 mask; 10 digits are stored."
},
{
"Id": "277c27d8-7dbd-5adc-8e89-b26fc103b7c3",
"CultureName": "tr",
"ResourceName": "Platform",
"Key": "App.PresetPhoneTr.PhoneTrDescription",
"Value": "(532) 123 45 67 maskesi; 10 hane saklanır."
},
{
"Id": "a2338e30-dfd9-5276-9ace-76b595d4904c",
"CultureName": "en",
"ResourceName": "Platform",
"Key": "App.PresetPhoneIntl.PhoneIntlLabel",
"Value": "Phone (international)"
},
{
"Id": "956e8d1f-34fe-57cc-9bde-56af9ff63c3c",
"CultureName": "tr",
"ResourceName": "Platform",
"Key": "App.PresetPhoneIntl.PhoneIntlLabel",
"Value": "Telefon (uluslararası)"
},
{
"Id": "d6829967-340d-548c-8faf-75b1cda42052",
"CultureName": "en",
"ResourceName": "Platform",
"Key": "App.PresetPhoneIntl.PhoneIntlDescription",
"Value": "Number with country code (up to 15 digits); lists show it in the country's format."
},
{
"Id": "d247d0c8-555a-542a-b345-55132f893f07",
"CultureName": "tr",
"ResourceName": "Platform",
"Key": "App.PresetPhoneIntl.PhoneIntlDescription",
"Value": "Ülke koduyla numara (en çok 15 hane); listede ülkenin biçimiyle gösterilir."
},
{
"Id": "13ef2c0e-be5e-517e-8ceb-739777e68348",
"CultureName": "en",
"ResourceName": "Platform",
"Key": "App.PresetPhoneCountry.PhoneCountryLabel",
"Value": "Phone (by country)"
},
{
"Id": "04a9844b-6ba8-5521-b2b5-717b0688880b",
"CultureName": "tr",
"ResourceName": "Platform",
"Key": "App.PresetPhoneCountry.PhoneCountryLabel",
"Value": "Telefon (ülkeye göre)"
},
{
"Id": "6f7483b3-6433-5347-a5ae-a40d2405dd3d",
"CultureName": "en",
"ResourceName": "Platform",
"Key": "App.PresetPhoneCountry.PhoneCountryDescription",
"Value": "Mask follows the country selected in the Country field (code, format, min/max digits)."
},
{
"Id": "bf6caee2-9833-545b-8ee9-f386632c9f3f",
"CultureName": "tr",
"ResourceName": "Platform",
"Key": "App.PresetPhoneCountry.PhoneCountryDescription",
"Value": "Maske, Ülke alanında seçilen ülkeye göre değişir (kod, biçim, en az/en çok hane)."
},
{ {
"Id": "832bc1fd-0fd8-5bcb-8809-5923f1473110", "Id": "832bc1fd-0fd8-5bcb-8809-5923f1473110",
"CultureName": "en", "CultureName": "en",

View file

@ -263,10 +263,10 @@
"Id": "f1effbfd-7fec-5056-845c-6223010eb2d6", "Id": "f1effbfd-7fec-5056-845c-6223010eb2d6",
"Code": "App.Definitions.TerminationReason", "Code": "App.Definitions.TerminationReason",
"DisplayName": "App.Definitions.TerminationReason", "DisplayName": "App.Definitions.TerminationReason",
"Order": 9, "Order": 18,
"Url": "/admin/list/App.Definitions.TerminationReason", "Url": "/admin/list/App.Definitions.TerminationReason",
"Icon": "FcExport", "Icon": "FcExport",
"ParentCode": "App.Saas.Definitions", "ParentCode": "App.Hr.Definitions",
"RequiredPermissionName": "App.Definitions.TerminationReason", "RequiredPermissionName": "App.Definitions.TerminationReason",
"CssClass": null, "CssClass": null,
"Target": null, "Target": null,

View file

@ -20,7 +20,7 @@ Satış ve satınalmanın ortak kullandığı **Kontaklar** kartı (müşteri ve
- Adres, Kiracılar ekranıyla aynı zinciri kullanır: Ülke › Şehir › İlçe › Mahalle; mahalle seçimi posta kodunu doldurur. - Adres, Kiracılar ekranıyla aynı zinciri kullanır: Ülke › Şehir › İlçe › Mahalle; mahalle seçimi posta kodunu doldurur.
- Logo tek görseldir (`multiple: false`) ve formda 120×120 gösterilir. - Logo tek görseldir (`multiple: false`) ve formda 120×120 gösterilir.
- Sektör (`SectorId`) Sektörler ekranının (`App.Definitions.Sector`, `Adm_T_Sector`) kiracıya ait aktif kayıtlarından seçilir. `JobPosition` kolonu tabloda kalır, ekranda yoktur. - Sektör (`SectorId`) Sektörler ekranının (`App.Definitions.Sector`, `Adm_T_Sector`) kiracıya ait aktif kayıtlarından seçilir. `JobPosition` kolonu tabloda kalır, ekranda yoktur.
- Telefon ve cep (`Phone`, `Mobile` — İlgili Kişiler dahil) `phoneGlobal` maskesi ve `pattern` `^[0-9]{10}$` kuralı taşır; web sitesi `pattern`, e-posta `email` doğrulaması taşır. - Kontak telefonu (`Phone`) `phoneCountry` biçimindedir: maske seçilen ülkenin kodu ve numara kalıbıyla değişir, `pattern` `^[0-9]{4,15}$`. Ülke alanı olmayan İlgili Kişiler telefonu `phoneGlobal` maskesi ve `^[0-9]{10}$` kuralı taşır; web sitesi `pattern`, e-posta `email` doğrulaması taşır.
## 3. Menü ve ekranlar ## 3. Menü ve ekranlar

View file

@ -140,12 +140,6 @@
"En": "E-mail", "En": "E-mail",
"Tr": "E-posta" "Tr": "E-posta"
}, },
{
"FieldName": "PhoneNumber",
"CaptionName": "App.Listform.ListformField.PhoneNumber",
"En": "Phone",
"Tr": "Telefon"
},
{ {
"FieldName": "Gender", "FieldName": "Gender",
"CaptionName": "App.Listform.ListformField.Gender", "CaptionName": "App.Listform.ListformField.Gender",
@ -170,6 +164,12 @@
"En": "City", "En": "City",
"Tr": "Şehir" "Tr": "Şehir"
}, },
{
"FieldName": "PhoneNumber",
"CaptionName": "App.Listform.ListformField.PhoneNumber",
"En": "Phone",
"Tr": "Telefon"
},
{ {
"FieldName": "Address", "FieldName": "Address",
"CaptionName": "App.Listform.ListformField.Address", "CaptionName": "App.Listform.ListformField.Address",
@ -378,6 +378,13 @@
"UpdateAfterCommand": "UPDATE a SET CandidateName = c.FirstName + ' ' + c.LastName, PostingTitle = COALESCE(p.PostingNo + ' - ', '') + p.Title FROM Hr_T_JobApplication a INNER JOIN Hr_T_Candidate c ON c.Id = a.CandidateId INNER JOIN Hr_T_JobPosting p ON p.Id = a.JobPostingId WHERE a.IsDeleted = 'false' AND (a.CandidateName IS NULL OR a.PostingTitle IS NULL OR a.CandidateName <> c.FirstName + ' ' + c.LastName OR a.PostingTitle <> COALESCE(p.PostingNo + ' - ', '') + p.Title); UPDATE i SET Title = COALESCE(a.CandidateName, '') + ' - ' + COALESCE(a.PostingTitle, '') FROM Hr_T_Interview i INNER JOIN Hr_T_JobApplication a ON a.Id = i.ApplicationId WHERE i.IsDeleted = 'false' AND (i.Title IS NULL OR i.Title <> COALESCE(a.CandidateName, '') + ' - ' + COALESCE(a.PostingTitle, '')); UPDATE o SET CandidateName = a.CandidateName FROM Hr_T_JobOffer o INNER JOIN Hr_T_JobApplication a ON a.Id = o.ApplicationId WHERE o.IsDeleted = 'false' AND a.CandidateName IS NOT NULL AND (o.CandidateName IS NULL OR o.CandidateName <> a.CandidateName)", "UpdateAfterCommand": "UPDATE a SET CandidateName = c.FirstName + ' ' + c.LastName, PostingTitle = COALESCE(p.PostingNo + ' - ', '') + p.Title FROM Hr_T_JobApplication a INNER JOIN Hr_T_Candidate c ON c.Id = a.CandidateId INNER JOIN Hr_T_JobPosting p ON p.Id = a.JobPostingId WHERE a.IsDeleted = 'false' AND (a.CandidateName IS NULL OR a.PostingTitle IS NULL OR a.CandidateName <> c.FirstName + ' ' + c.LastName OR a.PostingTitle <> COALESCE(p.PostingNo + ' - ', '') + p.Title); UPDATE i SET Title = COALESCE(a.CandidateName, '') + ' - ' + COALESCE(a.PostingTitle, '') FROM Hr_T_Interview i INNER JOIN Hr_T_JobApplication a ON a.Id = i.ApplicationId WHERE i.IsDeleted = 'false' AND (i.Title IS NULL OR i.Title <> COALESCE(a.CandidateName, '') + ' - ' + COALESCE(a.PostingTitle, '')); UPDATE o SET CandidateName = a.CandidateName FROM Hr_T_JobOffer o INNER JOIN Hr_T_JobApplication a ON a.Id = o.ApplicationId WHERE o.IsDeleted = 'false' AND a.CandidateName IS NOT NULL AND (o.CandidateName IS NULL OR o.CandidateName <> a.CandidateName)",
"UpdateFieldsDefaultValueDto": [], "UpdateFieldsDefaultValueDto": [],
"FormFieldsDefaultValueDto": [ "FormFieldsDefaultValueDto": [
{
"FieldName": "Country",
"FieldDbType": 16,
"Value": "Türkiye",
"CustomValueType": 1,
"SqlQuery": ""
},
{ {
"FieldName": "IsActive", "FieldName": "IsActive",
"FieldDbType": 3, "FieldDbType": 3,
@ -1767,11 +1774,11 @@
}, },
"Alignment": "left", "Alignment": "left",
"Format": null, "Format": null,
"EditOrderNo": 5, "EditOrderNo": 9,
"EditGroupOrderNo": 1, "EditGroupOrderNo": 1,
"EditorType2": "dxTextBox", "EditorType2": "dxTextBox",
"ColSpan": 1, "ColSpan": 1,
"EditorOptions": "{\"showClearButton\":true,\"maxLength\":32}", "EditorOptions": "{\"showClearButton\":true,\"format\":\"phoneCountry\",\"phoneCountryField\":\"Country\"}",
"GridBoxOptions": null, "GridBoxOptions": null,
"TagBoxOptions": null, "TagBoxOptions": null,
"ImageUploadOptions": null, "ImageUploadOptions": null,
@ -1893,7 +1900,7 @@
}, },
"Alignment": "left", "Alignment": "left",
"Format": null, "Format": null,
"EditOrderNo": 6, "EditOrderNo": 5,
"EditGroupOrderNo": 1, "EditGroupOrderNo": 1,
"EditorType2": "dxSelectBox", "EditorType2": "dxSelectBox",
"ColSpan": 1, "ColSpan": 1,
@ -2019,7 +2026,7 @@
}, },
"Alignment": "left", "Alignment": "left",
"Format": null, "Format": null,
"EditOrderNo": 7, "EditOrderNo": 6,
"EditGroupOrderNo": 1, "EditGroupOrderNo": 1,
"EditorType2": "dxDateBox", "EditorType2": "dxDateBox",
"ColSpan": 1, "ColSpan": 1,
@ -2145,7 +2152,7 @@
}, },
"Alignment": "left", "Alignment": "left",
"Format": null, "Format": null,
"EditOrderNo": 8, "EditOrderNo": 7,
"EditGroupOrderNo": 1, "EditGroupOrderNo": 1,
"EditorType2": "dxSelectBox", "EditorType2": "dxSelectBox",
"ColSpan": 1, "ColSpan": 1,
@ -2271,7 +2278,7 @@
}, },
"Alignment": "left", "Alignment": "left",
"Format": null, "Format": null,
"EditOrderNo": 9, "EditOrderNo": 8,
"EditGroupOrderNo": 1, "EditGroupOrderNo": 1,
"EditorType2": "dxSelectBox", "EditorType2": "dxSelectBox",
"ColSpan": 1, "ColSpan": 1,

View file

@ -225,7 +225,22 @@
"UpdateBeforeCommand": null, "UpdateBeforeCommand": null,
"UpdateAfterCommand": null, "UpdateAfterCommand": null,
"UpdateFieldsDefaultValueDto": [], "UpdateFieldsDefaultValueDto": [],
"FormFieldsDefaultValueDto": [], "FormFieldsDefaultValueDto": [
{
"FieldName": "Country",
"FieldDbType": 16,
"Value": "Türkiye",
"CustomValueType": 1,
"SqlQuery": ""
},
{
"FieldName": "IsActive",
"FieldDbType": 3,
"Value": "true",
"CustomValueType": 1,
"SqlQuery": ""
}
],
"WidgetsDto": [ "WidgetsDto": [
{ {
"ColGap": 3, "ColGap": 3,
@ -1614,7 +1629,7 @@
"IgnoreEmptyValue": true, "IgnoreEmptyValue": true,
"Reevaluate": true, "Reevaluate": true,
"ComparisonType": "", "ComparisonType": "",
"Pattern": "^[0-9]{10}$", "Pattern": "^[0-9]{4,15}$",
"Max": null, "Max": null,
"Min": null, "Min": null,
"Trim": true "Trim": true
@ -1647,11 +1662,11 @@
}, },
"Alignment": "left", "Alignment": "left",
"Format": null, "Format": null,
"EditOrderNo": 4, "EditOrderNo": 8,
"EditGroupOrderNo": 1, "EditGroupOrderNo": 1,
"EditorType2": "dxTextBox", "EditorType2": "dxTextBox",
"ColSpan": 1, "ColSpan": 1,
"EditorOptions": "{\"showClearButton\":true,\"format\":\"phoneGlobal\",\"mask\":\"(000) 000-0000\",\"useMaskedValue\":false,\"maskRules\":{\"X\":\"[0-9]\"},\"placeholder\":\"(555) 123-4567\"}", "EditorOptions": "{\"showClearButton\":true,\"format\":\"phoneCountry\",\"phoneCountryField\":\"Country\"}",
"GridBoxOptions": null, "GridBoxOptions": null,
"TagBoxOptions": null, "TagBoxOptions": null,
"ImageUploadOptions": null, "ImageUploadOptions": null,
@ -1773,7 +1788,7 @@
}, },
"Alignment": "left", "Alignment": "left",
"Format": null, "Format": null,
"EditOrderNo": 5, "EditOrderNo": 4,
"EditGroupOrderNo": 1, "EditGroupOrderNo": 1,
"EditorType2": "dxSelectBox", "EditorType2": "dxSelectBox",
"ColSpan": 1, "ColSpan": 1,
@ -1899,7 +1914,7 @@
}, },
"Alignment": "left", "Alignment": "left",
"Format": null, "Format": null,
"EditOrderNo": 6, "EditOrderNo": 5,
"EditGroupOrderNo": 1, "EditGroupOrderNo": 1,
"EditorType2": "dxSelectBox", "EditorType2": "dxSelectBox",
"ColSpan": 1, "ColSpan": 1,
@ -2025,7 +2040,7 @@
}, },
"Alignment": "left", "Alignment": "left",
"Format": null, "Format": null,
"EditOrderNo": 7, "EditOrderNo": 6,
"EditGroupOrderNo": 1, "EditGroupOrderNo": 1,
"EditorType2": "dxSelectBox", "EditorType2": "dxSelectBox",
"ColSpan": 1, "ColSpan": 1,
@ -2151,7 +2166,7 @@
}, },
"Alignment": "left", "Alignment": "left",
"Format": null, "Format": null,
"EditOrderNo": 8, "EditOrderNo": 7,
"EditGroupOrderNo": 1, "EditGroupOrderNo": 1,
"EditorType2": "dxTextBox", "EditorType2": "dxTextBox",
"ColSpan": 1, "ColSpan": 1,

382
ui/src/utils/phoneFormat.ts Normal file
View file

@ -0,0 +1,382 @@
/**
* 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
}

View file

@ -3,6 +3,7 @@
* diğer ayarları silmez. * diğer ayarları silmez.
*/ */
import { buildHtmlEditorDefaults } from '@/views/shared/htmlEditorOptions' import { buildHtmlEditorDefaults } from '@/views/shared/htmlEditorOptions'
import { DEFAULT_PHONE_COUNTRY_FIELD, PhoneFormats } from '@/utils/phoneFormat'
import type { JsonObject } from './jsonUtils' import type { JsonObject } from './jsonUtils'
export type EditorOptionsPreset = { export type EditorOptionsPreset = {
@ -110,6 +111,31 @@ export const editorOptionsPresets: EditorOptionsPreset[] = [
placeholder: '(555) 123-4567', placeholder: '(555) 123-4567',
}, },
}, },
{
key: 'phoneTr',
label: 'App.PresetPhoneTr.PhoneTrLabel',
description: 'App.PresetPhoneTr.PhoneTrDescription',
editors: ['dxTextBox'],
value: { format: PhoneFormats.Turkey, showClearButton: true },
},
{
key: 'phoneIntl',
label: 'App.PresetPhoneIntl.PhoneIntlLabel',
description: 'App.PresetPhoneIntl.PhoneIntlDescription',
editors: ['dxTextBox'],
value: { format: PhoneFormats.International, showClearButton: true },
},
{
key: 'phoneCountry',
label: 'App.PresetPhoneCountry.PhoneCountryLabel',
description: 'App.PresetPhoneCountry.PhoneCountryDescription',
editors: ['dxTextBox'],
value: {
format: PhoneFormats.Country,
phoneCountryField: DEFAULT_PHONE_COUNTRY_FIELD,
showClearButton: true,
},
},
{ {
key: 'textAreaAuto', key: 'textAreaAuto',
label: 'App.PresetTextAreaAuto.TextAreaAutoLabel', label: 'App.PresetTextAreaAuto.TextAreaAutoLabel',

View file

@ -1228,6 +1228,9 @@ const WizardStepFields = ({
<pre>{`• {"showClearButton":true }`}</pre> <pre>{`• {"showClearButton":true }`}</pre>
<pre>{`• {"format": "fixedPoint", "precision": 2}`}</pre> <pre>{`• {"format": "fixedPoint", "precision": 2}`}</pre>
<pre>{`• {"format": "phoneGlobal", "mask":"(000) 000-0000", "maskInvalidMessage":"Lütfen geçerli bir telefon numarası girin", "useMaskedValue":false, "maskRules": { "X": "[1-9]" }, "placeholder": "(555) 123-4567" }`}</pre> <pre>{`• {"format": "phoneGlobal", "mask":"(000) 000-0000", "maskInvalidMessage":"Lütfen geçerli bir telefon numarası girin", "useMaskedValue":false, "maskRules": { "X": "[1-9]" }, "placeholder": "(555) 123-4567" }`}</pre>
<pre>{`• {"format": "phoneTR"}`}</pre>
<pre>{`• {"format": "phoneIntl"}`}</pre>
<pre>{`• {"format": "phoneCountry", "phoneCountryField": "Country"}`}</pre>
<pre>{`• {"tooltip": { "enabled": true }}`}</pre> <pre>{`• {"tooltip": { "enabled": true }}`}</pre>
<pre>{`• {"height":200}`}</pre> <pre>{`• {"height":200}`}</pre>
<pre>{`• {"type":"date"}`}</pre> <pre>{`• {"type":"date"}`}</pre>

View file

@ -71,6 +71,7 @@ import {
attachEditorValueChangedScript, attachEditorValueChangedScript,
createEditingFormHandlers, createEditingFormHandlers,
} from './shared/editingFormScripts' } from './shared/editingFormScripts'
import { attachFieldValueBroadcast } from '@/utils/phoneFormat'
import { attachCascadeBehavior, createCascadeMaps } from './shared/cascade' import { attachCascadeBehavior, createCascadeMaps } from './shared/cascade'
import { createConditionalCellStyleHandler } from './shared/cellStyles' import { createConditionalCellStyleHandler } from './shared/cellStyles'
import { import {
@ -81,7 +82,6 @@ import {
useWidgetGroupHeight, useWidgetGroupHeight,
} from './shared/hooks' } from './shared/hooks'
import { startWorkflowForInsertedRow } from './shared/workflowRows' import { startWorkflowForInsertedRow } from './shared/workflowRows'
import { applyPhoneGlobalFormat } from './shared/columnFormatters'
interface GridProps { interface GridProps {
listFormCode: string listFormCode: string
@ -379,6 +379,9 @@ const Grid = (props: GridProps) => {
attachCascadeBehavior({ editor, gridDto, cascadeInfo, maps: cascadeMaps }) attachCascadeBehavior({ editor, gridDto, cascadeInfo, maps: cascadeMaps })
} }
// phoneCountry maskesi ülke alanını bu olaydan izler; DataGrid formu fieldDataChanged yayınlamaz.
attachFieldValueBroadcast(editor)
// Yalnızca düzenleme formunda yer alan alanların script'i çalıştırılır. // Yalnızca düzenleme formunda yer alan alanların script'i çalıştırılır.
if (columnFormat?.editOrderNo != null && columnFormat.editorScript) { if (columnFormat?.editOrderNo != null && columnFormat.editorScript) {
attachEditorValueChangedScript({ attachEditorValueChangedScript({
@ -417,9 +420,7 @@ const Grid = (props: GridProps) => {
const memoizedColumns = useMemo(() => { const memoizedColumns = useMemo(() => {
if (!gridDto) return undefined if (!gridDto) return undefined
const columns = getBandedColumns() return getBandedColumns()
columns?.forEach(applyPhoneGlobalFormat)
return columns
// getBandedColumns her render'da yeni referans üretebildiği için bağımlılığa alınmaz. // getBandedColumns her render'da yeni referans üretebildiği için bağımlılığa alınmaz.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [gridDto, currentCulture, localizationTexts]) }, [gridDto, currentCulture, localizationTexts])

View file

@ -67,6 +67,7 @@ import {
attachEditorValueChangedScript, attachEditorValueChangedScript,
createEditingFormHandlers, createEditingFormHandlers,
} from './shared/editingFormScripts' } from './shared/editingFormScripts'
import { attachFieldValueBroadcast } from '@/utils/phoneFormat'
import { attachCascadeBehavior, createCascadeMaps } from './shared/cascade' import { attachCascadeBehavior, createCascadeMaps } from './shared/cascade'
import { createConditionalCellStyleHandler } from './shared/cellStyles' import { createConditionalCellStyleHandler } from './shared/cellStyles'
import { import {
@ -389,6 +390,9 @@ const Tree = (props: TreeProps) => {
attachCascadeBehavior({ editor, gridDto, cascadeInfo, maps: cascadeMaps }) attachCascadeBehavior({ editor, gridDto, cascadeInfo, maps: cascadeMaps })
} }
// phoneCountry maskesi ülke alanını bu olaydan izler; DataGrid formu fieldDataChanged yayınlamaz.
attachFieldValueBroadcast(editor)
if (columnFormat?.editOrderNo != null && columnFormat.editorScript) { if (columnFormat?.editOrderNo != null && columnFormat.editorScript) {
attachEditorValueChangedScript({ attachEditorValueChangedScript({
editor, editor,

View file

@ -1,31 +1,35 @@
/** /**
* Kolon düzeyinde özel görüntüleme biçimleri. * Kolon düzeyinde özel görüntüleme biçimleri.
*/ */
import {
DEFAULT_PHONE_COUNTRY_FIELD,
formatPhone,
isPhoneFormat,
PhoneFormats,
} from '@/utils/phoneFormat'
/** Görüntüsü ülke tablosundan okunan telefon biçimleri. */
export const needsPhoneCountries = (editorOptions: Record<string, any> | undefined) =>
editorOptions?.format === PhoneFormats.International ||
editorOptions?.format === PhoneFormats.Country
/** /**
* 10 haneli yerel numarayı `(XXX) XXX-XXXX` biçiminde gösterir. * Telefon token'ı (`phoneGlobal`, `phoneTR`, `phoneIntl`, `phoneCountry`) taşıyan kolonun
* Ülke kodu (+90 / 0090 / 0) ön ekleri temizlenir; 10 hane değilse boş döner. * görüntü metnini üretir. `phoneCountry` ülkeyi satırdan okuduğu için `customizeText`
* değil `calculateDisplayValue` kullanılır; sıralama ve gruplama ise saklanan değer
* üzerinden kalır, aksi hâlde uzak sıralamaya fonksiyon seçici gider.
*/ */
export const formatGlobalPhone = (value: unknown) => { export const applyPhoneColumnFormat = (column: any, editorOptions: Record<string, any>) => {
if (!value) return '' const format = editorOptions?.format
if (!isPhoneFormat(format) || !column?.dataField) return
let digits = String(value).replace(/\D/g, '') const dataField = column.dataField as string
if (digits.length > 10) digits = digits.slice(-10) const countryField = editorOptions.phoneCountryField || DEFAULT_PHONE_COUNTRY_FIELD
if (digits.length !== 10) return ''
const match = digits.match(/^(\d{3})(\d{3})(\d{4})$/)
return match ? `(${match[1]}) ${match[2]}-${match[3]}` : digits
}
/**
* `editorOptions.format === 'phoneGlobal'` olan kolonlara telefon biçimini uygular.
* DevExtreme bazı durumlarda `format.formatter`'ı çağırmadığı için
* `customizeText` de yedek olarak eklenir.
*/
export const applyPhoneGlobalFormat = (column: any) => {
if (column?.editorOptions?.format !== 'phoneGlobal') return
column.dataType = 'string' column.dataType = 'string'
column.format = { formatter: formatGlobalPhone } column.format = undefined
column.customizeText = (cellInfo: any) => formatGlobalPhone(cellInfo?.value) column.calculateDisplayValue = (rowData: Record<string, any>) =>
formatPhone(rowData?.[dataField], { format, countryId: rowData?.[countryField] })
column.calculateSortValue ??= dataField
column.calculateGroupValue ??= dataField
} }

View file

@ -16,6 +16,7 @@ import {
import { scanBarcode } from '@/components/shared/BarcodeScanner/scanBarcode' import { scanBarcode } from '@/components/shared/BarcodeScanner/scanBarcode'
import { getNextSequenceValue } from '@/services/form.service' import { getNextSequenceValue } from '@/services/form.service'
import { mergeHtmlEditorOptions } from '@/views/shared/htmlEditorOptions' import { mergeHtmlEditorOptions } from '@/views/shared/htmlEditorOptions'
import { withPhoneEditorOptions } from '@/utils/phoneFormat'
import type { EditorType2, RowMode, SimpleItemWithColData } from '../../form/types' import type { EditorType2, RowMode, SimpleItemWithColData } from '../../form/types'
import { import {
autoNumber, autoNumber,
@ -431,7 +432,7 @@ export const withEditorTypeDefaults = <T extends Record<string, any>>(
): T => ): T =>
editorType2 === 'dxHtmlEditor' editorType2 === 'dxHtmlEditor'
? mergeHtmlEditorOptions(editorOptions) ? mergeHtmlEditorOptions(editorOptions)
: editorOptions : withPhoneEditorOptions(editorOptions)
type FormItemMapperParams = { type FormItemMapperParams = {
mode: RowMode mode: RowMode

View file

@ -16,7 +16,7 @@ import {
UiCommandButtonPositionTypeEnum, UiCommandButtonPositionTypeEnum,
UiLookupDataSourceTypeEnum, UiLookupDataSourceTypeEnum,
} from '@/proxy/form/models' } from '@/proxy/form/models'
import { addCss, getEditingFormFields } from './Utils' import { addCss, getEditingFormFields, safeJsonParse } from './Utils'
import { clearLookupCache, clearLookupCacheByPrefix } from '../shared/lookupCache' import { clearLookupCache, clearLookupCacheByPrefix } from '../shared/lookupCache'
import { useLookupDataSource } from '../form/useLookupDataSource' import { useLookupDataSource } from '../form/useLookupDataSource'
import { import {
@ -33,6 +33,8 @@ import {
} from '../form/editors/imageHoverPreview' } from '../form/editors/imageHoverPreview'
import { translateWorkflowLabel } from '@/utils/workflow/workflowHelpers' import { translateWorkflowLabel } from '@/utils/workflow/workflowHelpers'
import { findLookupColor, getLookupItemColor, tagBadgeColors } from '../shared/lookupColor' import { findLookupColor, getLookupItemColor, tagBadgeColors } from '../shared/lookupColor'
import { applyPhoneColumnFormat, needsPhoneCountries } from './shared/columnFormatters'
import { arePhoneCountriesLoaded, loadPhoneCountries } from '@/utils/phoneFormat'
const cellTemplateMultiValue = ( const cellTemplateMultiValue = (
cellElement: HTMLElement, cellElement: HTMLElement,
@ -219,6 +221,30 @@ const useListFormColumns = ({
clearLookupCache() clearLookupCache()
}, [listFormCode]) }, [listFormCode])
// Ülkeye bağlı telefon görüntüsü eşzamanlı üretilir; liste ilk açıldığında ülkeler
// satırlardan sonra gelirse hücreler yüklemeden sonra yeniden çizilir.
useEffect(() => {
if (arePhoneCountriesLoaded()) return
const hasPhoneCountryColumn = gridDto?.columnFormats?.some((colData) =>
needsPhoneCountries(safeJsonParse<Record<string, any>>(colData.editorOptions, {})),
)
if (!hasPhoneCountryColumn) return
let cancelled = false
loadPhoneCountries()
.then(() => {
if (cancelled) return
const instance = gridRef?.current?.instance?.()
if (typeof instance?.repaint === 'function') instance.repaint()
else instance?.refresh?.()
})
.catch((error) => console.error('Telefon ülke listesi yüklenemedi', error))
return () => {
cancelled = true
}
}, [gridDto, gridRef])
const { getLookupDataSource } = useLookupDataSource({ listFormCode, isSubForm }) const { getLookupDataSource } = useLookupDataSource({ listFormCode, isSubForm })
// DataGrid lookup'ı satırı `options.data` ile verir; görünen metin yüklemesi satırsız çağırır. // DataGrid lookup'ı satırı `options.data` ile verir; görünen metin yüklemesi satırsız çağırır.
@ -618,6 +644,8 @@ const useListFormColumns = ({
column.encodeHtml = editorOptions.encodeHtml column.encodeHtml = editorOptions.encodeHtml
} }
applyPhoneColumnFormat(column, editorOptions)
// columnCustomizationDto // columnCustomizationDto
column.fixed = colData.columnCustomizationDto?.fixed column.fixed = colData.columnCustomizationDto?.fixed
column.fixedPosition = colData.columnCustomizationDto?.fixedPosition as HorizontalEdge column.fixedPosition = colData.columnCustomizationDto?.fixedPosition as HorizontalEdge