diff --git a/.github/instructions/lowcode.instructions.md b/.github/instructions/lowcode.instructions.md index aca87d38..8d2b7e70 100644 --- a/.github/instructions/lowcode.instructions.md +++ b/.github/instructions/lowcode.instructions.md @@ -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` | | 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 (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` | | Aranabilir liste | `searchEnabled`, `searchMode:contains`, `searchTimeout`, `showClearButton` | | 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. | | 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#) | İ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()` | | | Tarih / tarih-saat / saat | `EditorOptions.Date()` · `DateTime()` · `Time(15)` | | | 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}}` | | 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) | diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Saas.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Saas.cs index 4efb4a8e..d90fec5b 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Saas.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Saas.cs @@ -259,7 +259,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.String, FieldName = "Country", EditGroupOrderNo = 1, - EditOrderNo = 13, + EditOrderNo = 10, EditorType2 = EditorTypes.dxSelectBox, ColSpan = 1, EditorOptions = EditorOptions.ShowClearButton(), @@ -289,7 +289,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.String, FieldName = "City", EditGroupOrderNo = 1, - EditOrderNo = 14, + EditOrderNo = 11, EditorType2 = EditorTypes.dxSelectBox, ColSpan = 1, EditorOptions = EditorOptions.ShowClearButton(), @@ -322,7 +322,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.String, FieldName = "District", EditGroupOrderNo = 1, - EditOrderNo = 15, + EditOrderNo = 12, EditorType2 = EditorTypes.dxSelectBox, ColSpan = 1, EditorOptions = EditorOptions.ShowClearButton(), @@ -355,7 +355,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.String, FieldName = "Township", EditGroupOrderNo = 1, - EditOrderNo = 16, + EditOrderNo = 13, EditorType2 = EditorTypes.dxSelectBox, ColSpan = 1, EditorOptions = EditorOptions.ShowClearButton(), @@ -429,7 +429,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.String, FieldName = "PostalCode", EditGroupOrderNo = 1, - EditOrderNo = 17, + EditOrderNo = 14, EditorType2 = EditorTypes.dxTextBox, ColSpan = 1, EditorOptions = EditorOptions.ShowClearButton(), @@ -450,7 +450,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.String, FieldName = "Email", EditGroupOrderNo = 1, - EditOrderNo = 10, + EditOrderNo = 7, EditorType2 = EditorTypes.dxTextBox, ColSpan = 1, CaptionName = "App.Listform.ListformField.Email", @@ -471,7 +471,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.String, FieldName = "Website", EditGroupOrderNo = 1, - EditOrderNo = 11, + EditOrderNo = 8, EditorType2 = EditorTypes.dxTextBox, ColSpan = 1, CaptionName = "App.Listform.ListformField.Website", @@ -492,10 +492,10 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.String, FieldName = "PhoneNumber", EditGroupOrderNo = 1, - EditOrderNo = 7, + EditOrderNo = 16, EditorType2 = EditorTypes.dxTextBox, ColSpan = 1, - EditorOptions = EditorOptions.Phone(), + EditorOptions = EditorOptions.PhoneByCountry(), CaptionName = "Abp.Identity.User.UserInformation.PhoneNumber", Width = 0, ListOrderNo = 16, @@ -514,10 +514,10 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.String, FieldName = "MobileNumber", EditGroupOrderNo = 1, - EditOrderNo = 6, + EditOrderNo = 15, EditorType2 = EditorTypes.dxTextBox, ColSpan = 1, - EditorOptions = EditorOptions.Phone(), + EditorOptions = EditorOptions.PhoneByCountry(), CaptionName = "App.Listform.ListformField.MobileNumber", Width = 0, ListOrderNo = 17, @@ -535,10 +535,10 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.Int64, FieldName = "FaxNumber", EditGroupOrderNo = 1, - EditOrderNo = 8, + EditOrderNo = 17, EditorType2 = EditorTypes.dxTextBox, ColSpan = 1, - EditorOptions = EditorOptions.Phone(), + EditorOptions = EditorOptions.PhoneByCountry(), CaptionName = "App.Listform.ListformField.FaxNumber", Width = 0, ListOrderNo = 18, @@ -556,7 +556,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.Boolean, FieldName = "IsActive", EditGroupOrderNo = 1, - EditOrderNo = 12, + EditOrderNo = 9, EditorType2 = EditorTypes.dxCheckBox, ColSpan = 1, CaptionName = "App.Listform.ListformField.IsActive", @@ -575,7 +575,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.String, FieldName = "Modules", EditGroupOrderNo = 1, - EditOrderNo = 9, + EditOrderNo = 6, EditorType2 = EditorTypes.dxTagBox, ColSpan = 1, CaptionName = "App.Listform.ListformField.Modules", @@ -932,7 +932,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.String, FieldName = "Address1", EditGroupOrderNo = 2, - EditOrderNo = 7, + EditOrderNo = 11, EditorType2 = EditorTypes.dxTextArea, ColSpan = 3, CaptionName = "App.Listform.ListformField.Address1", @@ -953,7 +953,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.String, FieldName = "Address2", EditGroupOrderNo = 2, - EditOrderNo = 8, + EditOrderNo = 12, EditorType2 = EditorTypes.dxTextArea, ColSpan = 3, CaptionName = "App.Listform.ListformField.Address2", @@ -973,8 +973,8 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "PostalCode", - EditGroupOrderNo = 1, - EditOrderNo = 9, + EditGroupOrderNo = 2, + EditOrderNo = 5, EditorType2 = EditorTypes.dxTextBox, ColSpan = 1, CaptionName = "App.Listform.ListformField.PostalCode", @@ -995,7 +995,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.String, FieldName = "Email", EditGroupOrderNo = 2, - EditOrderNo = 5, + EditOrderNo = 9, EditorType2 = EditorTypes.dxTextBox, ColSpan = 1, CaptionName = "App.Listform.ListformField.Email", @@ -1017,7 +1017,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.String, FieldName = "Website", EditGroupOrderNo = 2, - EditOrderNo = 6, + EditOrderNo = 10, EditorType2 = EditorTypes.dxTextBox, ColSpan = 1, CaptionName = "App.Listform.ListformField.Website", @@ -1038,11 +1038,11 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "PhoneNumber", - EditGroupOrderNo = 1, + EditGroupOrderNo = 2, EditOrderNo = 7, EditorType2 = EditorTypes.dxTextBox, ColSpan = 1, - EditorOptions = EditorOptions.Phone(), + EditorOptions = EditorOptions.PhoneByCountry(), CaptionName = "Abp.Identity.User.UserInformation.PhoneNumber", Width = 0, ListOrderNo = 15, @@ -1061,11 +1061,11 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "MobileNumber", - EditGroupOrderNo = 1, + EditGroupOrderNo = 2, EditOrderNo = 6, EditorType2 = EditorTypes.dxTextBox, ColSpan = 1, - EditorOptions = EditorOptions.Phone(), + EditorOptions = EditorOptions.PhoneByCountry(), CaptionName = "App.Listform.ListformField.MobileNumber", Width = 0, ListOrderNo = 16, @@ -1083,11 +1083,11 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.Int64, FieldName = "FaxNumber", - EditGroupOrderNo = 1, + EditGroupOrderNo = 2, EditOrderNo = 8, EditorType2 = EditorTypes.dxTextBox, ColSpan = 1, - EditorOptions = EditorOptions.Phone(), + EditorOptions = EditorOptions.PhoneByCountry(), CaptionName = "App.Listform.ListformField.FaxNumber", Width = 0, ListOrderNo = 17, @@ -1106,7 +1106,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency SourceDbType = DbType.Boolean, FieldName = "IsActive", EditGroupOrderNo = 1, - EditOrderNo = 10, + EditOrderNo = 6, EditorType2 = EditorTypes.dxCheckBox, ColSpan = 1, CaptionName = "App.Listform.ListformField.IsActive", @@ -7904,7 +7904,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency EditOrderNo = 14, EditorType2 = EditorTypes.dxTextBox, ColSpan = 1, - EditorOptions = EditorOptions.Phone(), + EditorOptions = EditorOptions.PhoneByCountry(), CaptionName = "Abp.Identity.User.UserInformation.PhoneNumber", Width = 0, ListOrderNo = 18, @@ -7927,7 +7927,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency EditOrderNo = 15, EditorType2 = EditorTypes.dxTextBox, ColSpan = 1, - EditorOptions = EditorOptions.Phone(), + EditorOptions = EditorOptions.PhoneByCountry(), CaptionName = "App.Listform.ListformField.MobileNumber", Width = 0, ListOrderNo = 19, @@ -7949,7 +7949,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency EditOrderNo = 16, EditorType2 = EditorTypes.dxTextBox, ColSpan = 1, - EditorOptions = EditorOptions.Phone(), + EditorOptions = EditorOptions.PhoneByCountry(), CaptionName = "App.Listform.ListformField.FaxNumber", Width = 0, ListOrderNo = 20, diff --git a/api/src/Sozsoft.Platform.Domain.Shared/Editors/EditorOptions.cs b/api/src/Sozsoft.Platform.Domain.Shared/Editors/EditorOptions.cs index a9d741dd..465a0e86 100644 --- a/api/src/Sozsoft.Platform.Domain.Shared/Editors/EditorOptions.cs +++ b/api/src/Sozsoft.Platform.Domain.Shared/Editors/EditorOptions.cs @@ -68,6 +68,13 @@ public static class EditorOptions .Raw("maskRules", "{\"X\":\"[0-9]\"}") .Placeholder("(555) 123-4567"); + /// + /// Maskesi formdaki ulke alaninin Sas_H_Country satirindan (kod, bicim, en az/en cok hane) + /// gelen telefon; deger ulke kodu olmadan saklanir. + /// + public static EditorOptionsBuilder PhoneByCountry(string countryField = "Country") + => New().Format("phoneCountry").Text("phoneCountryField", countryField).ShowClearButton(); + /// Kaydirma cubugu; degeri ipucu olarak gosterir. public static EditorOptionsBuilder Slider() => New().Raw("tooltip", "{\"enabled\":true}"); diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicFormReport.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicFormReport.cs index 46b08b10..513535da 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicFormReport.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicFormReport.cs @@ -158,6 +158,7 @@ public class DynamicFormReport : XtraReport .OrderBy(x => x.ListOrderNo ?? int.MaxValue) .ThenBy(x => x.CaptionName) .ToList(); + await DynamicReportImageHelper.EnsurePhoneCountriesAsync(serviceProvider, fields); var customizations = await customizationManager.GetUsersServerCustomizations(listFormCode); var (dynamicDataRepository, connectionString, dataSourceType) = await dynamicDataManager .GetAsync(listForm.IsTenant, listForm.DataSourceCode); @@ -524,7 +525,11 @@ public class DynamicFormReport : XtraReport 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; return true; diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicGridReport.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicGridReport.cs index 01274fd9..6b7a7694 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicGridReport.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicGridReport.cs @@ -89,6 +89,7 @@ public class DynamicGridReport : XtraReport .OrderBy(x => x.ListOrderNo ?? int.MaxValue) .ThenBy(x => x.CaptionName) .ToList(); + await DynamicReportImageHelper.EnsurePhoneCountriesAsync(scope.ServiceProvider, fields); var customizations = await customizationManager.GetUsersServerCustomizations(listFormCode); 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); return DynamicReportColumnFactory.Create( field, - selectFieldsByName[field.FieldName], + selectFieldsByName, defaultValueHelper, editorType, localizer); @@ -429,7 +430,8 @@ public class DynamicGridReport : XtraReport DynamicReportImageHelper.CreateFormatValueExpression( $"[{groupField.BindingName}]", groupField.Field, - groupField.EditorType))); + groupField.EditorType, + groupField.PhoneCountryExpression))); band.Controls.AddRange([captionLabel, valueLabel]); bands.Add(band); diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportColumn.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportColumn.cs index 6401a943..b515c087 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportColumn.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportColumn.cs @@ -25,6 +25,13 @@ internal sealed record DynamicReportColumn( { public float ImageWidth => DynamicReportImageHelper.GetGridImageColumnWidth(ImageCount); + /// phoneCountry biçimli telefon kolonunda satırın ülke değerini taşıyan kolon. + public string PhoneCountryBindingName { get; init; } + + /// Biçim ifadesine verilecek ülke operandı; telefon kolonu değilse null. + public string PhoneCountryExpression => + string.IsNullOrWhiteSpace(PhoneCountryBindingName) ? null : $"[{PhoneCountryBindingName}]"; + public bool HasBindingName => !string.IsNullOrWhiteSpace(BindingName) && BindingName != "undefined"; } @@ -32,6 +39,21 @@ internal sealed record DynamicReportColumn( internal static class DynamicReportColumnFactory { public static DynamicReportColumn Create( + ListFormField field, + IReadOnlyDictionary selectFieldsByName, + DefaultValueHelper defaultValueHelper, + string editorType, + IStringLocalizer 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, SelectField selectField, DefaultValueHelper defaultValueHelper, diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportImageHelper.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportImageHelper.cs index a861cedd..8c2676a1 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportImageHelper.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportImageHelper.cs @@ -531,17 +531,42 @@ internal static class DynamicReportImageHelper ListFormField field, object value, string editorType, + object phoneCountry, out string text) { - return TryFormatReportValue(value, field.SourceDbType, GetEffectiveFormat(field), editorType, out text); + return TryFormatReportValue(value, field.SourceDbType, GetEffectiveFormat(field), editorType, phoneCountry, out text); } + /// + /// phoneCountry biçimli telefon kolonunda satırın ülke değeri; diğer kolonlarda null. + /// public static string CreateFormatValueExpression( string valueExpression, 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})"; + } + + /// + /// phoneCountry biçimli telefon alanının ülkeyi okuduğu alan (phoneCountryField, + /// varsayılan Country); başka biçimde null. + /// + public static string GetPhoneCountryFieldName(ListFormField field) + { + return GetEffectiveFormat(field) == DynamicReportPhoneFormatter.ByCountry + ? TryGetFormatOption(field.EditorOptions, "phoneCountryField") ?? DynamicReportPhoneFormatter.DefaultCountryField + : null; + } + + /// Ülke tablosu gereken telefon biçimi varsa tabloyu yükler; rapor ifadesi eşzamanlı çalışır. + public static Task EnsurePhoneCountriesAsync(IServiceProvider serviceProvider, IEnumerable fields) + { + return fields.Any(x => DynamicReportPhoneFormatter.NeedsCountries(GetEffectiveFormat(x))) + ? DynamicReportPhoneFormatter.LoadCountriesAsync(serviceProvider) + : Task.CompletedTask; } public static string CreateFontFamilyExpression(string valueExpression) @@ -560,6 +585,7 @@ internal static class DynamicReportImageHelper DbType dbType, string format, string editorType, + object phoneCountry, out string text) { text = null; @@ -576,7 +602,7 @@ internal static class DynamicReportImageHelper } var formatOptions = ReportFormatOptions.Parse(format); - return TryFormatNamedTextValue(formatOptions, value, out text) || + return TryFormatNamedTextValue(formatOptions, value, phoneCountry, out text) || TryFormatDateValue(dbType, formatOptions, value, out text) || TryFormatNumericValue(dbType, format, value, out text); } @@ -669,35 +695,16 @@ internal static class DynamicReportImageHelper private static bool TryFormatNamedTextValue( ReportFormatOptions formatOptions, object value, + object phoneCountry, out string text) { text = null; - if (!string.Equals(formatOptions.Type, "phoneGlobal", StringComparison.OrdinalIgnoreCase)) + if (!DynamicReportPhoneFormatter.IsPhoneFormat(formatOptions.Type)) { return false; } - var digits = Regex.Replace( - 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; + text = DynamicReportPhoneFormatter.Format(value, formatOptions.Type, phoneCountry); return true; } @@ -1223,7 +1230,10 @@ internal static class DynamicReportImageHelper PlatformConsts.GridColumnOptions.ColumnValueFormatMinute or PlatformConsts.GridColumnOptions.ColumnValueFormatSecond or PlatformConsts.GridColumnOptions.ColumnValueFormatShortDateShortTime or - "phoneGlobal"; + DynamicReportPhoneFormatter.Global or + DynamicReportPhoneFormatter.Turkey or + DynamicReportPhoneFormatter.International or + DynamicReportPhoneFormatter.ByCountry; } private static bool IsDateField(DbType dbType) @@ -1893,7 +1903,7 @@ internal static class DynamicReportImageHelper { public string Name => FormatValueFunctionName; public int MinOperandCount => 4; - public int MaxOperandCount => 4; + public int MaxOperandCount => 5; public FunctionCategory Category => FunctionCategory.Text; 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 format = Convert.ToString(operands.ElementAtOrDefault(2), 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 : Convert.ToString(value, CultureInfo.CurrentCulture) ?? string.Empty; } @@ -1916,7 +1927,7 @@ internal static class DynamicReportImageHelper public bool IsValidOperandCount(int count) { - return count == 4; + return count is 4 or 5; } public bool IsValidOperandType(int operandIndex, int operandCount, Type type) diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportLayoutHelper.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportLayoutHelper.cs index 58377325..9bd57f87 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportLayoutHelper.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportLayoutHelper.cs @@ -115,7 +115,8 @@ internal static class DynamicReportLayoutHelper var valueExpression = DynamicReportImageHelper.CreateFormatValueExpression( $"[{column.BindingName}]", column.Field, - column.EditorType); + column.EditorType, + column.PhoneCountryExpression); var markupExpression = CreateColorMarkupExpression(column); cell.AllowMarkupText = markupExpression != null; var displayExpression = markupExpression ?? valueExpression; diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportPhoneFormatter.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportPhoneFormatter.cs new file mode 100644 index 00000000..089307f9 --- /dev/null +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportPhoneFormatter.cs @@ -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; + +/// +/// Telefon alanlarının editorOptions.format token'larını raporda listedeki gibi gösterir. +/// Kural UI'daki ui/src/utils/phoneFormat.ts ile birebir aynıdır; biri değişirse diğeri de değişir, +/// yoksa liste ile rapor aynı numarayı farklı yazar. +/// +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 _countries = + new Dictionary(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(); + var repository = serviceProvider.GetRequiredService>(); + + 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; + } + + /// Rakamlarla başlayan en uzun ülke kodu (1 ile 1268 gibi çakışan kodlarda doğru ülke). + 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'); + + /// 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. + 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; + } + + /// Rakamları kalıbın 0 yerlerine dizer; rakam bitince sondaki ayraçlar yazılmaz. + 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(); + } + + /// Değerin başında ülke kodu ya da yurt içi 0 öneki varsa ulusal kısmı döner. + 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))}"; + } + + /// Yerel 10 haneli biçimler: ülke kodu / 0 öneki atılır, son 10 hane kalıba dizilir. + 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; + } +} diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicTreeReport.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicTreeReport.cs index 087c9f5a..4bca3a31 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicTreeReport.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicTreeReport.cs @@ -95,6 +95,7 @@ public class DynamicTreeReport : XtraReport .OrderBy(x => x.ListOrderNo ?? int.MaxValue) .ThenBy(x => x.CaptionName) .ToList(); + await DynamicReportImageHelper.EnsurePhoneCountriesAsync(scope.ServiceProvider, fields); var customizations = await customizationManager.GetUsersServerCustomizations(listFormCode); 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); return DynamicReportColumnFactory.Create( field, - selectFieldsByName[field.FieldName], + selectFieldsByName, defaultValueHelper, editorType, localizer); diff --git a/claude.md b/claude.md index 9c0864d1..11c0bbdc 100644 --- a/claude.md +++ b/claude.md @@ -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. İ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. +- **`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 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ç diff --git a/configs/seeds/data/App.Languages.LanguageText.json b/configs/seeds/data/App.Languages.LanguageText.json index 7841c5e7..6b4bd7f1 100644 --- a/configs/seeds/data/App.Languages.LanguageText.json +++ b/configs/seeds/data/App.Languages.LanguageText.json @@ -54898,6 +54898,90 @@ "Key": "App.PresetPhoneMask.PhoneMaskDescription", "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", "CultureName": "en", diff --git a/configs/seeds/data/App.Menus.Menu.json b/configs/seeds/data/App.Menus.Menu.json index fb732473..fae7ed14 100644 --- a/configs/seeds/data/App.Menus.Menu.json +++ b/configs/seeds/data/App.Menus.Menu.json @@ -263,10 +263,10 @@ "Id": "f1effbfd-7fec-5056-845c-6223010eb2d6", "Code": "App.Definitions.TerminationReason", "DisplayName": "App.Definitions.TerminationReason", - "Order": 9, + "Order": 18, "Url": "/admin/list/App.Definitions.TerminationReason", "Icon": "FcExport", - "ParentCode": "App.Saas.Definitions", + "ParentCode": "App.Hr.Definitions", "RequiredPermissionName": "App.Definitions.TerminationReason", "CssClass": null, "Target": null, diff --git a/configs/seeds/docs/modules/AdministrationDefinitions.md b/configs/seeds/docs/modules/AdministrationDefinitions.md index 58fee237..ac21fc09 100644 --- a/configs/seeds/docs/modules/AdministrationDefinitions.md +++ b/configs/seeds/docs/modules/AdministrationDefinitions.md @@ -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. - 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. -- 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 diff --git a/configs/seeds/wizard/Candidate.json b/configs/seeds/wizard/Candidate.json index 74fd6859..01c29fcf 100644 --- a/configs/seeds/wizard/Candidate.json +++ b/configs/seeds/wizard/Candidate.json @@ -140,12 +140,6 @@ "En": "E-mail", "Tr": "E-posta" }, - { - "FieldName": "PhoneNumber", - "CaptionName": "App.Listform.ListformField.PhoneNumber", - "En": "Phone", - "Tr": "Telefon" - }, { "FieldName": "Gender", "CaptionName": "App.Listform.ListformField.Gender", @@ -170,6 +164,12 @@ "En": "City", "Tr": "Şehir" }, + { + "FieldName": "PhoneNumber", + "CaptionName": "App.Listform.ListformField.PhoneNumber", + "En": "Phone", + "Tr": "Telefon" + }, { "FieldName": "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)", "UpdateFieldsDefaultValueDto": [], "FormFieldsDefaultValueDto": [ + { + "FieldName": "Country", + "FieldDbType": 16, + "Value": "Türkiye", + "CustomValueType": 1, + "SqlQuery": "" + }, { "FieldName": "IsActive", "FieldDbType": 3, @@ -1767,11 +1774,11 @@ }, "Alignment": "left", "Format": null, - "EditOrderNo": 5, + "EditOrderNo": 9, "EditGroupOrderNo": 1, "EditorType2": "dxTextBox", "ColSpan": 1, - "EditorOptions": "{\"showClearButton\":true,\"maxLength\":32}", + "EditorOptions": "{\"showClearButton\":true,\"format\":\"phoneCountry\",\"phoneCountryField\":\"Country\"}", "GridBoxOptions": null, "TagBoxOptions": null, "ImageUploadOptions": null, @@ -1893,7 +1900,7 @@ }, "Alignment": "left", "Format": null, - "EditOrderNo": 6, + "EditOrderNo": 5, "EditGroupOrderNo": 1, "EditorType2": "dxSelectBox", "ColSpan": 1, @@ -2019,7 +2026,7 @@ }, "Alignment": "left", "Format": null, - "EditOrderNo": 7, + "EditOrderNo": 6, "EditGroupOrderNo": 1, "EditorType2": "dxDateBox", "ColSpan": 1, @@ -2145,7 +2152,7 @@ }, "Alignment": "left", "Format": null, - "EditOrderNo": 8, + "EditOrderNo": 7, "EditGroupOrderNo": 1, "EditorType2": "dxSelectBox", "ColSpan": 1, @@ -2271,7 +2278,7 @@ }, "Alignment": "left", "Format": null, - "EditOrderNo": 9, + "EditOrderNo": 8, "EditGroupOrderNo": 1, "EditorType2": "dxSelectBox", "ColSpan": 1, diff --git a/configs/seeds/wizard/Contact.json b/configs/seeds/wizard/Contact.json index 6aa87da5..39e110c6 100644 --- a/configs/seeds/wizard/Contact.json +++ b/configs/seeds/wizard/Contact.json @@ -225,7 +225,22 @@ "UpdateBeforeCommand": null, "UpdateAfterCommand": null, "UpdateFieldsDefaultValueDto": [], - "FormFieldsDefaultValueDto": [], + "FormFieldsDefaultValueDto": [ + { + "FieldName": "Country", + "FieldDbType": 16, + "Value": "Türkiye", + "CustomValueType": 1, + "SqlQuery": "" + }, + { + "FieldName": "IsActive", + "FieldDbType": 3, + "Value": "true", + "CustomValueType": 1, + "SqlQuery": "" + } + ], "WidgetsDto": [ { "ColGap": 3, @@ -1614,7 +1629,7 @@ "IgnoreEmptyValue": true, "Reevaluate": true, "ComparisonType": "", - "Pattern": "^[0-9]{10}$", + "Pattern": "^[0-9]{4,15}$", "Max": null, "Min": null, "Trim": true @@ -1647,11 +1662,11 @@ }, "Alignment": "left", "Format": null, - "EditOrderNo": 4, + "EditOrderNo": 8, "EditGroupOrderNo": 1, "EditorType2": "dxTextBox", "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, "TagBoxOptions": null, "ImageUploadOptions": null, @@ -1773,7 +1788,7 @@ }, "Alignment": "left", "Format": null, - "EditOrderNo": 5, + "EditOrderNo": 4, "EditGroupOrderNo": 1, "EditorType2": "dxSelectBox", "ColSpan": 1, @@ -1899,7 +1914,7 @@ }, "Alignment": "left", "Format": null, - "EditOrderNo": 6, + "EditOrderNo": 5, "EditGroupOrderNo": 1, "EditorType2": "dxSelectBox", "ColSpan": 1, @@ -2025,7 +2040,7 @@ }, "Alignment": "left", "Format": null, - "EditOrderNo": 7, + "EditOrderNo": 6, "EditGroupOrderNo": 1, "EditorType2": "dxSelectBox", "ColSpan": 1, @@ -2151,7 +2166,7 @@ }, "Alignment": "left", "Format": null, - "EditOrderNo": 8, + "EditOrderNo": 7, "EditGroupOrderNo": 1, "EditorType2": "dxTextBox", "ColSpan": 1, diff --git a/ui/src/utils/phoneFormat.ts b/ui/src/utils/phoneFormat.ts new file mode 100644 index 00000000..21eace05 --- /dev/null +++ b/ui/src/utils/phoneFormat.ts @@ -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(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 +} diff --git a/ui/src/views/admin/listForm/edit/json-row-operations/editor-options/presets.ts b/ui/src/views/admin/listForm/edit/json-row-operations/editor-options/presets.ts index a7323927..e7923ade 100644 --- a/ui/src/views/admin/listForm/edit/json-row-operations/editor-options/presets.ts +++ b/ui/src/views/admin/listForm/edit/json-row-operations/editor-options/presets.ts @@ -3,6 +3,7 @@ * diğer ayarları silmez. */ import { buildHtmlEditorDefaults } from '@/views/shared/htmlEditorOptions' +import { DEFAULT_PHONE_COUNTRY_FIELD, PhoneFormats } from '@/utils/phoneFormat' import type { JsonObject } from './jsonUtils' export type EditorOptionsPreset = { @@ -110,6 +111,31 @@ export const editorOptionsPresets: EditorOptionsPreset[] = [ 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', label: 'App.PresetTextAreaAuto.TextAreaAutoLabel', diff --git a/ui/src/views/admin/listForm/wizard/WizardStepFields.tsx b/ui/src/views/admin/listForm/wizard/WizardStepFields.tsx index d58a1ff0..011dd851 100644 --- a/ui/src/views/admin/listForm/wizard/WizardStepFields.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardStepFields.tsx @@ -1228,6 +1228,9 @@ const WizardStepFields = ({
{`• {"showClearButton":true }`}
{`• {"format": "fixedPoint", "precision": 2}`}
{`• {"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" }`}
+
{`• {"format": "phoneTR"}`}
+
{`• {"format": "phoneIntl"}`}
+
{`• {"format": "phoneCountry", "phoneCountryField": "Country"}`}
{`• {"tooltip": { "enabled": true }}`}
{`• {"height":200}`}
{`• {"type":"date"}`}
diff --git a/ui/src/views/list/Grid.tsx b/ui/src/views/list/Grid.tsx index a3b09a6c..618264f6 100644 --- a/ui/src/views/list/Grid.tsx +++ b/ui/src/views/list/Grid.tsx @@ -71,6 +71,7 @@ import { attachEditorValueChangedScript, createEditingFormHandlers, } from './shared/editingFormScripts' +import { attachFieldValueBroadcast } from '@/utils/phoneFormat' import { attachCascadeBehavior, createCascadeMaps } from './shared/cascade' import { createConditionalCellStyleHandler } from './shared/cellStyles' import { @@ -81,7 +82,6 @@ import { useWidgetGroupHeight, } from './shared/hooks' import { startWorkflowForInsertedRow } from './shared/workflowRows' -import { applyPhoneGlobalFormat } from './shared/columnFormatters' interface GridProps { listFormCode: string @@ -379,6 +379,9 @@ const Grid = (props: GridProps) => { 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. if (columnFormat?.editOrderNo != null && columnFormat.editorScript) { attachEditorValueChangedScript({ @@ -417,9 +420,7 @@ const Grid = (props: GridProps) => { const memoizedColumns = useMemo(() => { if (!gridDto) return undefined - const columns = getBandedColumns() - columns?.forEach(applyPhoneGlobalFormat) - return columns + return getBandedColumns() // getBandedColumns her render'da yeni referans üretebildiği için bağımlılığa alınmaz. // eslint-disable-next-line react-hooks/exhaustive-deps }, [gridDto, currentCulture, localizationTexts]) diff --git a/ui/src/views/list/Tree.tsx b/ui/src/views/list/Tree.tsx index 240eee93..7abcca9f 100644 --- a/ui/src/views/list/Tree.tsx +++ b/ui/src/views/list/Tree.tsx @@ -67,6 +67,7 @@ import { attachEditorValueChangedScript, createEditingFormHandlers, } from './shared/editingFormScripts' +import { attachFieldValueBroadcast } from '@/utils/phoneFormat' import { attachCascadeBehavior, createCascadeMaps } from './shared/cascade' import { createConditionalCellStyleHandler } from './shared/cellStyles' import { @@ -389,6 +390,9 @@ const Tree = (props: TreeProps) => { 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) { attachEditorValueChangedScript({ editor, diff --git a/ui/src/views/list/shared/columnFormatters.ts b/ui/src/views/list/shared/columnFormatters.ts index e677b913..1fd8e54d 100644 --- a/ui/src/views/list/shared/columnFormatters.ts +++ b/ui/src/views/list/shared/columnFormatters.ts @@ -1,31 +1,35 @@ /** * 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 | undefined) => + editorOptions?.format === PhoneFormats.International || + editorOptions?.format === PhoneFormats.Country /** - * 10 haneli yerel numarayı `(XXX) XXX-XXXX` biçiminde gösterir. - * Ülke kodu (+90 / 0090 / 0) ön ekleri temizlenir; 10 hane değilse boş döner. + * Telefon token'ı (`phoneGlobal`, `phoneTR`, `phoneIntl`, `phoneCountry`) taşıyan kolonun + * 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) => { - if (!value) return '' +export const applyPhoneColumnFormat = (column: any, editorOptions: Record) => { + const format = editorOptions?.format + if (!isPhoneFormat(format) || !column?.dataField) return - let digits = String(value).replace(/\D/g, '') - if (digits.length > 10) digits = digits.slice(-10) - 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 + const dataField = column.dataField as string + const countryField = editorOptions.phoneCountryField || DEFAULT_PHONE_COUNTRY_FIELD column.dataType = 'string' - column.format = { formatter: formatGlobalPhone } - column.customizeText = (cellInfo: any) => formatGlobalPhone(cellInfo?.value) + column.format = undefined + column.calculateDisplayValue = (rowData: Record) => + formatPhone(rowData?.[dataField], { format, countryId: rowData?.[countryField] }) + column.calculateSortValue ??= dataField + column.calculateGroupValue ??= dataField } diff --git a/ui/src/views/list/shared/editingForm.ts b/ui/src/views/list/shared/editingForm.ts index b36676bd..ea1ef6c9 100644 --- a/ui/src/views/list/shared/editingForm.ts +++ b/ui/src/views/list/shared/editingForm.ts @@ -16,6 +16,7 @@ import { import { scanBarcode } from '@/components/shared/BarcodeScanner/scanBarcode' import { getNextSequenceValue } from '@/services/form.service' import { mergeHtmlEditorOptions } from '@/views/shared/htmlEditorOptions' +import { withPhoneEditorOptions } from '@/utils/phoneFormat' import type { EditorType2, RowMode, SimpleItemWithColData } from '../../form/types' import { autoNumber, @@ -431,7 +432,7 @@ export const withEditorTypeDefaults = >( ): T => editorType2 === 'dxHtmlEditor' ? mergeHtmlEditorOptions(editorOptions) - : editorOptions + : withPhoneEditorOptions(editorOptions) type FormItemMapperParams = { mode: RowMode diff --git a/ui/src/views/list/useListFormColumns.ts b/ui/src/views/list/useListFormColumns.ts index 3ce12ff5..f8711328 100644 --- a/ui/src/views/list/useListFormColumns.ts +++ b/ui/src/views/list/useListFormColumns.ts @@ -16,7 +16,7 @@ import { UiCommandButtonPositionTypeEnum, UiLookupDataSourceTypeEnum, } from '@/proxy/form/models' -import { addCss, getEditingFormFields } from './Utils' +import { addCss, getEditingFormFields, safeJsonParse } from './Utils' import { clearLookupCache, clearLookupCacheByPrefix } from '../shared/lookupCache' import { useLookupDataSource } from '../form/useLookupDataSource' import { @@ -33,6 +33,8 @@ import { } from '../form/editors/imageHoverPreview' import { translateWorkflowLabel } from '@/utils/workflow/workflowHelpers' import { findLookupColor, getLookupItemColor, tagBadgeColors } from '../shared/lookupColor' +import { applyPhoneColumnFormat, needsPhoneCountries } from './shared/columnFormatters' +import { arePhoneCountriesLoaded, loadPhoneCountries } from '@/utils/phoneFormat' const cellTemplateMultiValue = ( cellElement: HTMLElement, @@ -219,6 +221,30 @@ const useListFormColumns = ({ clearLookupCache() }, [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>(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 }) // 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 } + applyPhoneColumnFormat(column, editorOptions) + // columnCustomizationDto column.fixed = colData.columnCustomizationDto?.fixed column.fixedPosition = colData.columnCustomizationDto?.fixedPosition as HorizontalEdge