Barkod ve Image Capture özelliği

This commit is contained in:
Sedat ÖZTÜRK 2026-09-21 18:01:40 +03:00
parent 722decd040
commit 6af68e6ef5
32 changed files with 1120 additions and 103 deletions

View file

@ -774,7 +774,7 @@ sessizce yok sayılır:
| --- | --- | --- | | --- | --- | --- |
| `GridBoxOptionsDto` | `dxGridBox` | `Columns[]` (ilk kolon `Key` ile aynı adda olmalı), `SelectionMode` (`"single"`), `FilterRowVisible` (`false`), `AcceptCustomValue` (`false`), `ShowClearButton` (`true`), `Height` (`250`), `Width` (`500`) | | `GridBoxOptionsDto` | `dxGridBox` | `Columns[]` (ilk kolon `Key` ile aynı adda olmalı), `SelectionMode` (`"single"`), `FilterRowVisible` (`false`), `AcceptCustomValue` (`false`), `ShowClearButton` (`true`), `Height` (`250`), `Width` (`500`) |
| `TagBoxOptionsDto` | `dxTagBox` | `ShowSelectionControls` (`true`), `MaxDisplayedTags` (`3`), `ShowMultiTagOnly` (`false`), `ApplyValueMode` (`"useButtons"`), `SearchEnabled` (`true`), `AcceptCustomValue` (`false`), `ShowClearButton` (`true`) — değer tek kolonda `\|` ile saklanır, bkz. §4.3 | | `TagBoxOptionsDto` | `dxTagBox` | `ShowSelectionControls` (`true`), `MaxDisplayedTags` (`3`), `ShowMultiTagOnly` (`false`), `ApplyValueMode` (`"useButtons"`), `SearchEnabled` (`true`), `AcceptCustomValue` (`false`), `ShowClearButton` (`true`) — değer tek kolonda `\|` ile saklanır, bkz. §4.3 |
| `ImageUploadOptionsDto` | `dxImageUpload` · `dxImageViewer` | `UploadUrl`, `Accept`, `Multiple`, `MaxFileSize` (bayt), `Width`, `Height` — hepsi nullable, varsayılanı yok | | `ImageUploadOptionsDto` | `dxImageUpload` · `dxImageViewer` | `UploadUrl`, `Accept`, `Multiple`, `MaxFileSize` (bayt), `Width`, `Height`, `Capture` (`environment`/`user` — mobilde dosya seçici yerine **yalnızca** kamerayı açar; boş bırakılırsa dokunmatik cihazda dosya seçicinin yanına ayrı bir kamera butonu gelir) — hepsi nullable, varsayılanı yok |
Çözüm **yalnızca alanın `EditorType2` değeri eşleşiyorsa** yapılır: `dxTextBox` bir alana Çözüm **yalnızca alanın `EditorType2` değeri eşleşiyorsa** yapılır: `dxTextBox` bir alana
`columns` yazmak `GridBoxOptions`'ı doldurmaz. Ayrıca `ListFormField.EditorOptions`'ın ham `columns` yazmak `GridBoxOptions`'ı doldurmaz. Ayrıca `ListFormField.EditorOptions`'ın ham
@ -1017,6 +1017,7 @@ alır):
| `Knob` | `value`/`defaultValue` (`0`), `min`/`max` (`0/100`), `step` (`1`), `size` (`100`), `strokeWidth` (`14`), `valueColor`/`rangeColor`/`textColor`, `valueTemplate` (`'{value}'`), `showValue` (`true`), `readOnly`, `disabled`, `name` | | `Knob` | `value`/`defaultValue` (`0`), `min`/`max` (`0/100`), `step` (`1`), `size` (`100`), `strokeWidth` (`14`), `valueColor`/`rangeColor`/`textColor`, `valueTemplate` (`'{value}'`), `showValue` (`true`), `readOnly`, `disabled`, `name` |
| `Rate` | `count` (`5`), `value`/`defaultValue`, `allowHalf` (`false`), `allowClear` (`true`), `color` (`amber`), `size` (`20`), `gap` (`4`), `tooltips[]`, `disabled`, `readOnly` | | `Rate` | `count` (`5`), `value`/`defaultValue`, `allowHalf` (`false`), `allowClear` (`true`), `color` (`amber`), `size` (`20`), `gap` (`4`), `tooltips[]`, `disabled`, `readOnly` |
| `Upload` | `accept`, `multiple`, `draggable` (`false`), `uploadLimit`, `showList` (`true`), `tip`, `fileList[]`, `disabled` — dosyanın **gerçek** yüklenmesi `beforeUpload`/`onChange` script'inde `api.post` ile yapılır | | `Upload` | `accept`, `multiple`, `draggable` (`false`), `uploadLimit`, `showList` (`true`), `tip`, `fileList[]`, `disabled` — dosyanın **gerçek** yüklenmesi `beforeUpload`/`onChange` script'inde `api.post` ile yapılır |
| `BarcodeScanField` | `label`, `name` (`'barcode'`), `placeholder`, `buttonText`, `continuous` (`false` — açıkken diyalog okumadan sonra kapanmaz), `readOnly` (`true`), `className`; **olay**: `onScan(event)` — `event` okunan kodun metin hâli (Event workspace'te Input'un `onChange`'i gibi düzenlenir). Kamerayı `BarcodeDetector` (Android/Chromium) ya da lazy `zxing-wasm` (iOS/Firefox) ile okur — 1D + QR. **HTTPS şart**; iOS'ta yüklü PWA içinde yalnız 16.4+ çalışır, bileşen aksini kullanıcıya söyler |
**Gösterim/geri bildirim bileşenleri:** **Gösterim/geri bildirim bileşenleri:**

View file

@ -806,7 +806,7 @@ anahtarları listeler; ortak anahtarlar (§4.3.3) her editörde geçerlidir.
| `dxSlider` | Tek değerli kaydırıcı | Sayı | `min`, `max`, `tooltip.showMode` | | `dxSlider` | Tek değerli kaydırıcı | Sayı | `min`, `max`, `tooltip.showMode` |
| `dxRangeSlider` | Aralık kaydırıcı | Sayı | `min`, `max`, `showRange` | | `dxRangeSlider` | Aralık kaydırıcı | Sayı | `min`, `max`, `showRange` |
| `dxHtmlEditor` | Zengin metin | String (16) | `toolbar.items`, `toolbar.multiline`, `valueType` (`html`/`markdown`), `mediaResizing.enabled`, `imageUpload.uploadUrl`, `imageUpload.fileUploadMode` | | `dxHtmlEditor` | Zengin metin | String (16) | `toolbar.items`, `toolbar.multiline`, `valueType` (`html`/`markdown`), `mediaResizing.enabled`, `imageUpload.uploadUrl`, `imageUpload.fileUploadMode` |
| `dxImageUpload` | Görsel yükleme (platform) | String (16) | `uploadUrl`\*, `accept`\*, `multiple`\*, `maxFileSize`\*, `width`\*, `height`\* | | `dxImageUpload` | Görsel yükleme (platform) | String (16) | `uploadUrl`\*, `accept`\*, `capture`\*, `multiple`\*, `maxFileSize`\*, `width`\*, `height`\* |
| `dxImageViewer` | Görsel gösterimi (platform) | String (16) | `width`, `height` | | `dxImageViewer` | Görsel gösterimi (platform) | String (16) | `width`, `height` |
`\*` ile işaretli anahtarlar backend'de **tipli DTO'ya** çözülür: `\*` ile işaretli anahtarlar backend'de **tipli DTO'ya** çözülür:
@ -1537,6 +1537,12 @@ Kurallar:
her diyalog `open` + `onDialogClose` prop'larını alır. her diyalog `open` + `onDialogClose` prop'larını alır.
Kendi ekranını açmak istiyorsan **Adres** modunu kullan (custom component route'u ya da Kendi ekranını açmak istiyorsan **Adres** modunu kullan (custom component route'u ya da
`/admin/list/{ListFormCode}`). `/admin/list/{ListFormCode}`).
- `DialogName: "BarcodeScanDialog"` **toolbar butonuna özeldir** (`ButtonPosition: 1`) ve sürekli
barkod taramasını açar: okunan her kod listeye yeni bir satır olarak eklenir.
`DialogParameters`'ta `field` zorunludur (barkodun yazılacağı kolon); kalan anahtarlar her
satıra sabit değer olarak yazılır (`{"field":"Barcode","WarehouseId":"1"}`). Satır ekleme
ekranın kendi `InsertServiceAddress` ucundan geçer — yetki ve kiracı izolasyonu normal satır
eklemeyle aynıdır. Kamera HTTPS ister.
- Onay akışının kendi butonları (Onayla/Reddet) `WorkflowJson` üzerinden **otomatik** gelir; - Onay akışının kendi butonları (Onayla/Reddet) `WorkflowJson` üzerinden **otomatik** gelir;
onları `CommandColumnJson` ile tekrar tanımlama. onları `CommandColumnJson` ile tekrar tanımlama.
@ -1719,6 +1725,7 @@ bağlıdır.
| interaction | `ask` | message | `if (!ask('Emin misiniz?')) return` — vazgeçilirse alan eski değerine döner ve script durur | | interaction | `ask` | message | `if (!ask('Emin misiniz?')) return` — vazgeçilirse alan eski değerine döner ve script durur |
| interaction | `openUrl` | url, target? | `openUrl('/report?id={Id}')` · `_self` verilirse ikinci argüman eklenir | | interaction | `openUrl` | url, target? | `openUrl('/report?id={Id}')` · `_self` verilirse ikinci argüman eklenir |
| integration | `apiToField` | target, url, path? | `set('T', await api('/api/x/{value}', 'data.name'))` | | integration | `apiToField` | target, url, path? | `set('T', await api('/api/x/{value}', 'data.name'))` |
| integration | `location` | lat, lng, accuracy? | `await geoTo('Enlem', 'Boylam')` — cihaz konumunu alanlara yazar; HTTPS ve kullanıcı izni ister |
| integration | `custom` | code | Satırın kendisi. Son çare; sondaki `;` atılır | | integration | `custom` | code | Satırın kendisi. Son çare; sondaki `;` atılır |
`sum` ve `clear` çoklu alan alır (`fields` listesi); diğerlerinde her parametre tek alandır. `sum` ve `clear` çoklu alan alır (`fields` listesi); diğerlerinde her parametre tek alandır.
@ -1791,6 +1798,7 @@ Script `AsyncFunction` ile derlenir, aşağıdaki adlar parametre olarak enjekte
| `revert()` | Alanı değişiklik öncesi değerine döndürür (editöre de yazar) | | `revert()` | Alanı değişiklik öncesi değerine döndürür (editöre de yazar) |
| `openUrl('/adres', '_blank')` | Yeni sekme/pencere | | `openUrl('/adres', '_blank')` | Yeni sekme/pencere |
| `await api(url, 'data.name', options?)` | Ham `fetch`. **Kimlik doğrulama başlığı eklemez**; korumalı bir uç için Custom Endpoint tarafında erişim açık olmalıdır | | `await api(url, 'data.name', options?)` | Ham `fetch`. **Kimlik doğrulama başlığı eklemez**; korumalı bir uç için Custom Endpoint tarafında erişim açık olmalıdır |
| `await geoTo('Lat', 'Lng', 'Acc'?)` | Cihaz konumunu alanlara yazar. İzin yoksa/okunamazsa alanlara **dokunmaz** ve uyarı gösterir, `false` döner |
| `$` | Tüm yardımcıların tek nesne hâli | | `$` | Tüm yardımcıların tek nesne hâli |
Eski (uzun) script'ler bozulmasın diye geriye dönük uyum vardır: script içinde yardımcı adlarıyla Eski (uzun) script'ler bozulmasın diye geriye dönük uyum vardır: script içinde yardımcı adlarıyla
@ -1841,7 +1849,7 @@ if (isReady) {
| copy / setValue / clear | `Copy(sourcePath, target)` · `SetValue(target, text)` · `Clear("Il", "Ilce")` | | copy / setValue / clear | `Copy(sourcePath, target)` · `SetValue(target, text)` · `Clear("Il", "Ilce")` |
| readOnly | `ReadOnly(target, invert)` | | readOnly | `ReadOnly(target, invert)` |
| notify / ask / openUrl | `Notify(message)` · `Ask(message)` · `OpenUrl(url, target)` | | notify / ask / openUrl | `Notify(message)` · `Ask(message)` · `OpenUrl(url, target)` |
| apiToField / custom | `ApiToField(target, url, responsePath)` · `Custom(code)` | | apiToField / location / custom | `ApiToField(target, url, responsePath)` · `Location(latField, lngField, accuracyField)` · `Custom(code)` |
Akıcı ekler: `.When(...)` (hepsi sağlanmalı), `.WhenAny(...)` (biri yeterli), Akıcı ekler: `.When(...)` (hepsi sağlanmalı), `.WhenAny(...)` (biri yeterli),
`.OnChange()` / `.OnOpen()` / `.OnOpenAndChange()` / `.WithTrigger(EditorScriptTrigger.X)`, `.OnChange()` / `.OnOpen()` / `.OnOpenAndChange()` / `.WithTrigger(EditorScriptTrigger.X)`,
@ -1938,7 +1946,7 @@ Dizi elemanları yaprak sayılmaz; `toolbar.items` tek bir `json` alanıdır.
| Açılır liste | `searchEnabled`\*, `searchMode`, `searchExpr`, `searchTimeout`, `minSearchLength`, `showDataBeforeSearch`, `acceptCustomValue`\*, `noDataText`, `deferRendering`, `wrapItemText`, `dropDownOptions.width/height/hideOnOutsideClick` | `dxSelectBox`, `dxLookup`, `dxTagBox`, `dxDropDownBox`, `dxGridBox`, `dxAutocomplete` | | Açılır liste | `searchEnabled`\*, `searchMode`, `searchExpr`, `searchTimeout`, `minSearchLength`, `showDataBeforeSearch`, `acceptCustomValue`\*, `noDataText`, `deferRendering`, `wrapItemText`, `dropDownOptions.width/height/hideOnOutsideClick` | `dxSelectBox`, `dxLookup`, `dxTagBox`, `dxDropDownBox`, `dxGridBox`, `dxAutocomplete` |
| TagBox | `showSelectionControls`\*, `maxDisplayedTags`\*, `showMultiTagOnly`\*, `applyValueMode`\*, `multiline`, `hideSelectedItems` | `dxTagBox` | | TagBox | `showSelectionControls`\*, `maxDisplayedTags`\*, `showMultiTagOnly`\*, `applyValueMode`\*, `multiline`, `hideSelectedItems` | `dxTagBox` |
| GridBox | `columns`\*, `selectionMode`\*, `filterRowVisible`\* | `dxGridBox` | | GridBox | `columns`\*, `selectionMode`\*, `filterRowVisible`\* | `dxGridBox` |
| Görsel | `uploadUrl`\*, `fileFieldName`, `accept`\*, `multiple`\*, `maxFileSize`\* | `dxImageUpload`, `dxImageViewer` | | Görsel | `uploadUrl`\*, `fileFieldName`, `accept`\*, `capture`\*, `multiple`\*, `maxFileSize`\* | `dxImageUpload`, `dxImageViewer` |
| Seçim | `text` (`dxCheckBox`), `switchedOnText`/`switchedOffText` (`dxSwitch`), `layout` (`dxRadioGroup`), `editAlphaChannel`/`keyStep` (`dxColorBox`) | ilgili editör | | Seçim | `text` (`dxCheckBox`), `switchedOnText`/`switchedOffText` (`dxSwitch`), `layout` (`dxRadioGroup`), `editAlphaChannel`/`keyStep` (`dxColorBox`) | ilgili editör |
| Kaydırıcı | `min`, `max`, `step`, `tooltip.enabled`, `tooltip.showMode`, `showRange` | `dxSlider`, `dxRangeSlider` | | Kaydırıcı | `min`, `max`, `step`, `tooltip.enabled`, `tooltip.showMode`, `showRange` | `dxSlider`, `dxRangeSlider` |
| HTML | `valueType`, `toolbar.multiline`, `toolbar.items`, `mediaResizing.enabled`, `imageUpload.fileUploadMode`, `imageUpload.uploadUrl` | `dxHtmlEditor` | | HTML | `valueType`, `toolbar.multiline`, `toolbar.items`, `mediaResizing.enabled`, `imageUpload.fileUploadMode`, `imageUpload.uploadUrl` | `dxHtmlEditor` |
@ -1966,6 +1974,8 @@ Preset mevcut JSON ile **birleşir**, diğer ayarları silmez; aynı anahtar var
| Çoklu görsel | `width`, `height`, `multiple`, `accept:image/*` | | Çoklu görsel | `width`, `height`, `multiple`, `accept:image/*` |
| HTML tam araç çubuğu | `toolbar.items` (tam liste), `toolbar.multiline`, `mediaResizing`, `imageUpload` | | HTML tam araç çubuğu | `toolbar.items` (tam liste), `toolbar.multiline`, `mediaResizing`, `imageUpload` |
| Yükseklik 100 / 200 | `{"height":100}` · `{"height":200}` | | 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. |
| 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.5 Seeder tarafı (C#) ### 6.5 Seeder tarafı (C#)

View file

@ -443,7 +443,7 @@ buradaki karşılığı da güncellenmelidir.
| Tip | Kullanım | | Tip | Kullanım |
| --- | --- | | --- | --- |
| `EditorScript` | `Multiply`, `Subtract`, `Percent`, `Sum`, `Formula`, `Today`, `Days`, `Hours`, `Copy`, `SetValue`, `Clear`, `ReadOnly`, `Notify`, `Ask`, `OpenUrl`, `ApiToField`, `Custom` → `EditorScript.Build(rules)` | | `EditorScript` | `Multiply`, `Subtract`, `Percent`, `Sum`, `Formula`, `Today`, `Days`, `Hours`, `Copy`, `SetValue`, `Clear`, `ReadOnly`, `Notify`, `Ask`, `OpenUrl`, `ApiToField`, `Location`, `Custom` → `EditorScript.Build(rules)` |
| `EditorScriptRule` | `.When(...)` / `.WhenAny(...)` koşulları, `.OnChange()` / `.OnOpen()` / `.OnOpenAndChange()` tetikleyicisi; `string`'e örtük dönüşür | | `EditorScriptRule` | `.When(...)` / `.WhenAny(...)` koşulları, `.OnChange()` / `.OnOpen()` / `.OnOpenAndChange()` tetikleyicisi; `string`'e örtük dönüşür |
| `EditorScriptCondition` | `Is`, `IsNot`, `Contains`, `IsEmpty`, `IsNotEmpty`, `GreaterThan`, `LessThan`, `IsTrue`, `IsFalse`, `Always` | | `EditorScriptCondition` | `Is`, `IsNot`, `Contains`, `IsEmpty`, `IsNotEmpty`, `GreaterThan`, `LessThan`, `IsTrue`, `IsFalse`, `Always` |
| `EditorOptions` | Hazır başlangıçlar: `Disabled()`, `ReadOnly()`, `ShowClearButton()`, `Multiline(h)`, `Number(precision)`, `Percent()`, `Date()`, `DateTime()`, `Time(interval)`, `Phone()`, `Slider()`, `ImageUpload()`, `Html(h)` | | `EditorOptions` | Hazır başlangıçlar: `Disabled()`, `ReadOnly()`, `ShowClearButton()`, `Multiline(h)`, `Number(precision)`, `Percent()`, `Date()`, `DateTime()`, `Time(interval)`, `Phone()`, `Slider()`, `ImageUpload()`, `Html(h)` |
@ -1019,8 +1019,8 @@ dosyası yoktur; liste her `npm run build` / `npm start` ile tazelenir.
**Katalog düğümleri.** Kanvasın doğrudan çizdiği, derlenen bileşen listesinde yer almayan **Katalog düğümleri.** Kanvasın doğrudan çizdiği, derlenen bileşen listesinde yer almayan
bileşenler tek bir sözlükte durur (`RUNTIME_NODE_COMPONENTS`): kanvas onu `VisualCanvas` içinde, bileşenler tek bir sözlükte durur (`RUNTIME_NODE_COMPONENTS`): kanvas onu `VisualCanvas` içinde,
üretilen kod ise runtime kapsamı üzerinden kullanır — iki taraf aynı kaynaktan beslendiği için üretilen kod ise runtime kapsamı üzerinden kullanır — iki taraf aynı kaynaktan beslendiği için
tasarım ve çalışma zamanı ayrışamaz. Sözlükte `PlatformIcon` ve public site parçaları tasarım ve çalışma zamanı ayrışamaz. Sözlükte `PlatformIcon`, `BarcodeScanField`
(`components/publicSite`) bulunur. Yeni bir düğüm eklerken: bileşen `components/publicSite` (ya da (`components/shared/BarcodeScanner`) ve public site parçaları (`components/publicSite`) bulunur. Yeni bir düğüm eklerken: bileşen `components/publicSite` (ya da
uygun klasör) → `runtimeNodes.ts` sözlüğü → `catalog.ts` tanımı (`kind: 'custom'`) → `LanguagesData.json` uygun klasör) → `runtimeNodes.ts` sözlüğü → `catalog.ts` tanımı (`kind: 'custom'`) → `LanguagesData.json`
açıklama anahtarı. açıklama anahtarı.

View file

@ -8,4 +8,10 @@ public class ImageUploadOptionsDto
public long? MaxFileSize { get; set; } public long? MaxFileSize { get; set; }
public int? Width { get; set; } public int? Width { get; set; }
public int? Height { get; set; } public int? Height { get; set; }
/// <summary>
/// Mobil cihazda dosya secici yerine dogrudan kamerayi acar:
/// "environment" arka, "user" on kamera. Bos birakilirsa dosya secici acilir.
/// </summary>
public string Capture { get; set; }
} }

View file

@ -28895,6 +28895,198 @@
"key": "App.Listform.ListformField.OrderId", "key": "App.Listform.ListformField.OrderId",
"en": "Order", "en": "Order",
"tr": "Sipariş" "tr": "Sipariş"
},
{
"resourceName": "Platform",
"key": "App.BarcodeScanner.Title",
"en": "Scan barcode",
"tr": "Barkod okut"
},
{
"resourceName": "Platform",
"key": "App.BarcodeScanner.Starting",
"en": "Starting the camera...",
"tr": "Kamera açılıyor..."
},
{
"resourceName": "Platform",
"key": "App.BarcodeScanner.Aim",
"en": "Hold the barcode inside the frame.",
"tr": "Barkodu çerçevenin içinde tutun."
},
{
"resourceName": "Platform",
"key": "App.BarcodeScanner.PermissionDenied",
"en": "Camera permission was denied. Allow camera access for this site and try again.",
"tr": "Kamera izni verilmedi. Bu site için kamera erişimine izin verip tekrar deneyin."
},
{
"resourceName": "Platform",
"key": "App.BarcodeScanner.InsecureContext",
"en": "The camera is only available over HTTPS.",
"tr": "Kamera yalnızca HTTPS üzerinden kullanılabilir."
},
{
"resourceName": "Platform",
"key": "App.BarcodeScanner.StandaloneIos",
"en": "On this iOS version the camera does not work in the installed app. Open the page in Safari.",
"tr": "Bu iOS sürümünde kamera yüklü uygulama içinde çalışmıyor. Sayfayı Safari'de açın."
},
{
"resourceName": "Platform",
"key": "App.BarcodeScanner.CameraError",
"en": "The camera could not be started.",
"tr": "Kamera başlatılamadı."
},
{
"resourceName": "Platform",
"key": "App.BarcodeScanner.SaveFailed",
"en": "The scanned code could not be saved.",
"tr": "Okunan kod kaydedilemedi."
},
{
"resourceName": "Platform",
"key": "App.BarcodeScanner.ScannedCount",
"en": "Scanned",
"tr": "Okunan"
},
{
"resourceName": "Platform",
"key": "App.BarcodeScanner.NotConfigured",
"en": "The barcode button is not configured: a target field and an insert address are required.",
"tr": "Barkod butonu yapılandırılmamış: hedef alan ve ekleme adresi gerekli."
},
{
"resourceName": "Platform",
"key": "App.BarcodeScanner.Placeholder",
"en": "Scan a barcode",
"tr": "Barkod okutun"
},
{
"resourceName": "Platform",
"key": "App.BarcodeScanner.Scan",
"en": "Scan",
"tr": "Okut"
},
{
"resourceName": "Platform",
"key": "App.PresetBarcodeScan.BarcodeScanLabel",
"en": "Barcode scan button",
"tr": "Barkod okutma butonu"
},
{
"resourceName": "Platform",
"key": "App.PresetBarcodeScan.BarcodeScanDescription",
"en": "Adds a camera button next to the text box; the scanned code is written into the field.",
"tr": "Metin kutusunun yanına kamera butonu ekler; okunan kod alana yazılır."
},
{
"resourceName": "Platform",
"key": "App.CatalogBarcodeScanField.BarcodeScanFieldDescription",
"en": "Text field with a camera button that reads barcodes and QR codes.",
"tr": "Barkod ve QR okuyan kamera butonlu metin alanı."
},
{
"resourceName": "Platform",
"key": "App.Geolocation.Unsupported",
"en": "This device does not provide location information.",
"tr": "Bu cihaz konum bilgisi vermiyor."
},
{
"resourceName": "Platform",
"key": "App.Geolocation.Failed",
"en": "Location could not be read. Allow location access and try again.",
"tr": "Konum okunamadı. Konum erişimine izin verip tekrar deneyin."
},
{
"resourceName": "Platform",
"key": "App.ScriptBuilderLocation.LocationLabel",
"en": "Write device location",
"tr": "Cihaz konumunu yaz"
},
{
"resourceName": "Platform",
"key": "App.ScriptBuilderLocation.LocationSummary",
"en": "Reads the device location once and writes it into the latitude/longitude fields. Requires HTTPS and the user's permission.",
"tr": "Cihaz konumunu tek seferde okur ve enlem/boylam alanlarına yazar. HTTPS ve kullanıcı izni gerekir."
},
{
"resourceName": "Platform",
"key": "App.ScriptBuilderLocation.LatLabel",
"en": "Latitude field",
"tr": "Enlem alanı"
},
{
"resourceName": "Platform",
"key": "App.ScriptBuilderLocation.LngLabel",
"en": "Longitude field",
"tr": "Boylam alanı"
},
{
"resourceName": "Platform",
"key": "App.ScriptBuilderLocation.AccuracyLabel",
"en": "Accuracy field (m)",
"tr": "Doğruluk alanı (m)"
},
{
"resourceName": "Platform",
"key": "App.ScriptBuilderLocation.AccuracyHelp",
"en": "Optional. The accuracy radius reported by the device, in metres.",
"tr": "İsteğe bağlı. Cihazın bildirdiği doğruluk yarıçapı, metre."
},
{
"resourceName": "Platform",
"key": "App.ScriptBuilderDescribe.Location",
"en": "Writes the device location into {lat} and {lng}.",
"tr": "Cihaz konumunu {lat} ve {lng} alanlarına yazar."
},
{
"resourceName": "Platform",
"key": "App.SpecCapture.Help",
"en": "On mobile, opens the camera directly instead of the file picker. Leave empty for the file picker.",
"tr": "Mobilde dosya seçici yerine doğrudan kamerayı açar. Boş bırakılırsa dosya seçici açılır."
},
{
"resourceName": "Platform",
"key": "App.SpecCapture.Environment",
"en": "Rear camera",
"tr": "Arka kamera"
},
{
"resourceName": "Platform",
"key": "App.SpecCapture.User",
"en": "Front camera",
"tr": "Ön kamera"
},
{
"resourceName": "Platform",
"key": "App.PresetPhotoCapture.PhotoCaptureLabel",
"en": "Take photo with camera",
"tr": "Kamerayla foto çek"
},
{
"resourceName": "Platform",
"key": "App.PresetPhotoCapture.PhotoCaptureDescription",
"en": "accept: image/* + capture: environment — on mobile the rear camera opens directly.",
"tr": "accept: image/* + capture: environment — mobilde doğrudan arka kamera açılır."
},
{
"resourceName": "Platform",
"key": "App.CatalogBarcodeScanField.OnScanDescription",
"en": "Runs on every successful scan; event is the scanned code as text.",
"tr": "Her başarılı okumada çalışır; event okunan kodun metin halidir."
},
{
"resourceName": "Platform",
"key": "App.ImageUpload.Upload",
"en": "Upload image",
"tr": "Görsel yükle"
},
{
"resourceName": "Platform",
"key": "App.ImageUpload.TakePhoto",
"en": "Take photo with camera",
"tr": "Kamerayla fotoğraf çek"
} }
] ]
} }

View file

@ -136,6 +136,14 @@ public static class EditorScript
public static EditorScriptRule ApiToField(string target, string url, string responsePath = null) public static EditorScriptRule ApiToField(string target, string url, string responsePath = null)
=> new EditorScriptRule("apiToField").Set("target", target).Set("url", url).Set("path", responsePath); => new EditorScriptRule("apiToField").Set("target", target).Set("url", url).Set("path", responsePath);
/// <summary>
/// Cihazin konumunu alanlara yazar. accuracyField bos birakilirsa dogruluk
/// yarcapi yazilmaz. Kamera gibi konum da HTTPS ister.
/// </summary>
public static EditorScriptRule Location(string latField, string lngField, string accuracyField = null)
=> new EditorScriptRule("location")
.Set("lat", latField).Set("lng", lngField).Set("accuracy", accuracyField);
/// <summary>Serbest JS satiri. Runtime yardimcilari (get/set/num/...) burada da kullanilabilir.</summary> /// <summary>Serbest JS satiri. Runtime yardimcilari (get/set/num/...) burada da kullanilabilir.</summary>
public static EditorScriptRule Custom(string code) public static EditorScriptRule Custom(string code)
=> new EditorScriptRule("custom").Set("code", code); => new EditorScriptRule("custom").Set("code", code);

View file

@ -29,6 +29,7 @@ public sealed class EditorScriptRule
["ask"] = new[] { "message" }, ["ask"] = new[] { "message" },
["openUrl"] = new[] { "url" }, ["openUrl"] = new[] { "url" },
["apiToField"] = new[] { "target", "url" }, ["apiToField"] = new[] { "target", "url" },
["location"] = new[] { "lat", "lng" },
["custom"] = new[] { "code" } ["custom"] = new[] { "code" }
}; };
@ -59,6 +60,7 @@ public sealed class EditorScriptRule
["ask"] = EditorScriptTrigger.Change, ["ask"] = EditorScriptTrigger.Change,
["openUrl"] = EditorScriptTrigger.Change, ["openUrl"] = EditorScriptTrigger.Change,
["apiToField"] = EditorScriptTrigger.Change, ["apiToField"] = EditorScriptTrigger.Change,
["location"] = EditorScriptTrigger.Change,
["custom"] = EditorScriptTrigger.Change ["custom"] = EditorScriptTrigger.Change
}; };
@ -382,6 +384,10 @@ public sealed class EditorScriptRule
return "set(" + q(Param("target")) + ", await api(" + q(Param("url")) + return "set(" + q(Param("target")) + ", await api(" + q(Param("url")) +
(Param("path").Length > 0 ? ", " + q(Param("path")) : string.Empty) + "))"; (Param("path").Length > 0 ? ", " + q(Param("path")) : string.Empty) + "))";
case "location":
return "await geoTo(" + q(Param("lat")) + ", " + q(Param("lng")) +
(Param("accuracy").Length > 0 ? ", " + q(Param("accuracy")) : string.Empty) + ")";
case "custom": case "custom":
return Param("code").Trim().TrimEnd(';'); return Param("code").Trim().TrimEnd(';');

File diff suppressed because one or more lines are too long

49
ui/package-lock.json generated
View file

@ -36,7 +36,8 @@
"react-modal": "^3.16.3", "react-modal": "^3.16.3",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"react-select": "^5.10.2", "react-select": "^5.10.2",
"yup": "^1.7.1" "yup": "^1.7.1",
"zxing-wasm": "^3.1.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.5", "@eslint/js": "^9.39.5",
@ -3263,6 +3264,12 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/emscripten": {
"version": "1.41.6",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz",
"integrity": "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -11692,6 +11699,18 @@
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.19", "version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@ -12126,6 +12145,21 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/type-fest": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz",
"integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typed-array-buffer": { "node_modules/typed-array-buffer": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@ -13175,6 +13209,19 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
} }
},
"node_modules/zxing-wasm": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.4.tgz",
"integrity": "sha512-1+v8p7sauddySVNG+ye7GD/0TS95BG2ycdkrC+EnBrFBFkHeIghzUt2yVjtx3NucJeNrQnhT8yGAIz60Vx9o0w==",
"license": "MIT",
"dependencies": {
"@types/emscripten": "^1.41.6",
"type-fest": "^5.9.0"
},
"peerDependencies": {
"@types/emscripten": ">=1.39.6"
}
} }
} }
} }

View file

@ -54,7 +54,8 @@
"react-modal": "^3.16.3", "react-modal": "^3.16.3",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"react-select": "^5.10.2", "react-select": "^5.10.2",
"yup": "^1.7.1" "yup": "^1.7.1",
"zxing-wasm": "^3.1.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.5", "@eslint/js": "^9.39.5",

View file

@ -1,5 +1,6 @@
import type React from 'react' import type React from 'react'
import PlatformIcon from './PlatformIcon' import PlatformIcon from './PlatformIcon'
import { BarcodeScanField } from '@/components/shared'
import { import {
PublicDemoButton, PublicDemoButton,
PublicHeaderBar, PublicHeaderBar,
@ -17,6 +18,7 @@ import {
*/ */
export const RUNTIME_NODE_COMPONENTS = { export const RUNTIME_NODE_COMPONENTS = {
PlatformIcon, PlatformIcon,
BarcodeScanField,
PublicHeaderBar, PublicHeaderBar,
PublicLogo, PublicLogo,
PublicLanguageSelector, PublicLanguageSelector,

View file

@ -0,0 +1,83 @@
import { useState } from 'react'
import { Button, Input } from '@/components/ui'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { isCameraAvailable } from '@/utils/device'
import BarcodeScannerDialog from './BarcodeScannerDialog'
export type BarcodeScanFieldProps = {
label?: string
placeholder?: string
buttonText?: string
/** Okunan kod formda bu adla taşınır; üretilen kod da aynı adı kullanır. */
name?: string
/** Açıkken diyalog okumadan sonra kapanmaz, arka arkaya okur. */
continuous?: boolean
readOnly?: boolean
className?: string
value?: string
onScan?: (code: string) => void
}
/**
* Tasarımcıdan sürüklenip bırakılabilen barkod alanı. Kanvas ve üretilen kod
* aynı bileşeni çizer; okunan değer hem içeride tutulur hem `onScan` ile dışarı
* verilir, böylece hem tek başına hem bir formun parçası olarak kullanılabilir.
*/
const BarcodeScanField = ({
label,
placeholder,
buttonText,
name,
continuous = false,
readOnly = true,
className,
value,
onScan,
}: BarcodeScanFieldProps) => {
const { translate } = useLocalization()
const [open, setOpen] = useState(false)
const [scanned, setScanned] = useState('')
const current = value ?? scanned
const handleDetect = (code: string) => {
setScanned(code)
onScan?.(code)
}
return (
<div className={className}>
{label && <label className="mb-1 block text-sm font-semibold">{label}</label>}
<div className="flex gap-2">
<Input
name={name}
value={current}
placeholder={placeholder ?? translate('::App.BarcodeScanner.Placeholder')}
readOnly={readOnly}
onChange={(event) => {
setScanned(event.target.value)
onScan?.(event.target.value)
}}
/>
<Button
type="button"
variant="solid"
disabled={!isCameraAvailable()}
onClick={() => setOpen(true)}
>
{buttonText ?? translate('::App.BarcodeScanner.Scan')}
</Button>
</div>
{open && (
<BarcodeScannerDialog
open
continuous={continuous}
onDetect={handleDetect}
onDialogClose={() => setOpen(false)}
/>
)}
</div>
)
}
export default BarcodeScanField

View file

@ -0,0 +1,111 @@
import { useState } from 'react'
import { Button, Dialog } from '@/components/ui'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { useBarcodeReader, type BarcodeReaderStatus } from './useBarcodeReader'
export type BarcodeScannerDialogProps = {
open: boolean
onDialogClose: () => void
/**
* Okunan kod. `false` dönerse kod reddedilmiş sayılır ve listeye yazılmaz;
* sürekli modda yazma hatasını kullanıcıya göstermek için kullanılır.
*/
onDetect: (code: string) => boolean | void | Promise<boolean | void>
/** Açıkken diyalog okumadan sonra kapanmaz, okunanları listeler. */
continuous?: boolean
title?: string
}
const statusMessageKeys: Record<BarcodeReaderStatus, string> = {
starting: '::App.BarcodeScanner.Starting',
scanning: '::App.BarcodeScanner.Aim',
denied: '::App.BarcodeScanner.PermissionDenied',
insecure: '::App.BarcodeScanner.InsecureContext',
standaloneIos: '::App.BarcodeScanner.StandaloneIos',
error: '::App.BarcodeScanner.CameraError',
}
/**
* Kamerayla barkod/QR okuma diyaloğu. Tek okuma ve sürekli tarama aynı
* bileşendir; fark yalnızca okumadan sonra kapanıp kapanmadığıdır.
*/
const BarcodeScannerDialog = ({
open,
onDialogClose,
onDetect,
continuous = false,
title,
}: BarcodeScannerDialogProps) => {
const { translate } = useLocalization()
const [scanned, setScanned] = useState<string[]>([])
const [failed, setFailed] = useState<string>()
const handleDetect = async (code: string) => {
const accepted = await onDetect(code)
if (accepted === false) {
setFailed(code)
return
}
if (!continuous) {
onDialogClose()
return
}
setFailed(undefined)
setScanned((previous) => [code, ...previous])
}
const { videoRef, status } = useBarcodeReader({ active: open, onDetect: handleDetect })
return (
<Dialog isOpen={open} width={520} onClose={onDialogClose} onRequestClose={onDialogClose}>
<h5 className="mb-4">{title ?? translate('::App.BarcodeScanner.Title')}</h5>
<div className="relative overflow-hidden rounded-lg bg-black">
<video
ref={videoRef}
autoPlay
muted
playsInline
className="h-64 w-full object-cover sm:h-80"
/>
{status === 'scanning' && (
// Nişangah: kullanıcıya kodu nereye tutacağını gösterir.
<div className="pointer-events-none absolute inset-x-10 inset-y-16 rounded-lg border-2 border-white/80" />
)}
</div>
<p className="mt-3 text-sm opacity-80">{translate(statusMessageKeys[status])}</p>
{failed && (
<p className="mt-1 text-sm text-red-500">
{translate('::App.BarcodeScanner.SaveFailed')} ({failed})
</p>
)}
{continuous && scanned.length > 0 && (
<div className="mt-3">
<p className="mb-1 text-sm font-semibold">
{translate('::App.BarcodeScanner.ScannedCount')}: {scanned.length}
</p>
<ul className="max-h-32 overflow-auto rounded border text-sm">
{scanned.map((code, index) => (
<li key={`${code}-${index}`} className="border-b px-2 py-1 last:border-b-0">
{code}
</li>
))}
</ul>
</div>
)}
<div className="mt-5 text-right">
<Button variant="solid" onClick={onDialogClose}>
{translate(continuous ? '::App.Platform.Close' : '::App.Platform.Cancel')}
</Button>
</div>
</Dialog>
)
}
export default BarcodeScannerDialog

View file

@ -0,0 +1,33 @@
import { useEffect, useState } from 'react'
import BarcodeScannerDialog from './BarcodeScannerDialog'
import { registerBarcodeScanner } from './scanBarcode'
/**
* `scanBarcode()` çağrılarını karşılayan tekil diyalog. Uygulama kökünde bir
* kez mount edilir; React ağacı dışından açılabilmesinin tek yolu budur.
*/
const BarcodeScannerHost = () => {
const [resolve, setResolve] = useState<((code: string | null) => void) | null>(null)
useEffect(() => {
registerBarcodeScanner((next) => setResolve(() => next))
return () => registerBarcodeScanner(undefined)
}, [])
const finish = (code: string | null) => {
resolve?.(code)
setResolve(null)
}
if (!resolve) return null
return (
<BarcodeScannerDialog
open
onDetect={(code) => finish(code)}
onDialogClose={() => finish(null)}
/>
)
}
export default BarcodeScannerHost

View file

@ -0,0 +1,3 @@
export { default as BarcodeScanField } from './BarcodeScanField'
export { default as BarcodeScannerDialog } from './BarcodeScannerDialog'
export { default as BarcodeScannerHost } from './BarcodeScannerHost'

View file

@ -0,0 +1,22 @@
type ScanRequest = (resolve: (code: string | null) => void) => void
let requestScan: ScanRequest | undefined
/** Diyaloğu tutan host bileşen kendini burada kaydeder. */
export const registerBarcodeScanner = (request?: ScanRequest) => {
requestScan = request
}
/**
* React ağacı dışından (DevExtreme editör butonu, editorScript) barkod okutur.
* Vazgeçilirse `null` döner. Host mount edilmemişse sessizce `null` döner ki
* çağıran taraf hata yönetmek zorunda kalmasın.
*/
export const scanBarcode = () =>
new Promise<string | null>((resolve) => {
if (!requestScan) {
resolve(null)
return
}
requestScan(resolve)
})

View file

@ -0,0 +1,123 @@
import { useEffect, useRef, useState } from 'react'
import {
createBarcodeDecoder,
openCameraStream,
stopCameraStream,
vibrateOnScan,
} from '@/utils/barcode'
import { isCameraAvailable, isCameraBlockedByStandaloneIos } from '@/utils/device'
export type BarcodeReaderStatus =
| 'starting'
| 'scanning'
| 'denied'
| 'insecure'
| 'standaloneIos'
| 'error'
/** Kare çözme aralığı. Her karede çözmek mobilde pili hızla tüketir. */
const DECODE_INTERVAL_MS = 180
/** Aynı kodun arka arkaya birden çok kez okunmasını engelleyen pencere. */
const DUPLICATE_WINDOW_MS = 1500
/**
* Kamerayı açar, kareleri çözer ve okunan kodu bildirir.
*
* `onDetect` her okumada yeniden bağlanabildiği için ref üzerinden tutulur;
* aksi hâlde her render kamerayı kapatıp yeniden açardı.
*/
export const useBarcodeReader = ({
active,
onDetect,
}: {
active: boolean
onDetect: (code: string) => void
}) => {
const videoRef = useRef<HTMLVideoElement>(null)
const onDetectRef = useRef(onDetect)
const [status, setStatus] = useState<BarcodeReaderStatus>('starting')
onDetectRef.current = onDetect
useEffect(() => {
if (!active) return
if (isCameraBlockedByStandaloneIos()) {
setStatus('standaloneIos')
return
}
if (!isCameraAvailable()) {
setStatus('insecure')
return
}
const video = videoRef.current
let stream: MediaStream | undefined
let timer: number | undefined
let cancelled = false
let lastCode = ''
let lastCodeAt = 0
const start = async () => {
setStatus('starting')
try {
const [cameraStream, decode] = await Promise.all([
openCameraStream(),
createBarcodeDecoder(),
])
if (cancelled) {
stopCameraStream(cameraStream)
return
}
if (!video) return
stream = cameraStream
video.srcObject = cameraStream
await video.play()
setStatus('scanning')
const tick = async () => {
if (cancelled) return
try {
const code = await decode(video)
const now = Date.now()
if (code && (code !== lastCode || now - lastCodeAt > DUPLICATE_WINDOW_MS)) {
lastCode = code
lastCodeAt = now
vibrateOnScan()
onDetectRef.current(code)
}
} catch {
// Tek bir karenin çözülememesi tarama döngüsünü durdurmaz.
}
if (!cancelled) {
timer = window.setTimeout(tick, DECODE_INTERVAL_MS)
}
}
tick()
} catch (error) {
if (cancelled) return
const name = (error as DOMException)?.name
setStatus(name === 'NotAllowedError' || name === 'SecurityError' ? 'denied' : 'error')
}
}
start()
return () => {
cancelled = true
if (timer) window.clearTimeout(timer)
stopCameraStream(stream)
if (video) video.srcObject = null
}
}, [active])
return { videoRef, status }
}

View file

@ -2,6 +2,7 @@ export { default as ActionLink } from './ActionLink'
export { default as AdaptableCard } from './AdaptableCard' export { default as AdaptableCard } from './AdaptableCard'
export { default as Affix } from './Affix' export { default as Affix } from './Affix'
export { default as AuthorityCheck } from './AuthorityCheck' export { default as AuthorityCheck } from './AuthorityCheck'
export { BarcodeScanField, BarcodeScannerDialog, BarcodeScannerHost } from './BarcodeScanner'
export { default as PermissionCheck } from './PermissionCheck' export { default as PermissionCheck } from './PermissionCheck'
export { default as ConfirmDialog } from './ConfirmDialog' export { default as ConfirmDialog } from './ConfirmDialog'
export { default as Container } from './Container' export { default as Container } from './Container'

View file

@ -110,6 +110,7 @@ const UI_PROPERTY_ALIASES: Record<string, Record<string, string>> = {
} }
export const DESIGNER_PRIMARY_EVENTS: Record<string, string[]> = { export const DESIGNER_PRIMARY_EVENTS: Record<string, string[]> = {
AutoComplete: ['onInputChange', 'onSelect'], AutoComplete: ['onInputChange', 'onSelect'],
BarcodeScanField: ['onScan'],
Button: ['onClick'], Button: ['onClick'],
Checkbox: ['onChange'], Checkbox: ['onChange'],
Dropdown: ['onSelect'], Dropdown: ['onSelect'],
@ -129,6 +130,11 @@ const DESIGNER_EVENT_SNIPPETS: Record<string, Record<string, string>> = {
// refs.input1.setEnabled(false) // refs.input1.setEnabled(false)
// refs.card1.setVisible(false) // refs.card1.setVisible(false)
console.log('Button clicked', event)`, console.log('Button clicked', event)`,
},
BarcodeScanField: {
onScan: `// Okunan barkod/QR değeridir (metin).
const code = event
refs.input1.setValue(code)`,
}, },
Checkbox: { Checkbox: {
onChange: `// Checkbox bileşeninin yeni checked değeri. onChange: `// Checkbox bileşeninin yeni checked değeri.
@ -1217,6 +1223,35 @@ export const DESIGNER_EXTRAS: DesignerComponentDefinition[] = [
], ],
hooks: [], hooks: [],
}, },
{
// Kamerayla barkod/QR okuyan alan. Kanvas ve uretilen kod ayni bileseni
// cizer (`components/shared/BarcodeScanner`).
name: 'BarcodeScanField',
icon: 'ScanLine',
category: 'basic',
kind: 'custom',
toolboxGroup: 'ui',
description: 'App.CatalogBarcodeScanField.BarcodeScanFieldDescription',
properties: [
{ name: 'label', type: 'string', value: '', category: 'properties' },
{ name: 'name', type: 'string', value: 'barcode', category: 'properties' },
{ name: 'placeholder', type: 'string', value: '', category: 'properties' },
{ name: 'buttonText', type: 'string', value: '', category: 'properties' },
{ name: 'continuous', type: 'boolean', value: false, category: 'properties' },
{ name: 'readOnly', type: 'boolean', value: true, category: 'properties' },
{ name: 'className', type: 'string', value: '', category: 'styling' },
// `category: 'events'` alanı Özellikler panelinden çıkarıp JavaScript
// sekmesine taşır; orada değer değil script olarak düzenlenir.
{
name: 'onScan',
type: 'function',
value: '',
category: 'events',
description: 'App.CatalogBarcodeScanField.OnScanDescription',
},
],
hooks: [],
},
{ {
name: 'Spacer', name: 'Spacer',
icon: 'Space', icon: 'Space',

View file

@ -341,6 +341,8 @@ export interface ImageUploadOptionsDto {
maxFileSize?: number maxFileSize?: number
width?: number width?: number
height?: number height?: number
/** Mobilde dosya seçici yerine kamerayı açar. */
capture?: 'environment' | 'user'
} }
export interface RowDto { export interface RowDto {

119
ui/src/utils/barcode.ts Normal file
View file

@ -0,0 +1,119 @@
/**
* Barkod okuma altyapısı.
*
* Tarayıcıda iki çözücü yolu var: Chromium tabanlı tarayıcılar `BarcodeDetector`
* ile yerleşik (WASM indirmeden, donanım hızlandırmalı) okur; iOS Safari ve
* Firefox'ta bu API yok, o yüzden `zxing-wasm` yalnızca gerektiğinde lazy
* yüklenir. Böylece Android cihazlarda ek bir byte inmez.
*/
/** `BarcodeDetector` format adları (snake_case). 1D + QR. */
const DETECTOR_FORMATS = [
'qr_code',
'code_128',
'code_39',
'code_93',
'ean_13',
'ean_8',
'upc_a',
'upc_e',
'itf',
] as const
/** Aynı küme zxing adlandırmasıyla. İki liste birlikte değişir. */
const ZXING_FORMATS = [
'QRCode',
'Code128',
'Code39',
'Code93',
'EAN13',
'EAN8',
'UPCA',
'UPCE',
'ITF',
] as const
type BarcodeDetectorLike = {
detect: (source: CanvasImageSource) => Promise<{ rawValue: string }[]>
}
type DetectorWindow = Window & {
BarcodeDetector?: new (options?: { formats?: readonly string[] }) => BarcodeDetectorLike
}
export type BarcodeDecoder = (frame: HTMLVideoElement) => Promise<string | undefined>
type ZxingReader = (imageData: ImageData) => Promise<string | undefined>
let zxingReader: Promise<ZxingReader> | undefined
/**
* zxing-wasm'ı bir kez yükler. WASM dosyası paketten `?url` ile alınır; paketin
* varsayılanı jsDelivr CDN'idir ve çevrimdışı/kapalı ağ kurulumlarında çalışmaz.
*/
const getZxingReader = () => {
zxingReader ??= (async () => {
const [{ prepareZXingModule, readBarcodesFromImageData }, { default: wasmUrl }] =
await Promise.all([
import('zxing-wasm/reader'),
import('zxing-wasm/reader/zxing_reader.wasm?url'),
])
prepareZXingModule({ overrides: { locateFile: () => wasmUrl } })
return async (imageData: ImageData) => {
const results = await readBarcodesFromImageData(imageData, {
formats: [...ZXING_FORMATS],
tryHarder: false,
})
return results.find((result) => result.text)?.text
}
})()
return zxingReader
}
/**
* Ortama göre çözücü seçer. Çağıran taraf hangi yolun kullanıldığını bilmez;
* her iki yol da video karesini alıp okunan metni döner.
*/
export const createBarcodeDecoder = async (): Promise<BarcodeDecoder> => {
const DetectorCtor = (window as DetectorWindow).BarcodeDetector
if (DetectorCtor) {
const detector = new DetectorCtor({ formats: DETECTOR_FORMATS })
return async (frame) => {
const codes = await detector.detect(frame)
return codes.find((code) => code.rawValue)?.rawValue
}
}
const read = await getZxingReader()
// WASM yolunda kare canvas'a indirgenir; canvas tek kez oluşturulup yeniden kullanılır.
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d', { willReadFrequently: true })
return async (frame) => {
if (!context || !frame.videoWidth || !frame.videoHeight) return undefined
canvas.width = frame.videoWidth
canvas.height = frame.videoHeight
context.drawImage(frame, 0, 0)
return read(context.getImageData(0, 0, canvas.width, canvas.height))
}
}
/** Arka kamerayı tercih eden akış. İzin reddedilirse hata çağırana bırakılır. */
export const openCameraStream = () =>
navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
})
export const stopCameraStream = (stream?: MediaStream | null) =>
stream?.getTracks().forEach((track) => track.stop())
/** Okuma geri bildirimi. Desteklemeyen cihazda sessizce atlanır. */
export const vibrateOnScan = () => navigator.vibrate?.(60)

35
ui/src/utils/device.ts Normal file
View file

@ -0,0 +1,35 @@
/**
* Cihaz yetenekleri. Kamera/konum gibi özellikler tarayıcıya ve bağlama göre
* değiştiği için tek yerden sorulur; ekranlar `navigator` kontrolü yapmaz.
*/
/** Kamera güvenli bağlam ister; HTTP üzerinden getUserMedia hiç tanımlı değildir. */
export const isCameraAvailable = () =>
typeof window !== 'undefined' && window.isSecureContext && !!navigator.mediaDevices?.getUserMedia
/**
* iOS'ta ana ekrana eklenmiş PWA içinde getUserMedia yalnızca 16.4+ sürümlerde
* çalışır; altındaki sürümlerde kamera sessizce boş akış döner. Kullanıcıya
* "Safari'de aç" diyebilmek için ayrıca tespit ediyoruz.
*/
export const isCameraBlockedByStandaloneIos = () => {
if (typeof navigator === 'undefined') return false
if (!/iPad|iPhone|iPod/.test(navigator.userAgent)) return false
if ((navigator as Navigator & { standalone?: boolean }).standalone !== true) return false
const version = /OS (\d+)_(\d+)/.exec(navigator.userAgent)
if (!version) return false
const [major, minor] = [Number(version[1]), Number(version[2])]
return major < 16 || (major === 16 && minor < 4)
}
/**
* `<input capture>` yalnızca dokunmatik cihazlarda kamerayı açar; masaüstünde
* sessizce yok sayılır ve aynı dosya seçicisini ikinci kez açan bir buton
* bırakırdı. Bu yüzden kamera butonu yalnızca kaba işaretçili (parmak) ve
* arka kamerası olması beklenen cihazlarda gösterilir.
*/
export const supportsPhotoCapture = () =>
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(pointer: coarse)').matches

View file

@ -13,6 +13,9 @@
* yeniden derlenir. * yeniden derlenir.
*/ */
import { getLocalization } from '@/services/localization.service'
import { store } from '@/store'
export type EditorScriptRuntimeContext = { export type EditorScriptRuntimeContext = {
formData: Record<string, any> formData: Record<string, any>
e: any e: any
@ -56,6 +59,7 @@ const HELPER_PARAMS = [
'ask', 'ask',
'openUrl', 'openUrl',
'api', 'api',
'geoTo',
'revert', 'revert',
] ]
@ -63,6 +67,12 @@ const HELPER_PARAMS = [
* Ortak yardımcılar * Ortak yardımcılar
* ------------------------------------------------------------------ */ * ------------------------------------------------------------------ */
/** Script runtime React ağacının dışındadır; dil metni store'dan okunur. */
const translateRuntime = (key: string) => {
const { texts, config } = store.getState().abpConfig
return getLocalization(texts ?? {}, config?.localization?.defaultResourceName, key)
}
/** Alan adını büyük/küçük harf duyarsız arar; SQL kolonları tutarsız gelebiliyor. */ /** Alan adını büyük/küçük harf duyarsız arar; SQL kolonları tutarsız gelebiliyor. */
const resolveKey = (source: any, name: string): string | undefined => { const resolveKey = (source: any, name: string): string | undefined => {
if (!source || typeof source !== 'object') return undefined if (!source || typeof source !== 'object') return undefined
@ -243,6 +253,36 @@ const buildHelpers = ({
return responsePath ? pickPath(json, responsePath) : json return responsePath ? pickPath(json, responsePath) : json
} }
/**
* Cihazın konumunu alanlara yazar. Tek atıştır; izin verilmezse ya da konum
* alınamazsa alanlara dokunulmaz ve kullanıcıya mesaj gösterilir — sessizce
* boş değer yazmak, kullanıcının konumu kaydettiğini sanmasına yol açardı.
*/
const geoTo = async (latField: string, lngField?: string, accuracyField?: string) => {
if (!navigator.geolocation) {
notify(translateRuntime('::App.Geolocation.Unsupported'))
return false
}
try {
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
})
})
set(latField, position.coords.latitude)
if (lngField) set(lngField, position.coords.longitude)
if (accuracyField) set(accuracyField, Math.round(position.coords.accuracy))
return true
} catch {
notify(translateRuntime('::App.Geolocation.Failed'))
return false
}
}
const helpers = { const helpers = {
value: e?.value, value: e?.value,
field, field,
@ -270,6 +310,7 @@ const buildHelpers = ({
ask, ask,
openUrl, openUrl,
api, api,
geoTo,
revert, revert,
} }
@ -304,6 +345,7 @@ const buildHelpers = ({
helpers.ask, helpers.ask,
helpers.openUrl, helpers.openUrl,
helpers.api, helpers.api,
helpers.geoTo,
helpers.revert, helpers.revert,
], ],
} }

View file

@ -1,4 +1,5 @@
import type { LayoutType } from '@/proxy/theme/models' import type { LayoutType } from '@/proxy/theme/models'
import { BarcodeScannerHost } from '@/components/shared'
import Loading from '@/components/shared/Loading' import Loading from '@/components/shared/Loading'
import { Alert, Button } from '@/components/ui' import { Alert, Button } from '@/components/ui'
import { store, useStoreState } from '@/store' import { store, useStoreState } from '@/store'
@ -89,6 +90,7 @@ const Views = (props: ViewsProps) => {
<small className="italic">{e.cid}</small> <small className="italic">{e.cid}</small>
</UiDialog> </UiDialog>
))} ))}
<BarcodeScannerHost />
<DialogProvider> <DialogProvider>
<DialogShowComponent /> <DialogShowComponent />
<DynamicRouter /> <DynamicRouter />

View file

@ -782,6 +782,19 @@ export const optionSpecs: OptionSpec[] = [
platform: true, platform: true,
placeholder: 'image/*', placeholder: 'image/*',
}, },
{
path: 'capture',
label: 'capture',
type: 'select',
group: 'image',
editors: IMAGE_EDITORS,
platform: true,
help: 'App.SpecCapture.Help',
choices: [
{ value: 'environment', label: 'App.SpecCapture.Environment' },
{ value: 'user', label: 'App.SpecCapture.User' },
],
},
{ {
path: 'multiple', path: 'multiple',
label: 'multiple', label: 'multiple',

View file

@ -264,6 +264,29 @@ export const editorOptionsPresets: EditorOptionsPreset[] = [
editors: ['dxHtmlEditor'], editors: ['dxHtmlEditor'],
value: buildHtmlEditorOptions(), value: buildHtmlEditorOptions(),
}, },
{
key: 'photoCapture',
label: 'App.PresetPhotoCapture.PhotoCaptureLabel',
description: 'App.PresetPhotoCapture.PhotoCaptureDescription',
editors: ['dxImageUpload'],
value: { accept: 'image/*', capture: 'environment' },
},
{
key: 'barcodeScan',
label: 'App.PresetBarcodeScan.BarcodeScanLabel',
description: 'App.PresetBarcodeScan.BarcodeScanDescription',
editors: ['dxTextBox'],
value: {
buttons: [
{
name: 'scanBarcode',
location: 'after',
// '@SCAN_BARCODE' token'ini editingForm.ts kamera diyaloguna baglar.
options: { icon: 'photo', stylingMode: 'text', onClick: '@SCAN_BARCODE' },
},
],
},
},
{ {
key: 'height100', key: 'height100',
label: 'App.PresetHeight100.Height100Label', label: 'App.PresetHeight100.Height100Label',

View file

@ -118,6 +118,7 @@ export type RecipeId =
| 'ask' | 'ask'
| 'openUrl' | 'openUrl'
| 'apiToField' | 'apiToField'
| 'location'
| 'custom' | 'custom'
export const recipeGroups: { key: RecipeGroupKey; title: string; description: string }[] = [ export const recipeGroups: { key: RecipeGroupKey; title: string; description: string }[] = [
@ -667,6 +668,33 @@ export const recipes: Recipe[] = [
target: label(params.target, t('::App.Listform.ListformField.Target')), target: label(params.target, t('::App.Listform.ListformField.Target')),
}), }),
}, },
{
id: 'location',
label: 'App.ScriptBuilderLocation.LocationLabel',
group: 'integration',
summary: 'App.ScriptBuilderLocation.LocationSummary',
example: "await geoTo('Enlem', 'Boylam')",
params: [
{ key: 'lat', label: 'App.ScriptBuilderLocation.LatLabel', type: 'field' },
{ key: 'lng', label: 'App.ScriptBuilderLocation.LngLabel', type: 'field' },
{
key: 'accuracy',
label: 'App.ScriptBuilderLocation.AccuracyLabel',
type: 'field',
optional: true,
help: 'App.ScriptBuilderLocation.AccuracyHelp',
},
],
build: ({ params }) =>
`await geoTo(${q(params.lat)}, ${q(params.lng)}${
params.accuracy ? `, ${q(params.accuracy)}` : ''
})`,
describe: ({ params }, t) =>
t('::App.ScriptBuilderDescribe.Location', {
lat: label(params.lat, t('::App.ScriptBuilderLocation.LatLabel')),
lng: label(params.lng, t('::App.ScriptBuilderLocation.LngLabel')),
}),
},
{ {
id: 'custom', id: 'custom',
label: 'App.ScriptBuilderCustom.CustomLabel', label: 'App.ScriptBuilderCustom.CustomLabel',

View file

@ -5732,6 +5732,13 @@ const VisualComponentDesigner = () => {
<FaBolt className="shrink-0 text-[10px]" /> <FaBolt className="shrink-0 text-[10px]" />
<span className="min-w-0 flex-1 truncate text-xs font-semibold"> <span className="min-w-0 flex-1 truncate text-xs font-semibold">
{target.node.type} {target.node.type}
{/* Aynı tipten birden çok bileşen listede ayırt edilemiyordu;
ref adı script içinde kullanılan addır. */}
{target.node.ref && (
<span className="ml-1 font-normal text-sky-600 dark:text-sky-400">
({target.node.ref})
</span>
)}
</span> </span>
<span className="rounded bg-slate-200 px-1.5 py-0.5 text-[9px] dark:bg-gray-800"> <span className="rounded bg-slate-200 px-1.5 py-0.5 text-[9px] dark:bg-gray-800">
{target.eventNames.length} {target.eventNames.length}

View file

@ -1,6 +1,6 @@
import { ImageUploadOptionsDto } from '@/proxy/form/models' import { ImageUploadOptionsDto } from '@/proxy/form/models'
import { ReactElement, useEffect, useMemo, useRef, useState } from 'react' import { ReactElement, useEffect, useMemo, useRef, useState } from 'react'
import { FaSpinner, FaUpload } from 'react-icons/fa' import { FaCamera, FaSpinner, FaUpload } from 'react-icons/fa'
import { Button } from '@/components/ui' import { Button } from '@/components/ui'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { import {
@ -10,6 +10,7 @@ import {
} from './imageHoverPreview' } from './imageHoverPreview'
import { normalizeImageSize } from '../../list/shared/imageValue' import { normalizeImageSize } from '../../list/shared/imageValue'
import Input from '@/components/ui/Input' import Input from '@/components/ui/Input'
import { supportsPhotoCapture } from '@/utils/device'
const ImageUploadEditorComponent = ({ const ImageUploadEditorComponent = ({
value, value,
@ -34,6 +35,9 @@ const ImageUploadEditorComponent = ({
const thumbW = normalizeImageSize(resolvedOptions.width, 40) const thumbW = normalizeImageSize(resolvedOptions.width, 40)
const thumbH = normalizeImageSize(resolvedOptions.height, 40) const thumbH = normalizeImageSize(resolvedOptions.height, 40)
// Alanda `capture` acikca ayarliysa kullanicinin istedigi tek davranis odur;
// aksi halde dokunmatik cihazda dosya seciciye ek olarak kamera sunulur.
const showCameraButton = !resolvedOptions.capture && supportsPhotoCapture()
const valueUrls = useMemo<string[]>( const valueUrls = useMemo<string[]>(
() => (Array.isArray(value) ? value : [value]).filter(Boolean) as string[], () => (Array.isArray(value) ? value : [value]).filter(Boolean) as string[],
@ -43,6 +47,7 @@ const ImageUploadEditorComponent = ({
const [urls, setUrls] = useState<string[]>(valueUrls) const [urls, setUrls] = useState<string[]>(valueUrls)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const cameraInputRef = useRef<HTMLInputElement>(null)
// Değer dışarıdan (editör script'i, veri yenileme…) değiştiğinde önizlemeler // Değer dışarıdan (editör script'i, veri yenileme…) değiştiğinde önizlemeler
// güncellenmiyordu; state yalnızca ilk render'da kuruluyordu. // güncellenmiyordu; state yalnızca ilk render'da kuruluyordu.
@ -98,6 +103,8 @@ const ImageUploadEditorComponent = ({
} finally { } finally {
setUploading(false) setUploading(false)
if (inputRef.current) inputRef.current.value = '' if (inputRef.current) inputRef.current.value = ''
// Ayni fotografi ikinci kez cekmek degisiklik olayini tetiklemezdi.
if (cameraInputRef.current) cameraInputRef.current.value = ''
} }
} }
@ -159,13 +166,27 @@ const ImageUploadEditorComponent = ({
ref={inputRef} ref={inputRef}
type="file" type="file"
accept={resolvedOptions.accept ?? 'image/*'} accept={resolvedOptions.accept ?? 'image/*'}
capture={resolvedOptions.capture}
multiple={isMultiple} multiple={isMultiple}
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={(e) => handleFiles(e.target.files)} onChange={(e) => handleFiles(e.target.files)}
/> />
{/* Kamera ayri bir input'tur: `capture` dosya seciciyi kapattigi icin
tek input ile hem galeri hem kamera sunulamaz. */}
{showCameraButton && (
<Input
ref={cameraInputRef}
unstyle
type="file"
accept={resolvedOptions.accept ?? 'image/*'}
capture="environment"
style={{ display: 'none' }}
onChange={(e) => handleFiles(e.target.files)}
/>
)}
<Button <Button
type="button" type="button"
title="Görsel Yükle" title={translate('::App.ImageUpload.Upload')}
disabled={uploading} disabled={uploading}
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
variant="solid" variant="solid"
@ -190,6 +211,19 @@ const ImageUploadEditorComponent = ({
<FaUpload style={{ fontSize: 14 }} /> <FaUpload style={{ fontSize: 14 }} />
)} )}
</Button> </Button>
{showCameraButton && (
<Button
type="button"
title={translate('::App.ImageUpload.TakePhoto')}
disabled={uploading}
variant="solid"
shape="none"
className="!h-auto !rounded !bg-[#0078d4] !px-2.5 !py-1 !text-[13px] !text-white"
onClick={() => cameraInputRef.current?.click()}
>
<FaCamera style={{ fontSize: 14 }} />
</Button>
)}
</> </>
)} )}
</div> </div>

View file

@ -1,6 +1,6 @@
import type { ReactElement } from 'react' import type { ReactElement } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { FaSpinner, FaUpload } from 'react-icons/fa' import { FaCamera, FaSpinner, FaUpload } from 'react-icons/fa'
import { Button } from '@/components/ui' import { Button } from '@/components/ui'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { import {
@ -10,9 +10,12 @@ import {
} from '../../form/editors/imageHoverPreview' } from '../../form/editors/imageHoverPreview'
import { normalizeImageSize, parseJsonObject } from '../shared/imageValue' import { normalizeImageSize, parseJsonObject } from '../shared/imageValue'
import Input from '@/components/ui/Input' import Input from '@/components/ui/Input'
import { supportsPhotoCapture } from '@/utils/device'
type ImageUploadOptions = { type ImageUploadOptions = {
accept?: string accept?: string
/** Mobilde dosya seçici yerine kamerayı açar. */
capture?: 'environment' | 'user'
fileFieldName?: string fileFieldName?: string
height?: number | string height?: number | string
multiple?: boolean | string multiple?: boolean | string
@ -79,6 +82,7 @@ const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
const col = cellElement.column const col = cellElement.column
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const cameraInputRef = useRef<HTMLInputElement>(null)
const [urls, setUrls] = useState<string[]>(() => normalizeUploadValue(cellElement.value)) const [urls, setUrls] = useState<string[]>(() => normalizeUploadValue(cellElement.value))
useEffect(() => { useEffect(() => {
@ -96,6 +100,9 @@ const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
const thumbW = normalizeImageSize(options.width, 40) const thumbW = normalizeImageSize(options.width, 40)
const thumbH = normalizeImageSize(options.height, 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 removeImage = (index: number) => {
const newUrls = urls.filter((_, i) => i !== index) const newUrls = urls.filter((_, i) => i !== index)
@ -141,6 +148,8 @@ const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
} finally { } finally {
setUploading(false) setUploading(false)
if (inputRef.current) inputRef.current.value = '' if (inputRef.current) inputRef.current.value = ''
// Ayni fotografi ikinci kez cekmek degisiklik olayini tetiklemezdi.
if (cameraInputRef.current) cameraInputRef.current.value = ''
} }
} }
@ -198,13 +207,27 @@ const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
ref={inputRef} ref={inputRef}
type="file" type="file"
accept={options.accept ?? 'image/*'} accept={options.accept ?? 'image/*'}
capture={options.capture}
multiple={isMultiple} multiple={isMultiple}
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={(e) => handleFiles(e.target.files)} onChange={(e) => 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 && (
<Input
ref={cameraInputRef}
unstyle
type="file"
accept={options.accept ?? 'image/*'}
capture="environment"
style={{ display: 'none' }}
onChange={(e) => handleFiles(e.target.files)}
/>
)}
<Button <Button
type="button" type="button"
title="Görsel Yükle" title={translate('::App.ImageUpload.Upload')}
disabled={uploading} disabled={uploading}
variant="solid" variant="solid"
shape="none" shape="none"
@ -229,6 +252,19 @@ const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
<FaUpload style={{ fontSize: 14 }} /> <FaUpload style={{ fontSize: 14 }} />
)} )}
</Button> </Button>
{showCameraButton && (
<Button
type="button"
title={translate('::App.ImageUpload.TakePhoto')}
disabled={uploading}
variant="solid"
shape="none"
className="!h-auto !rounded !bg-[#0078d4] !px-2.5 !py-1 !text-[13px] !text-white"
onClick={() => cameraInputRef.current?.click()}
>
<FaCamera style={{ fontSize: 14 }} />
</Button>
)}
</div> </div>
) )
} }

View file

@ -13,6 +13,7 @@ import {
GridDto, GridDto,
PlatformEditorTypes, PlatformEditorTypes,
} from '@/proxy/form/models' } from '@/proxy/form/models'
import { scanBarcode } from '@/components/shared/BarcodeScanner/scanBarcode'
import { getNextSequenceValue } from '@/services/form.service' import { getNextSequenceValue } from '@/services/form.service'
import type { EditorType2, RowMode, SimpleItemWithColData } from '../../form/types' import type { EditorType2, RowMode, SimpleItemWithColData } from '../../form/types'
import { import {
@ -26,6 +27,21 @@ import {
export type EditorOptionsWithButtons = { buttons?: any[] } & Record<string, any> export type EditorOptionsWithButtons = { buttons?: any[] } & Record<string, any>
/**
* `editorOptions.buttons` içinde bu değeri taşıyan `onClick`, kamerayla barkod
* okuyup değeri alana yazar. Token kullanılmasının nedeni, buton tanımının
* veritabanında JSON olarak durması ve oradan fonksiyon taşınamamasıdır.
*/
const SCAN_BARCODE_TOKEN = '@SCAN_BARCODE'
/** Token'ın arkasındaki gerçek davranış. Okuma iptal edilirse alana dokunulmaz. */
const scanBarcodeIntoEditor = async (e: any) => {
const code = await scanBarcode()
if (code !== null) {
e?.component?.option('value', code)
}
}
/* ------------------------------------------------------------------ * /* ------------------------------------------------------------------ *
* Satır anahtarı yardımcıları * Satır anahtarı yardımcıları
* ------------------------------------------------------------------ */ * ------------------------------------------------------------------ */
@ -476,7 +492,9 @@ export const createFormItemMapper = ({
if (editorOptions.buttons) { if (editorOptions.buttons) {
editorOptions.buttons = editorOptions.buttons.map((btn: any) => { editorOptions.buttons = editorOptions.buttons.map((btn: any) => {
if (typeof btn?.options?.onClick === 'string') { 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). // Yapılandırmadan gelen fonksiyon tanımı (platform tasarımı gereği).
btn.options.onClick = eval(`(${btn.options.onClick})`) btn.options.onClick = eval(`(${btn.options.onClick})`)
} }

View file

@ -15,6 +15,10 @@ import { workflowService } from '@/services/workflow.service'
import type { WorkflowRunResultDto } from '@/services/workflow.service' import type { WorkflowRunResultDto } from '@/services/workflow.service'
import { splitWorkflowApprovers, translateWorkflowLabel } from '@/utils/workflow/workflowHelpers' import { splitWorkflowApprovers, translateWorkflowLabel } from '@/utils/workflow/workflowHelpers'
import { safeJsonParse } from './Utils' import { safeJsonParse } from './Utils'
import { BarcodeScannerDialog } from '@/components/shared'
/** Toolbar butonunun `dialogName` degeri; surekli barkod taramasini acar. */
const BARCODE_SCAN_DIALOG = 'BarcodeScanDialog'
type ToolbarModalData = { type ToolbarModalData = {
open: boolean open: boolean
@ -87,6 +91,56 @@ const useToolbar = ({
const [toolbarModalData, setToolbarModalData] = useState<ToolbarModalData>() const [toolbarModalData, setToolbarModalData] = useState<ToolbarModalData>()
const grdOpt = gridDto?.gridOptions const grdOpt = gridDto?.gridOptions
/**
* Surekli barkod taramasi. Okunan her kod listeye yeni bir satir olarak
* yazilir; ekranin kendi insert ucundan gectigi icin kiracin izolasyonu ve
* yetki kontrolu normal satir eklemeyle aynidir.
*/
const openBarcodeScan = (action: { dialogParameters?: string }) => {
const parameters = safeJsonParse<Record<string, string>>(action.dialogParameters, {})
const targetField = parameters.field
const insertUrl = grdOpt?.insertServiceAddress
if (!targetField || !insertUrl) {
toast.push(
<Notification type="warning" duration={4000}>
{translate('::App.BarcodeScanner.NotConfigured')}
</Notification>,
{ placement: 'bottom-end' },
)
return
}
// Barkod disindaki sabit alanlar (durum, depo vb.) dialogParameters'tan gelir.
const { field: _field, ...defaults } = parameters
setToolbarModalData({
open: true,
content: (
<BarcodeScannerDialog
open
continuous
onDetect={async (code) => {
try {
await dynamicFetch(insertUrl, 'POST', undefined, {
data: { ...defaults, [targetField]: code },
listFormCode,
})
return true
} catch {
// Diyalog acik kalir; kullanici kodu tekrar okutabilir.
return false
}
}}
onDialogClose={() => {
setToolbarModalData(undefined)
refreshData()
}}
/>
),
})
}
function getToolbarData() { function getToolbarData() {
const items: ToolbarItem[] = [] const items: ToolbarItem[] = []
@ -673,7 +727,9 @@ const useToolbar = ({
if (typeof e.event?.preventDefault === 'function') { if (typeof e.event?.preventDefault === 'function') {
e?.event?.preventDefault() e?.event?.preventDefault()
} }
if (action.url) { if (action.dialogName === BARCODE_SCAN_DIALOG) {
openBarcodeScan(action)
} else if (action.url) {
let url = action.url let url = action.url
// griddeki secili satirlari al // griddeki secili satirlari al
const selectedRowsData = getSelectedRowsData() const selectedRowsData = getSelectedRowsData()