diff --git a/.github/instructions/lowcode.instructions.md b/.github/instructions/lowcode.instructions.md
index 307cbea4..9f38d394 100644
--- a/.github/instructions/lowcode.instructions.md
+++ b/.github/instructions/lowcode.instructions.md
@@ -1490,9 +1490,28 @@ cascade eksik yetenek değildir ve analizde `Ready to Start`'ı engellemez. Tam
| `CascadeFilterOperator` | `StaticData` cascade filtresinin operatörü; varsayılan `=` |
| `CascadeEmptyFields` | **Ebeveyn alanda** doldurulur; bu alan değişince boşaltılacak çocuk alanlar, virgülle. Ara halka (İl) hem çocuk hem ebeveyn olduğu için iki tarafı birden taşır |
-Cascade liste popup'ında ve form sayfasında (`FormDevExpress`) aynı davranır. Form sayfasında çocuk
+Cascade liste popup'ında (Grid, Tree), satır içi düzenlemede ve form sayfasında (`FormDevExpress`)
+aynı kuralla çalışır; kural tek yerdedir (`list/shared/cascade.ts`, `getCascadeClearFields`): bir alan
+değişince ebeveyni o alan olan çocuklar **ve** alanın `CascadeEmptyFields` listesi boşaltılır. Kilit
+ve temizleme platform editörlerinde (TagBox, GridBox, ImageUpload) de uygulanır. Form sayfasında çocuk
yalnızca ebeveynin değeri **gerçekten değiştiğinde** temizlenir ve listesi yeni ebeveyn değeriyle
yeniden kurulur; formun aynı değeri tekrar bildirmesi (veri forma geri verilince) yok sayılır.
+
+**Liste popup'ı ↔ form sayfası editör eşliği.** İkisi aynı ListForm tanımından beslendiği için
+editör yetenekleri ortak koddan gelir; bir tarafa yeni yetenek ortak yardımcıya eklenerek verilir:
+
+| Yetenek | Ortak kaynak |
+| --- | --- |
+| `EditorOptions` varsayılanları (tarih maskesi/biçimi, HtmlEditor, telefon) | `withEditorTypeDefaults` (`list/shared/editingForm.ts`) |
+| Lookup varsayılanları (`valueExpr`, `displayExpr`, `searchEnabled`) | `getLookupEditorDefaults` |
+| Buton `onClick` (fonksiyon metni, `@SCAN_BARCODE`) ve bağlamı | `resolveEditorButtons` + `withButtonContext` |
+| Cascade temizleme/kilit | `list/shared/cascade.ts` |
+| Grid/Tree `onEditorPreparing` gövdesi | `prepareEditingEditor` (`list/shared/editingFormScripts.ts`) |
+| TagBox, GridBox, ImageUpload, ImageViewer | Tek uygulama `form/editors/*`; liste hücresi `list/editors/cellEditors.tsx` adaptörüyle bağlanır |
+
+DataGrid/TreeList platform editörlerini `editCellTemplate` ile çizer ve `onEditorPreparing` onlar için
+çalışmaz; salt-okunur/devre dışı kararı, cascade ve `EditorScript` bu editörlere
+`createCustomEditorCellAdapter` ile verilir.
Adres zincirinin hazır örneği Kiracılar ekranıdır: Ülke › İl › İlçe › Mahalle; mahalle lookup'ı
`PostalCode`'u `group` olarak döndürür ve `copy("Group", "PostalCode")` tarifi posta kodunu doldurur.
@@ -2842,7 +2861,7 @@ Preset mevcut JSON ile **birleşir**, diğer ayarları silmez; aynı anahtar var
| Çoklu görsel | `width`, `height`, `multiple`, `accept:image/*` |
| HTML tam araç çubuğu | `htmlEditorOptions.ts` varsayılanı: `toolbar.items` (tam liste), `toolbar.multiline`, `mediaResizing`, `imageUpload` (`tabs: file/url`, base64), `tableResizing`, `tableContextMenu` — boş `editorOptions` da aynı varsayılanı alır |
| Yükseklik 100 / 200 | `{"height":100}` · `{"height":200}` |
-| 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'ı `resolveEditorButtons` (`editingForm.ts`) kamera diyaloğuna bağlar, okunan kod alana yazılır; liste popup'ı ve form sayfası aynı. HTTPS şart. Fonksiyon metni yazılan `onClick` olayla birlikte `formData`, `fieldName`, `mode` alır (popup'ta ek olarak `rowKey`, `rowIndex`). |
| 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'ı)
@@ -2867,7 +2886,7 @@ varsayılanı ezer.
- Ü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`
+ Grid/Tree'de `onEditorPreparing`'deki (`prepareEditingEditor`) `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,
diff --git a/README.md b/README.md
index d300db38..1116c845 100644
--- a/README.md
+++ b/README.md
@@ -446,9 +446,13 @@ ve atanan kullanıcı alanlarından kurulur.
`dxDropDownBox`, `dxGridBox`, `dxAutocomplete`, `dxColorBox`, `dxHtmlEditor`, `dxSlider`,
`dxRangeSlider` — ayrıca görsel yükleme/görüntüleme editörleri.
- **Lookup kaynakları:** `StaticData`, `Query`, `WebService`; ebeveyn–çocuk (cascade) desteklidir.
- Cascade liste popup'ında ve form sayfasında (`/admin/form/...`, `FormDevExpress`) aynı çalışır.
+ Cascade liste popup'ında, satır içi düzenlemede ve form sayfasında (`/admin/form/...`, `FormDevExpress`)
+ aynı kuralla çalışır: alan değişince ebeveyni o alan olan çocuklar ve alanın `CascadeEmptyFields`
+ listesi boşaltılır.
Lookup'lı seçim kutusunda yazarak arama ikisinde de varsayılan açıktır; alanın `EditorOptions`'ında
`{"searchEnabled": false}` kapatır.
+- **Popup ↔ form sayfası eşliği:** `EditorOptions` varsayılanları, butonlar, `EditorScript`, cascade ve
+ platform editörleri (TagBox, GridBox, görsel) iki yerde ortak koddan gelir; tablo `lowcode.instructions.md` §4.3.4.
- **Doğrulama:** `ValidationRuleJson` ile required/range/pattern/custom kuralları. Kurallar liste popup'ında ve form sayfasında (`/admin/form/...`) aynı uygulanır; maske (`mask`) `EditorOptions` ile verilir.
- **Editör script:** `EditorScript` alanı ile alan değişiminde çalışan JS (örn. tarih farkından
gün hesaplama, miktar × birim fiyat toplamı). Çalıştırma `utils/editorScriptRuntime.ts` üzerinden yapılır.
diff --git a/claude.md b/claude.md
index 33a8907d..f85a9554 100644
--- a/claude.md
+++ b/claude.md
@@ -231,6 +231,11 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
- **`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.
+- **Liste popup'ı (Grid/Tree) ↔ form sayfası (`FormDevExpress`)** — editör yetenekleri ortak yardımcılardadır
+ (`list/shared/editingForm.ts`, `editingFormScripts.ts`, `cascade.ts`); TagBox/GridBox/ImageUpload/ImageViewer'ın
+ tek uygulaması `form/editors/*`, liste hücresi yalnızca `list/editors/cellEditors.tsx` adaptörüdür. Bir tarafa
+ yetenek eklenirken ortak yardımcıya eklenir; tek tarafa yazılan yetenek eşliği bozar. Tablo:
+ `lowcode.instructions.md` §4.3.4.
- **`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.DevRequests/5d11851e-5f00-4a0b-b5a3-b8211e56c8b5.json b/configs/seeds/data/App.DevRequests/5d11851e-5f00-4a0b-b5a3-b8211e56c8b5.json
index b8f65781..6d1b4980 100644
--- a/configs/seeds/data/App.DevRequests/5d11851e-5f00-4a0b-b5a3-b8211e56c8b5.json
+++ b/configs/seeds/data/App.DevRequests/5d11851e-5f00-4a0b-b5a3-b8211e56c8b5.json
@@ -18,9 +18,9 @@
"Priority": "5",
"Completed": false,
"SortOrder": 2,
- "Timesheets": "[{\"id\":\"6a817f32-69b6-6e06-3cc4-3a2428dfe74d\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Talep analiz edildi\",\"duration\":0.0167},{\"id\":\"9c434c1b-9451-efc8-02b1-3a2428e13282\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Inv_T_ProductCategory tablosu olu\\u015Fturuldu\",\"duration\":0.0167},{\"id\":\"30fb4513-f16d-bb46-f408-3a2428e14ab8\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Inv_T_Attribute tablosu olu\\u015Fturuldu\",\"duration\":0.0167},{\"id\":\"1dc7152e-2ff6-f6de-9205-3a2428e1655b\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Inv_T_AttributeValue tablosu olu\\u015Fturuldu\",\"duration\":0.0167},{\"id\":\"dd3504f3-51d0-042b-a5b5-3a2428e19b77\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Inv_T_Product tablosu olu\\u015Fturuldu\",\"duration\":0.0167},{\"id\":\"560153a2-526d-c786-f5b5-3a2428e2bfab\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Inv_T_ProductAttributeLine tablosu olu\\u015Fturuldu\",\"duration\":0.0167},{\"id\":\"3414b481-2140-b3e6-dab3-3a2428e2d9b2\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Inv_T_ProductVariant tablosu olu\\u015Fturuldu\",\"duration\":0.0167},{\"id\":\"a715a8e5-cb36-311c-2aa2-3a2428e2fa1a\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Inv_T_ProductSupplier tablosu olu\\u015Fturuldu\",\"duration\":0.0167},{\"id\":\"8d745d78-25bf-b82a-c2f4-3a2428e31cbc\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Inv_T_WorkCenter tablosu olu\\u015Fturuldu\",\"duration\":0.0167},{\"id\":\"c493b41a-a80e-e72d-2f9f-3a2428e333d3\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Inv_T_WorkCenterCapacity tablosu olu\\u015Fturuldu\",\"duration\":0.0167},{\"id\":\"01397c97-65b8-c5e8-5766-3a2428e35783\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Inv_T_Operation tablosu olu\\u015Fturuldu\",\"duration\":0.0167},{\"id\":\"a57fcb7d-12e9-9ca2-5a55-3a2428e36d82\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Inv_T_OperationInstruction tablosu olu\\u015Fturuldu\",\"duration\":0.0167},{\"id\":\"4df60eac-980d-a497-93c4-3a2428e38bd9\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Inv_T_Bom tablosu olu\\u015Fturuldu\",\"duration\":0.0167},{\"id\":\"6a68db4d-bb53-33b9-cf0b-3a2428e3a1bb\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Inv_T_BomOperation tablosu olu\\u015Fturuldu\",\"duration\":0.0167},{\"id\":\"fb6aea44-6996-80d1-8d19-3a2428e3ba5a\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Inv_T_BomLine tablosu olu\\u015Fturuldu\",\"duration\":0.0167},{\"id\":\"ab475620-374d-e4a1-dc1b-3a2428e40761\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"App.DeveloperKit.CustomEndpoints ekran\\u0131na sat\\u0131r eklendi\",\"duration\":0.0167},{\"id\":\"52e08632-e712-8432-977e-3a2428e48174\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"ProductCategory.json wizard\\u0027\\u0131 deploy edildi (2 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"4e8bdcfc-9621-2861-1954-3a2428e4e6a6\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"AttributeValue.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"c007dd02-6b11-d995-c720-3a2428e56195\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Attribute.json wizard\\u0027\\u0131 deploy edildi (3 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"4fee692c-bce3-3c65-80ad-3a2428e5c017\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"WorkCenterCapacity.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"da9fe300-0f4e-f900-b08d-3a2428e64100\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"WorkCenterGeneral.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"4bbfcb44-0ddb-a68e-5a7c-3a2428e7a2f3\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"WorkCenter.json wizard\\u0027\\u0131 deploy edildi (1 demo sat\\u0131r)\",\"duration\":0.0333},{\"id\":\"4f66f26a-ee05-4a6a-847f-3a2428e809f9\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"OperationInstruction.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"913a0473-6e15-797a-7160-3a2428e868cd\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"OperationSettings.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"85b8537e-a7ab-54ef-bb16-3a2428e8d1b9\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Operation.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"ca24a627-11b3-f8db-6f13-3a2428e934d0\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"BomOperation.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"6c0040f0-b981-9546-22ab-3a2428e9ac84\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"BomLine.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"037a0ab6-d2b5-5a38-a030-3a2428eaa433\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Bom.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"fa6d1319-7742-f551-e75e-3a2428eb27cc\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"ProductVariant.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"62798abf-b710-74a7-006e-3a2428ecadd4\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"ProductGeneral.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.0333},{\"id\":\"93b9f36f-5e8c-8607-373e-3a2428ed1027\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"ProductAttributeLine.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"c7fe1df4-3b68-222c-3a4b-3a2428ed62bd\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"ProductPurchasing.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"d6c003ce-d518-45df-94a2-3a2428ee01c8\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"ProductSupplier.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"d6b290e4-6dc9-7830-f0f9-3a2428ee7bc4\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"ProductStock.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"bc64a3db-7a2f-5e01-effa-3a2428eedc11\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"ProductAccounting.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"d8959a4c-3c31-d2a3-8ca4-3a2428ef93c6\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"Product.json wizard\\u0027\\u0131 deploy edildi (6 demo sat\\u0131r)\",\"duration\":0.0167},{\"id\":\"e06847ef-a0b7-d8c5-7b14-3a2428f2c514\",\"date\":\"2026-10-07\",\"userName\":\"system@sozsoft.com\",\"description\":\"BomOverview.json wizard\\u0027\\u0131 deploy edildi (0 demo sat\\u0131r)\",\"duration\":0.05}]",
+ "Timesheets": null,
"SubTasks": null,
- "Analysis": "
1. Amaç \nEnvanter ve üretimin ana verisini kurmak: ürün kartı (satış/masraf/satınalma bayrakları, mal/hizmet, envanter takibi), nitelik–değer–varyant yapısı, tedarikçi fiyatları, iş merkezleri, operasyonlar, ürün ve varyant bazında reçeteler (BOM) ve iç içe açılan reçete genel bakışı (ağaç). Kullanıcılar stok, satınalma ve üretim planlama personelidir; sonraki satış/satınalma/üretim belgeleri bu ekranları lookup kaynağı olarak kullanır.
\n\n2. Görsel analizi \n\n[image-1] Ürünler listesi: Ürün Adı, İç Referans, Satış Fiyatı, Maliyet, Stokta, Öngörülen, Birim. Talep: liste ürün adı, resim, Satış, Masraflar, Satınalma kolonlarından oluşur; bu görseldeki kolonlar da listede olabilir. Stokta/Öngörülen stok hareketi gerektirdiği için alınmaz. Satırlar demo veri adayı. \n[image-2] Nitelik formu: Nitelik Adı, Görünüm Türü, Varyant Oluşturma; Nitelik Değerleri: Değer, Serbest metin, Varsayılan Ekstra Fiyat. Demo: renk → kırmızı, mavi. \n[image-3] Ürün – Genel Bilgi: Ürün Türü, Envanter Takibi (Seri No / Lot / Miktar), Satış Vergisi, Satınalma Vergisi, Kategori, Barkod, GTİP, İç Notlar. Talep: liste dışındaki bilgiler detayda sekme olarak (Kontaklar ekranı gibi). \n[image-4] Nitelik & Varyantlar: Nitelik + Değerler (çoklu seçim). \n[image-5] Satınalma: Tedarikçi, Miktar, Birim, Birim Fiyat, Kur, Teslim Süresi; Kontrol Kuralı, Satınalma Açıklaması. \n[image-6] Stok: Sorumlu, Ağırlık, Hacim, GTİP, Menşei, Lot/Seri Değerleme, Özel Lot/Seri, açıklamalar. \n[image-7] Muhasebe: Gelir Hesabı, Gider Hesabı. \n[image-8] Reçete – Bileşenler: Komponent, Miktar, Birim, Operasyonda Tüketilen. Talep: BoM Türü eklenmez; reçete ürün ya da varyant bazında yazılır. \n[image-9] Reçete – Operasyonlar: Operasyon, İş Merkezi, Süre (dk). Demo: Apre – ram makinası – 60. \n[image-10] Reçete – Diğer: Üretim Hazırlığı, Esnek Tüketim, Operasyon Türü, Akış Zamanı, Hazırlama Günleri, Parti Büyüklüğü. \n[image-11] İş Merkezi. Talep: listede ad, etiketler, çalışma saatleri (İK ayarı haftalık çalışma limiti); detayda Genel Bilgi ve Ürün Kapasitesi sekmeleri. \n[image-12] Operasyon formu. Talep: listede Operasyon Adı, İş Merkezi, Varsayılan Süre; detayda Açıklamalar listesi. \n[image-13] Ürün Varyantları: 12 satır = renk × malzeme × boyut. \n[image-14] Reçete genel bakış. Talep: iç içe malzeme ve miktarlar, Tree Layout . \n \n\n3. Çözüm yolu \nWizard List ekranları + alt formlar. Kontaklar deseni: ana liste yalnız istenen kolonları taşır, diğer alanlar aynı tabloyu okuyan TabType: Form alt formlarda (ilişki Id → Id, yalnız düzenleme). Varyant üretimi Custom Endpoint Inventory.GenerateVariants (mevcut kayıt korunur); tetikleyici Ürün › Genel Bilgi sekmesinin açılış EditorScript'i (api()). İş merkezi çalışma saati için GET Custom Endpoint Inventory.WeeklyWorkingHours (ayar istemciye açık değil). Reçete genel bakışı Wizard List, Query kaynağı, Tree görünümü (RowId / ParentRowId); Query türetilmiş tabloya sarıldığından recursive CTE kullanılamaz, açılım CROSS APPLY ile sabit 4 seviye. Custom component gerekmez.
\n\n4. Menü yeri \nEnvanter › Tanımlamalar › {ekran}
\n\nSeviye Kod Ad (TR / EN) İkon Durum \nKök App.Inventory Envanter / Inventory FcPackage Yeni \nAra App.Inventory.Definitions Tanımlamalar / Definitions FcSettings Yeni \nEkran App.Inventory.ProductCategory Ürün Kategorileri / Product Categories FcFolderTree Yeni \nEkran App.Inventory.Attribute Nitelikler / Attributes FcList Yeni \nEkran App.Inventory.WorkCenter İş Merkezleri / Work Centers FcFactory Yeni \nEkran App.Inventory.Operation Operasyonlar / Operations FcProcess Yeni \nEkran App.Inventory.Bom Ürün Reçeteleri / Bills of Materials FcDataSheet Yeni \nEkran App.Inventory.ProductVariant Ürün Varyantları / Product Variants FcGrid Yeni \nEkran App.Inventory.Product Ürünler / Products FcPackage Yeni \nEkran App.Inventory.BomOverview Reçete Genel Bakış / BoM Overview FcTreeStructure Yeni \n
\nModuleId: Inv · İzin grubu: App.Inventory · Route: /admin/list/{ListFormCode}. Menü sırası deploy sırasını (bağımlılık sırası) izler. \nYetki: App.Inventory › App.Inventory.Definitions › {ListFormCode}; menüsüz alt formlar taşıyıcı listenin altında.
\n\n5. Veri modeli \n14 tablonun hepsi yeni (create_table; Id PK, TenantId, tam audit + soft delete otomatik; decimal = 18,4). UomId nvarchar(64) = Adm_T_Uom.Id, lookup host + kiracı birimleri, DbSourceType 16.
\n\nTablo Tanım tablosu Kolonlar (* NOT NULL, = varsayılan) Lookup \nInv_T_ProductCategory Evet Name* (128), ParentId, IsActive* =1 ParentId → kendisi \nInv_T_Attribute Evet Name* (128), DisplayType* (16) ='Radio', VariantCreation* (16) ='Always', IsActive* =1 static \nInv_T_AttributeValue Hayır AttributeId*, Name* (128), IsCustom* =0, DefaultExtraPrice* =0, ColorCode (16), Image MAX, SortOrder* int =0 Attribute \nInv_T_Product Evet Name* (256), Image MAX, IsFavorite* =0, CanBeSold* =1, CanBeExpensed* =0, CanBePurchased* =1, ProductType* (16) ='Goods', IsInventoryTracked* =0, TrackingType (16), SalesPrice* =0, SalesVatRate* =20, Cost* =0, PurchaseVatRate* =20, CategoryId, UomId* (64) ='Adet', InternalReference (64), Barcode (64), GtipCode (32), InternalNote MAX, PurchaseControlPolicy* (16) ='Received', PurchaseDescription MAX, ResponsibleUserId, Weight, Volume, OriginCountry (128), LotValuation* =0, CustomLotSerialFormat (64), ReceiptNote/DeliveryNote/InternalTransferNote MAX, IncomeAccount (32), ExpenseAccount (32), HasVariants* =0, IsActive* =1 ProductCategory, Adm_T_Uom, AbpUsers, Sas_H_Country \nInv_T_ProductAttributeLine Hayır ProductId*, AttributeId*, ValueIds* MAX (çoklu seçim) Attribute, AttributeValue (cascade) \nInv_T_ProductVariant Evet ProductId*, CombinationKey* (900), ValueText (512), InternalReference (64), Barcode (64), ExtraPrice* =0, Cost* =0, IsActive* =1 Product \nInv_T_ProductSupplier Hayır ProductId*, ProductVariantId, ContactId*, MinQuantity* =1, UomId* (64) ='Adet', UnitPrice* =0, Currency* (16) ='TRY', LeadTimeDays* int =0 Adm_T_Contact, ProductVariant, Adm_T_Uom, Sas_H_Currency \nInv_T_WorkCenter Evet Name* (128), Code (32), Tags (256), AlternativeWorkCenterIds MAX, WeeklyWorkingHours decimal , TimeEfficiency* =100, OeeTarget* =90, SetupTimeMin* =0, CleanupTimeMin* =0, CostPerHour* =0, CostPerEmployeeHour* =0, ExpenseAccount (32), AllowedUserIds MAX, Description MAX, IsActive* =1 WorkCenter, AbpUsers \nInv_T_WorkCenterCapacity Hayır WorkCenterId*, ProductId*, Capacity* =1, SetupTimeMin, CleanupTimeMin Product \nInv_T_Operation Evet Name* (128), WorkCenterId*, ApplyOnVariantValueIds MAX, CostBasis* (16) ='Actual', DurationMode* (16) ='Fixed', DefaultDurationMin* =60, IsActive* =1 WorkCenter, AttributeValue \nInv_T_OperationInstruction Hayır OperationId*, Title* (256), Type* (16) ='Text', Reference MAX, SortOrder* int =0 static \nInv_T_Bom Evet ProductId*, ProductVariantId, Quantity* =1, UomId* (64) ='Adet', Reference (64), ReadinessPolicy* (16) ='AllComponents', FlexibleConsumption* (16) ='Warning', OperationType (64), ManufacturingLeadDays* int =0, PreparationDays* int =0, UseBatchSize* =0, BatchSize, IsActive* =1 — BomType yok Product, ProductVariant, Adm_T_Uom \nInv_T_BomOperation Hayır BomId*, OperationId*, WorkCenterId*, DurationMin* =0, SortOrder* int =0 Operation, WorkCenter \nInv_T_BomLine Hayır BomId*, ComponentProductId*, ComponentVariantId, Quantity* =1, UomId* (64) ='Adet', BomOperationId, ApplyOnVariantValueIds MAX Product, ProductVariant, Adm_T_Uom, BomOperation \n
\nStatic kümeler (TR/EN): DisplayType Radio/Pills/Select/Color/Multi/Image · VariantCreation Always/Dynamic/Never · ProductType Goods/Service/Combo · TrackingType Serial/Lot/Quantity · PurchaseControlPolicy Ordered/Received · ReadinessPolicy AllComponents/FirstOperation · FlexibleConsumption Allowed/Warning/Blocked · CostBasis Actual/Theoretical · DurationMode Fixed/Computed · Instruction Type Text/Pdf/Image/Link.
\nBomOverview Query : TableName \"Q\", KeyFieldName RowId (DbSourceType 16), ParentRowId ağaç ebeveyni; Inv_T_Bom'un TenantId ve IsDeleted'ı seçilir. Kök satır (Level 0, ParentRowId NULL) = reçete; bileşen ve operasyon satırları ebeveynlerinin altında; bileşenin aktif reçetesi (varyantı eşleşen, yoksa varyantsız, en yeni) varsa satırları o bileşenin altında açılır, 4 seviyeye kadar. Miktar = Π(üst satır miktarı ÷ alt reçete miktarı) × satır miktarı; bileşen maliyeti = miktar × (varyant maliyeti, yoksa ürün maliyeti); operasyon maliyeti = süre/60 × CostPerHour; kök ve üst bileşen ürünleri yeniden açılmaz. Kolonlar: RowId, ParentRowId, BomId, RootProduct, Level, RowType, ItemName, Quantity, Uom, UnitCost, TotalCost.
\n\n6. Alt form ağacı \n\nTaşıyıcı › Sekme Hedef TabType Menü İlişki Ekle/Düzenle/Sil \nNitelik › Nitelik Değerleri App.Inventory.AttributeValue List Menüsüz Id → AttributeId ✓/✓/✓ \nÜrün › Genel Bilgi App.Inventory.ProductGeneral Form Menüsüz Id → Id –/✓/– \nÜrün › Nitelik & Varyantlar App.Inventory.ProductAttributeLine List Menüsüz Id → ProductId ✓/✓/✓ \nÜrün › Varyantlar App.Inventory.ProductVariant List Menülü Id → ProductId –/✓/– \nÜrün › Satınalma App.Inventory.ProductPurchasing Form Menüsüz Id → Id –/✓/– \nÜrün › Tedarikçiler App.Inventory.ProductSupplier List Menüsüz Id → ProductId ✓/✓/✓ \nÜrün › Stok App.Inventory.ProductStock Form Menüsüz Id → Id –/✓/– \nÜrün › Muhasebe App.Inventory.ProductAccounting Form Menüsüz Id → Id –/✓/– \nÜrün › Ürün Reçeteleri App.Inventory.Bom List Menülü Id → ProductId ✓/✓/✓ \nVaryant › Varyant Reçeteleri App.Inventory.Bom List Menülü Id → ProductVariantId, ProductId → ProductId ✓/✓/✓ \nİş Merkezi › Genel Bilgi App.Inventory.WorkCenterGeneral Form Menüsüz Id → Id –/✓/– \nİş Merkezi › Ürün Kapasitesi App.Inventory.WorkCenterCapacity List Menüsüz Id → WorkCenterId ✓/✓/✓ \nOperasyon › Açıklamalar App.Inventory.OperationInstruction List Menüsüz Id → OperationId ✓/✓/✓ \nOperasyon › Ayarlar App.Inventory.OperationSettings Form Menüsüz Id → Id –/✓/– \nReçete › Bileşenler App.Inventory.BomLine List Menüsüz Id → BomId ✓/✓/✓ \nReçete › Operasyonlar App.Inventory.BomOperation List Menüsüz Id → BomId ✓/✓/✓ \n
\n\n7. Etkilenen kısımlar \n\n14 yeni tablo; yeni menü kökü Envanter + Tanımlamalar; 8 menülü, 14 menüsüz ekran (6'sı aynı tabloyu okuyan Form sekmesi), her ekranda C/R/U/D/Export/Import/Note. \nCustom Endpoint Inventory.GenerateVariants (POST) — kayıt zaten var, değişmez. Yeni Custom Endpoint Inventory.WeeklyWorkingHours (GET /inventory/weekly-working-hours/): kiracı ayarı, yoksa global, yoksa tanım varsayılanı (45). \nEndpoint'leri kullanacak rollere App.DeveloperKit.CustomEndpoints.Post ve .Get yetkisi gerekir. \nİK ayarı App.HrSettings.Attendance.WeeklyLimitHours yalnız okunur. Dil anahtarları TR/EN; modül dokümanı App.Inventory.md. \n \n\n8. Görünümler ve davranış \n\nÜrünler listesi: Ürün Adı, Resim, Satış, Masraflar, Satınalma, İç Referans, Satış Fiyatı, Maliyet, Birim (ekleme formunda da bunlar; diğer alanlar varsayılanla açılır). Detayda sekmeler: Genel Bilgi (Favori, Ürün Türü, Envanter Takibi, Takip Şekli, Satış Vergisi, Satınalma Vergisi, Kategori, Barkod, GTİP, İç Notlar, Varyantlı, Aktif), Nitelik & Varyantlar, Varyantlar, Satınalma (Kontrol Kuralı, Açıklama), Tedarikçiler, Stok, Muhasebe, Ürün Reçeteleri. \nGenel Bilgi sekmesi açılınca Varyantlı alanının script'i varyant endpoint'ini çağırır; nitelik satırları değişince Genel Bilgi sekmesini yeniden açmak varyantları günceller. \nEditorScript (readOnly/set/clear): Envanter Takibi kapalı → Takip Şekli kilitli ve boş, açık → varsayılan Miktara Göre; Hizmet → Envanter Takibi kilitli ve kapalı; Reçete Parti Büyüklüğü kapalı → Parti Miktarı kilitli ve boş. \nNitelik & Varyantlar : Nitelik seçilir, Değerler çoklu seçim (tagbox) niteliğe göre süzülür. \nİş Merkezleri listesi: İş Merkezi Adı, Etiketler, Çalışma Saatleri (haftalık saat). Yeni kayıtta Çalışma Saatleri boşsa script Inventory.WeeklyWorkingHours'tan doldurur (değiştirilebilir). Formda ayrıca Kod, Alternatif İş Merkezleri, Aktif. Detay: Genel Bilgi (verimlilik, OEE, kurulum/temizlik, saatlik maliyetler, gider hesabı, izin verilen çalışanlar, açıklama) ve Ürün Kapasitesi. \nOperasyonlar listesi: Operasyon Adı, İş Merkezi, Varsayılan Süre. Detay: Açıklamalar listesi ve Ayarlar (Varyantlara Uygula, Maliyete Dayalı, Süre Hesaplama, Aktif). \nÜrün Reçeteleri : BoM Türü yok. Ürün seçilir, Varyant boşsa ürün bazında, doluysa o varyant için; Varyantlar listesinin detayında Varyant Reçeteleri sekmesi. Satır butonu Genel Bakış (Url, BomId filtresi). \nReçete Genel Bakış : açılış Tree (KeyExpr RowId, ParentIdExpr ParentRowId); salt okunur; Grid/Pivot da açık. \nWidget'lar: Toplam · Aktif · Pasif; Ürünler'de ek Satılabilir · Satınalınabilir · Varyantlı. \n \n\n9. Kararlar \n\nDetay sekmeleri Kontaklar desenindedir: aynı tabloyu okuyan Form alt formlar, ilişki Id → Id, ekleme/silme kapalı. \nÜrün listesine [image-1]'deki İç Referans, Satış Fiyatı, Maliyet, Birim eklendi; Stokta ve Öngörülen stok hareketi tabloları olmadığı için eklenmez (onay) . \nÇalışma Saatleri = haftalık saat (WeeklyWorkingHours), varsayılan İK ayarı haftalık çalışma limitinden gelir; Vardiya bağlantısı kaldırıldı. \nEtiketler serbest metin (virgülle ayrılmış) (onay) . \nVaryantlar ürünün Genel Bilgi sekmesi açılınca üretilir; ayrı buton yok (onay) . \nReçete Genel Bakış 4 seviyeye kadar açar; birim çevrimi yok (onay) . \nTek KDV oranı (%20), gelir/gider hesabı serbest kod (onay) . \nEkipman/Bakım sekmeleri, akıllı butonlar bu talepte yok (onay) . \nGörünüm Türü kayıt bilgisi; Always ve Dynamic aynı, Never kombinasyona katılmaz (onay) . \nDemo veri yalnız bağımsız satırlarda: Kategoriler (2), Nitelikler renk/malzeme/boyut, İş Merkezi ram makinası, Ürünler [image-1]'den 6 satır (birim Adet). Nitelik değerleri, Apre operasyonu ve reçete modül dokümanındaki akışla elle girilir. \n \n\n10. Üretim planı \n\ncreate_table: Inv_T_ProductCategory \ncreate_table: Inv_T_Attribute \ncreate_table: Inv_T_AttributeValue \ncreate_table: Inv_T_Product \ncreate_table: Inv_T_ProductAttributeLine \ncreate_table: Inv_T_ProductVariant \ncreate_table: Inv_T_ProductSupplier \ncreate_table: Inv_T_WorkCenter \ncreate_table: Inv_T_WorkCenterCapacity \ncreate_table: Inv_T_Operation \ncreate_table: Inv_T_OperationInstruction \ncreate_table: Inv_T_Bom \ncreate_table: Inv_T_BomOperation \ncreate_table: Inv_T_BomLine \ninsert_row: App.DeveloperKit.CustomEndpoints (Inventory.WeeklyWorkingHours) \ndeploy_wizard: ProductCategory.json \ndeploy_wizard: AttributeValue.json \ndeploy_wizard: Attribute.json \ndeploy_wizard: WorkCenterCapacity.json \ndeploy_wizard: WorkCenterGeneral.json \ndeploy_wizard: WorkCenter.json \ndeploy_wizard: OperationInstruction.json \ndeploy_wizard: OperationSettings.json \ndeploy_wizard: Operation.json \ndeploy_wizard: BomOperation.json \ndeploy_wizard: BomLine.json \ndeploy_wizard: Bom.json \ndeploy_wizard: ProductVariant.json \ndeploy_wizard: ProductGeneral.json \ndeploy_wizard: ProductAttributeLine.json \ndeploy_wizard: ProductPurchasing.json \ndeploy_wizard: ProductSupplier.json \ndeploy_wizard: ProductStock.json \ndeploy_wizard: ProductAccounting.json \ndeploy_wizard: Product.json \ndeploy_wizard: BomOverview.json \nsave_module_doc: App.Inventory.Definitions \n \n\n11. Üretime hazır \nEvet
"
+ "Analysis": ""
}
]
}
diff --git a/ui/src/views/form/FormButtons.tsx b/ui/src/views/form/FormButtons.tsx
index 419b89ef..9bce8f0c 100644
--- a/ui/src/views/form/FormButtons.tsx
+++ b/ui/src/views/form/FormButtons.tsx
@@ -19,7 +19,7 @@ import FormFieldOrderPanel from './FormFieldOrderPanel'
import type { FormFieldOrder } from './shared/formItems'
/** Form sayfasının kullanıcı bazlı alan sırası (`useGridData`). */
-export type FormFieldOrderControl = {
+type FormFieldOrderControl = {
value?: FormFieldOrder
preview: (order: FormFieldOrder | undefined) => void
save: (order: FormFieldOrder | undefined) => Promise
diff --git a/ui/src/views/form/FormDevExpress.tsx b/ui/src/views/form/FormDevExpress.tsx
index cebdb3cb..3dc66e8b 100644
--- a/ui/src/views/form/FormDevExpress.tsx
+++ b/ui/src/views/form/FormDevExpress.tsx
@@ -23,7 +23,9 @@ import {
setFormEditorReadOnly,
shouldRunEditorScriptOnContentReady,
translateCaption,
+ withButtonContext,
} from '../list/shared/editingForm'
+import { getCascadeClearFields, isCascadeParentEmpty } from '../list/shared/cascade'
import { isTouchLikeDevice } from '../list/shared/editPopup'
import { flattenFormGroups, getCascadeParentFields } from './shared/formItems'
@@ -164,11 +166,27 @@ const FormDevExpress = (props: {
}
}
+ /**
+ * Alan değeri değişince cascade kuralının boşalttığı alanları uygular: ebeveyni bu alan olan
+ * çocuklar ve alanın `CascadeEmptyFields` listesi (liste popup'ıyla aynı kural).
+ */
+ const withCascadeCleared = (data: Record, dataField: string) => {
+ const columns = flattenFormGroups(formItemsRef.current).map((item) => item.colData)
+ getCascadeClearFields(columns, dataField).forEach((field) => {
+ data[field] = null
+ })
+ return data
+ }
+
/** Özel editörlerin ortak değer değişimi akışı. */
const handleCustomEditorChange = (formItem: SimpleItemWithColData, value: any) => {
if (!formItem.dataField) return
if (isSameValue(formDataRef.current?.[formItem.dataField], value)) return
- formDataRef.current = { ...formDataRef.current, [formItem.dataField]: value }
+ formDataRef.current = withCascadeCleared(
+ { ...formDataRef.current, [formItem.dataField]: value },
+ formItem.dataField,
+ )
+ formDataSnapshotRef.current = { ...formDataRef.current }
setFormData(formDataRef.current)
runEditorScript(formItem, value)
}
@@ -182,9 +200,6 @@ const FormDevExpress = (props: {
...(index !== undefined && mode !== 'view' && !isTouchLikeDevice()
? { autoFocus: index === 1 }
: {}),
- ...(formItem.editorType === 'dxDateBox'
- ? { useMaskBehavior: true, openOnFieldClick: true, showClearButton: true }
- : {}),
...(formItem.colData?.placeHolder
? { placeholder: translate('::' + formItem.colData.placeHolder) }
: {}),
@@ -202,27 +217,14 @@ const FormDevExpress = (props: {
},
}
: {}),
+ // onClick metni item üretilirken çözülür (`resolveEditorButtons`); burada kayıt bağlamı eklenir.
...(configuredButtons?.length
? {
- buttons: configuredButtons.map((button: any) => {
- // Yapılandırmadan string olarak gelen onClick bir kez fonksiyona çevrilir.
- if (typeof button?.options?.onClick !== 'string') return button
-
- const originalClick = eval(`(${button.options.onClick})`)
- return {
- ...button,
- options: {
- ...button.options,
- onClick: (e: any) =>
- originalClick({
- ...e,
- formData: formDataRef.current,
- fieldName: formItem.dataField,
- mode,
- }),
- },
- }
- }),
+ buttons: withButtonContext(configuredButtons, () => ({
+ formData: formDataRef.current,
+ fieldName: formItem.dataField,
+ mode,
+ })),
}
: {}),
}
@@ -266,7 +268,7 @@ const FormDevExpress = (props: {
editor.option(
'disabled',
item.editorOptions?.disabled === true ||
- parentFields.some((parentField) => !formDataRef.current?.[parentField]),
+ isCascadeParentEmpty(parentFields, formDataRef.current),
)
} catch (err) {
console.debug('Cascade disabled update skipped for', item.dataField, err)
@@ -350,7 +352,15 @@ const FormDevExpress = (props: {
/** Platform editörünün render fonksiyonunu döner. */
const renderCustomEditor = (formItem: SimpleItemWithColData): ReactNode => {
const value = formData[formItem.dataField!]
- const editorOptions = getEditorOptions(formItem)
+ const parentFields = getCascadeParentFields(formItem)
+ const editorOptions = {
+ ...getEditorOptions(formItem),
+ // DevExtreme editörlerinde updateCascadeDisabledStates yapar; platform editörleri
+ // form.getEditor ile bulunamadığı için kilit burada verilir.
+ ...(mode !== 'view' && parentFields.length && isCascadeParentEmpty(parentFields, formData)
+ ? { disabled: true }
+ : {}),
+ }
const onChange = (next: any) => handleCustomEditorChange(formItem, next)
switch (formItem.editorType2) {
@@ -359,7 +369,6 @@ const FormDevExpress = (props: {
getCascadeParentFields(item).includes(e.dataField!))
- .forEach((child) => {
- newFormData[child.dataField!] = null
- })
+ const newFormData = withCascadeCleared({ ...formData, [e.dataField]: e.value }, e.dataField)
formDataSnapshotRef.current = { ...newFormData }
formDataRef.current = newFormData
diff --git a/ui/src/views/form/editors/GridBoxEditorComponent.tsx b/ui/src/views/form/editors/GridBoxEditorComponent.tsx
index 6742f271..1a977c66 100644
--- a/ui/src/views/form/editors/GridBoxEditorComponent.tsx
+++ b/ui/src/views/form/editors/GridBoxEditorComponent.tsx
@@ -2,6 +2,7 @@ import { ColumnFormatDto, GridBoxOptionsDto } from '@/proxy/form/models'
import { ReactElement } from 'react'
import { SharedGridBoxEditor } from '@/views/shared/SharedGridBoxEditor'
+/** Form sayfası ve liste düzenleme hücresinin ortak GridBox editörü. */
const GridBoxEditorComponent = ({
value,
options,
@@ -11,7 +12,6 @@ const GridBoxEditorComponent = ({
}: {
value: any
options?: GridBoxOptionsDto
- values: any // all row data
onValueChanged: (e: any) => void
col?: ColumnFormatDto
editorOptions: any
@@ -22,9 +22,13 @@ const GridBoxEditorComponent = ({
)
diff --git a/ui/src/views/form/editors/ImageUploadEditorComponent.tsx b/ui/src/views/form/editors/ImageUploadEditorComponent.tsx
index 7d72d27b..c60c2145 100644
--- a/ui/src/views/form/editors/ImageUploadEditorComponent.tsx
+++ b/ui/src/views/form/editors/ImageUploadEditorComponent.tsx
@@ -13,6 +13,51 @@ import { normalizeImageSize } from '../../list/shared/imageValue'
import Input from '@/components/ui/Input'
import { supportsPhotoCapture } from '@/utils/device'
+/**
+ * Yüklenen değerin ham biçimini korur; ayraçlara göre bölmez.
+ * (Görüntüleme tarafındaki `normalizeImageValue` ile bilinçli olarak farklıdır:
+ * burada kullanıcı tarafından yazılmış virgüllü URL'ler bozulmamalıdır.)
+ */
+const normalizeUploadValue = (value: unknown): string[] => {
+ if (!value) return []
+ if (Array.isArray(value)) return value.flatMap(normalizeUploadValue)
+
+ if (typeof value === 'string') {
+ const trimmed = value.trim()
+ if (!trimmed) return []
+
+ if (trimmed.startsWith('[')) {
+ try {
+ const parsed = JSON.parse(trimmed)
+ if (Array.isArray(parsed)) return parsed.flatMap(normalizeUploadValue)
+ } catch {
+ return [trimmed]
+ }
+ }
+
+ return [trimmed]
+ }
+
+ return [String(value)].filter(Boolean)
+}
+
+const resolveUploadedUrl = (data: unknown) => {
+ if (typeof data === 'string') return data
+ if (!data || typeof data !== 'object') return ''
+
+ const record = data as Record
+ return String(record.url ?? record.fileUrl ?? record.path ?? '').trim()
+}
+
+const toBase64 = (file: File): Promise =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => resolve(reader.result as string)
+ reader.onerror = reject
+ reader.readAsDataURL(file)
+ })
+
+/** Form sayfası ve liste düzenleme hücresinin ortak görsel yükleme editörü. */
const ImageUploadEditorComponent = ({
value,
options,
@@ -25,7 +70,7 @@ const ImageUploadEditorComponent = ({
editorOptions?: any
}): ReactElement => {
const { translate } = useLocalization()
- const readOnly: boolean = editorOptions?.readOnly ?? false
+ const readOnly: boolean = editorOptions?.readOnly === true || editorOptions?.disabled === true
// options önce prop olarak gelen ImageUploadOptionsDto'dan, sonra editorOptions JSON'undan okunur
const resolvedOptions: ImageUploadOptionsDto = options ?? editorOptions ?? {}
@@ -40,10 +85,7 @@ const ImageUploadEditorComponent = ({
// aksi halde dokunmatik cihazda dosya seciciye ek olarak kamera sunulur.
const showCameraButton = !resolvedOptions.capture && supportsPhotoCapture()
- const valueUrls = useMemo(
- () => (Array.isArray(value) ? value : [value]).filter(Boolean) as string[],
- [value],
- )
+ const valueUrls = useMemo(() => normalizeUploadValue(value), [value])
const [urls, setUrls] = useState(valueUrls)
const [uploading, setUploading] = useState(false)
@@ -63,14 +105,6 @@ const ImageUploadEditorComponent = ({
onValueChanged(newValue)
}
- const toBase64 = (file: File): Promise =>
- new Promise((resolve, reject) => {
- const reader = new FileReader()
- reader.onload = () => resolve(reader.result as string)
- reader.onerror = reject
- reader.readAsDataURL(file)
- })
-
const handleFiles = async (files: FileList | null) => {
if (!files?.length) return
@@ -87,7 +121,7 @@ const ImageUploadEditorComponent = ({
const res = await fetch(resolvedOptions.uploadUrl, { method: 'POST', body: formData })
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
const data = await res.json()
- uploadedUrls.push(String(data?.url ?? data?.fileUrl ?? data?.path ?? data ?? '').trim())
+ uploadedUrls.push(resolveUploadedUrl(data))
} else {
// uploadUrl yoksa base64 olarak sakla
const base64 = await toBase64(file)
diff --git a/ui/src/views/form/editors/TagBoxEditorComponent.tsx b/ui/src/views/form/editors/TagBoxEditorComponent.tsx
index 6c9bc7a5..353dfce1 100644
--- a/ui/src/views/form/editors/TagBoxEditorComponent.tsx
+++ b/ui/src/views/form/editors/TagBoxEditorComponent.tsx
@@ -14,7 +14,6 @@ const TagBoxEditorComponent = ({
value: any
setDefaultValue: boolean
options?: TagBoxOptionsDto
- values: any // all row data
onValueChanged: (e: any) => void
col?: ColumnFormatDto
editorOptions: any
@@ -27,11 +26,6 @@ const TagBoxEditorComponent = ({
[lookupDto?.displayExpr],
)
- // const dataSource =
- // typeof lookup?.dataSource === 'function'
- // ? lookup.dataSource(values)
- // : (lookup?.dataSource ?? [])
-
return (
+const flattenFormItems = (items: any[] = []): SimpleItemWithColData[] =>
items.flatMap((item) => [
...(item?.dataField ? [item] : []),
...flattenFormItems(item?.items || []),
@@ -30,10 +33,7 @@ export const flattenFormGroups = (groups: GroupItem[] = []): SimpleItemWithColDa
/** Bir alanın cascade parent alan adlarını döner. */
export const getCascadeParentFields = (item: SimpleItemWithColData): string[] =>
- item.colData?.lookupDto?.cascadeParentFields
- ?.split(',')
- .map((field: string) => field.trim())
- .filter(Boolean) ?? []
+ getCascadeParentFieldsOf(item.colData)
type BuildFormItemsParams = {
gridDto: GridDto
@@ -77,16 +77,18 @@ const toFormItem = (
console.error('EditorOptions parse error:', colFormat.fieldName, err)
}
+ // Liste popup'ıyla aynı lookup varsayılanları; veri kaynağını form sayfası kendisi kurar.
const lookupEditorOptions = colFormat.lookupDto?.dataSourceType
? {
+ ...getLookupEditorDefaults(colFormat),
dataSource: getLookupDataSource(colFormat, formData),
- valueExpr: colFormat.lookupDto?.valueExpr?.toLowerCase(),
- displayExpr: colFormat.lookupDto?.displayExpr?.toLowerCase(),
- // Grid popup'ındaki lookup kolonu aramayı varsayılan açar; form sayfası aynı davranır.
- searchEnabled: true,
}
: {}
+ if (parsedEditorOptions.buttons) {
+ parsedEditorOptions.buttons = resolveEditorButtons(parsedEditorOptions.buttons)
+ }
+
const item: SimpleItemWithColData = {
canRead: colFormat.canRead ?? false,
canUpdate: colFormat.canUpdate ?? false,
@@ -99,10 +101,11 @@ const toFormItem = (
editorType2: colFormat.editorType2,
editorType: resolveEditorType(colFormat.editorType2),
colSpan: colFormat.colSpan,
- editorOptions: withEditorTypeDefaults(colFormat.editorType2, {
- ...lookupEditorOptions,
- ...parsedEditorOptions,
- }),
+ editorOptions: withEditorTypeDefaults(
+ colFormat.editorType2,
+ { ...lookupEditorOptions, ...parsedEditorOptions },
+ colFormat.sourceDbType,
+ ),
// DevExtreme item seçeneklerini yerinde (deep extend) günceller; ekran tanımı (gridDto) paylaşılırsa
// sıra değişince bir alanın tanımı diğerininkiyle ezilir. Item'a tanımın kopyası verilir.
colData: structuredClone(colFormat),
diff --git a/ui/src/views/form/useLookupDataSource.ts b/ui/src/views/form/useLookupDataSource.ts
index fcbf15b5..fad31b64 100644
--- a/ui/src/views/form/useLookupDataSource.ts
+++ b/ui/src/views/form/useLookupDataSource.ts
@@ -28,7 +28,7 @@ const isEmptyValue = (value: unknown) => value === undefined || value === null |
* StaticData için öğelerdeki `CascadeRelationField` alanı (varsayılan `group`) ilk ebeveynin
* değeriyle karşılaştırılır; ebeveyn boşsa süzülmez.
*/
-export const buildCascadeFilters = (lookupDto: any, rowData: any): any[] | null => {
+const buildCascadeFilters = (lookupDto: any, rowData: any): any[] | null => {
const parentFields = splitFields(lookupDto?.cascadeParentFields)
if (!parentFields.length) return null
@@ -46,7 +46,7 @@ export const buildCascadeFilters = (lookupDto: any, rowData: any): any[] | null
return parentFields.map((field) => (isEmptyValue(rowData?.[field]) ? null : rowData[field]))
}
-export const createLookupStaticDataSource = (
+const createLookupStaticDataSource = (
load: () => any,
filter: any[] | null,
cacheKey: string,
@@ -65,7 +65,7 @@ export const createLookupStaticDataSource = (
filter,
})
-export const createLookupQueryDataSource = (
+const createLookupQueryDataSource = (
listFormCode?: string,
listFormFieldName?: string,
filters?: any[] | null,
@@ -106,7 +106,7 @@ export const createLookupQueryDataSource = (
/** Gövde JSON şablonudur; değer tırnak içine yerleştiği için JSON kaçışlı yazılır. */
const toJsonText = (value: unknown) => (value === null ? 'null' : JSON.stringify(String(value)).slice(1, -1))
-export const createLookupApiDataSource = (
+const createLookupApiDataSource = (
listFormCode?: string,
lookupQuery?: string,
filters?: any[] | null,
diff --git a/ui/src/views/list/CardView.tsx b/ui/src/views/list/CardView.tsx
index 1150f300..862feb66 100644
--- a/ui/src/views/list/CardView.tsx
+++ b/ui/src/views/list/CardView.tsx
@@ -41,8 +41,7 @@ import { NotePanel } from '../form/notes/NotePanel'
import SubForms from '../form/SubForms'
import GridFilterDialogs from './GridFilterDialogs'
import { GridExtraFilterToolbar } from './GridExtraFilterToolbar'
-import { ImageUploadEditorComponent } from './editors/ImageUploadEditorComponent'
-import { ImageViewerEditorComponent } from './editors/ImageViewerEditorComponent'
+import { ImageUploadEditorComponent, ImageViewerEditorComponent } from './editors/cellEditors'
import {
autoNumber,
extractSearchParamsFields,
diff --git a/ui/src/views/list/Grid.tsx b/ui/src/views/list/Grid.tsx
index 618264f6..6a8bdf64 100644
--- a/ui/src/views/list/Grid.tsx
+++ b/ui/src/views/list/Grid.tsx
@@ -37,10 +37,7 @@ import SubForms from '../form/SubForms'
import { RowMode } from '../form/types'
import { GridColumnData } from './GridColumnData'
import GridFilterDialogs from './GridFilterDialogs'
-import { GridBoxEditorComponent } from './editors/GridBoxEditorComponent'
-import { ImageUploadEditorComponent } from './editors/ImageUploadEditorComponent'
-import { ImageViewerEditorComponent } from './editors/ImageViewerEditorComponent'
-import { TagBoxEditorComponent } from './editors/TagBoxEditorComponent'
+import { GridBoxEditorComponent, ImageUploadEditorComponent, ImageViewerEditorComponent, TagBoxEditorComponent } from './editors/cellEditors'
import { useFilters } from './useFilters'
import { updateWorkflowApprovalToolbarItems, useToolbar } from './useToolbar'
import { ImportDashboard } from '@/components/importManager/ImportDashboard'
@@ -63,16 +60,14 @@ import {
buildUpdatePayload,
createFormItemMapper,
expandJsonFormFields,
- getActiveEditingForm,
getValueByField,
} from './shared/editingForm'
import {
- attachEditorButtonContext,
- attachEditorValueChangedScript,
+ createCustomEditorCellAdapter,
createEditingFormHandlers,
+ prepareEditingEditor,
} from './shared/editingFormScripts'
-import { attachFieldValueBroadcast } from '@/utils/phoneFormat'
-import { attachCascadeBehavior, createCascadeMaps } from './shared/cascade'
+import { createCascadeMaps } from './shared/cascade'
import { createConditionalCellStyleHandler } from './shared/cellStyles'
import {
useExtraFilters,
@@ -343,61 +338,27 @@ const Grid = (props: GridProps) => {
const onEditorPreparing = useCallback(
(editor: DataGridTypes.EditorPreparingEvent) => {
- if (editor.parentType !== 'dataRow' || !editor.dataField || !gridDto) return
-
- const columnFormat = gridDto.columnFormats.find(
- (column) => column.fieldName === editor.dataField,
- )
- const isNewRow = Boolean((editor as any).row?.isNewRow) || mode === 'new'
-
- if (columnFormat?.editorOptions) {
- try {
- editor.editorOptions = {
- ...editor.editorOptions,
- ...JSON.parse(columnFormat.editorOptions),
- }
- } catch (err) {
- console.error('EditorOptions parse error:', columnFormat.fieldName, err)
- }
- }
-
- const hasReadOnlyEditorOption = editor.editorOptions?.readOnly === true
- const hasDisabledEditorOption = editor.editorOptions?.disabled === true
-
- if (
- (isNewRow && columnFormat?.allowAdding === false) ||
- (!isNewRow && columnFormat?.allowEditing === false)
- ) {
- editor.editorOptions.readOnly = true
- } else if (isNewRow && columnFormat?.allowAdding === true) {
- if (!hasReadOnlyEditorOption) editor.editorOptions.readOnly = false
- if (!hasDisabledEditorOption) editor.editorOptions.disabled = false
- }
-
- const cascadeInfo = cascadeMaps.cascadeFieldsMap.get(editor.dataField)
- if (cascadeInfo) {
- 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({
- editor,
- editorScript: columnFormat.editorScript,
- fieldName: columnFormat.fieldName,
- editingFormInstanceRef,
- getActiveEditingForm,
- })
- }
-
- attachEditorButtonContext(editor)
+ if (!gridDto) return
+ prepareEditingEditor({ editor, gridDto, mode, cascadeMaps, editingFormInstanceRef })
},
[gridDto, cascadeMaps, mode],
)
+ // Şablon fonksiyonları DevExtreme'de bir kez bağlanır; mod ref üzerinden okunur.
+ const modeRef = useRef(mode)
+ modeRef.current = mode
+
+ const adaptCustomEditorCell = useMemo(
+ () =>
+ createCustomEditorCellAdapter({
+ getGridDto: () => gridDto,
+ getMode: () => modeRef.current,
+ cascadeMaps,
+ editingFormInstanceRef,
+ }),
+ [gridDto, cascadeMaps],
+ )
+
/* =======================
COLUMNS & DATASOURCE
======================= */
@@ -665,9 +626,18 @@ const Grid = (props: GridProps) => {
items: editingFormItems,
}}
/>
-
-
-
+ TagBoxEditorComponent(adaptCustomEditorCell(cell))}
+ />
+ GridBoxEditorComponent(adaptCustomEditorCell(cell))}
+ />
+ ImageUploadEditorComponent(adaptCustomEditorCell(cell))}
+ />
(
diff --git a/ui/src/views/list/Tree.tsx b/ui/src/views/list/Tree.tsx
index 7abcca9f..86e8f970 100644
--- a/ui/src/views/list/Tree.tsx
+++ b/ui/src/views/list/Tree.tsx
@@ -31,10 +31,7 @@ import PageTitle from '@/components/shared/PageTitle'
import { RowMode } from '../form/types'
import { GridColumnData } from './GridColumnData'
import GridFilterDialogs from './GridFilterDialogs'
-import { GridBoxEditorComponent } from './editors/GridBoxEditorComponent'
-import { ImageUploadEditorComponent } from './editors/ImageUploadEditorComponent'
-import { ImageViewerEditorComponent } from './editors/ImageViewerEditorComponent'
-import { TagBoxEditorComponent } from './editors/TagBoxEditorComponent'
+import { GridBoxEditorComponent, ImageUploadEditorComponent, ImageViewerEditorComponent, TagBoxEditorComponent } from './editors/cellEditors'
import { useFilters } from './useFilters'
import { updateWorkflowApprovalToolbarItems, useToolbar } from './useToolbar'
import WidgetGroup from '@/components/ui/Widget/WidgetGroup'
@@ -59,16 +56,14 @@ import {
buildUpdatePayload,
createFormItemMapper,
expandJsonFormFields,
- getActiveEditingForm,
getValueByField,
} from './shared/editingForm'
import {
- attachEditorButtonContext,
- attachEditorValueChangedScript,
+ createCustomEditorCellAdapter,
createEditingFormHandlers,
+ prepareEditingEditor,
} from './shared/editingFormScripts'
-import { attachFieldValueBroadcast } from '@/utils/phoneFormat'
-import { attachCascadeBehavior, createCascadeMaps } from './shared/cascade'
+import { createCascadeMaps } from './shared/cascade'
import { createConditionalCellStyleHandler } from './shared/cellStyles'
import {
useExtraFilters,
@@ -354,60 +349,27 @@ const Tree = (props: TreeProps) => {
const onEditorPreparing = useCallback(
(editor: TreeListTypes.EditorPreparingEvent) => {
- if (editor.parentType !== 'dataRow' || !editor.dataField || !gridDto) return
-
- const columnFormat = gridDto.columnFormats.find(
- (column) => column.fieldName === editor.dataField,
- )
- const isNewRow = Boolean((editor as any).row?.isNewRow) || mode === 'new'
-
- if (columnFormat?.editorOptions) {
- try {
- editor.editorOptions = {
- ...editor.editorOptions,
- ...JSON.parse(columnFormat.editorOptions),
- }
- } catch (err) {
- console.error('EditorOptions parse error:', columnFormat.fieldName, err)
- }
- }
-
- const hasReadOnlyEditorOption = editor.editorOptions?.readOnly === true
- const hasDisabledEditorOption = editor.editorOptions?.disabled === true
-
- if (
- (isNewRow && columnFormat?.allowAdding === false) ||
- (!isNewRow && columnFormat?.allowEditing === false)
- ) {
- editor.editorOptions.readOnly = true
- } else if (isNewRow && columnFormat?.allowAdding === true) {
- if (!hasReadOnlyEditorOption) editor.editorOptions.readOnly = false
- if (!hasDisabledEditorOption) editor.editorOptions.disabled = false
- }
-
- const cascadeInfo = cascadeMaps.cascadeFieldsMap.get(editor.dataField)
- if (cascadeInfo) {
- 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,
- editorScript: columnFormat.editorScript,
- fieldName: columnFormat.fieldName,
- editingFormInstanceRef,
- getActiveEditingForm,
- })
- }
-
- attachEditorButtonContext(editor)
+ if (!gridDto) return
+ prepareEditingEditor({ editor, gridDto, mode, cascadeMaps, editingFormInstanceRef })
},
[gridDto, cascadeMaps, mode],
)
+ // Şablon fonksiyonları DevExtreme'de bir kez bağlanır; mod ref üzerinden okunur.
+ const modeRef = useRef(mode)
+ modeRef.current = mode
+
+ const adaptCustomEditorCell = useMemo(
+ () =>
+ createCustomEditorCellAdapter({
+ getGridDto: () => gridDto,
+ getMode: () => modeRef.current,
+ cascadeMaps,
+ editingFormInstanceRef,
+ }),
+ [gridDto, cascadeMaps],
+ )
+
/* =======================
COLUMNS & DATASOURCE
======================= */
@@ -653,9 +615,18 @@ const Tree = (props: TreeProps) => {
items: editingFormItems,
}}
/>
-
-
-
+ TagBoxEditorComponent(adaptCustomEditorCell(cell))}
+ />
+ GridBoxEditorComponent(adaptCustomEditorCell(cell))}
+ />
+ ImageUploadEditorComponent(adaptCustomEditorCell(cell))}
+ />
(
diff --git a/ui/src/views/list/editors/GridBoxEditorComponent.tsx b/ui/src/views/list/editors/GridBoxEditorComponent.tsx
deleted file mode 100644
index 85f21d9a..00000000
--- a/ui/src/views/list/editors/GridBoxEditorComponent.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { ReactElement } from 'react'
-import { SharedGridBoxEditor } from '@/views/shared/SharedGridBoxEditor'
-
-const GridBoxEditorComponent = (cellElement: any): ReactElement => {
- const col = cellElement.column
- if (!col) {
- return <>>
- }
- const options = col.extras.gridBoxOptions
- const lookup = col?.lookup
- const dataSource: any =
- typeof lookup?.dataSource === 'function'
- ? lookup.dataSource(cellElement.row)
- : (lookup?.dataSource ?? [])
-
- return (
- cellElement.setValue(nextValue)}
- setInitialValueOnContentReady
- />
- )
-}
-
-export { GridBoxEditorComponent }
diff --git a/ui/src/views/list/editors/ImageUploadEditorComponent.tsx b/ui/src/views/list/editors/ImageUploadEditorComponent.tsx
deleted file mode 100644
index e126131e..00000000
--- a/ui/src/views/list/editors/ImageUploadEditorComponent.tsx
+++ /dev/null
@@ -1,273 +0,0 @@
-import type { ReactElement } from 'react'
-import { useEffect, useMemo, useRef, useState } from 'react'
-import { FaCamera, FaSpinner, FaUpload } from 'react-icons/fa'
-import { Button } from '@/components/ui'
-import { useLocalization } from '@/utils/hooks/useLocalization'
-import {
- hideImageHoverPreview,
- openImageInNewTab,
- showImageHoverPreview,
- thumbnailSource,
-} from '../../form/editors/imageHoverPreview'
-import { normalizeImageSize, parseJsonObject } from '../shared/imageValue'
-import Input from '@/components/ui/Input'
-import { supportsPhotoCapture } from '@/utils/device'
-
-type ImageUploadOptions = {
- accept?: string
- /** Mobilde dosya seçici yerine kamerayı açar. */
- capture?: 'environment' | 'user'
- fileFieldName?: string
- height?: number | string
- multiple?: boolean | string
- uploadUrl?: string
- width?: number | string
-}
-
-/**
- * Yüklenen değerin ham biçimini korur; ayraçlara göre bölmez.
- * (Görüntüleme tarafındaki `normalizeImageValue` ile bilinçli olarak farklıdır:
- * burada kullanıcı tarafından yazılmış virgüllü URL'ler bozulmamalıdır.)
- */
-const normalizeUploadValue = (value: unknown): string[] => {
- if (!value) return []
- if (Array.isArray(value)) return value.flatMap(normalizeUploadValue)
-
- if (typeof value === 'string') {
- const trimmed = value.trim()
- if (!trimmed) return []
-
- if (trimmed.startsWith('[')) {
- try {
- const parsed = JSON.parse(trimmed)
- if (Array.isArray(parsed)) return parsed.flatMap(normalizeUploadValue)
- } catch {
- return [trimmed]
- }
- }
-
- return [trimmed]
- }
-
- return [String(value)].filter(Boolean)
-}
-
-const resolveOptions = (column: any): ImageUploadOptions => {
- const configuredOptions = column?.extras?.imageUploadOptions
- if (configuredOptions && Object.keys(configuredOptions).length > 0) {
- return configuredOptions
- }
-
- const parsedOptions = parseJsonObject(column?.extras?.editorOptions ?? column?.editorOptions)
- return parsedOptions ?? {}
-}
-
-const resolveUploadedUrl = (data: unknown) => {
- if (typeof data === 'string') return data
- if (!data || typeof data !== 'object') return ''
-
- const record = data as Record
- return String(record.url ?? record.fileUrl ?? record.path ?? '').trim()
-}
-
-const toBase64 = (file: File): Promise =>
- new Promise((resolve, reject) => {
- const reader = new FileReader()
- reader.onload = () => resolve(reader.result as string)
- reader.onerror = reject
- reader.readAsDataURL(file)
- })
-
-const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
- const { translate } = useLocalization()
- const col = cellElement.column
- const [uploading, setUploading] = useState(false)
- const inputRef = useRef(null)
- const cameraInputRef = useRef(null)
- const [urls, setUrls] = useState(() => normalizeUploadValue(cellElement.value))
-
- useEffect(() => {
- setUrls(normalizeUploadValue(cellElement.value))
- }, [cellElement.value])
-
- const options = useMemo(() => resolveOptions(col), [col])
-
- if (!col) {
- return <>>
- }
-
- // JSON'dan string olarak gelebilir: "true" / "false"
- const isMultiple: boolean = options.multiple === true || options.multiple === 'true'
-
- const thumbW = normalizeImageSize(options.width, 40)
- const thumbH = normalizeImageSize(options.height, 40)
- // Alanda `capture` acikca ayarliysa kullanicinin istedigi tek davranis odur;
- // aksi halde dokunmatik cihazda dosya seciciye ek olarak kamera sunulur.
- const showCameraButton = !options.capture && supportsPhotoCapture()
-
- const removeImage = (index: number) => {
- const newUrls = urls.filter((_, i) => i !== index)
- setUrls(newUrls)
- if (isMultiple) {
- cellElement.setValue(newUrls.length > 0 ? newUrls : null)
- } else {
- cellElement.setValue(newUrls[0] ?? null)
- }
- }
-
- const handleFiles = async (files: FileList | null) => {
- if (!files?.length) return
-
- setUploading(true)
- try {
- // isMultiple=true ise mevcut url'leri koru, üstüne ekle; false ise sıfırla
- const uploadedUrls: string[] = isMultiple ? [...urls] : []
- for (const file of Array.from(files)) {
- if (options.uploadUrl) {
- // HTTP upload
- const formData = new FormData()
- const fileFieldName: string = options.fileFieldName ?? 'file'
- formData.append(fileFieldName, file)
- const res = await fetch(options.uploadUrl, { method: 'POST', body: formData })
- if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
- const data = await res.json()
- const url = resolveUploadedUrl(data)
- uploadedUrls.push(url)
- } else {
- // uploadUrl yapılandırılmamışsa base64 olarak sakla
- const base64 = await toBase64(file)
- uploadedUrls.push(base64)
- }
- }
-
- const newUrls = uploadedUrls.filter(Boolean)
- setUrls(newUrls)
- const newValue = isMultiple ? newUrls : (newUrls[newUrls.length - 1] ?? null)
- cellElement.setValue(newValue)
- } catch (e) {
- console.error('ImageUploadEditorComponent upload error:', e)
- } finally {
- setUploading(false)
- if (inputRef.current) inputRef.current.value = ''
- // Ayni fotografi ikinci kez cekmek degisiklik olayini tetiklemezdi.
- if (cameraInputRef.current) cameraInputRef.current.value = ''
- }
- }
-
- return (
-
- {urls.map((url, i) => (
-
-
showImageHoverPreview(url, event)}
- onMouseMove={(event) => showImageHoverPreview(url, event)}
- onMouseLeave={hideImageHoverPreview}
- onClick={() => openImageInNewTab(url)}
- />
-
removeImage(i)}
- >
- ×
-
-
- ))}
-
-
handleFiles(e.target.files)}
- />
- {/* Kamera ayrı bir input'tur: `capture` dosya seçiciyi kapattığı için
- tek input ile hem galeri hem kamera sunulamaz. */}
- {showCameraButton && (
-
handleFiles(e.target.files)}
- />
- )}
-
inputRef.current?.click()}
- >
- {uploading ? (
-
- ) : (
-
- )}
-
- {showCameraButton && (
-
cameraInputRef.current?.click()}
- >
-
-
- )}
-
- )
-}
-
-export { ImageUploadEditorComponent }
diff --git a/ui/src/views/list/editors/ImageViewerEditorComponent.tsx b/ui/src/views/list/editors/ImageViewerEditorComponent.tsx
deleted file mode 100644
index 1fdb9d39..00000000
--- a/ui/src/views/list/editors/ImageViewerEditorComponent.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import { ReactElement } from 'react'
-import { Button } from '@/components/ui'
-import {
- hideImageHoverPreview,
- openImageInNewTab,
- showImageHoverPreview,
-} from '../../form/editors/imageHoverPreview'
-import {
- NO_IMAGE,
- normalizeImageSize,
- normalizeImageValue,
- parseJsonObject,
-} from '../shared/imageValue'
-
-const resolveTemplateValue = (templateData: any) => {
- const dataField =
- templateData?.dataField ||
- templateData?.item?.dataField ||
- templateData?.editorOptions?.name ||
- templateData?.name
-
- const fieldCandidates = String(dataField || '')
- .split(':')
- .reduce(
- (fields, field) => {
- const value = field.trim()
- return value ? [...fields, value] : fields
- },
- dataField ? [String(dataField)] : [],
- )
-
- const formDataSources = [
- templateData?.component?.option?.('formData'),
- templateData?.formData,
- templateData?.data,
- templateData?.fallbackFormData,
- ].filter((source) => source && typeof source === 'object')
-
- for (const formData of formDataSources) {
- for (const field of fieldCandidates) {
- if (Object.prototype.hasOwnProperty.call(formData, field)) {
- return formData[field]
- }
- }
-
- const actualKey = Object.keys(formData).find((key) =>
- fieldCandidates.some((field) => key.toLowerCase() === field.toLowerCase()),
- )
- if (actualKey) {
- return formData[actualKey]
- }
- }
-
- return templateData?.value ?? templateData?.editorOptions?.value
-}
-
-const resolveTemplateOptions = (templateData: any) => {
- const itemOptions =
- templateData?.editorOptions?.imageUploadOptions ??
- templateData?.imageUploadOptions ??
- parseJsonObject(templateData?.editorOptions?.editorOptions) ??
- parseJsonObject(templateData?.editorOptions)
-
- return itemOptions && typeof itemOptions === 'object' ? itemOptions : {}
-}
-
-const ImageViewerEditorComponent = (templateData: any): ReactElement => {
- const value = resolveTemplateValue(templateData)
- const options = resolveTemplateOptions(templateData)
- const urls = normalizeImageValue(value)
- const thumbW = normalizeImageSize(options?.width, 80)
- const thumbH = normalizeImageSize(options?.height, 80)
-
- if (!urls.length) {
- return -
- }
-
- return (
-
- {urls.map((url, index) => (
-
openImageInNewTab(url)}
- variant="plain"
- shape="none"
- className="!border-0 !bg-transparent !p-0 hover:!bg-transparent active:!bg-transparent dark:hover:!bg-transparent dark:active:!bg-transparent"
- style={{ cursor: 'zoom-in' }}
- >
- showImageHoverPreview(url, event)}
- onMouseMove={(event) => showImageHoverPreview(url, event)}
- onMouseLeave={hideImageHoverPreview}
- style={{
- width: thumbW,
- height: thumbH,
- objectFit: 'cover',
- borderRadius: 4,
- display: 'block',
- }}
- onError={({ currentTarget }) => {
- currentTarget.onerror = null
- currentTarget.src = NO_IMAGE
- }}
- />
-
- ))}
-
- )
-}
-
-export { ImageViewerEditorComponent }
diff --git a/ui/src/views/list/editors/TagBoxEditorComponent.tsx b/ui/src/views/list/editors/TagBoxEditorComponent.tsx
deleted file mode 100644
index 42c26f5c..00000000
--- a/ui/src/views/list/editors/TagBoxEditorComponent.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import TagBox from 'devextreme-react/tag-box'
-import { ReactElement } from 'react'
-import { createColoredTagRender } from '../../shared/lookupColor'
-
-const TagBoxEditorComponent = (cellElement: any): ReactElement => {
- const col = cellElement.column
- if (!col) {
- return <>>
- }
- const options = col.extras.tagBoxOptions
-
- const val = Array.isArray(cellElement.value) ? cellElement.value : [cellElement.value]
- const lookup = col?.lookup
- const dataSource =
- typeof lookup?.dataSource === 'function'
- ? lookup.dataSource(cellElement.row)
- : (lookup?.dataSource ?? [])
-
- return (
- {
- cellElement.setValue(typeof e.value === 'string' ? e.value?.trim() : e.value)
- }}
- onSelectionChanged={cellElement.component.updateDimensions}
- >
- )
-}
-
-export { TagBoxEditorComponent }
diff --git a/ui/src/views/list/editors/cellEditors.tsx b/ui/src/views/list/editors/cellEditors.tsx
new file mode 100644
index 00000000..2d3fd0ae
--- /dev/null
+++ b/ui/src/views/list/editors/cellEditors.tsx
@@ -0,0 +1,172 @@
+import type { ReactElement } from 'react'
+import type { ImageUploadOptionsDto } from '@/proxy/form/models'
+import { GridBoxEditorComponent as FormGridBoxEditor } from '../../form/editors/GridBoxEditorComponent'
+import { ImageUploadEditorComponent as FormImageUploadEditor } from '../../form/editors/ImageUploadEditorComponent'
+import { ImageViewerEditorComponent as FormImageViewerEditor } from '../../form/editors/ImageViewerEditorComponent'
+import { TagBoxEditorComponent as FormTagBoxEditor } from '../../form/editors/TagBoxEditorComponent'
+import { parseJsonObject } from '../shared/imageValue'
+
+/**
+ * Liste düzenleme hücresini (DataGrid/TreeList `editCellTemplate`, CardView form şablonu) form
+ * sayfasının editör bileşenlerine bağlayan adaptörler. Editörlerin tek uygulaması
+ * `form/editors/*`tir; burada yalnızca hücre verisi bileşen prop'larına çevrilir.
+ */
+
+
+/**
+ * Ekranın editorOptions'ı + hücre bağlamının salt-okunur/devre dışı kararı. Bağlamın geri
+ * kalanı (`value`, `onValueChanged`…) editörün kendi değer akışını ezeceği için alınmaz.
+ */
+const getCellEditorOptions = (cellElement: any) => {
+ const { readOnly, disabled } = cellElement.editorOptions ?? {}
+ return {
+ ...cellElement.column?.extras?.editorOptions,
+ ...(readOnly !== undefined ? { readOnly } : {}),
+ ...(disabled !== undefined ? { disabled } : {}),
+ }
+}
+
+/** Kolonun lookup kaynağı; cascade ebeveynleri düzenlenen satırdan okunur. */
+const getCellLookupDataSource = (cellElement: any) => {
+ const lookup = cellElement.column?.lookup
+ return typeof lookup?.dataSource === 'function'
+ ? lookup.dataSource(cellElement.row)
+ : (lookup?.dataSource ?? [])
+}
+
+/** Liste düzenleme hücresi; editör form sayfasıyla aynı bileşendir. */
+const TagBoxEditorComponent = (cellElement: any): ReactElement => {
+ const col = cellElement.column
+ if (!col) {
+ return <>>
+ }
+
+ return (
+ cellElement.setValue(value)}
+ />
+ )
+}
+
+/** Liste düzenleme hücresi; editör form sayfasıyla aynı bileşendir. */
+const GridBoxEditorComponent = (cellElement: any): ReactElement => {
+ const col = cellElement.column
+ if (!col) {
+ return <>>
+ }
+
+ return (
+ cellElement.setValue(value)}
+ />
+ )
+}
+
+const resolveOptions = (column: any): ImageUploadOptionsDto => {
+ const configuredOptions = column?.extras?.imageUploadOptions
+ if (configuredOptions && Object.keys(configuredOptions).length > 0) {
+ return configuredOptions
+ }
+
+ return parseJsonObject(column?.extras?.editorOptions ?? column?.editorOptions) ?? {}
+}
+
+/** Liste düzenleme hücresi (Grid, Tree, CardView); editör form sayfasıyla aynı bileşendir. */
+const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
+ const col = cellElement.column
+ if (!col) {
+ return <>>
+ }
+
+ return (
+ cellElement.setValue(value)}
+ />
+ )
+}
+
+const resolveTemplateValue = (templateData: any) => {
+ const dataField =
+ templateData?.dataField ||
+ templateData?.item?.dataField ||
+ templateData?.editorOptions?.name ||
+ templateData?.name
+
+ const fieldCandidates = String(dataField || '')
+ .split(':')
+ .reduce(
+ (fields, field) => {
+ const value = field.trim()
+ return value ? [...fields, value] : fields
+ },
+ dataField ? [String(dataField)] : [],
+ )
+
+ const formDataSources = [
+ templateData?.component?.option?.('formData'),
+ templateData?.formData,
+ templateData?.data,
+ templateData?.fallbackFormData,
+ ].filter((source) => source && typeof source === 'object')
+
+ for (const formData of formDataSources) {
+ for (const field of fieldCandidates) {
+ if (Object.prototype.hasOwnProperty.call(formData, field)) {
+ return formData[field]
+ }
+ }
+
+ const actualKey = Object.keys(formData).find((key) =>
+ fieldCandidates.some((field) => key.toLowerCase() === field.toLowerCase()),
+ )
+ if (actualKey) {
+ return formData[actualKey]
+ }
+ }
+
+ return templateData?.value ?? templateData?.editorOptions?.value
+}
+
+const resolveTemplateOptions = (templateData: any) => {
+ const itemOptions =
+ templateData?.editorOptions?.imageUploadOptions ??
+ templateData?.imageUploadOptions ??
+ parseJsonObject(templateData?.editorOptions?.editorOptions) ??
+ parseJsonObject(templateData?.editorOptions)
+
+ return itemOptions && typeof itemOptions === 'object' ? itemOptions : {}
+}
+
+/** Liste düzenleme şablonu (Grid, Tree, CardView); görünüm form sayfasıyla aynı bileşendir. */
+const ImageViewerEditorComponent = (templateData: any): ReactElement => (
+
+)
+
+export {
+ GridBoxEditorComponent,
+ ImageUploadEditorComponent,
+ ImageViewerEditorComponent,
+ TagBoxEditorComponent,
+}
diff --git a/ui/src/views/list/shared/cascade.ts b/ui/src/views/list/shared/cascade.ts
index a07a0ec5..5bdcfdcc 100644
--- a/ui/src/views/list/shared/cascade.ts
+++ b/ui/src/views/list/shared/cascade.ts
@@ -1,19 +1,21 @@
/**
* Lookup cascade (bağımlı açılır liste) davranışı.
- * Grid ve Tree aynı kuralları paylaşır.
+ * Grid, Tree ve form sayfası aynı kuralları paylaşır.
*/
-import type { GridDto } from '@/proxy/form/models'
+import type { ColumnFormatDto, GridDto } from '@/proxy/form/models'
+import { getActiveEditingForm } from './editingForm'
-export type CascadeInfo = {
+type CascadeInfo = {
parentFields: string[]
- childFields?: string[]
}
export type CascadeMaps = {
- /** fieldName -> cascade bilgisi */
+ /** child fieldName -> cascade bilgisi */
cascadeFieldsMap: Map
/** parentField -> etkilediği child alan adları */
parentToChildrenMap: Map>
+ /** fieldName -> değişince boşaltılacak alanlar */
+ clearFieldsMap: Map
}
const splitFields = (value?: string | null) =>
@@ -22,103 +24,182 @@ const splitFields = (value?: string | null) =>
.map((field) => field.trim())
.filter(Boolean)
+export const getCascadeParentFieldsOf = (colFormat?: Pick) =>
+ splitFields(colFormat?.lookupDto?.cascadeParentFields)
+
+/**
+ * Bir alan değiştiğinde boşaltılacak alanlar: ebeveyni bu alan olan çocuklar
+ * (`CascadeParentFields`) ile alanın kendi `CascadeEmptyFields` listesi.
+ */
+export const getCascadeClearFields = (
+ columns: (Pick | undefined)[],
+ fieldName: string,
+) => {
+ const fields = new Set()
+
+ columns.forEach((column) => {
+ if (!column?.fieldName) return
+ if (column.fieldName === fieldName) {
+ splitFields(column.lookupDto?.cascadeEmptyFields).forEach((field) => fields.add(field))
+ } else if (getCascadeParentFieldsOf(column).includes(fieldName)) {
+ fields.add(column.fieldName)
+ }
+ })
+
+ fields.delete(fieldName)
+ return [...fields]
+}
+
+/** Ebeveynlerinden biri boşsa çocuk kilitlenir (bool ebeveynde `false` da boştur). */
+export const isCascadeParentEmpty = (parentFields: string[], data?: Record) =>
+ parentFields.some((parentField) => !data?.[parentField])
+
+/** Ekranın cascade ilişkilerini tek geçişte çıkarır; `clearFieldsMap` `getCascadeClearFields` ile aynıdır. */
export const createCascadeMaps = (gridDto?: GridDto): CascadeMaps => {
const cascadeFieldsMap = new Map()
const parentToChildrenMap = new Map>()
+ const clearFieldSets = new Map>()
+
+ const addClearField = (fieldName: string, clearField: string) => {
+ if (fieldName === clearField) return
+ const fields = clearFieldSets.get(fieldName) ?? new Set()
+ fields.add(clearField)
+ clearFieldSets.set(fieldName, fields)
+ }
gridDto?.columnFormats.forEach((col) => {
- if (!col.fieldName || !col.lookupDto?.cascadeParentFields) return
+ const fieldName = col.fieldName
+ if (!fieldName) return
- const parentFields = splitFields(col.lookupDto.cascadeParentFields)
- const childFields = col.lookupDto.cascadeEmptyFields
- ? splitFields(col.lookupDto.cascadeEmptyFields)
- : undefined
+ splitFields(col.lookupDto?.cascadeEmptyFields).forEach((field) =>
+ addClearField(fieldName, field),
+ )
- cascadeFieldsMap.set(col.fieldName, { parentFields, childFields })
+ const parentFields = getCascadeParentFieldsOf(col)
+ if (!parentFields.length) return
+ cascadeFieldsMap.set(fieldName, { parentFields })
parentFields.forEach((parentField) => {
const children = parentToChildrenMap.get(parentField) ?? new Set()
- children.add(col.fieldName!)
+ children.add(fieldName)
parentToChildrenMap.set(parentField, children)
+ addClearField(parentField, fieldName)
})
})
- return { cascadeFieldsMap, parentToChildrenMap }
+ const clearFieldsMap = new Map(
+ [...clearFieldSets].map(([fieldName, fields]) => [fieldName, [...fields]]),
+ )
+ return { cascadeFieldsMap, parentToChildrenMap, clearFieldsMap }
+}
+
+/** Alan cascade zincirinde (ebeveyn ya da çocuk olarak) yer alıyor mu? */
+export const hasCascadeBehavior = (maps: CascadeMaps, fieldName: string) =>
+ maps.cascadeFieldsMap.has(fieldName) ||
+ maps.parentToChildrenMap.has(fieldName) ||
+ maps.clearFieldsMap.has(fieldName)
+
+const getEditingRow = (grid: any) => {
+ const rowKey = grid.option('editing.editRowKey')
+ return {
+ rowIndex: grid.getRowIndexByKey(rowKey) as number,
+ rowData: grid.getVisibleRows().find((row: any) => row.key === rowKey)?.data as
+ | Record
+ | undefined,
+ }
+}
+
+/** Parent değiştikten sonra etkilenen çocuk editörlerin kilidini yeniler. */
+const updateChildDisabledStates = (
+ grid: any,
+ gridDto: GridDto,
+ maps: CascadeMaps,
+ children: Iterable,
+ rowData: Record,
+) => {
+ const form = getActiveEditingForm(grid)
+ if (!form?.getEditor) return
+
+ for (const childFieldName of children) {
+ const childInfo = maps.cascadeFieldsMap.get(childFieldName)
+ if (!childInfo) continue
+
+ try {
+ const childColumn = gridDto.columnFormats.find(
+ (column) => column.fieldName === childFieldName,
+ )
+ const childEditorOptions = childColumn?.editorOptions
+ ? JSON.parse(childColumn.editorOptions)
+ : {}
+
+ form
+ .getEditor(childFieldName)
+ ?.option(
+ 'disabled',
+ childEditorOptions?.disabled === true ||
+ isCascadeParentEmpty(childInfo.parentFields, rowData),
+ )
+ } catch (err) {
+ console.error(`Cascade child editor güncellenemedi: "${childFieldName}"`, err)
+ }
+ }
+}
+
+/**
+ * Bir alanın değeri değiştiğinde cascade kurallarını düzenlenen satıra uygular:
+ * bağımlı alanları boşaltır ve çocuk editörlerin kilidini yeniler. Grid editörleri
+ * (`onEditorPreparing`) ve platform editörleri (TagBox, GridBox…) aynı yoldan geçer.
+ */
+export const applyCascadeChange = (
+ grid: any,
+ gridDto: GridDto,
+ maps: CascadeMaps,
+ fieldName: string,
+ value: any,
+) => {
+ if (!grid?.getVisibleRows) return
+
+ const { rowIndex, rowData } = getEditingRow(grid)
+ const clearFields = maps.clearFieldsMap.get(fieldName) ?? []
+
+ if (rowIndex >= 0) {
+ clearFields.forEach((childField) => grid.cellValue(rowIndex, childField, null))
+ }
+
+ const affectedChildren = maps.parentToChildrenMap.get(fieldName)
+ if (!affectedChildren?.size || !rowData) return
+
+ const nextRowData = {
+ ...rowData,
+ [fieldName]: value,
+ ...Object.fromEntries(clearFields.map((field) => [field, null])),
+ }
+ updateChildDisabledStates(grid, gridDto, maps, affectedChildren, nextRowData)
}
type AttachCascadeParams = {
editor: any
gridDto: GridDto
- cascadeInfo: CascadeInfo
maps: CascadeMaps
}
/**
* `onEditorPreparing` içinde cascade davranışını editöre bağlar:
- * - parent değiştiğinde child alanları temizler,
- * - etkilenen child editörlerin `disabled` durumunu günceller,
+ * - değer değişince bağımlı alanları temizler ve çocukların kilidini yeniler,
* - form ilk açıldığında parent'ı boş olan alanı devre dışı bırakır.
*/
-export const attachCascadeBehavior = ({
- editor,
- gridDto,
- cascadeInfo,
- maps,
-}: AttachCascadeParams) => {
- const { cascadeFieldsMap, parentToChildrenMap } = maps
- const affectedChildren = parentToChildrenMap.get(editor.dataField)
+export const attachCascadeBehavior = ({ editor, gridDto, maps }: AttachCascadeParams) => {
+ const fieldName = editor.dataField as string
const previousHandler = editor.editorOptions.onValueChanged
editor.editorOptions.onValueChanged = (e: any) => {
previousHandler?.(e)
-
- const grid = editor.component
- const rowKey = grid.option('editing.editRowKey')
- const rowIndex = grid.getRowIndexByKey(rowKey)
-
- // Parent değiştiğinde bağımlı alanları temizle.
- if (cascadeInfo.childFields && rowIndex >= 0) {
- cascadeInfo.childFields.forEach((childField) => grid.cellValue(rowIndex, childField, null))
- }
-
- if (!affectedChildren?.size) return
-
- const formInstance = grid.option('editing.form') as any
- if (!formInstance?.getEditor) return
-
- const rowData = grid.getVisibleRows().find((row: any) => row.key === rowKey)?.data
- if (!rowData) return
-
- affectedChildren.forEach((childFieldName) => {
- const childInfo = cascadeFieldsMap.get(childFieldName)
- if (!childInfo) return
-
- try {
- const childColumn = gridDto.columnFormats.find(
- (column) => column.fieldName === childFieldName,
- )
- const childEditorOptions = childColumn?.editorOptions
- ? JSON.parse(childColumn.editorOptions)
- : {}
- const childEditor = formInstance.getEditor(childFieldName)
-
- childEditor?.option(
- 'disabled',
- childEditorOptions?.disabled === true ||
- childInfo.parentFields.some((parentField) => !rowData[parentField]),
- )
- } catch (err) {
- console.error(`Cascade child editor güncellenemedi: "${childFieldName}"`, err)
- }
- })
+ applyCascadeChange(editor.component, gridDto, maps, fieldName, e.value)
}
- // İlk açılışta parent'ı dolu değilse editörü kilitle.
- const grid = editor.component
- const rowKey = grid.option('editing.editRowKey')
- const rowData = grid.getVisibleRows().find((row: any) => row.key === rowKey)?.data
-
- if (rowData && cascadeInfo.parentFields.some((parentField) => !rowData[parentField])) {
+ const cascadeInfo = maps.cascadeFieldsMap.get(fieldName)
+ const { rowData } = getEditingRow(editor.component)
+ if (cascadeInfo && rowData && isCascadeParentEmpty(cascadeInfo.parentFields, rowData)) {
editor.editorOptions.disabled = true
}
}
diff --git a/ui/src/views/list/shared/editPopup.ts b/ui/src/views/list/shared/editPopup.ts
index b7ec815b..ef82ca10 100644
--- a/ui/src/views/list/shared/editPopup.ts
+++ b/ui/src/views/list/shared/editPopup.ts
@@ -9,7 +9,7 @@ export const isTouchLikeDevice = () =>
typeof window !== 'undefined' &&
(window.matchMedia?.('(pointer: coarse)').matches || window.matchMedia?.('(hover: none)').matches)
-export const isMobileViewport = () =>
+const isMobileViewport = () =>
typeof window !== 'undefined' && window.matchMedia?.('(max-width: 767px)').matches
/** Popup'ın mobil davranış kurallarıyla açılıp açılmayacağını belirler. */
diff --git a/ui/src/views/list/shared/editingForm.ts b/ui/src/views/list/shared/editingForm.ts
index ea1ef6c9..fae0e61e 100644
--- a/ui/src/views/list/shared/editingForm.ts
+++ b/ui/src/views/list/shared/editingForm.ts
@@ -27,7 +27,7 @@ import {
setFormEditingExtraItemValues,
} from '../Utils'
-export type EditorOptionsWithButtons = { buttons?: any[] } & Record
+type EditorOptionsWithButtons = { buttons?: any[] } & Record
/**
* `editorOptions.buttons` içinde bu değeri taşıyan `onClick`, kamerayla barkod
@@ -44,6 +44,41 @@ const scanBarcodeIntoEditor = async (e: any) => {
}
}
+/**
+ * Yapılandırmadan gelen buton `onClick` değerlerini fonksiyona çevirir: barkod token'ı ya da
+ * fonksiyon metni. Liste popup'ı ve form sayfası aynı çözümü kullanır; nesneler yerinde
+ * değiştirilmez, aynı tanım iki kez çözülse de metin kaybolmaz.
+ */
+export const resolveEditorButtons = (buttons?: any[]) =>
+ buttons?.map((button: any) => {
+ const onClick = button?.options?.onClick
+ if (typeof onClick !== 'string') return button
+
+ return {
+ ...button,
+ options: {
+ ...button.options,
+ // Yapılandırmadan gelen fonksiyon tanımı (platform tasarımı gereği).
+ onClick: onClick === SCAN_BARCODE_TOKEN ? scanBarcodeIntoEditor : eval(`(${onClick})`),
+ },
+ }
+ })
+
+/** Buton tıklamasına kayıt bağlamını (formData, fieldName, mode…) ekler. */
+export const withButtonContext = (
+ buttons: any[] | undefined,
+ getContext: () => Record,
+) =>
+ buttons?.map((button: any) => {
+ const onClick = button?.options?.onClick
+ if (typeof onClick !== 'function') return button
+
+ return {
+ ...button,
+ options: { ...button.options, onClick: (e: any) => onClick({ ...e, ...getContext() }) },
+ }
+ })
+
/* ------------------------------------------------------------------ *
* Satır anahtarı yardımcıları
* ------------------------------------------------------------------ */
@@ -400,6 +435,9 @@ const dateTimeEditorDefaults = {
displayFormat: 'shortDateShortTime',
}
+/** Tarih editörünün klavye/temizleme davranışı; liste popup'ı ve form sayfası aynı açılır. */
+const dateBoxDefaults = { useMaskBehavior: true, openOnFieldClick: true, showClearButton: true }
+
/** Kolonun DB tipine göre tarih editörü varsayılanlarını döner. */
const getDateEditorDefaults = (sourceDbType?: DbTypeEnum) => {
if (sourceDbType === DbTypeEnum.Date) return dateEditorDefaults
@@ -429,10 +467,26 @@ export const resolveEditorType = (editorType2?: EditorType2) => {
export const withEditorTypeDefaults = >(
editorType2: EditorType2 | undefined,
editorOptions: T,
-): T =>
- editorType2 === 'dxHtmlEditor'
- ? mergeHtmlEditorOptions(editorOptions)
- : withPhoneEditorOptions(editorOptions)
+ sourceDbType?: DbTypeEnum,
+): T => {
+ if (editorType2 === 'dxHtmlEditor') return mergeHtmlEditorOptions(editorOptions)
+
+ const dateDefaults = {
+ ...(editorType2 === 'dxDateBox' ? dateBoxDefaults : {}),
+ ...(getDateEditorDefaults(sourceDbType) ?? {}),
+ }
+ return withPhoneEditorOptions({ ...dateDefaults, ...editorOptions })
+}
+
+/** Lookup alanının editör varsayılanları; veri kaynağını liste kolonu ya da form sayfası verir. */
+export const getLookupEditorDefaults = (colFormat: ColumnFormatDto) =>
+ colFormat.lookupDto?.dataSourceType
+ ? {
+ valueExpr: colFormat.lookupDto.valueExpr?.toLowerCase(),
+ displayExpr: colFormat.lookupDto.displayExpr?.toLowerCase(),
+ searchEnabled: true,
+ }
+ : {}
type FormItemMapperParams = {
mode: RowMode
@@ -477,9 +531,7 @@ export const createFormItemMapper = ({
console.error('EditorOptions parse error:', fieldName, err)
}
- const defaultEditorOptions: EditorOptionsWithButtons = {
- ...(getDateEditorDefaults(colFormat.sourceDbType) ?? {}),
- }
+ const defaultEditorOptions: EditorOptionsWithButtons = getLookupEditorDefaults(colFormat)
if (colFormat.placeHolder) {
defaultEditorOptions.placeholder = translate('::' + colFormat.placeHolder)
@@ -495,22 +547,14 @@ export const createFormItemMapper = ({
? { readOnly: true }
: {}
- const editorOptions: EditorOptionsWithButtons = withEditorTypeDefaults(colFormat.editorType2, {
- ...defaultEditorOptions,
- ...parsedEditorOptions,
- ...forcedEditorOptions,
- })
+ const editorOptions: EditorOptionsWithButtons = withEditorTypeDefaults(
+ colFormat.editorType2,
+ { ...defaultEditorOptions, ...parsedEditorOptions, ...forcedEditorOptions },
+ colFormat.sourceDbType,
+ )
if (editorOptions.buttons) {
- editorOptions.buttons = editorOptions.buttons.map((btn: any) => {
- if (btn?.options?.onClick === SCAN_BARCODE_TOKEN) {
- btn.options.onClick = scanBarcodeIntoEditor
- } else if (typeof btn?.options?.onClick === 'string') {
- // Yapılandırmadan gelen fonksiyon tanımı (platform tasarımı gereği).
- btn.options.onClick = eval(`(${btn.options.onClick})`)
- }
- return btn
- })
+ editorOptions.buttons = resolveEditorButtons(editorOptions.buttons)
}
const item: SimpleItemWithColData = {
diff --git a/ui/src/views/list/shared/editingFormScripts.ts b/ui/src/views/list/shared/editingFormScripts.ts
index 5f256df4..636592c2 100644
--- a/ui/src/views/list/shared/editingFormScripts.ts
+++ b/ui/src/views/list/shared/editingFormScripts.ts
@@ -6,12 +6,24 @@
*/
import type { MutableRefObject } from 'react'
import { executeEditorScript } from '@/utils/editorScriptRuntime'
+import { attachFieldValueBroadcast } from '@/utils/phoneFormat'
import type { GridDto } from '@/proxy/form/models'
+import type { RowMode } from '../../form/types'
import { getEditingFormFields } from '../Utils'
import {
+ applyCascadeChange,
+ attachCascadeBehavior,
+ type CascadeMaps,
+ hasCascadeBehavior,
+ isCascadeParentEmpty,
+} from './cascade'
+import {
+ getActiveEditingForm,
getValueByField,
+ resolveEditorButtons,
setFormEditorReadOnly,
shouldRunEditorScriptOnContentReady,
+ withButtonContext,
} from './editingForm'
type EditingFormHandlerParams = {
@@ -199,18 +211,16 @@ type EditorValueChangedScriptParams = {
editorScript: string
fieldName?: string
editingFormInstanceRef: MutableRefObject
- getActiveEditingForm: (component: any) => any
}
/**
* `onEditorPreparing` içinde editör script'ini `onValueChanged` zincirine ekler.
*/
-export const attachEditorValueChangedScript = ({
+const attachEditorValueChangedScript = ({
editor,
editorScript,
fieldName,
editingFormInstanceRef,
- getActiveEditingForm,
}: EditorValueChangedScriptParams) => {
const previousHandler = editor.editorOptions.onValueChanged
const editorDataField = editor.dataField
@@ -245,22 +255,161 @@ export const attachEditorValueChangedScript = ({
}
/**
- * Editör butonlarının `onClick` çağrılarına satır bağlamını (formData, rowKey…) ekler.
+ * Editör butonlarının `onClick` çağrılarına kayıt bağlamını (formData, fieldName, mode, rowKey…)
+ * ekler. Form sayfası aynı bağlamı `withButtonContext` ile verir.
*/
-export const attachEditorButtonContext = (editor: any) => {
+const attachEditorButtonContext = (editor: any, mode: RowMode) => {
if (!editor.editorOptions?.buttons) return
- editor.editorOptions.buttons = editor.editorOptions.buttons.map((btn: any) => {
- if (typeof btn?.options?.onClick !== 'function') return btn
-
- const originalClick = btn.options.onClick
- btn.options.onClick = (e: any) => {
- const grid = editor.component
- const { rowKey, rowIndex } = getEditingRowIndex(grid)
- const formData = grid.getVisibleRows().find((row: any) => row.key === rowKey)?.data || {}
-
- originalClick({ ...e, formData, fieldName: editor.dataField, rowKey, rowIndex })
- }
- return btn
+ editor.editorOptions.buttons = withButtonContext(editor.editorOptions.buttons, () => {
+ const grid = editor.component
+ const { rowKey, rowIndex } = getEditingRowIndex(grid)
+ const formData = grid.getVisibleRows().find((row: any) => row.key === rowKey)?.data || {}
+ return { formData, fieldName: editor.dataField, mode, rowKey, rowIndex }
})
}
+
+type PrepareEditingEditorParams = {
+ editor: any
+ gridDto: GridDto
+ mode: RowMode
+ cascadeMaps: CascadeMaps
+ editingFormInstanceRef: MutableRefObject
+}
+
+/**
+ * Grid ve Tree `onEditorPreparing` ortak gövdesi: ekranın editorOptions'ı, ekleme/düzenleme
+ * kilidi, cascade, telefon maskesi, editör script'i ve buton bağlamı.
+ */
+export const prepareEditingEditor = ({
+ editor,
+ gridDto,
+ mode,
+ cascadeMaps,
+ editingFormInstanceRef,
+}: PrepareEditingEditorParams) => {
+ if (editor.parentType !== 'dataRow' || !editor.dataField) return
+
+ const columnFormat = gridDto.columnFormats.find((column) => column.fieldName === editor.dataField)
+ const isNewRow = Boolean(editor.row?.isNewRow) || mode === 'new'
+
+ if (columnFormat?.editorOptions) {
+ try {
+ const parsedEditorOptions = JSON.parse(columnFormat.editorOptions)
+ editor.editorOptions = {
+ ...editor.editorOptions,
+ ...parsedEditorOptions,
+ // Ham tanımdaki buton onClick'i metindir; form sayfasıyla aynı çözümden geçer.
+ ...(parsedEditorOptions.buttons
+ ? { buttons: resolveEditorButtons(parsedEditorOptions.buttons) }
+ : {}),
+ }
+ } catch (err) {
+ console.error('EditorOptions parse error:', columnFormat.fieldName, err)
+ }
+ }
+
+ const hasReadOnlyEditorOption = editor.editorOptions?.readOnly === true
+ const hasDisabledEditorOption = editor.editorOptions?.disabled === true
+
+ if (
+ (isNewRow && columnFormat?.allowAdding === false) ||
+ (!isNewRow && columnFormat?.allowEditing === false)
+ ) {
+ editor.editorOptions.readOnly = true
+ } else if (isNewRow && columnFormat?.allowAdding === true) {
+ if (!hasReadOnlyEditorOption) editor.editorOptions.readOnly = false
+ if (!hasDisabledEditorOption) editor.editorOptions.disabled = false
+ }
+
+ if (hasCascadeBehavior(cascadeMaps, editor.dataField)) {
+ attachCascadeBehavior({ editor, gridDto, 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({
+ editor,
+ editorScript: columnFormat.editorScript,
+ fieldName: columnFormat.fieldName,
+ editingFormInstanceRef,
+ })
+ }
+
+ attachEditorButtonContext(editor, mode)
+}
+
+type CustomEditorCellAdapterParams = {
+ getGridDto: () => GridDto | undefined
+ getMode: () => RowMode
+ cascadeMaps: CascadeMaps
+ editingFormInstanceRef: MutableRefObject
+}
+
+/**
+ * Platform editörleri (TagBox, GridBox, ImageUpload) DataGrid'de `editCellTemplate` ile çizilir;
+ * `onEditorPreparing` onlar için çalışmaz. Bu adaptör hücre bağlamına form sayfasındaki
+ * davranışı ekler: salt-okunur/devre dışı durumu, cascade ve editör script'i.
+ */
+export const createCustomEditorCellAdapter =
+ ({ getGridDto, getMode, cascadeMaps, editingFormInstanceRef }: CustomEditorCellAdapterParams) =>
+ (cell: any) => {
+ const gridDto = getGridDto()
+ const fieldName: string | undefined = cell?.column?.dataField
+ if (!gridDto || !fieldName || !cell.setValue) return cell
+
+ const grid = cell.component
+ const columnFormat = gridDto.columnFormats.find((column) => column.fieldName === fieldName)
+ const isNewRow = Boolean(cell.row?.isNewRow) || getMode() === 'new'
+ const rowData: Record = cell.row?.data ?? {}
+ const columnEditorOptions = cell.column?.extras?.editorOptions ?? {}
+
+ // Form item'ının editorOptions'ı yetki/URL kilidini ve script'in readOnly kararını taşır.
+ const form = editingFormInstanceRef.current ?? getActiveEditingForm(grid)
+ let itemEditorOptions: Record | undefined
+ try {
+ itemEditorOptions = form?.itemOption?.(fieldName)?.editorOptions
+ } catch {
+ itemEditorOptions = undefined
+ }
+
+ const readOnly =
+ itemEditorOptions && 'readOnly' in itemEditorOptions
+ ? itemEditorOptions.readOnly === true
+ : columnEditorOptions.readOnly === true ||
+ (isNewRow
+ ? columnFormat?.canCreate === false || columnFormat?.allowAdding === false
+ : columnFormat?.canUpdate === false || columnFormat?.allowEditing === false)
+
+ const cascadeInfo = cascadeMaps.cascadeFieldsMap.get(fieldName)
+ const disabled =
+ columnEditorOptions.disabled === true ||
+ (!!cascadeInfo && isCascadeParentEmpty(cascadeInfo.parentFields, rowData))
+
+ const setValue = (value: any) => {
+ cell.setValue(value)
+ applyCascadeChange(grid, gridDto, cascadeMaps, fieldName, value)
+
+ if (columnFormat?.editOrderNo == null || !columnFormat.editorScript) return
+
+ const { rowIndex } = getEditingRowIndex(grid)
+ const activeForm = editingFormInstanceRef.current ?? getActiveEditingForm(grid)
+ try {
+ executeEditorScript(columnFormat.editorScript, {
+ formData: { ...rowData, [fieldName]: value },
+ e: { component: activeForm, dataField: fieldName, value, scriptEvent: 'valueChanged' },
+ editor: { dataField: fieldName, component: grid },
+ runtimeSetEditorReadOnly: (field: string, flag: boolean) =>
+ setFormEditorReadOnly(activeForm, field, flag),
+ setFormData: createGridCellWriter(grid, rowIndex),
+ })
+ } catch (err) {
+ console.error('Script exec error', fieldName, err)
+ }
+ }
+
+ return { ...cell, editorOptions: { ...cell.editorOptions, readOnly, disabled }, setValue }
+ }
diff --git a/ui/src/views/list/shared/imageValue.ts b/ui/src/views/list/shared/imageValue.ts
index 07834aff..62909e2b 100644
--- a/ui/src/views/list/shared/imageValue.ts
+++ b/ui/src/views/list/shared/imageValue.ts
@@ -33,7 +33,7 @@ export const normalizeImageSize = (value: unknown, fallback: number) => {
}
/** Obje formatındaki görsel değerinden (url/src/fileUrl/path/value) kaynağı çıkarır. */
-export const getImageSource = (value: unknown) => {
+const getImageSource = (value: unknown) => {
if (!value) return ''
if (typeof value === 'string') return value.trim()
@@ -49,7 +49,7 @@ const isProbablyBase64Image = (value: string) =>
value.length > 80 && /^[A-Za-z0-9+/]+={0,2}$/.test(value)
/** Ham base64 içeriğini data URL'e çevirir; zaten URL ise dokunmaz. */
-export const toImageSource = (value: string) => {
+const toImageSource = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed.startsWith('data:image/') || !isProbablyBase64Image(trimmed)) return trimmed
diff --git a/ui/src/views/list/shared/workflowRows.tsx b/ui/src/views/list/shared/workflowRows.tsx
index 95f7f8db..8f026e3a 100644
--- a/ui/src/views/list/shared/workflowRows.tsx
+++ b/ui/src/views/list/shared/workflowRows.tsx
@@ -7,7 +7,7 @@ import type { GridDto } from '@/proxy/form/models'
import { workflowService } from '@/services/workflow.service'
import { getPersistedInsertedKey } from './editingForm'
-export const showWorkflowMessages = (messages: string[] = []) => {
+const showWorkflowMessages = (messages: string[] = []) => {
if (!messages.length) return
toast.push(
diff --git a/ui/src/views/list/useListFormColumns.ts b/ui/src/views/list/useListFormColumns.ts
index f8711328..e7eeb8d5 100644
--- a/ui/src/views/list/useListFormColumns.ts
+++ b/ui/src/views/list/useListFormColumns.ts
@@ -18,6 +18,7 @@ import {
} from '@/proxy/form/models'
import { addCss, getEditingFormFields, safeJsonParse } from './Utils'
import { clearLookupCache, clearLookupCacheByPrefix } from '../shared/lookupCache'
+import { createCascadeMaps } from './shared/cascade'
import { useLookupDataSource } from '../form/useLookupDataSource'
import {
NO_IMAGE,
@@ -593,6 +594,7 @@ const useListFormColumns = ({
return columns
}
+ const { clearFieldsMap: cascadeClearFieldsMap } = createCascadeMaps(gridDto)
const workflowApprovalStatusField =
gridDto.gridOptions?.workflowDto?.approvalStatusFieldName?.toLowerCase()
columnFormats.forEach((colData) => {
@@ -748,15 +750,15 @@ const useListFormColumns = ({
dataSource: (o) => lookupDataSource(o, colData),
}
- //cascadeEmptyFields verisi dolu ise bu kolon/field bir parent field dir
- if (colData.lookupDto.cascadeEmptyFields) {
- // parent field guncellendigi zaman bu fonksiyon cagrilir
+ // Değeri değişince boşaltılacak alanlar (çocuklar + CascadeEmptyFields); popup ve
+ // satır içi düzenleme form sayfasıyla aynı kuralı izler.
+ const cascadeClearFields = cascadeClearFieldsMap.get(colData.fieldName)
+ if (cascadeClearFields?.length) {
column.setCellValue = function (rowData: any, value: any) {
if (!colData.fieldName) return
- //console.log({ rowData, value, colData })
- rowData[colData.fieldName] = Array.isArray(value) ? value[0] : value
- // cascadeEmptyFields alani aralarinda virgul olacak sekilde bosaltilmak istenen alanlari saklar
- colData?.lookupDto?.cascadeEmptyFields?.split(',').forEach((emptyField: any) => {
+ rowData[colData.fieldName] =
+ Array.isArray(value) && !column.extras?.multiValue ? value[0] : value
+ cascadeClearFields.forEach((emptyField) => {
rowData[emptyField] = null
})
}
diff --git a/ui/src/views/shared/MenuAddDialog.tsx b/ui/src/views/shared/MenuAddDialog.tsx
index 14d278a5..f451bdcc 100644
--- a/ui/src/views/shared/MenuAddDialog.tsx
+++ b/ui/src/views/shared/MenuAddDialog.tsx
@@ -154,7 +154,7 @@ export function IconPickerField({ value, onChange, invalid }: IconPickerFieldPro
// ─── MenuAddDialog ────────────────────────────────────────────────────────────
-export interface MenuAddDialogProps {
+interface MenuAddDialogProps {
isOpen: boolean
onClose: () => void
initialParentCode: string
diff --git a/ui/src/views/shared/SharedGridBoxEditor.tsx b/ui/src/views/shared/SharedGridBoxEditor.tsx
index cacc13f4..53239424 100644
--- a/ui/src/views/shared/SharedGridBoxEditor.tsx
+++ b/ui/src/views/shared/SharedGridBoxEditor.tsx
@@ -17,8 +17,6 @@ type SharedGridBoxEditorProps = {
editorOptions?: any
dropDownOptions?: any
onValueChanged?: (value: any) => void
- onCommit?: (value: any) => void
- setInitialValueOnContentReady?: boolean
}
const normalizeValue = (value: any) => (Array.isArray(value) ? value : [value])
@@ -46,18 +44,11 @@ const SharedGridBoxEditor = ({
editorOptions,
dropDownOptions,
onValueChanged,
- onCommit,
- setInitialValueOnContentReady,
}: SharedGridBoxEditorProps): ReactElement => {
const gridRef = useRef(null)
const currentValueRef = useRef(normalizeValue(value))
currentValueRef.current = normalizeValue(value)
- const commitValue = (nextValue: any) => {
- currentValueRef.current = nextValue
- onCommit?.(nextValue)
- }
-
return (
{
- if (setInitialValueOnContentReady) {
- e.component.option('value', currentValueRef.current)
- }
+ e.component.option('value', currentValueRef.current)
editorOptions?.onContentReady?.(e)
}}
@@ -81,7 +70,6 @@ const SharedGridBoxEditor = ({
onValueChanged?.(nextValue)
if (e.value === null) {
- commitValue(null)
e.component.close()
gridRef.current?.instance().deselectAll()
}
@@ -106,7 +94,6 @@ const SharedGridBoxEditor = ({
if (options?.selectionMode === 'single') {
const nextValue = event.data.key
dropDown.component.option('value', nextValue)
- commitValue(nextValue)
dropDown.component.close()
}
}}
@@ -138,7 +125,6 @@ const SharedGridBoxEditor = ({
.map((item: any) => item.key)
dropDown.component.option('value', selectedValue)
- commitValue(selectedValue)
dropDown.component.close()
}}
/>
diff --git a/ui/src/views/shared/htmlEditorOptions.ts b/ui/src/views/shared/htmlEditorOptions.ts
index 95bac9da..81bee47a 100644
--- a/ui/src/views/shared/htmlEditorOptions.ts
+++ b/ui/src/views/shared/htmlEditorOptions.ts
@@ -23,7 +23,7 @@ const headerValues = [false, 1, 2, 3, 4, 5, 6]
type ToolbarItem = NonNullable['items']>[number]
-export const htmlToolbarItemNames = [
+const htmlToolbarItemNames = [
'undo',
'redo',
'separator',
diff --git a/ui/src/views/shared/lookupColor.tsx b/ui/src/views/shared/lookupColor.tsx
index c8645fa4..820e896e 100644
--- a/ui/src/views/shared/lookupColor.tsx
+++ b/ui/src/views/shared/lookupColor.tsx
@@ -18,7 +18,7 @@ export const findLookupColor = (lookup: any, value: any): string | undefined =>
}
/** Açık zeminde beyaz yazı okunmadığı için yazı rengi zeminin parlaklığına göre seçilir. */
-export const readableTextColor = (color: string): string => {
+const readableTextColor = (color: string): string => {
const hex = color.replace('#', '')
const full =
hex.length === 3