diff --git a/README.md b/README.md index 0a254995..a21adcbb 100644 --- a/README.md +++ b/README.md @@ -1,1801 +1,850 @@ -# Uygulama Kullanıcı Kılavuzu +# Sozsoft Platform -## Dokümantasyon Kapsam Planı +**Çalışma zamanında yapılandırılabilen, çok kiracılı (multi-tenant) low-code uygulama motoru.** -Bu kılavuz, proje kodunda doğrulanabilen backend servisleri, frontend ekranları, dinamik route/menü yapısı, seed edilen liste formları, Swagger yapılandırması ve ortak kullanıcı arayüzü bileşenleri esas alınarak hazırlanmıştır. +Sozsoft Platform, klasik bir "kod yaz–derle–yayınla" uygulaması değildir. Ekranlar, menüler, +route'lar, yetkiler, veri kaynakları, iş akışları ve hatta API endpoint'leri **veritabanındaki +konfigürasyon kayıtlarından** üretilir. Yeni bir modül veya ekran eklemek çoğu durumda tek satır +React/C# kodu yazmadan, yalnızca konfigürasyon ile mümkündür. -Kapsama alınan ana alanlar: +> **Temel ilke:** Configuration first, code last. +> Bu ilkenin operasyonel karşılığı ve yapay zeka ajanları için bağlayıcı kurallar +> [`.github/instructions/ai.instructions.md`](.github/instructions/ai.instructions.md) dosyasındadır. -| Kapsam | Koddan Doğrulanan Kaynak | -| --- | --- | -| Giriş, kayıt, şifre sıfırlama, hesap doğrulama | `ui/src/views/auth`, `account.service.ts`, `auth.service.ts` | -| Dinamik menü ve route yapısı | `DynamicRouter`, `PlatformApplicationConfigurationContributor`, `MenusData.json` | -| Liste/form altyapısı | `Grid.tsx`, `FormDevExpress.tsx`, `ListForm*AppService`, `ListFormSeeder_*` | -| Yönetim ekranları | Kimlik, tenant, organizasyon, ayarlar, menüler, raporlar, dosyalar, audit log, intranet, forum, video oda | -| Platform modülleri | Languages, Settings, Notifications, MailQueue, Sender, SqlQueryManager | -| API ve Swagger | `PlatformHttpApiHostModule`, ABP conventional controllers, OAuth Swagger UI | -| Ortak komponentler | `components/ui`, `views/list`, `views/form`, `components/importManager`, `views/shared` | - -Kapsam dışında tutulan veya doğrulama notu düşülen alanlar: - -| Alan | Gerekçe | -| --- | --- | -| `supplychain`, `maintenance`, `warehouse`, `projects`, `crm`, `mrp`, `accounting` route sabitleri | `route.constant.ts` içinde sabit olarak görülüyor; ancak bu kod taramasında bu alanlara ait fiziksel view/servis veya seed edilmiş menü kaydı doğrulanamadı. | -| Canlı Swagger endpoint listesi | Uygulama çalıştırılmadan statik kod üzerinden incelendi; endpointler modül bazlı açıklanmıştır. | -| Kuruma özel rol isimleri | Kodda permission adları net; ancak müşteri ortamındaki rol-permission eşleştirmeleri veritabanında değişebilir. | +--- ## İçindekiler -1. [Genel Bakış](#1-genel-bakış) -2. [Giriş ve Yetkilendirme](#2-giriş-ve-yetkilendirme) -3. [Ana Menü ve Navigasyon](#3-ana-menü-ve-navigasyon) -4. [Modüller ve Ekranlar](#4-modüller-ve-ekranlar) -5. [Ortak Komponentler](#5-ortak-komponentler) -6. [API ve Swagger Açıklaması](#6-api-ve-swagger-açıklaması) -7. [Kullanıcı Rolleri ve Yetkiler](#7-kullanıcı-rolleri-ve-yetkiler) -8. [Formlar ve Zorunlu Alanlar](#8-formlar-ve-zorunlu-alanlar) -9. [Listeleme, Filtreleme ve Raporlama Özellikleri](#9-listeleme-filtreleme-ve-raporlama-özellikleri) -10. [Hata Mesajları ve Kullanıcı Bildirimleri](#10-hata-mesajları-ve-kullanıcı-bildirimleri) -11. [Sık Kullanılan Senaryolar](#11-sık-kullanılan-senaryolar) -12. [Sık Sorulan Sorular](#12-sık-sorulan-sorular) -13. [Ekler](#13-ekler) -14. [Eksik / Netleştirilmesi Gereken Konular](#eksik--netleştirilmesi-gereken-konular) +1. [Platform Ne Yapar?](#1-platform-ne-yapar) +2. [Mimari Genel Bakış](#2-mimari-genel-bakış) +3. [Teknoloji Yığını](#3-teknoloji-yığını) +4. [Depo Yapısı](#4-depo-yapısı) +5. [Hızlı Başlangıç](#5-hızlı-başlangıç) +6. [Yapılandırma (Configuration)](#6-yapılandırma-configuration) +7. [Low-Code Motoru: ListForm Ekosistemi](#7-low-code-motoru-listform-ekosistemi) +8. [Developer Kit](#8-developer-kit) +9. [Dinamik Menü, Route ve Yetki Modeli](#9-dinamik-menü-route-ve-yetki-modeli) +10. [Modül Kataloğu](#10-modül-kataloğu) +11. [Kimlik, Oturum ve Güvenlik](#11-kimlik-oturum-ve-güvenlik) +12. [Çok Kiracılılık (Multi-Tenancy)](#12-çok-kiracılılık-multi-tenancy) +13. [Bildirim ve Entegrasyonlar](#13-bildirim-ve-entegrasyonlar) +14. [Arka Plan İşleri](#14-arka-plan-işleri) +15. [Raporlama](#15-raporlama) +16. [Dosya Yönetimi ve CDN](#16-dosya-yönetimi-ve-cdn) +17. [Gerçek Zamanlı Özellikler](#17-gerçek-zamanlı-özellikler) +18. [Frontend Mimarisi](#18-frontend-mimarisi) +19. [API ve Swagger](#19-api-ve-swagger) +20. [Dağıtım (Deployment)](#20-dağıtım-deployment) +21. [Geliştirme Standartları ve Sık Kullanılan Komutlar](#21-geliştirme-standartları-ve-sık-kullanılan-komutlar) +22. [Sorun Giderme](#22-sorun-giderme) +23. [Bilinen Sınırlar ve Notlar](#23-bilinen-sınırlar-ve-notlar) -## 1. Genel Bakış +--- -Uygulama, ABP Framework tabanlı çok kiracılı bir platform uygulamasıdır. Backend tarafında C# ve ABP; frontend tarafında React, Tailwind CSS, DevExtreme bileşenleri ve özel UI komponentleri kullanılmaktadır. +## 1. Platform Ne Yapar? -Uygulamanın ana amacı, kurumların yönetim, tanım, içerik, liste/form, raporlama, bildirim, dosya, forum, intranet ve video toplantı süreçlerini tek bir yönetim paneli üzerinden yürütmesini sağlamaktır. - -Kodda doğrulanan başlıca kullanım alanları: - -| Alan | Açıklama | +| Yetenek | Açıklama | | --- | --- | -| Kimlik ve erişim yönetimi | Kullanıcı, rol, permission, permission group, organizasyon birimi ve oturum yönetimi. | -| Dinamik liste/form yönetimi | Kod veya veritabanı tanımıyla çalışan liste, form, grafik, pivot, ağaç, gantt, scheduler, widget ve workflow destekli ekranlar. | -| SaaS ve tenant yönetimi | Tenant kayıtları, tenant bağlantı ayarları, tenant bazlı ayrı veritabanı kullanımı, şube ve genel tanım tabloları. | -| Yönetim ve ayarlar | Sistem ayarları, menü, route, dinamik component, custom endpoint, datasource ve background worker yönetimi. | -| İçerik ve public site | Ana sayfa, hakkımızda, hizmetler, ürünler, ödeme, blog, demo ve iletişim içerikleri. | -| Intranet | Duyuru, anket, sosyal gönderi ve etkinlik kayıtları. | -| Forum | Kategori, konu, gönderi, yorum, beğeni, sabitleme, kilitleme ve çözüm işaretleme akışları. | -| Dosya yönetimi | Dosya/klasör listeleme, yükleme, indirme, kopyalama, taşıma, yeniden adlandırma ve silme. | -| Bildirimler | Bildirim türleri, kuralları, kullanıcı bildirimleri ve kanal bazlı bildirim ayarları. | -| Video oda | Oda oluşturma, listeleme, başlatma, bitirme, katılımcı, sohbet, ekran paylaşımı ve canlı bağlantı işlemleri. | -| Raporlama | DevExpress rapor görüntüleme/tasarım altyapısı ve rapor kategori/template listeleri. | +| **Dinamik liste/form ekranları** | `ListForm` + `ListFormField` kayıtlarıyla tanımlanan; grid, pivot, ağaç, grafik, gantt, scheduler, kart ve todo görünümlerini destekleyen ekranlar. | +| **Dinamik menü ve route** | Menüler ve route'lar veritabanından okunur; frontend bunları çalışma zamanında React Router'a bağlar. | +| **Yetki tabanlı görünürlük** | Menü, ekran, sütun ve buton seviyesinde ABP permission kontrolü. | +| **SQL Query Manager** | Tarayıcı içinden SQL nesne gezgini, sorgu editörü, sonuç grid'i ve tablo tasarımcısı. | +| **Custom / CRUD Endpoint** | Kod yazmadan, SQL veya tablo tanımından REST endpoint üretimi. | +| **Dynamic Service** | Roslyn ile çalışma zamanında derlenen ve controller olarak kaydedilen C# ApplicationService'ler. | +| **Custom Component** | Babel ile tarayıcıda derlenen, veritabanında saklanan React bileşenleri ve bunların route'ları. | +| **İş akışı / onay** | ListForm üzerinde görsel workflow tasarımcısı, koşullu dallanma ve çok kişili onay adımları. | +| **Çok kiracılılık** | Tenant başına ayrı bağlantı dizesi (ayrı veritabanı) desteği, şube (branch) ve organizasyon birimi kırılımı. | +| **Bildirim ve entegrasyon** | SMS, e-posta, WhatsApp, Rocket.Chat, masaüstü ve UI kanallarına kural tabanlı bildirim. | +| **Raporlama** | DevExpress Report Designer/Viewer + ListForm'dan otomatik üretilen dinamik raporlar. | +| **Arka plan işleri** | Hangfire + ABP Background Worker; zamanlanmış SQL, mail kuyruğu, yedekleme, oturum temizliği. | +| **Gerçek zamanlı** | SignalR tabanlı messenger ve video oda (WebRTC/TURN) altyapısı. | +| **Intranet & Forum** | Duyuru, anket, sosyal duvar, etkinlik; kategori/konu/gönderi tabanlı forum. | +| **Public web sitesi** | Ana sayfa, hakkımızda, hizmetler, ürün, ödeme, blog, demo ve iletişim içerikleri (tasarımcı ekranlarıyla düzenlenebilir). | +| **PWA** | Service worker ile offline kabuk, kontrollü sürüm güncellemesi ve changelog bildirimi. | -Uygulama farklı kullanıcı profilleri tarafından kullanılabilir: +--- -| Kullanıcı Tipi | Tipik Kullanım | +## 2. Mimari Genel Bakış + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ UI — React 18 + Vite + TypeScript + DevExtreme + Tailwind (PWA) │ +│ ├─ DynamicRouter (route kayıtlarından React Router üretimi) │ +│ ├─ ListForm runtime (Grid / Pivot / Tree / Chart / Gantt / ...) │ +│ ├─ Form runtime (DevExtreme Form, metadata ile alan üretimi)│ +│ ├─ Developer Kit UI (SQL, endpoint, component, dynamic service) │ +│ └─ easy-peasy store + React Query + SignalR │ +└───────────────┬──────────────────────────────────────────────────────┘ + │ HTTPS / OAuth2 (OpenIddict) / SignalR +┌───────────────▼──────────────────────────────────────────────────────┐ +│ API — ASP.NET Core (.NET 10) + ABP Framework 10 │ +│ ├─ HttpApi.Host : Swagger, OpenIddict, Hangfire, SignalR Hub, │ +│ │ DevExpress Reporting, Setup/Migration akışı │ +│ ├─ Application : ListForms, DeveloperKit, Identity, Tenants, │ +│ │ Menu, Routes, DataSource, FileManagement, ... │ +│ ├─ Domain : Entity'ler, dinamik veri erişimi, seed'ler │ +│ └─ EF Core : Migration'lar, dinamik DbContext, tenant DB │ +│ │ +│ Modüller: Languages · Settings · Notifications · MailQueue · │ +│ Sender · SqlQueryManager │ +└───────────────┬──────────────────────────────────────────────────────┘ + │ +┌───────────────▼──────────────────────────────────────────────────────┐ +│ Veri ve Altyapı │ +│ SQL Server / PostgreSQL · Redis (cache + SignalR backplane) · │ +│ Hangfire (job storage) · Dosya sistemi tabanlı BLOB/CDN · │ +│ coturn (TURN) · nginx · Elasticsearch (opsiyonel) │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +**Akışın özeti:** Kullanıcı giriş yapar → ABP `application-configuration` yanıtına +`PlatformApplicationConfigurationContributor` menü, route ve yetkileri ekler → `DynamicRouter` +route kayıtlarını React bileşenlerine bağlar → `/admin/list/{ListFormCode}` gibi bir ekran +açıldığında `ListFormsAppService` metadata'yı, `ListFormDataAppService` ise veriyi döner → +DevExtreme bileşenleri bu metadata'ya göre kendini kurar. + +--- + +## 3. Teknoloji Yığını + +### Backend + +| Bileşen | Sürüm / Notlar | | --- | --- | -| Son kullanıcı | Yetkili olduğu liste/form ekranlarında kayıt görüntüler, ekler, günceller, filtreler ve raporlar. | -| Yönetici | Kullanıcı, rol, tenant, menü, ayar, dosya, bildirim ve sistem tanımlarını yönetir. | -| İçerik yöneticisi | Public site içerikleri, blog, ürün, demo ve iletişim kayıtlarını yönetir. | -| Teknik kullanıcı | Swagger, SQL Query Manager, Developer Kit, data source, dynamic service ve custom endpoint ekranlarını kullanır. | +| .NET | `net10.0` (SDK 10.0.300, `global.json` ile sabit) | +| ABP Framework | 10.0.0 (Identity, TenantManagement, PermissionManagement, AuditLogging, OpenIddict, BlobStoring, BackgroundWorkers) | +| Veritabanı | SQL Server 2022 veya PostgreSQL 17 (`PlatformConsts.DefaultDatabaseProvider`) | +| ORM | Entity Framework Core | +| Kimlik | OpenIddict (password + refresh token, `/connect/token`) | +| Cache | Redis (opsiyonel; `Redis:IsEnabled`) | +| Job | Hangfire (`default`, `platform` kuyrukları) + ABP Background Workers | +| Raporlama | DevExpress Reporting (Web Document Viewer + Report Designer) | +| Log | Serilog | +| Dinamik derleme | Roslyn (`DynamicServiceCompiler`) | +| Şablon | Scriban (mail şablonları), MailKit/MimeKit | +| Gerçek zamanlı | SignalR (Redis backplane destekli) | -Genel kullanım mantığı menü odaklıdır. Kullanıcının sahip olduğu permission değerlerine göre menüler ve işlem butonları görünür. Dinamik liste ekranlarında ekleme, düzenleme, silme, dışa aktarma, içe aktarma, filtreleme ve not ekleme gibi davranışlar her liste formunun kendi ayarlarına göre değişebilir. +### Frontend -Platform aynı zamanda low-code geliştirme mantığını destekler. Yetkili teknik kullanıcılar veritabanı tablolarını, datasource kayıtlarını, dinamik listeleri, route kayıtlarını, menüleri, componentleri, custom endpointleri, CRUD endpointleri ve C# dynamic service kayıtlarını yöneterek yeni ekranlar ve iş akışları oluşturabilir. Bu yapı, uygulamanın müşteri veya tenant ihtiyaçlarına göre kod dağıtımı gerektirmeden genişletilmesine yardımcı olur. - -## 2. Giriş ve Yetkilendirme - -### Giriş Ekranı - -Giriş ekranı `/login` adresinde yer alır. Kodda doğrulanan alanlar: - -| Alan | Açıklama | +| Bileşen | Sürüm / Notlar | | --- | --- | -| Kullanıcı adı | Zorunludur. | -| Şifre | Zorunludur. | -| Beni hatırla | Opsiyonel seçimdir. | -| İki aşamalı doğrulama kodu | `twoFactor` aktif olduğunda zorunlu hale gelir. | +| React | 18.3 | +| Build | Vite 8 + TypeScript 5.9 | +| UI kütüphaneleri | DevExtreme 25.2 (React), Tailwind CSS 3.4, kendi `components/ui` tasarım sistemi | +| Routing | react-router-dom 6 (dinamik route üretimi) | +| State | easy-peasy (global store) + @tanstack/react-query | +| Form | Formik + Yup, DevExtreme Form | +| Kod editörü | Monaco Editor | +| Runtime derleme | @babel/standalone (Custom Component) | +| Takvim/Gantt | FullCalendar, DevExtreme Scheduler/Gantt | +| Gerçek zamanlı | @microsoft/signalr | +| PWA | vite-plugin-pwa (Workbox) | +| Node | 24.13 / npm 11 (`volta` ile sabitlenmiş) | -Giriş işleminde `auth.service.ts` üzerinden token alınır. Başarılı giriş sonrasında kullanıcı yetkili admin alanına yönlendirilir. +--- -### Kayıt ve Hesap İşlemleri +## 4. Depo Yapısı -Kodda aşağıdaki kullanıcı hesap ekranları doğrulanmıştır: +``` +sozsoft-platform/ +├── api/ # .NET çözümü (Sozsoft.Platform.sln) +│ ├── src/ +│ │ ├── Sozsoft.Platform.Domain.Shared/ # Sabitler, enum'lar, permission kodları (AppCodes) +│ │ ├── Sozsoft.Platform.Domain/ # Entity'ler, dinamik veri erişimi, repository'ler +│ │ ├── Sozsoft.Platform.Application.Contracts/ # DTO'lar, servis arayüzleri, permission tanımları +│ │ ├── Sozsoft.Platform.Application/ # Uygulama servisleri (iş mantığı) +│ │ ├── Sozsoft.Platform.EntityFrameworkCore/ # DbContext, migration'lar, dinamik veri katmanı +│ │ ├── Sozsoft.Platform.HttpApi/ # Controller'lar (conventional) +│ │ ├── Sozsoft.Platform.HttpApi.Client/ # HTTP istemci proxy'leri +│ │ ├── Sozsoft.Platform.HttpApi.Host/ # Host: Swagger, auth, Hangfire, SignalR, raporlama +│ │ └── Sozsoft.Platform.DbMigrator/ # Migration + seed uygulaması +│ ├── modules/ # Bağımsız ABP modülleri +│ │ ├── Sozsoft.Languages/ # Dil, dil anahtarı, çeviri metinleri +│ │ ├── Sozsoft.Settings/ # Ayar tanımları ve ayar UI'ı +│ │ ├── Sozsoft.Notifications/ # Bildirim tipleri, kuralları, kullanıcı bildirimleri +│ │ ├── Sozsoft.MailQueue/ # Şablonlu mail kuyruğu ve ekleri +│ │ ├── Sozsoft.Sender/ # Mail (SES), SMS, WhatsApp, Rocket.Chat göndericileri +│ │ └── Sozsoft.SqlQueryManager/ # SQL nesne yönetimi ve sorgu çalıştırma +│ └── test/ # Test projeleri +├── ui/ # React uygulaması +│ └── src/ +│ ├── routes/ # dynamicRouter, dynamicRouteLoader, route.constant +│ ├── views/ # list, form, admin, developerKit, public, intranet, forum, report, auth, setup +│ ├── components/ # ui (tasarım sistemi), template (layout), importManager, visualDesigner, componentEditor +│ ├── services/ # API istemcileri (her modül için ayrı servis dosyası) +│ ├── store/ # easy-peasy modelleri (auth, theme, locale, abpConfig, admin) +│ ├── contexts/ # ComponentContext (runtime bileşenler), Dialog, Scroll +│ └── utils/ # editorScriptRuntime, workflow, hooks, hoc, biçimlendirme yardımcıları +├── configs/ +│ ├── docker/ # Yerel geliştirme için veri katmanı compose dosyaları ve scriptler +│ ├── deployment/ # Dev/Prod compose dosyaları, nginx/redis/turn konfigürasyonu, deploy scriptleri +│ └── ai/ # AI entegrasyonu (n8n akış tanımları) +├── .github/instructions/ # ai.instructions.md (bağlayıcı platform kuralları), list.instructions.md +├── CLAUDE.md # Claude Code için operasyon kuralları +└── README.md +``` -| Ekran | Route | Temel İşlev | +--- + +## 5. Hızlı Başlangıç + +### 5.1. Önkoşullar + +- .NET SDK **10.0.300+** +- Node.js **24.x** ve npm **11.x** (Volta önerilir) +- Docker Desktop (veritabanı ve Redis için) +- DevExpress lisansı (`api/DevExpress_License.txt`) ve DevExtreme npm erişimi + +### 5.2. Veri katmanını ayağa kaldırma + +```powershell +# SQL Server + Redis +.\configs\docker\start_sql.bat + +# veya PostgreSQL + Redis +.\configs\docker\start_postgres.bat +``` + +Compose dosyaları `configs/docker/docker-compose-data.yml` içindedir; `sql` ve `postgres` +profilleri ile hangi veritabanının kalkacağı seçilir. Redis her iki profilde de çalışır. + +### 5.3. Backend + +```powershell +cd api +dotnet restore +dotnet run --project src/Sozsoft.Platform.HttpApi.Host +``` + +Uygulama `https://localhost:44344` adresinde açılır. Swagger UI kök adreste yayınlanır. + +**Veritabanı ilk kurulum:** Veritabanı yoksa host, tam ABP yığını yerine minimal bir +"setup" uygulaması ile açılır (`DbStartup/SetupAppRunner`). Bu durumda: + +- `GET /api/setup/application-status` — veritabanının hazır olup olmadığını döner (anonim). +- UI tarafında `/setup` route'u (`views/setup/DatabaseSetup.tsx`) migration'ı tetikleyen ekranı gösterir. +- Migration için `Setup:MigrationPassword` yapılandırması veya `App.Setup.Migrate` yetkisi gerekir. + +Alternatif olarak migration + seed'i doğrudan çalıştırabilirsiniz: + +```powershell +dotnet run --project api/src/Sozsoft.Platform.DbMigrator +``` + +DbMigrator; şema migration'larını uygular ve ardından menü, route, permission, dil, +host ayarları, ülke/şehir/ilçe verileri ile seed edilmiş ListForm ekranlarını yükler +(`api/src/Sozsoft.Platform.DbMigrator/Seeds/`). + +### 5.4. Frontend + +```powershell +cd ui +npm install +npm start # component metadata üretir + vite dev server (http://localhost:3000) +``` + +`npm start`, `vite` öncesinde `scripts/generate-component-metadata.mjs` çalıştırır; bu script +Visual Designer ve Custom Component altyapısının kullandığı bileşen kataloğunu günceller. + +### 5.5. İlk giriş + +Seed edilen host yöneticisi ile `/login` üzerinden giriş yapılır (kullanıcı bilgileri +`PlatformIdentityDataSeeder` ve `HostData.json` ile belirlenir; ortam bazlı değerler +`HostData.Dev.json` / `HostData.Production.json` dosyalarındadır). Giriş sonrası +`/admin/dashboard` açılır. + +--- + +## 6. Yapılandırma (Configuration) + +### 6.1. Backend (`appsettings.json`) + +| Anahtar | Açıklama | +| --- | --- | +| `App:SelfUrl` | API'nin kendi adresi (OpenIddict authority ile uyumlu olmalı). | +| `App:ClientUrl` | React uygulamasının kök adresi (mail linkleri bu adresi kullanır). | +| `App:CorsOrigins` | Virgülle ayrılmış izinli origin listesi; wildcard subdomain destekli. | +| `App:RedirectAllowedUrls` | OAuth yönlendirme için izinli adresler. | +| `App:CdnUrl` / `App:CdnPath` | Dosya servisinin adresi ve BLOB kök dizini. | +| `App:AttachmentsPath` | Mail kuyruğu eklerinin yazıldığı dizin. | +| `App:BackupPath` | Veritabanı yedek dizini (BackupWorker kullanır). | +| `App:Version` | Uygulama sürümü (UI sürüm kontrolü ile eşleşir). | +| `ConnectionStrings:SqlServer` / `PostgreSql` | Host veritabanı bağlantıları. | +| `Redis:IsEnabled` / `Redis:Configuration` | Dağıtık cache ve SignalR backplane. | +| `AuthServer:Authority` / `SwaggerClientId` | OpenIddict authority ve Swagger istemcisi. | +| `OpenIddict:TokenLifetimes:*` | Access/refresh token ömrü (dakika). | +| `Setup:MigrationPassword` | Setup ekranından migration tetiklemek için parola. | +| `StringEncryption:DefaultPassPhrase` | ABP string şifreleme anahtarı. | +| `Serilog:MinimumLevel` | Log seviyeleri. | + +> **Güvenlik:** Depodaki `appsettings.json` dosyaları geliştirme değerleri içerir. +> Üretimde bağlantı dizeleri, `MigrationPassword`, `DefaultPassPhrase` ve entegrasyon +> anahtarları **ortam değişkeni veya secret yöneticisi** ile verilmelidir; repoya yazılmamalıdır. + +### 6.2. Frontend (`ui/.env`, `.env.dev`, `.env.production`) + +| Değişken | Açıklama | +| --- | --- | +| `VITE_API_URL` | API kök adresi. | +| `VITE_CDN_URL` | Dosya/CDN kök adresi. | +| `VITE_REACT_APP_VERSION` | `package.json` sürümünden beslenir. | +| `VITE_AI_URL` | AI asistanının n8n webhook kökü. | +| `VITE_GOOGLE_MAPS_API_KEY` | Harita bileşenleri için anahtar. | +| `VITE_USE_POLLING` | Dosya izlemede polling (WSL/Docker senaryoları). | +| `VITE_PWA_DEV` | Geliştirmede service worker'ı açar (varsayılan kapalı). | + +### 6.3. Uygulama içi ayarlar + +Çalışma zamanı ayarları veritabanında tutulur ve **Ayarlar** ekranından (`/admin/settings`) +yönetilir. `Sozsoft.Settings` modülü ayar tanımlarını, `SettingUiAppService` ise bu tanımların +UI gösterimini yönetir. Şifre politikası, oturum kuralları, gönderici kimlik bilgileri (SMS, +mail, WhatsApp, Rocket) ve AI bot yapılandırması bu yolla değiştirilir — kod dağıtımı gerekmez. + +--- + +## 7. Low-Code Motoru: ListForm Ekosistemi + +Platformun kalbi budur. Bir iş ekranı; bir `ListForm` kaydı, ona bağlı `ListFormField` +kayıtları ve bir menü/route/permission üçlüsünden oluşur. + +### 7.1. Kavramlar + +| Varlık | Rolü | +| --- | --- | +| `DataSource` | Hedef veritabanı bağlantısı (MSSQL / PostgreSQL). Ekranlar `DataSourceCode` ile bağlanır. | +| `ListForm` | Ekranın tüm davranışı: veri kaynağı, sütun/filtre/sayfalama/düzenleme ayarları, yetkiler, alt formlar, iş akışı, grafik ayarları. | +| `ListFormField` | Tek bir sütun/alanın davranışı: başlık, tip, genişlik, editör, lookup, doğrulama, biçim, yetki. | +| `ListFormCustomization` | Kullanıcı bazlı kişiselleştirme: kayıtlı filtre, grid state, sunucu tarafı join/where. | +| `ListFormWorkflow` | Onay ve koşul düğümlerinden oluşan iş akışı grafiği. | +| `ListFormImport` / `ImportLog` | Excel/CSV içe aktarma tanımı ve satır bazlı sonuç günlüğü. | + +### 7.2. `ListForm` anatomisi + +Ekran davranışı büyük ölçüde JSON kolonlarında saklanır. Öne çıkanlar: + +| Alan | İşlevi | +| --- | --- | +| `SelectCommandType` + `SelectCommand` | Veri kaynağı türü: `Table`, `View`, `TableValuedFunction`, `Query`, `StoredProcedure`. | +| `KeyFieldName` / `KeyFieldDbSourceType` | Birincil anahtar alanı ve tipi. | +| `PermissionJson` | Ekranın create/read/update/delete/export/import/note yetki eşlemesi. | +| `EditingOptionJson` / `EditingFormJson` | Düzenleme modu (row/cell/batch/form/popup) ve düzenleme formu düzeni. | +| `FilterRowJson`, `HeaderFilterJson`, `FilterPanelJson`, `SearchPanelJson`, `GroupPanelJson` | Filtreleme, arama ve gruplama davranışı. | +| `ColumnOptionJson`, `PagerOptionJson`, `SelectionJson`, `StateStoringJson` | Sütun, sayfalama, seçim ve durum saklama ayarları. | +| `Insert/Update/DeleteCommand` (+ `Before` / `After`) | CRUD komutları ve öncesi/sonrası çalışacak SQL kancaları. | +| `Insert/Update/DeleteServiceAddress` | Varsayılan olarak `list-form-data/*`; özel endpoint'e yönlendirilebilir. | +| `SubFormsJson` | Ana-detay ilişkisi (ParentFieldName → ChildFieldName eşlemesi). | +| `WidgetsJson` | Ekranın üstünde gösterilecek KPI kartları. | +| `WorkflowJson` | İş akışı bağlaması. | +| `ExtraFilterJson` | Ekrana özel ek filtre araç çubuğu. | +| `PivotOptionJson`, `TreeOptionJson`, `GanttOptionJson`, `SchedulerOptionJson`, `TodoOptionJson` | Alternatif görünümlerin ayarları. | +| Grafik alanları (`SeriesJson`, `LegendJson`, `ArgumentAxisJson`, `ValueAxisJson`, `TooltipJson`, `ZoomAndPanJson`, …) | DevExtreme Chart yapılandırması. | +| `CustomJsSourcesJson` / `CustomStyleSourcesJson` | Ekran yüklendiğinde çalışacak JS/CSS. | +| `IsTenant`, `IsBranch`, `IsOrganizationUnit` | Otomatik tenant/şube/organizasyon birimi filtrelemesi. | +| `UserId`, `RoleId`, `CultureName` | Aynı ekranın kullanıcı, rol veya dil bazlı varyantları. | + +### 7.3. Görünüm tipleri + +Aynı `ListForm` tanımı, aynı veri hattı üzerinden birden fazla görünümle sunulabilir: + +`Grid` · `Pivot` · `Tree` · `Chart` · `Gantt` · `Scheduler` · `CardView` · `TodoBoard` · `Widget Group` + +Kullanıcı görünümler arasında geçiş yapabilir; grid durumu (sütun sırası, genişlik, filtre, +gruplama) kullanıcı bazlı saklanır ve sıfırlanabilir. + +### 7.4. Alan (field) davranışı + +- **Editör tipleri:** `dxTextBox`, `dxTextArea`, `dxNumberBox`, `dxDateBox`, `dxDateRangeBox`, + `dxCalendar`, `dxCheckBox`, `dxSwitch`, `dxRadioGroup`, `dxSelectBox`, `dxTagBox`, `dxLookup`, + `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. +- **Doğrulama:** `ValidationRuleJson` ile required/range/pattern/custom kuralları. +- **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. +- **Koşullu biçimlendirme:** `ColumnStylingJson`, `ColumnCssClass`/`ColumnCssValue`. +- **Alan bazlı yetki:** `PermissionJson` ile sütun düzeyinde okuma/yazma/dışa aktarma kontrolü. + +### 7.5. İş akışı ve onay + +`FormTabWorkflow` ve `views/admin/listForm/workflow/WorkflowDesigner.tsx` üzerinden görsel +tasarlanır. Düğüm türleri koşul karşılaştırması (`CompareColumn`/`CompareOperator`/`CompareValue`) +ve onay adımlarıdır; her adım için `NextOnTrue`, `NextOnFalse`, `NextOnApprove`, `NextOnReject` +geçişleri tanımlanır. Onaycı birden fazla kişi olabilir. Formlar içinde iş akışına bağlı özel +butonlar gösterilebilir. + +### 7.6. Yeni ekran ekleme akışı (Wizard) + +`/admin/listform/wizard` altındaki 11 adımlı sihirbaz, aşağıdaki yedi artefaktı tek akışta üretir: + +1. **ListForm** kaydı (kod, ad, başlık, veri kaynağı, select komutu, anahtar alan) +2. **ListFormField** kümesi (sütunlar, editörler, lookup'lar, doğrulamalar) +3. **Route** kaydı (`key`, `path`, `componentPath`, `routeType`, `authority`) +4. **Menu** kaydı (`ParentCode`, `Code`, `DisplayName`, `Url`, `Icon`, `RequiredPermissionName`, `Order`) +5. **Permission** kayıtları (`.Default`, `.Create`, `.Update`, `.Delete`, `.Export`, `.Import`, `.Note`) +6. Gerekiyorsa **ayar/entegrasyon** bağımlılıkları +7. **Doğrulama ve geri alma** notları + +Dinamik liste ekranlarının route deseni `/admin/list/{ListFormCode}` biçimindedir; form +görünümleri `/admin/form/{listFormCode}/:id`, grafik `/admin/chart/{listFormCode}`, pivot +`/admin/pivot/{listFormCode}` yollarını kullanır. + +Var olan bir ekranı düzenlemek için `/admin/listform/edit/{listFormCode}` — tüm JSON +yapılandırması sekmeli bir editörde yönetilir (Veritabanı, Sütunlar, Filtreler, Düzenleme, +Yetkiler, Alt Form, Pivot, Tree, Gantt, Scheduler, Todo, Widget, Workflow, Durum, Grafik sekmeleri). + +### 7.7. İçe/dışa aktarma + +- **Dışa aktarma:** xlsx, csv, pdf (grid durumuna ve görünür sütunlara saygı duyar). +- **İçe aktarma:** `components/importManager` altındaki dosya yükleme → önizleme → ilerleme → + sonuç akışı; sonuçlar `ListFormImportLog` üzerinde satır bazında raporlanır. Şablon dosyası + ekran tanımından üretilir. + +--- + +## 8. Developer Kit + +Teknik kullanıcılar için `/admin/developerkit` altında toplanan araçlar: + +| Araç | Route | Ne işe yarar | | --- | --- | --- | -| Kayıt | `/register` | Yeni kullanıcı kaydı oluşturur. | -| Şifremi unuttum | `/forgot-password` | Şifre sıfırlama kodu gönderir. | -| Şifre sıfırlama | `/reset-password` | Token ile yeni şifre belirler. | -| Hesap doğrulama kodu gönderme | `/confirm` | E-posta doğrulama kodu gönderir. | -| Hesap doğrulama | `/confirm/:userId/:token` | E-posta doğrulamasını tamamlar. | -| Süreli giriş talebi | `/extend-login` | Ek giriş süresi veya özel giriş isteği gönderir. | +| **SQL Query Manager** | `/admin/sql-manager` | Nesne gezgini, Monaco tabanlı SQL editörü, sonuç grid'i, nesne özellikleri ve **tablo tasarımcısı**. | +| **Custom Endpoint** | `/admin/developerkit/endpoints` | SQL veya servis tanımından REST endpoint üretimi; GET/POST/PUT/DELETE için ayrı yetki kapıları (`App.DeveloperKit.CustomEndpoints.*`) ve kullanıcı/rol/global erişim kuralları. | +| **CRUD Endpoint** | `/admin/developerkit/entities` | Tablo tanımından tam CRUD endpoint kümesi üretimi. | +| **Dynamic Service** | `/admin/developerkit/dynamic-services` | C# kodunu tarayıcıda yazıp Roslyn ile derleme (`TestCompile`), yayınlama (`Publish`) ve çalışan uygulamaya controller olarak kaydetme. Yetkiler: Create/Edit/Delete/Manage/TestCompile/Publish/ViewCode. | +| **Custom Component** | `/admin/developerkit/components` | React bileşenini veritabanında saklama; `@babel/standalone` ile tarayıcıda derleyip route'a bağlama. | +| **Visual Designer** | Component editörü içinde | Sürükle-bırak kanvas ile bileşen üretimi ve kod üretimi (`visualDesigner/codeGenerator.ts`). | -Kayıt formunda e-posta, şifre, şifre tekrarı, ad, soyad ve captcha alanları zorunlu olarak tanımlanmıştır. Şifre tekrarı alanı şifreyle aynı olmalıdır. +> **Tablo tasarımı varsayılanı:** Yeni tablo oluştururken tenant ve tam denetim (audit) kolonları +> varsayılan olarak eklenir: `Id`, `TenantId`, `CreationTime`, `CreatorId`, `LastModificationTime`, +> `LastModifierId`, `IsDeleted`, `DeletionTime`, `DeleterId`. Kullanıcı açıkça istemedikçe bu +> kolonlar çıkarılmaz. -### Kullanıcı Rolleri +**Dinamik servis yaşam döngüsü:** `DynamicServiceCompiler` kodu derler → +`DynamicAssemblyRegistrationService` assembly'yi tenant bağlamıyla kaydeder → +`ActionDescriptorChangeProvider` MVC'ye route tablosunun değiştiğini bildirir → +`DynamicControllerActivator` bağımlılıkları enjekte eder. Uygulama yeniden başlatılmaz. -Kodda sabit rol isimlerinden çok permission odaklı yetkilendirme kullanımı görülmektedir. ABP Identity rollerine permission atanır; kullanıcılar rol veya kullanıcı bazlı permission üzerinden ekran ve işlem erişimi kazanır. +--- -Öne çıkan yetki kaynakları: +## 9. Dinamik Menü, Route ve Yetki Modeli -| Kaynak | Açıklama | +``` +Permission (ABP) ──┐ + ├──► Menu.RequiredPermissionName ──► menüde görünürlük +Route.Authority ──┘ └► PermissionGuard ile erişim +``` + +- **Route** (`Entities/Tenant/Administration/Route.cs`): `Key`, `Path`, `ComponentType`, + `ComponentPath`, `RouteType` (`public` / `protected`), `Authority[]`. +- **Menu** (`Menu.cs`): `Code`, `ParentCode`, `DisplayName`, `Url`, `Icon`, `Order`, + `RequiredPermissionName`, `Target`, `IsDisabled`, ayrıca `UserId` / `RoleId` / `CultureName` + ile kullanıcı, rol ve dil bazlı menü varyantları. +- **Menü yöneticisi** (`/admin/menuManager`): sürükle-bırak ağaç ile menü düzenleme. +- Frontend'de `dynamicRoutesContext` route'ları çeker, `dynamicRouteLoader` bunları fiziksel + view'lara veya runtime derlenmiş Custom Component'lere eşler, `DynamicRouter` React Router + ağacını kurar; her korumalı route `ProtectedRoute` + `PermissionGuard` ile sarılır. + +**Yetki kodları** `PlatformConsts.AppCodes` içinde merkezî olarak tanımlıdır ve +`PermissionsData.json` ile seed edilir. Ana gruplar: `App.Saas`, `App.Branches`, `App.Intranet.*`, +`App.Definitions.*`, `App.Restrictions.*`, `App.Languages.*`, `App.Listforms.*`, +`App.Notifications.*`, `App.BackgroundWorkers.*`, `App.Menus.*`, `App.DeveloperKit.*`, +`App.Orders.*`, `App.BlogManagement.*`, `App.IdentityManagement.*`, `App.Reports.*`, +`App.Administration`, `App.Setting`, `App.Setup.Migrate`. + +> **Kural:** Yetki sözleşmesi olmayan menü/route önerilmez ve eklenmez. + +--- + +## 10. Modül Kataloğu + +### 10.1. Backend modülleri (`api/modules`) + +| Modül | Kapsam | | --- | --- | -| ABP Identity permissionları | Kullanıcı, rol, organizasyon birimi, tenant ve hesap yönetimi. | -| `PlatformConsts.IdentityPermissions` | Kullanıcı/rol için create, update, delete, export, import, note, manage permissions ve manage roles izinleri. | -| `SeedConsts` permissionları | Menü, liste form, ayar, bildirim, public içerik, rapor ve tanım ekranları için seed edilen permissionlar. | -| Dinamik list form permissionları | Her liste formu için okuma, ekleme, güncelleme, silme, export, import ve note gibi CRUD tabanlı yetkiler. | -| Route authority alanı | Dinamik route kayıtlarında ilgili ekranın ihtiyaç duyduğu permission listesi. | +| **Sozsoft.Languages** | Dil tanımları, dil anahtarları, çeviri metinleri; tenant bazlı lokalizasyon (`TenantLocalizationMiddleware`). | +| **Sozsoft.Settings** | Ayar tanımları, sağlayıcılar, şifreleme bayrakları ve ayar ekranı metadata'sı. | +| **Sozsoft.Notifications** | Bildirim tipleri, kuralları ve kullanıcı bildirimleri; kanal bazlı yönlendirme. | +| **Sozsoft.MailQueue** | Şablon (Scriban) tabanlı gövde üretimi, ek dosya yaşam döngüsü, kuyruk çalıştırma ve günlükleme. | +| **Sozsoft.Sender** | Amazon SES (mail), Posta Güvercini (SMS), WhatsApp Cloud API, Rocket.Chat göndericileri. | +| **Sozsoft.SqlQueryManager** | SQL nesne yönetimi, sorgu çalıştırma, tablo/kolon metadata'sı. | -### Yetkilere Göre Ekran/İşlem Farklılıkları +### 10.2. Uygulama servisleri (`api/src/Sozsoft.Platform.Application`) -Kullanıcı yetkisi yoksa: +`AiBots` · `AuditLogs` · `BackgroundWorker` · `Blog` · `Branch` · `Dashboard` · `DataSource` · +`DeveloperKit` · `FileManagement` · `Forum` · `GlobalSearch` · `Hr` · `Identity` · `Intranet` · +`ListForms` (+ `Administration`) · `Menu` · `Messenger` · `Note` · `OrgChart` · +`OrganizationUnits` · `Public` · `Routes` · `Tenants` · `Videoroom` -| Durum | Kullanıcı Davranışı | -| --- | --- | -| Menü yetkisi yok | Menü öğesi kullanıcıya gösterilmeyebilir. | -| Route yetkisi yok | `PermissionGuard` erişimi engeller. | -| API 401/403 döner | Kullanıcı `/admin/access-denied` veya `/access-denied` ekranına yönlendirilir. | -| Liste işlem yetkisi yok | Ekleme, düzenleme, silme, içe/dışa aktarma veya not butonları görünmeyebilir ya da devre dışı kalabilir. | +### 10.3. Yönetim ekranları (UI) -## 3. Ana Menü ve Navigasyon - -Menü yapısı veritabanı/seed kayıtları üzerinden yönetilir. Backend `PlatformApplicationConfigurationContributor`, route ve custom component kayıtlarını uygulama konfigürasyonuna ekler. Frontend `DynamicRouter`, bu route kayıtlarına göre ekranları yükler. - -Koddan doğrulanan ana menü grupları: - -| Menü Grubu | Açıklama | -| --- | --- | -| `App.Saas` | Tenant, şube, tanımlar, list form, language, notification, background worker, menu ve developer kit gibi platform yönetimi ekranları. | -| `App.Administration` | Ayarlar, kimlik yönetimi, organizasyon, kısıtlar, raporlar, intranet, video oda, dosya ve forum ekranları. | -| `App.Public` | Site içerikleri, ürün, ödeme, satış siparişi, blog, demo ve iletişim yönetimi. | -| `App.DeveloperKit` | List form wizard, custom endpoint, CRUD endpoint, component ve dynamic service yönetimi. | -| `Abp.Identity` | Kullanıcı, rol, permission, permission group, claim type, audit log, session ve organizasyon birimi ekranları. | - -Navigasyon kuralları: - -| Kullanım | Açıklama | -| --- | --- | -| Kök adres `/` | Subdomain varsa login ekranına, yoksa public home ekranına yönlenir. | -| Admin alanı `/admin/*` | Korunan route alanıdır; giriş ve permission kontrolü yapılır. | -| Public ekranlar | Giriş gerektirmeyen site sayfalarıdır. | -| Dynamic route | Route kaydındaki `componentPath` fiziksel React component veya dinamik component olarak yüklenir. | -| Bulunamayan sayfa | `NotFound` ekranı gösterilir. | -| Yetkisiz erişim | `AccessDenied` ekranı gösterilir. | - -Uygulama route yapısı üç ana kullanım tipini destekler: - -| Route Tipi | Açıklama | -| --- | --- | -| Public | Ziyaretçilerin veya giriş yapmamış kullanıcıların erişebildiği public site ekranları. | -| Authenticated | Login, register, şifre sıfırlama, hesap doğrulama gibi kimlik ekranları. | -| Protected | Giriş ve permission kontrolü gerektiren admin/yönetim ekranları. | - -Route kayıtları veritabanından beslendiği için hem uygulama içindeki hazır React ekranları hem de Developer Kit ile oluşturulan dinamik componentler route olarak sunulabilir. Menü kayıtları da aynı route pathleriyle ilişkilendirilir; böylece menü grupları, alt menüler ve sürükle-bırak sıralama ile kullanıcı navigasyonu yönetilebilir. - -## 4. Modüller ve Ekranlar - -Bu bölümde yalnızca kodda doğrulanabilen modüller ve ekranlar açıklanmıştır. - -### Giriş ve Hesap Yönetimi - -#### Amaç - -Kullanıcının sisteme giriş yapmasını, yeni hesap oluşturmasını, şifresini sıfırlamasını ve hesabını doğrulamasını sağlar. - -#### Ekranda Görülen Alanlar - -| Ekran | Alanlar | -| --- | --- | -| Login | Kullanıcı adı, şifre, beni hatırla, iki aşamalı doğrulama kodu. | -| Register | E-posta, şifre, şifre tekrarı, ad, soyad, captcha. | -| Forgot Password | E-posta, captcha. | -| Reset Password | Yeni şifre, şifre tekrarı. | -| Confirmation | E-posta veya doğrulama token bilgileri. | - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | -| --- | --- | -| Giriş yapma | Kullanıcı adı ve şifre ile oturum açılır. | -| Kayıt olma | Zorunlu alanlar doldurularak hesap oluşturulur. | -| Şifre sıfırlama | E-posta ile alınan token üzerinden yeni şifre belirlenir. | -| Hesap doğrulama | Doğrulama linki veya kodu ile hesap onaylanır. | -| Çıkış yapma | Token iptal edilerek oturum kapatılır. | - -#### İş Akışı - -1. Kullanıcı giriş ekranını açar. -2. Zorunlu alanları doldurur. -3. Sistem kimlik bilgilerini doğrular. -4. İki aşamalı doğrulama gerekiyorsa ek kod istenir. -5. Başarılı işlem sonrası kullanıcı yetkili olduğu admin veya public alana yönlendirilir. - -#### Dikkat Edilmesi Gerekenler - -| Konu | Açıklama | -| --- | --- | -| Captcha | Kayıt, şifre sıfırlama ve doğrulama kodu gönderme ekranlarında zorunlu olabilir. | -| Şifre tekrarı | Şifre ve şifre tekrarı aynı olmalıdır. | -| 401/403 | Oturum süresi dolduğunda veya yetki yoksa erişim engellenir. | -| Token süreleri | Backend access token için 60 dakika, refresh token için 90 dakika yapılandırmıştır. | - -### Dashboard - -#### Amaç - -Admin alanına giriş yapan kullanıcıya başlangıç ekranı sağlar. Route sabiti `/admin/dashboard` olarak tanımlıdır ve `DashboardAppService` backend tarafında bulunur. - -#### Ekranda Görülen Alanlar - -Kodda `Dashboard.tsx` ekranı ve `DashboardAppService` doğrulanmıştır. Dashboard içeriği kurulum ve route kayıtlarına bağlı olarak değişebilir. - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | -| --- | --- | -| Genel durumu görüntüleme | Kullanıcı ana yönetim panelinden özet bilgilere ulaşır. | -| Menüye geçiş | Sol/üst menüden yetkili olduğu modüle geçer. | - -#### İş Akışı - -1. Kullanıcı giriş yapar. -2. Admin kök route'u dashboard ekranına yönlendirir. -3. Kullanıcı menüden işlem yapmak istediği modülü seçer. - -#### Dikkat Edilmesi Gerekenler - -Dashboard içeriği route, menu ve backend veri durumuna bağlıdır. Koddan tüm dashboard kartları ayrıntılı olarak doğrulanmadığı için bu bölüm genel tutulmuştur. - -### Dinamik Liste Ekranları - -#### Amaç - -Uygulamanın birçok yönetim ekranı, tek bir dinamik liste altyapısı üzerinden çalışır. Bu altyapı liste kolonlarını, filtreleri, form alanlarını, yetkileri, alt formları, notları, import/export davranışını ve workflow aksiyonlarını list form tanımından alır. - -Dinamik listeler yalnızca klasik tablo görünümü üretmez. Platform DevExpress tabanlı Grid, Pivot, Chart, Tree, Gantt, Scheduler ve Form bileşenlerini dinamik olarak yönetebilir. Böylece aynı datasource ve list form tanımı üzerinden farklı veri görünümü ve iş akışı ekranları hazırlanabilir. - -#### Ekranda Görülen Alanlar - -Dinamik liste ekranlarında görülebilen başlıca alanlar: - -| Alan | Açıklama | -| --- | --- | -| Veri tablosu | DevExtreme DataGrid tabanlı kayıt listesi. | -| Kolonlar | List form field tanımına göre gelir. | -| Arama paneli | Genel arama yapılmasını sağlar. | -| Filtre satırı | Kolon bazlı filtreleme yapılabilir. | -| Header filter | Kolon değerleri üzerinden seçimli filtreleme sağlar. | -| Grup paneli | Alanlar sürüklenerek gruplama yapılabilir. | -| Kolon seçici | Yetkili ve aktifse kolon görünürlüğü düzenlenebilir. | -| Sayfalama | Sayfa boyutu ve navigasyon butonları list form ayarına göre gösterilir. | -| Özet alanları | Toplam, ortalama, adet, minimum, maksimum gibi özetler gösterilebilir. | -| Extra filter | Listeye özel ek filtre toolbar alanları. | -| Alt formlar | Seçili kayda bağlı alt liste veya form sekmeleri. | -| Not paneli | Kayıt bazlı not görüntüleme/ekleme yetkiye bağlıdır. | -| Import yöneticisi | Excel/CSV içe aktarma desteklenir. | -| Widget alanları | Listenin üst kısmında SQL ile beslenebilen birden fazla özet widget gösterilebilir. | - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | -| --- | --- | -| Listeleme | Kayıtlar tablolarda görüntülenir. | -| Kayıt ekleme | Yetki ve list form ayarı uygunsa yeni kayıt formu açılır. | -| Kayıt düzenleme | Yetki ve alan bazlı düzenleme ayarına göre kayıt güncellenir. | -| Kayıt silme | Tekil veya çoklu silme yapılabilir. Çoklu silme birden fazla kayıt seçildiğinde görünür. | -| Filtreleme | Filtre satırı, header filter, extra filter ve arama paneli kullanılabilir. | -| Gruplama | Grup paneli açıksa kolon bazlı gruplama yapılabilir. | -| Dışa aktarma | List form ayarına göre export kullanılabilir. | -| İçe aktarma | Import Dashboard üzerinden template alma, dosya yükleme, önizleme ve aktarım yapılabilir. | -| Workflow işlemi | Seçili satırların durumuna göre onay/red gibi workflow butonları gösterilebilir. | -| Not ekleme | Not yetkisi ve kayıt anahtarı varsa not paneli açılır. | -| Alt liste oluşturma | Dinamik formun altında ilişkili sub listeler gösterilebilir. | -| İlişkili form kullanma | İki form arasında parent-child ilişkiler kurulabilir. | - -#### İş Akışı - -1. Kullanıcı menüden bir liste ekranı açar. -2. Sistem ilgili `listFormCode` için kolon ve grid ayarlarını backendden alır. -3. Kullanıcı kayıtları görüntüler, filtreler veya arar. -4. Yetkisi varsa yeni kayıt ekler veya mevcut kaydı düzenler. -5. Kaydetme sonrası grid yenilenir. -6. İhtiyaç varsa içe aktarma, dışa aktarma, not veya workflow aksiyonu kullanılır. - -#### Dikkat Edilmesi Gerekenler - -| Konu | Açıklama | -| --- | --- | -| Alan yetkileri | Alanlar okuma, ekleme ve güncelleme moduna göre gizlenebilir veya pasif olabilir. | -| Varsayılan değerler | `@AUTONUMBER`, sequence veya URL filtresi üzerinden otomatik değer atanabilir. | -| Cascade lookup | Üst alan boşsa bağlı lookup alanı pasif olabilir; üst alan değişince alt alan temizlenebilir. | -| Grid state | Kullanıcı kolon/filtre düzenini kaydedebilir; başarılı kayıtta bildirim gösterilir. | -| Mobil davranış | Düzenleme popup'ı mobil veya dokunmatik cihazlarda tam genişlikte açılabilir. | -| Datasource seçimi | Liste, tenant veya ekran ihtiyacına göre farklı datasource üzerinden çalışabilir. | -| Veritabanı tipi | Datasource yapısı MSSQL ve PostgreSQL kaynaklarıyla çalışacak şekilde tasarlanmıştır. | - -### List Form Yönetimi - -#### Amaç - -Dinamik liste ve form ekranlarının tanımlarını yönetmek için kullanılır. Kodda list form, field, customization, query preview, json row operasyonları, wizard ve workflow servisleri doğrulanmıştır. - -#### Ekranda Görülen Alanlar - -| Ekran | Açıklama | -| --- | --- | -| List Form Listesi | List form kayıtlarını listeler. | -| List Form Düzenleme | Detay, database, kolon, edit form, filtre, arama, grup, pivot, tree, gantt, scheduler, pager, state, sub form, widget, workflow ve chart ayar sekmeleri. | -| Alan Yönetimi | Her kolon/alan için detay, permission, seçenek, lookup, filtreleme, başlık, grup, özet, validasyon, koşullu format ve pivot ayarları. | -| Wizard | Yeni list form, menü, permission ve seed dosyası üretimi için adımlı kurulum ekranı. | -| Workflow Designer | Liste form bazlı workflow kriterleri ve akış tasarımı. | - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | -| --- | --- | -| Liste form oluşturma | Wizard ile yeni liste/form tanımı hazırlanabilir. | -| Liste form düzenleme | Grid, form, datasource, yetki, filtre ve görünüm seçenekleri düzenlenir. | -| Alan ekleme/düzenleme | Kolon özellikleri ve formdaki davranışları ayarlanır. | -| JSON satır işlemleri | Komut, ekstra filtre, sub form, widget, chart series/pane/axis gibi yapılandırmalar eklenir. | -| Workflow tanımlama | Onay akışı için kriter ve alanlar belirlenir. | - -#### İş Akışı - -1. Yönetici List Form menüsünü açar. -2. Yeni ekran gerekiyorsa wizard başlatır. -3. Liste kodu, menü bilgisi, tablo/veri kaynağı ve alanlar tanımlanır. -4. Oluşturulan list form düzenleme ekranında detaylandırılır. -5. Permission ve menü ayarları tamamlandıktan sonra kullanıcılar ekrana erişebilir. - -#### Dikkat Edilmesi Gerekenler - -Bu ekran teknik/yönetici kullanıcılar içindir. Yanlış datasource, SQL veya permission tanımı son kullanıcı ekranlarının çalışmasını etkileyebilir. - -### Kimlik Yönetimi - -#### Amaç - -Kullanıcı, rol, permission, claim type, oturum, concurrent lisans kapsamındaki aktif kullanıcılar ve organizasyon birimi yönetimini sağlar. Bu bölüm, uygulamayı kullanabilecek kişilerin genel bilgilerini, iş bilgilerini, yetki gruplarını, şube erişimlerini, hesap durumlarını ve güvenlik seçeneklerini yönetmek için kullanılır. - -#### Ekranda Görülen Alanlar - -Kodda seed edilen veya servisle doğrulanan ekranlar: - -| Ekran | Menü/Route | Açıklama | +| Alan | Route | Açıklama | | --- | --- | --- | -| Kullanıcılar | `/admin/list/AbpIdentity.Users` | Kullanıcı listesi ve kullanıcı detayına geçiş. | -| Kullanıcı Detayı | `/admin/users/detail/:userId` | Kullanıcı bilgileri, avatar, kilit/aktiflik, rol ve permission işlemleri. | -| Roller | `/admin/list/AbpIdentity.Roles` | Rol listesi ve rol izinleri. | -| Permission Groups | `/admin/list/Abp.Identity.PermissionGroups` | Permission grup tanımları. | -| Permissions | `/admin/list/Abp.Identity.Permissions` | Permission tanımları. | -| Claim Types | `/admin/list/App.IdentityManagement.ClaimTypes` | Kullanıcı claim tipleri. | -| Audit Logs | `/admin/list/App.IdentityManagement.AuditLogs` | Denetim kayıtları. | -| Sessions | `/admin/list/App.IdentityManagement.Sessions` | Kullanıcı oturum kayıtları. | -| Organization Units | `/admin/ous` | Organizasyon birimi ağacı, üye ve rol atamaları. | - -Kullanıcı tanımında yönetilebilen başlıca bilgi grupları: - -| Bilgi Grubu | Açıklama | -| --- | --- | -| Genel bilgiler | Ad, soyad, e-posta, telefon, kullanıcı adı ve temel profil bilgileri. | -| İş bilgileri | Kullanıcının departmanı, job position bilgisi, şubesi ve çalışma saati tanımı. | -| Kimlik bilgileri | Kullanıcıya ait ek kimlik/claim bilgileri. | -| Rol ve yetkiler | Kullanıcının hangi rollere sahip olduğu ve kullanıcı bazlı özel permissionları. | -| Şube erişimi | Kullanıcının hangi branch kayıtlarını görebileceği veya yönetebileceği. | -| Hesap durumu | Aktiflik, status, admin verification, email confirm, two factor ve account end date gibi hesap alanları. | -| Şifre politikası | Şifre değiştirme zamanı ve periyodik şifre değişim kuralları. | - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | -| --- | --- | -| Kullanıcı listeleme | Kullanıcı kayıtları listelenir ve filtrelenir. | -| Kullanıcı bilgisi düzenleme | E-posta, ad, soyad, telefon, aktiflik ve doğrulama bilgileri düzenlenebilir. | -| Rol atama | Kullanıcıya rol atanabilir. | -| Permission yönetimi | Kullanıcı veya role permission verilebilir. | -| Permission kopyalama | Kullanıcı veya rol yetkileri başka kullanıcı/role kopyalanabilir. | -| Claim type yönetimi | ABP claim type kayıtları tanımlanır ve kullanıcı tanımlama sürecinde kullanılabilir. | -| Kullanıcı oturumu sonlandırma | `kickUser` servisi ile oturum düşürülebilir. | -| Organizasyon birimi yönetimi | Birim oluşturma, taşıma, silme, kullanıcı/rol atama işlemleri yapılabilir. | -| Aktif kullanıcı izleme | Concurrent lisans yönetimi kapsamında aktif kullanıcılar ve oturumlar takip edilebilir. | - -#### İş Akışı - -1. Yönetici Kimlik Yönetimi menüsünden ilgili ekranı açar. -2. Kullanıcı, rol, permission group, claim type veya organizasyon birimi kaydını seçer. -3. Kullanıcı tanımında genel bilgileri, iş bilgilerini, departman/job position alanlarını, şube erişimini ve rol bilgilerini düzenler. -4. Gerekirse kullanıcı veya rol permissionlarını tanımlar ya da mevcut yetkileri başka kullanıcı/role kopyalar. -5. Organization Unit ekranında takım/birim oluşturur ve ilgili kullanıcıları bu birime atar. -6. Sistem değişiklikleri kaydeder ve ilgili listeyi günceller. - -#### Dikkat Edilmesi Gerekenler - -| Konu | Açıklama | -| --- | --- | -| Kullanıcı permissionları | Rol permissionlarına ek olarak kullanıcı özelinde verilebilir. | -| Kilit yönetimi | Hesap kilidi ve aktiflik alanları kullanıcının girişini etkiler. | -| Permission değişikliği | Menü görünürlüğü ve işlem butonları üzerinde etkili olur. | -| Audit takibi | Audit kayıtları üzerinden hangi kullanıcının ne zaman hangi API çağrısını yaptığı, insert/update/delete gibi işlemler ve işlem detayları takip edilebilir. | -| Aktif oturum | Concurrent lisans yönetimi nedeniyle aktif kullanıcı listesi önemlidir; yetkili kullanıcı gerektiğinde aktif oturumu sonlandırabilir. | - -### Profil - -#### Amaç - -Kullanıcının kendi profil bilgilerini, şifresini ve bildirim tercihlerini yönetmesini sağlar. - -#### Ekranda Görülen Alanlar - -| Sekme | Açıklama | -| --- | --- | -| Genel | Profil bilgileri. | -| Şifre | Şifre değiştirme alanları. | -| Bildirim Ayarları | Kullanıcı bildirim kuralları ve kanalları. | - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | -| --- | --- | -| Profil güncelleme | Kullanıcı kendi bilgilerini günceller. | -| Şifre değiştirme | Mevcut şifre ve yeni şifre bilgileriyle şifre değiştirir. | -| Bildirim tercihi | Bildirim tiplerine göre kural ve kanal ayarlarını yönetir. | - -#### İş Akışı - -1. Kullanıcı Profil menüsüne girer. -2. İlgili sekmeyi seçer. -3. Alanları düzenler. -4. Kaydet butonuyla değişiklikleri uygular. - -#### Dikkat Edilmesi Gerekenler - -Şifre kuralları sistem ayarlarında tanımlanabilir. Profil ve şifre alanlarının görünürlüğü permission veya ayarlarla kısıtlanabilir. - -### Tenant ve Şube Yönetimi - -#### Amaç - -Çok kiracılı yapıda tenant kayıtlarını, tenant bazlı bağlantı ayarlarını, şube tanımlarını, tenant bazında değiştirilebilen tanım listelerini ve organizasyon birimlerini yönetmek için kullanılır. Platform tenant, branch ve organization unit bazında kayıtları ayırabilir veya filtreleyebilir. Kurulum tercihine göre her tenant ayrı veritabanı bağlantısı ile de çalışabilir. - -#### Ekranda Görülen Alanlar - -| Ekran | Açıklama | -| --- | --- | -| Tenant Listesi | Tenant kayıtlarının listelenmesi. | -| Tenant Connection String | Tenant varsayılan bağlantı cümlesini görüntüleme/güncelleme/silme. | -| Tenant Seed | Tenant verisini oluşturma veya güncelleme. | -| Şubeler | `App.Branches` list formu. | -| Organization Units | Organizasyon birimi ağacı, kullanıcı ve rol atamaları. | -| Sectors | Tenant bazında sektör tanımları. | -| Departments | Tenant bazında departman tanımları. | -| Job Positions | Tenant bazında görev/pozisyon tanımları. | -| Organizasyon Şeması | Departman, job position ve kullanıcı bilgileriyle oluşturulan organizasyon görünümü. | - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | -| --- | --- | -| Tenant listeleme | Tenant kayıtlarını görüntüler. | -| Tenant detay görüntüleme | Tenant bilgilerine ulaşır. | -| Bağlantı ayarı yönetme | Varsayılan connection string düzenlenir. | -| Seed çalıştırma | Tenant için başlangıç verileri oluşturulur. | -| Şube yönetimi | Şube kayıtları dinamik liste üzerinden yönetilir. | -| Branch bazlı yetkilendirme | Kullanıcıların yetkileri ve veri erişimleri şube bazında sınırlandırılabilir. | -| Organizasyon birimi yönetimi | Departman/birim ağacı üzerinden kullanıcı ve rol ilişkileri yönetilir. | -| Tenant bazlı tanım yönetimi | Sector, Department ve Job Position gibi tanımlar tenant ihtiyacına göre düzenlenir. | -| Organizasyon şeması oluşturma | Tanımlanan departman, pozisyon ve kullanıcı bilgileriyle organizasyon şeması hazırlanır. | - -#### İş Akışı - -1. Yönetici tenant veya şube ekranını açar. -2. İlgili kaydı seçer. -3. Gerekirse tenant bağlantı cümlesini veya tenant seed işlemini yönetir. -4. Tenant altında kullanılacak branch kayıtlarını tanımlar. -5. Tenant için sector, department ve job position tanımlarını oluşturur. -6. Kullanıcıları departman, pozisyon ve şube bilgileriyle ilişkilendirir. -7. Organization Unit veya organizasyon şeması ekranında birim/takım yapısını düzenler. -8. Sistem başarı veya hata bildirimi gösterir. - -#### Dikkat Edilmesi Gerekenler - -Tenant connection string değişiklikleri uygulamanın veri erişimini doğrudan etkiler. Bu işlem teknik yetki gerektirir. Branch ve organization unit yetkileri kullanıcıların hangi kayıtları görebileceğini etkileyebileceği için bu tanımlar dikkatli yapılmalıdır. Tenant bazlı sector, department ve job position tanımları kullanıcı kartları, organizasyon şeması ve filtreleme davranışlarında kullanılabilir. - -### Sistem Ayarları - -#### Amaç - -Uygulama ayarlarını gruplar halinde görüntülemek ve güncellemek için kullanılır. ABP setting altyapısı kullanılır; mevcut ayarlar yönetilebildiği gibi sisteme yeni ayar tanımları da eklenebilir. Bazı ayarlar global düzeyde, bazı ayarlar tenant düzeyinde değiştirilebilir. - -#### Ekranda Görülen Alanlar - -| Alan | Açıklama | -| --- | --- | -| Ana grup menüsü | Ayar ana grupları sol menüde listelenir. | -| Alt grup kartları | Her ana grup altında alt grup kartları yer alır. | -| Ayar alanları | Text, Number, Memo, Bool ve List tipleri desteklenir. | -| Yardım ikonu | Ayar açıklaması tooltip olarak gösterilir. | -| Kaydet | Değişiklikleri kaydeder. | - -Yönetilebilen önemli ayar grupları: - -| Ayar Grubu | Açıklama | -| --- | --- | -| Tema | Elstar tema yapısı, açık/koyu mod ve public/admin görünüm ayarları. | -| Kayıt ve giriş | Register, local login, iki aşamalı doğrulama ve süreli giriş davranışları. | -| Captcha | Cloudflare Turnstile/Captcha endpoint, site key ve secret key ayarları. | -| Hesap doğrulama | Admin verification, e-posta doğrulama ve telefon doğrulama ayarları. | -| Şifre standartları | Minimum uzunluk, benzersiz karakter, küçük/büyük harf, rakam, özel karakter ve periyodik şifre değiştirme kuralları. | -| Kilitlenme | Hatalı giriş sayısı ve kilit süresi ayarları. | -| E-posta | SMTP, varsayılan gönderici, Amazon SES profil/region/access key ayarları. | -| SMS | Posta Güvercini URL, kullanıcı adı ve şifre ayarları. | -| WhatsApp | WhatsApp URL, phone number id, template name ve token ayarları. | -| Rocket.Chat | Rocket URL, user id ve token ayarları. | - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | -| --- | --- | -| Ayar görüntüleme | Kullanıcı yetkili olduğu ayarları görür. | -| Ayar düzenleme | Alan tipine göre değer girer veya seçim yapar. | -| Yeni ayar tanımı yönetme | Yetkili teknik/yönetici kullanıcılar setting definition kayıtlarını genişletebilir. | -| Tema değiştirme | Site theme style ayarı değiştiğinde açık/koyu mod güncellenir. | -| Kaydetme | Başarılı işlemde başarı bildirimi gösterilir. | - -#### İş Akışı - -1. Yönetici Ayarlar ekranına girer. -2. Sol menüden ana ayar grubunu seçer. -3. Alt grup içindeki değerleri günceller. -4. Kaydet butonuna basar. -5. Sistem ayarları kaydeder ve uygulama konfigürasyonunu yeniler. - -#### Dikkat Edilmesi Gerekenler - -Ayarlar permission ve provider bilgilerine göre değişebilir. Yanlış e-posta, captcha, SMS, Rocket.Chat, WhatsApp, Amazon SES, SMTP veya tema ayarı ilgili entegrasyonları etkileyebilir. Tenant bazında değiştirilebilen ayarlar yalnızca ilgili tenant davranışını etkilerken global ayarlar tüm sistemi etkileyebilir. - -### Datasource Yönetimi - -#### Amaç - -Datasource tanımları, dinamik listelerin ve Developer Kit araçlarının hangi veritabanı veya bağlantı üzerinden çalışacağını belirler. Platform birden fazla veritabanı kaynağından liste, form ve endpoint üretimini destekler. - -#### Ekranda Görülen Alanlar - -| Alan | Açıklama | -| --- | --- | -| Datasource listesi | Tanımlı veri kaynakları listelenir. | -| Kod/ad bilgisi | Datasource'un list form veya endpointlerde seçilebilmesi için kullanılır. | -| Veritabanı sağlayıcısı | MSSQL veya PostgreSQL gibi sağlayıcı bilgisi. | -| Bağlantı bilgisi | İlgili veritabanına erişim için kullanılan bağlantı tanımı. | -| Tenant ilişkisi | Datasource tenant bazlı kullanılacaksa ilgili tenant bağlamı. | - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | -| --- | --- | -| Datasource ekleme | Yeni veritabanı kaynağı tanımlanır. | -| Datasource düzenleme | Bağlantı ve sağlayıcı bilgileri güncellenir. | -| Listeye datasource seçme | Dinamik list formun hangi datasource üzerinden çalışacağı belirlenir. | -| Endpointlere datasource bağlama | CRUD endpoint veya custom endpoint için veri kaynağı seçilir. | - -#### İş Akışı - -1. Teknik kullanıcı Datasource ekranını açar. -2. MSSQL veya PostgreSQL bağlantısı için datasource kaydı oluşturur. -3. Dinamik list form, CRUD endpoint veya custom endpoint içinde bu datasource'u seçer. -4. Sistem liste, form veya API işlemlerini seçilen kaynak üzerinden yürütür. - -#### Dikkat Edilmesi Gerekenler - -Yanlış datasource veya bağlantı bilgisi dinamik listelerin veri getirememesine ya da hatalı veritabanında işlem yapmasına neden olabilir. Tenant bazlı çalışmada datasource seçimi veri izolasyonunu doğrudan etkiler. - -### Menü ve Route Yönetimi - -#### Amaç - -Uygulama menülerini, menü gruplarını, route kayıtlarını ve menü ağacını yönetir. Route yapısı veritabanından beslendiği için public, authenticated ve protected ekranlar merkezi olarak tanımlanabilir. Route kayıtlarında hem hazır statik componentler hem de Developer Kit ile oluşturulan dinamik componentler kullanılabilir. - -#### Ekranda Görülen Alanlar - -| Ekran | Açıklama | -| --- | --- | -| Menü Grupları | Menü grubu kayıtları. | -| Menü Listesi | Menü kodu, parent code, URL, ikon, sıra, permission gibi alanlar. | -| Menü Manager | Sürükle-bırak veya ağaç yapısında menü düzenleme. | -| Routes | Route path, component type, component path, route type ve authority bilgileri. | - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | -| --- | --- | -| Menü ekleme/düzenleme | Menü kaydı oluşturulur veya güncellenir. | -| Menü sıralama | Menü ağacında sıralama ve parent ilişkisi düzenlenir. | -| Permission bağlama | Menü görünürlüğü için `RequiredPermissionName` atanır. | -| Route yönetme | Route kayıtlarının component ve yetki bilgileri düzenlenir. | -| Dinamik component bağlama | Aktif custom componentler istenilen route path altında kullanıcıya sunulur. | -| Menü grubu yönetme | Tenant veya uygulama ihtiyacına göre menü grupları tanımlanır. | - -#### İş Akışı +| Dashboard | `/admin/dashboard` | Widget tabanlı özet ekran. | +| Kullanıcı / Rol | `/admin/list/...`, `/admin/users/detail/:userId` | Kullanıcı, rol ve permission atamaları. | +| Organizasyon birimleri | `/admin/ous` | OU ağacı ve üyelik yönetimi. | +| Tenant yönetimi | ListForm ekranları | Tenant kayıtları, bağlantı dizeleri, siparişten otomatik tenant açma. | +| Ayarlar | `/admin/settings` | Sistem ve entegrasyon ayarları. | +| Menü yöneticisi | `/admin/menuManager` | Menü ağacı düzenleme. | +| Dosya yöneticisi | `/admin/files` | Klasör/dosya işlemleri. | +| Aktivite ve denetim kaydı | `/admin/activityLog`, `/admin/changeLog` | Kullanıcı hareketleri ve ABP audit log detayı. | +| Bildirimler | `/admin/profile/notification-settings`, bildirim ekranları | Kanal tercihleri ve bildirim oluşturma. | +| Forum | `/admin/forum`, `/admin/forumManagement` | Forum kullanımı ve yönetimi. | +| Intranet | Intranet dashboard ve widget'ları | Duyuru, anket, sosyal duvar, etkinlik. | +| Video oda | `/admin/videoroom/*` | Oda listesi, detay, planlama, katılımcı ve ekran paylaşımı. | +| Raporlar | `/admin/reports/:report/view|design/...` | DevExpress rapor görüntüleyici ve tasarımcı. | +| AI asistanı | `/admin/ai` | n8n webhook üzerinden sohbet arayüzü. | +| Profil | `/admin/profile/*` | Genel bilgiler, şifre, bildirim ayarları. | -1. Yönetici Menü Yönetimi ekranını açar. -2. Menü veya route kaydını seçer. -3. URL, component path, yetki ve görünüm bilgilerini düzenler. -4. Kaydeder. -5. Uygulama konfigürasyonu yenilendiğinde route ve menü davranışı güncellenir. +### 10.4. Public site -#### Dikkat Edilmesi Gerekenler +`/home` · `/about` · `/services` · `/products` · `/checkout` · `/payment` · `/success` · +`/blog` · `/blog/:id` · `/demo` · `/contact` — içerikler veritabanından yönetilir; +`/about/designer` ve `/services/designer` sayfa tasarımcılarıdır. -Yanlış component path route yüklenememesine neden olur. Yanlış permission adı menünün görünmemesine veya yetkisiz erişime neden olabilir. Menü pathi ile route pathi uyumlu değilse kullanıcı menüden ekrana gidemeyebilir. +--- -### Developer Kit +## 11. Kimlik, Oturum ve Güvenlik -#### Amaç +### 11.1. Kimlik doğrulama -Teknik kullanıcıların SQL nesnelerini, custom componentleri, custom endpointleri, CRUD endpointleri, dynamic service kayıtlarını ve Wizard Manager ile yeni low-code ekranları yönetmesini sağlar. Developer Kit, uygulamanın kullanıcı tarafından genişletilebilmesi için hazırlanmış teknik araç setidir. +- **OpenIddict** password + refresh token akışı. Token uç noktası: `POST /connect/token` + (`grant_type=password`, `client_id=Platform_PublicApi`, `scope=offline_access Platform`). +- Token ömürleri `OpenIddict:TokenLifetimes` ile yapılandırılır (varsayılan 60 dk access, 90 dk refresh). +- Swagger için ayrı istemci: `AuthServer:SwaggerClientId`. +- İki aşamalı doğrulama (2FA) ve hesap doğrulama (e-posta/telefon) desteklenir. +- Kayıt ve iletişim formlarında Cloudflare Turnstile tabanlı captcha (`CaptchaMiddleware`). -#### Ekranda Görülen Alanlar +### 11.2. Oturum yönetimi -| Ekran | Route | Açıklama | -| --- | --- | --- | -| List Form Wizard | `/admin/listform/wizard` veya seed menüde wizard manager | Yeni dinamik ekran üretimi. | -| Custom Endpoints | `/admin/list/App.DeveloperKit.CustomEndpoints` | Custom endpoint tanımları. | -| CRUD Endpoints | `/admin/developerkit/endpoints` | Entity üzerinden endpoint üretimi/yönetimi. | -| Components | `/admin/developerkit/components` | Dinamik React component yönetimi. | -| Dynamic Services | `/admin/developerkit/dynamic-services` | C# dynamic service kodu test/compile/publish işlemleri. | -| SQL Query Manager | `/admin/sqlQueryManager` | SQL nesne ve sorgu yönetimi. | +- `PlatformSessionValidationMiddleware` her istekte oturumun hâlâ geçerli olduğunu doğrular. +- `PlatformSessionRevocationHandler` token iptalinde oturumu sonlandırır. +- `PlatformSessionCleanupWorker` süresi dolmuş oturumları temizler. +- Eş zamanlı kullanıcı limiti aşıldığında giriş reddedilir. -Developer Kit içinde öne çıkan araçlar: +### 11.3. Giriş engelleri -| Araç | Kullanım Amacı | -| --- | --- | -| SQL Query Manager | Veritabanı nesnelerini inceleme, sorgu çalıştırma, tablo oluşturma, seçili nesneleri farklı veritabanına kopyalama ve değişiklikleri seed dosyalarına taşıma. | -| CRUD Endpoints | Seçilen datasource ve tablo üzerinden CRUD API endpointleri üretme. | -| Custom Endpoints | Dinamik SQL çalıştıran, GET/POST destekleyen, URL, parametre ve permission ile yönetilen özel endpointler oluşturma. | -| Custom Components | React tabanlı dinamik component tanımlama, aktif/pasif yapma ve route üzerinden sunma. | -| Dynamic Services | C# ile yazılan servisleri derleme, yayınlama, Swagger üzerinde API olarak görünür hale getirme ve componentlerle entegre etme. | -| Wizard Manager | Menü, alt menü, SQL tablo seçimi, sütun seçimi, editor seçimi, list form, sub list, workflow, widget, dil metni ve permission üretimini adımlı olarak yapma. | - -#### Kullanıcı İşlemleri +`UserCannotSignInErrors` altında tanımlı, kullanıcıya lokalize mesajla dönen durumlar: -| İşlem | Açıklama | -| --- | --- | -| Component oluşturma | Dinamik component adı, açıklaması, bağımlılıkları, kodu ve aktiflik durumu yönetilir. | -| Endpoint üretme | CRUD endpoint tanımı oluşturulur veya aktif/pasif yapılır. | -| Custom endpoint oluşturma | SQL, parametre, HTTP metodu, URL ve permission bilgileriyle özel API tanımlanır. | -| Dynamic service derleme | Kod test compile edilir, yayınlanır veya pasif yapılır. | -| SQL çalıştırma | Yetkili kullanıcı SQL nesnelerini ve sorgularını yönetir. | -| Veritabanı nesnesi kopyalama | Table, stored procedure, view ve function nesneleri birden fazla veritabanına kopyalanabilir. | -| Seed güncelleme | Deploy edilen SQL objesi `SqlData` yapısı altında JSON olarak saklanarak sonraki seeder çalıştırmalarında güncel tutulabilir. | -| Low-code ekran deploy etme | Wizard Manager sonunda menü, dil metni, permission, grid/form/gantt/chart gibi ayarlar otomatik oluşturulabilir. | - -#### İş Akışı - -1. Teknik kullanıcı Developer Kit menüsünden ilgili aracı açar. -2. Gerekiyorsa SQL Query Manager ile tablo, view, function veya stored procedure hazırlar. -3. Datasource ve tablo seçimi yapar. -4. CRUD endpoint, custom endpoint, dynamic service veya custom component tanımlar. -5. Wizard Manager kullanılıyorsa menü, alt menü, kolonlar, editorler, sub listeler, widgetlar ve workflow adımları belirlenir. -6. Test/compile/generate işlemi çalıştırılır. -7. Başarılıysa deploy/publish edilir. -8. Oluşturulan route ve menüler permissionlarla birlikte kullanıcılara sunulur. - -#### Hazır Component Örnekleri - -| Component | Açıklama | +| Kod | Anlamı | | --- | --- | -| `DynamicEntityComponent` | Dinamik entity veya tablo kaynaklı veriyi component içinde göstermek için örnek olarak kullanılabilir. | -| `RoleListComponent` | Rol listesini veya rol odaklı bir görünümü örneklemek için kullanılan hazır componenttir. | +| `LoginNotAllowed_NotVerified` | Hesap doğrulanmamış. | +| `LoginNotAllowed_NotAllowedIp` | IP kısıtlaması (`IpRestriction`). | +| `LoginNotAllowed_WorkHour` | Çalışma saati kısıtlaması (`WorkHour`). | +| `LoginNotAllowed_LoginEndDateDue` | Kullanıcının giriş yetki süresi dolmuş. | +| `ShouldChangePasswordOnNextLogin` / `ShouldChangePasswordPeriodic` | Zorunlu şifre değişimi. | +| `LoginNotAllowed_TenantIsPassive` / `TenantNotFound` | Tenant pasif veya bulunamadı. | +| `LoginNotAllowed_BranchLimit` | Şube lisans limiti aşıldı. | +| `LoginNotAllowed_ConcurrentUserLimit` | Eş zamanlı kullanıcı limiti aşıldı. | -#### Wizard Manager ile Low-Code Ekran Oluşturma +### 11.4. Güvenlik kuralları -Wizard Manager, yeni bir ekranın teknik detaylarını adım adım oluşturmak için kullanılır: +- Tüm katmanlarda RBAC ve yetki tabanlı görünürlük zorunludur. +- Dinamik SQL'de **yalnızca parametreli** desenler kullanılır; kullanıcı girdisi string birleştirme ile sorguya eklenmez. +- Her sorgu ve eylemde tenant izolasyonu korunur (`IsTenant`, `IsBranch`, `IsOrganizationUnit` bayrakları ve ABP tenant filtreleri). +- Gerçek kimlik bilgileri, token ve anahtarlar örneklerde/dokümanda yer almaz; yer tutucu kullanılır. +- Denetim: kritik işlemler ABP Audit Log'a yazılır; yüksek frekanslı `list-form-customization` çağrıları denetim dışı bırakılmıştır. -| Adım | Açıklama | -| --- | --- | -| Menü seçimi | Yeni menü eklenir veya mevcut bir menünün altına alt menü oluşturulur. | -| SQL tablo seçimi | Datasource içindeki tablolar arasından kaynak tablo seçilir. | -| Sütun seçimi | Listede ve formda gösterilecek sütunlar belirlenir. | -| Editor seçimi | SelectBox, TextBox ve DevExpress editorleri gibi alan tipleri seçilir. | -| Alt liste ilişkisi | Oluşturulan formun altında sub listeler tanımlanabilir. | -| Form ilişkisi | İki form arasında ilişki kurulabilir. | -| Widget tanımı | Listenin üst kısmında SQL ile beslenen özet widgetlar oluşturulabilir. | -| Workflow tanımı | Start, Compare, Approval, Information ve Finish adımlarından oluşan dinamik akış kurulabilir. | -| Deploy | Menü, dil textleri, grid/form/gantt/chart ayarları, permission group ve permissionlar otomatik tanımlanır. | +--- -#### Dikkat Edilmesi Gerekenler +## 12. Çok Kiracılılık (Multi-Tenancy) -Bu modül son kullanıcıdan çok teknik yönetici içindir. Dynamic service, custom endpoint ve SQL işlemleri sistem davranışını doğrudan etkiler. SQL çalıştırma için `SqlQueryManager.SqlExecution.Execute` izni gerekir. Deploy edilen dinamik ekranlar kullanıcı menüsüne eklendiğinde ilgili permissionlar da doğru rol veya kullanıcılara atanmalıdır. +- `PlatformConsts.IsMultiTenant = true`. +- Tenant çözümleme host adı (subdomain) ve ABP tenant resolver'ları ile yapılır (`utils/subdomain.ts`). +- **Tenant başına ayrı veritabanı:** `TenantConnectionString` kayıtları ile her tenant kendi + veritabanına yönlendirilebilir; migration'lar `DatabaseMigrationEventHandlerBase` üzerinden + tenant oluşturulduğunda otomatik uygulanır. +- **Şube (Branch):** Tenant altındaki fiziksel/örgütsel kırılım; kullanıcı–şube eşlemesi + `BranchUsers` ile tutulur, lisans limiti girişte kontrol edilir. +- **Organizasyon birimi:** ABP `OrganizationUnit` ağacı; ListForm'lar OU bazlı filtrelenebilir. +- Tenant bazlı lokalizasyon: `TenantLocalizationInitializer` + `TenantLocalizationMiddleware`. -### Dosya Yönetimi +--- -#### Amaç +## 13. Bildirim ve Entegrasyonlar -Host veya tenant bazında dosya ve klasörlerin yönetilmesini sağlar. File Manager, ABP BlobManager/BlobStoring altyapısında tutulan dosyaları kullanıcı arayüzünden dinamik olarak yönetilebilir hale getirir. Tenant bazında her kiracıya ait dizin ayrı yönetilebilir. +### 13.1. Kanallar -#### Ekranda Görülen Alanlar +`Sms` · `Mail` · `Rocket` · `Desktop` · `UiActivity` · `UiToast` · `WhatsApp` (`Telegram` altyapıda tanımlı, UI'da kapalı) -| Alan | Açıklama | -| --- | --- | -| Tenant seçimi | Host bağlamında tenant veya Host dosya alanı seçilebilir. Tenant kullanıcısında seçim sabitlenir. | -| Breadcrumb | Klasör yolu gösterilir. | -| Dosya/klasör grid veya liste görünümü | Kullanıcı görünüm modunu değiştirebilir. | -| Arama | Dosya ve klasör adıyla arama yapılır. | -| Sıralama | Ada, boyuta veya değiştirilme tarihine göre sıralama yapılır. | -| Toolbar | Yükle, klasör oluştur, kopyala, kes, yapıştır, yeniden adlandır, indir, sil, tümünü seç. | -| Tenant dizini | Her tenant/kiracı için ayrı dosya alanı yönetilebilir. | - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | -| --- | --- | -| Dosya yükleme | Dosya seçilerek mevcut klasöre yüklenir. | -| Klasöre girme | Klasör üzerinde çift tıklanarak klasör içine girilir. | -| Klasör oluşturma | Mevcut klasör altında yeni klasör oluşturulur. | -| Yeniden adlandırma | Seçili tek öğenin adı değiştirilir. | -| İndirme | Sadece dosya tipindeki tek öğe indirilebilir. | -| Silme | Tekil veya çoklu silme yapılır. | -| Kopyalama/taşıma | Seçili öğeler panoya alınır ve hedef klasöre yapıştırılır. | -| Cut/Copy/Paste | Dosya veya klasörler kesilebilir, kopyalanabilir ve başka dizine yapıştırılabilir. | -| Arama/sıralama | Liste görünümü daraltılır veya sıralanır. | - -#### İş Akışı - -1. Kullanıcı Dosyalar ekranını açar. -2. Host bağlamındaysa tenant/kiracı dizinini seçer. -3. Klasör üzerinde çift tıklayarak klasör içinde gezinir. -4. Dosya yükler, dosya indirir, dizin oluşturur veya öğe seçer. -5. Copy, cut, paste, rename veya delete gibi toolbar işlemlerini kullanır. -6. Sistem başarı/hata bildirimi gösterir. - -#### Dikkat Edilmesi Gerekenler - -| Kısıt | Açıklama | -| --- | --- | -| Sistem klasörleri | `intranet`, `avatar`, `import`, `note`, `backup` klasörleri korumalıdır. | -| Koruma | Korumalı klasörler seçilemez, silinemez, kopyalanamaz veya yeniden adlandırılamaz. | -| Klasör silme | İçinde dosya bulunan klasör güvenlik nedeniyle silinmez. | -| Aynı klasöre taşıma | Aynı klasöre taşıma işlemi engellenir. | +### 13.2. Yönlendirme modeli -### Bildirim Yönetimi +Bildirim **tip + kanal** eşleşmesiyle yönlendirilir. Alıcı hedefleme seçenekleri: +`All`, `User`, `Role`, `OrganizationUnit`, `Custom`. Kullanıcılar kendi bildirim tercihlerini +`/admin/profile/notification-settings` üzerinden kanal bazında yönetir. -#### Amaç +### 13.3. Göndericiler (`Sozsoft.Sender`) -Sistem bildirimlerinin türlerini, kurallarını, kullanıcı bildirimlerini ve kullanıcı bildirim tercihlerini yönetir. Bildirimler yazılımın herhangi bir anında tanımlanan kişi, telefon, e-posta, rol veya organizasyon hedefine iletilebilir. - -#### Ekranda Görülen Alanlar - -| Ekran | Açıklama | -| --- | --- | -| Notification Types | Bildirim tipleri. | -| Notification Rules | Bildirim kuralları. | -| Notifications | Gönderilen veya kullanıcıya ulaşan bildirim kayıtları. | -| Profil Bildirim Ayarları | Kullanıcının kendi bildirim tercihleri. | - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | +| Kanal | Sağlayıcı | | --- | --- | -| Bildirim listeleme | Bildirim kayıtları görüntülenir. | -| Okundu bilgisi güncelleme | Bildirim tekil veya kanal bazlı okundu yapılabilir. | -| Kural yönetme | Kullanıcı kendi bildirim kuralını ekleyebilir veya silebilir. | -| Test bildirim gönderme | Uygun servis üzerinden bildirim kuralına bağlı mesaj gönderilebilir. | -| Rol bazlı bildirim | Bildirim hedefi rol olarak seçilebilir. | -| Kişi bazlı bildirim | Belirli kullanıcı veya iletişim bilgilerine bildirim gönderilebilir. | -| Kanal bazlı gönderim | Mail, SMS, Rocket, UI toast, desktop veya WhatsApp gibi kanallar kullanılabilir. | - -#### İş Akışı +| E-posta | ABP Emailing + Amazon SES (`AmazonSesEmailSender`), arka plan iş kuyruğu ile | +| SMS | Posta Güvercini HTTP API | +| WhatsApp | WhatsApp Cloud API (şablon tabanlı) | +| Rocket.Chat | Rocket.Chat HTTP API | -1. Yönetici bildirim tip ve kurallarını tanımlar. -2. Gönderim kanalı, hedef kullanıcı/rol/telefon/e-posta bilgisi ve mesaj şablonu belirlenir. -3. Kullanıcı Profil > Bildirim Ayarları bölümünden tercihlerini düzenler. -4. Sistem olay oluştuğunda uygun kanaldan bildirim üretir. -5. Kullanıcı bildirimi okundu olarak işaretleyebilir. +Tüm sağlayıcı kimlik bilgileri ayar ekranından yönetilir (`App.Settings.*` anahtarları); +koda gömülmez. -#### Dikkat Edilmesi Gerekenler +### 13.4. Mail kuyruğu -Kodda SMS, Mail, Rocket, Desktop, UiActivity, UiToast ve WhatsApp kanal sabitleri görülmektedir. Kanalın çalışması ilgili ayarların doğru yapılandırılmasına bağlıdır. +`Sozsoft.MailQueue`; şablondan gövde üretir, PDF/XLS/TXT çıktıları ve ek dosyaları yönetir, +kuyruğu arka plan işçisiyle çalıştırır ve sonuçları günlükler. -### Intranet +### 13.5. AI entegrasyonu -#### Amaç +`AiBot` kayıtları tenant bazlıdır; n8n webhook üzerinden LangChain ajanı + bellek penceresi ve +Gemini sohbet modeli bağlanır (`configs/ai/Chat.json`). UI tarafında `/admin/ai` ve header'daki +asistan bileşeni bu uç noktayı kullanır. -Tenant bazında kurum içi duyuru, anket, sosyal gönderi, etkinlik ve kullanıcıya sunulacak dosya içeriklerini yönetmek için kullanılır. Kullanıcılar online olduklarında intranet alanında anonsları görebilir, anketlere katılabilir, sosyal duvar üzerinden paylaşım yapabilir, etkinlikleri takip edebilir ve doğum günü gibi dashboard bilgilerini görüntüleyebilir. +--- -#### Ekranda Görülen Alanlar - -| Ekran/Alan | Açıklama | -| --- | --- | -| Duyurular | `App.Intranet.Announcement` list formu. | -| Anketler | `App.Intranet.Survey`, soru, seçenek, cevap ve yanıt list formları. | -| Sosyal Gönderiler | `App.Intranet.SocialPost` ve yorum kayıtları. | -| Etkinlikler | Etkinlik tipi, kategori, etkinlik ve beğeni kayıtları. | -| Dashboard widget'ları | Yaklaşan etkinlikler, doğum günleri, anketler, dokümanlar ve duyurular. | -| Social Wall | Gönderi oluşturma, medya, konum ve etkileşim bileşenleri. | -| Dosya gösterimi | Kullanıcılara sunulacak dosyalar ve dokümanlar intranet widgetları içinde gösterilebilir. | +## 14. Arka Plan İşleri -#### Kullanıcı İşlemleri +İki motor birlikte kullanılır: **Hangfire** (kalıcı iş deposu, `default` ve `platform` kuyrukları) +ve **ABP Background Workers** (yinelenen işler). Yönetim `/admin` altındaki Background Worker +ekranlarından yapılır; yetkiler `App.BackgroundWorkers.RecurringJobs` ve `.Jobs`. -| İşlem | Açıklama | +| Worker tipi | İşlevi | | --- | --- | -| Duyuru görüntüleme | Kullanıcı duyuruları dashboard veya modal üzerinden görür. | -| Anons yönetimi | Yetkili kullanıcı tenant bazında anons/duyuru tanımlayabilir. | -| Ankete katılma | Anket modalı üzerinden yanıt verilir. | -| Anket oylama | Kullanıcı anket sorularına oy verebilir veya yanıt gönderebilir. | -| Sosyal gönderi oluşturma | Metin, medya veya konum içeren gönderi oluşturulabilir. | -| Sosyal duvar etkileşimi | Kullanıcı gönderileri görüntüler, yorum veya beğeni gibi etkileşimlerde bulunabilir. | -| Etkinlik görüntüleme | Etkinlik bilgisi modal üzerinden açılabilir. | -| Event tanımlama | Yetkili kullanıcı etkinlik tipi, kategori ve etkinlik kayıtlarını yönetebilir. | -| Doğum günü görüntüleme | Doğum günü olan kullanıcılar dashboard widgetında gösterilebilir. | -| Doküman görüntüleme | Kullanıcıya sunulan dosya veya dokümanlar widget üzerinden erişilebilir hale gelir. | - -#### İş Akışı - -1. Yönetici tenant kapsamında duyuru, anket, sosyal gönderi ve event kayıtlarını oluşturur. -2. Dosya gösterilecekse ilgili dokümanlar File Manager/Blob alanında hazırlanır. -3. Kullanıcı intranet dashboard veya social wall ekranını açar. -4. Widget veya gönderiler üzerinden içeriğe ulaşır. -5. Anket oylama, yorum, medya, beğeni veya etkinlik gibi etkileşimler yapılır. - -#### Dikkat Edilmesi Gerekenler - -Google Maps veya konum işlevleri için ilgili API anahtarı gerekebilir. Konum picker hata mesajları kodda ayrıca ele alınmıştır. Intranet içerikleri tenant bağlamında çalıştığı için bir tenantta tanımlanan duyuru, anket veya event başka tenant kullanıcılarına gösterilmeyebilir. - -### Forum - -#### Amaç - -Kurum, platform veya kiralama yapan kullanıcıların kategori, konu ve gönderiler üzerinden ortak çalışmasını ve görüş alışverişi yapmasını sağlar. - -#### Ekranda Görülen Alanlar - -| Ekran | Açıklama | -| --- | --- | -| Forum | Kullanıcıların kategori, konu ve gönderileri görüntülediği ekran. | -| Forum Management | Yönetim dashboard'u ve kategori/konu/gönderi yönetimi. | -| Search Modal | Forum içinde arama. | -| Create Topic Modal | Yeni konu oluşturma. | -| Create Post Modal | Konuya gönderi/cevap ekleme. | - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | -| --- | --- | -| Kategori listeleme | Forum kategorileri görüntülenir. | -| Konu oluşturma | Yetkili kullanıcı yeni konu açar. | -| Gönderi oluşturma | Konuya cevap eklenir. | -| Beğenme | Konu veya gönderi beğenilebilir. | -| Sabitleme/kilitleme | Yetkili kullanıcı konuyu sabitleyebilir veya kilitleyebilir. | -| Çözüm işaretleme | Bir gönderi kabul edilen cevap olarak işaretlenebilir. | -| Yönetim işlemleri | Kategori, konu ve gönderi kayıtları yönetilir. | -| Ortak çalışma | Kiralama yapan veya platformu kullanan kullanıcılar ortak bir bilgi paylaşım alanında buluşabilir. | - -#### İş Akışı - -1. Kullanıcı Forum ekranını açar. -2. Kategori seçer veya arama yapar. -3. Konu detayını görüntüler. -4. Cevap yazar veya etkileşimde bulunur. -5. Yetkili kullanıcı gerekiyorsa konuyu kilitler, sabitler veya çözüm işaretler. - -#### Dikkat Edilmesi Gerekenler +| `MailQueueWorker` | Mail kuyruğunu işler. | +| `SqlWorker` | Zamanlanmış SQL çalıştırır. | +| `NotificationWorker` | Bildirim kurallarını değerlendirir ve gönderir. | +| `SessionCleanupWorker` | Süresi dolmuş oturumları temizler. | +| `BackupWorker` | Veritabanı yedeği alır ve eski yedekleri temizler. | -Kilitli kategori veya konu üzerinde yeni gönderi oluşturma davranışı backend ve yetkiye bağlıdır. Yönetim ekranları yalnızca ilgili permissionlara sahip kullanıcılara açılmalıdır. +Hangfire panosu geliştirmede serbest, üretimde `AbpHangfireAuthorizationFilter` ile korumalıdır +(`/hangfire`). -### Video Oda +--- -#### Amaç +## 15. Raporlama -Canlı video oda/toplantı süreçlerini yönetir. Kodda oda listesi, oda detayı, dashboard, katılımcı, sohbet, ekran paylaşımı ve SignalR/WebRTC servisleri doğrulanmıştır. Kullanıcı bir oda oluşturup diğer kullanıcıları davet ederek uygulama içinde videolu görüşme yapabilir. +- **DevExpress Reporting**: Web Document Viewer (`/admin/reports/:report/view/...`) ve + Report Designer (`/admin/reports/:report/design/...`). +- **Rapor deposu**: `ReportTemplate` ve `ReportCategory` varlıkları; `CustomReportStorageWebExtension` + ile veritabanı üzerinden okuma/yazma. +- **Dinamik raporlar** (`HttpApi.Host/PredefinedReports`): ListForm tanımından otomatik üretilen + grid, form ve ağaç raporları — grid durumunu (sütun sırası, gruplama, filtre) rapora yansıtır. +- Linux konteynerlerde font sorunlarını önlemek için `DevExpressFontConfiguration` devrededir. -#### Ekranda Görülen Alanlar +--- -| Ekran/Panel | Açıklama | -| --- | --- | -| Video Oda Dashboard | Genel video oda yönetim alanı. | -| Oda Listesi | Oda kayıtlarını listeler. | -| Oda Detayı | Oda ve katılımcı bilgileri. | -| Video Player | Kamera/ses akışı. | -| Participants Panel | Katılımcı listesi. | -| Chat Panel | Genel ve özel mesajlar. | -| Documents Panel | Oda dokümanları. | -| Screen Share Panel | Ekran paylaşımı. | -| Kick Participant Modal | Katılımcı çıkarma onayı. | - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | -| --- | --- | -| Oda oluşturma/düzenleme | Video oda kaydı oluşturulur veya güncellenir. | -| Kullanıcı davet etme | Odaya katılacak kullanıcılar davet edilebilir veya katılımcı listesine eklenebilir. | -| Oda başlatma/bitirme | Toplantı başlatılır veya sonlandırılır. | -| Odaya katılma | SignalR bağlantısı kurulur, WebRTC medya akışı başlatılır. | -| Mesaj gönderme | Genel veya özel mesaj gönderilebilir. | -| Duyuru gönderme | Katılımcılara duyuru iletilebilir. | -| El kaldırma | Katılımcı el kaldırabilir; yetkili kullanıcı onaylayabilir veya indirebilir. | -| Susturma/çıkarma | Yetkili kullanıcı katılımcıyı susturabilir veya odadan çıkarabilir. | -| Kamera/mikrofon açma kapama | Kullanıcı medya ayarlarını değiştirebilir. | +## 16. Dosya Yönetimi ve CDN -#### İş Akışı +- ABP BlobStoring, dosya sistemi sağlayıcısıyla `App:CdnPath` altına yazar; dosyalar `App:CdnUrl` + üzerinden servis edilir. +- `FileManagementAppService`: listeleme, yükleme, indirme, kopyalama, taşıma, yeniden adlandırma, silme. +- UI: `/admin/files` (`views/admin/files/FileManager.tsx`) — breadcrumb, çoklu yükleme, önizleme. +- Avatar, aktivite görselleri, içe aktarma dosyaları ve veritabanı yedekleri ayrı konteynerlerde tutulur. -1. Yönetici veya yetkili kullanıcı video oda kaydı oluşturur. -2. Görüşmeye katılacak kullanıcıları davet eder. -3. Oda başlatılır. -4. Katılımcılar odaya bağlanır. -5. Kamera, mikrofon, sohbet ve ekran paylaşımı kullanılır. -6. Oda sonunda toplantı bitirilir. +--- -#### Dikkat Edilmesi Gerekenler +## 17. Gerçek Zamanlı Özellikler -Kamera ve mikrofon için tarayıcı izni gerekir. SignalR bağlantısı koptuğunda yeniden bağlanma bildirimleri gösterilir. Kamera/mikrofon açılamazsa hata bildirimi verilir. +- **Messenger**: SignalR hub'ı `/messengerhub`. Konuşma ve mesaj varlıkları veritabanında; + Redis etkinse backplane olarak kullanılır. Query string ile gelen `access_token`, + hub bağlantısı için `Authorization` başlığına çevrilir. +- **Video oda**: Oda oluşturma/başlatma/bitirme, katılımcı yönetimi, sohbet, doküman ve ekran + paylaşımı panelleri; TURN sunucusu `configs/deployment/configs/turnserver.conf` ile yapılandırılır. +- **Global arama**: `GlobalSearchAppService` üzerinden çapraz modül arama. -### Public Site ve İçerik Yönetimi +--- -#### Amaç +## 18. Frontend Mimarisi -Public site sayfalarını, ürünleri, ödeme akışını, blog içeriklerini, demo taleplerini, iletişim kayıtlarını ve kiralama/sipariş süreçlerini yönetir. Public sayfalar dinamik component ve Tailwind CSS düzenlemeleriyle yönetilebilir. +### 18.1. Route ve bileşen çözümü -#### Ekranda Görülen Alanlar +``` +Route kayıtları (DB) + ├─ componentPath ──► fiziksel view (React.lazy) + └─ CustomComponent ──► @babel/standalone ile runtime derleme ──► ComponentContext + │ + DynamicRouter ──► ProtectedRoute ──► PermissionGuard ──► PageContainer +``` -| Public Ekran | Route | -| --- | --- | -| Home | `/home` | -| About | `/about` | -| Services | `/services` | -| Products | `/products` | -| Checkout | `/checkout` | -| Payment | `/payment` | -| Success | `/success` | -| Blog | `/blog` | -| Blog Detail | `/blog/:id` | -| Demo | `/demo` | -| Contact | `/contact` | - -Yönetim tarafında doğrulanan listeler: - -| Yönetim Ekranı | Açıklama | -| --- | --- | -| Home/About/Services/Contact designer | Public sayfa tasarım/yönetim ekranları. | -| Products | Ürün kayıtları. | -| Payment Methods | Ödeme yöntemi kayıtları. | -| Installment Options | Taksit seçenekleri. | -| Sales Orders | Satış siparişleri ve satış sipariş kalemleri. | -| Blog Categories | Blog kategori kayıtları. | -| Blog Posts | Blog yazıları. | -| Demos | Demo talep kayıtları. | - -Public site tarafında kullanılan temel içerikler: - -| İçerik | Açıklama | -| --- | --- | -| Home, About, Services | Public sayfa içerikleri ve dinamik tasarım alanları. | -| Product | Kiralanabilir ürün veya paket kayıtları. | -| Payments | Ödeme yöntemi yönetimi. | -| Installment Options | Taksit veya ödeme periyodu seçenekleri. | -| Sales Orders | Aylık veya yıllık kiralama siparişleri. | -| Blog Posts | Blog içerikleri. | -| Demo, Contact | Talep ve iletişim formları. | +`ROUTES_ENUM` (`src/routes/route.constant.ts`) statik yol sözleşmelerini tutar; iş modülleri için +(`supplychain`, `maintenance`, `warehouse`, `projects`, `hr`, `crm`, `mrp`, `accounting`) ayrılmış +yol desenleri de buradadır. Bu alanların ekranları, fiziksel React sayfaları yerine ListForm +konfigürasyonu ile üretilir. -#### Kullanıcı İşlemleri +### 18.2. Durum yönetimi -| İşlem | Açıklama | -| --- | --- | -| Public içerik görüntüleme | Ziyaretçi public sayfaları görüntüler. | -| Demo talebi gönderme | Demo formu üzerinden talep oluşturulur. | -| Ürün seçme | Ürün ve ödeme ekranları üzerinden sipariş akışı başlatılır. | -| Sipariş oluşturma | Ödeme bilgileri ile satış siparişi oluşturulur. | -| Aylık/yıllık kiralama | Kullanıcı ürün veya paket için aylık/yıllık sipariş oluşturabilir. | -| Subdomain ile kullanım | Sipariş/tenant süreci sonrasında kiracıya subdomain üzerinden sistem kullandırılabilir. | -| Blog görüntüleme | Blog listesi ve detayları görüntülenir. | -| İçerik yönetimi | Yönetici list form ekranları üzerinden içerik kayıtlarını yönetir. | - -#### İş Akışı +- **easy-peasy** global store: `auth`, `theme`, `locale`, `abpConfig`, `admin`, `client`, `base`. + Oturum ve tercih verileri `localStorage` yerine store üzerinden yönetilir (`stateSync.ts`). +- **React Query**: sunucu verisi önbellekleme. +- **Context'ler**: `ComponentContext` (runtime bileşen kaydı), `DialogContext`, `ScrollContext`. -1. Ziyaretçi public siteye girer. -2. Ürün, hizmet veya blog içeriğini inceler. -3. Demo, iletişim veya ödeme akışını kullanır. -4. Yönetici admin panelinden public içerikleri ve gelen kayıtları yönetir. +### 18.3. Tema ve lokalizasyon -#### Dikkat Edilmesi Gerekenler +- Tailwind tabanlı tasarım sistemi (`components/ui`) + DevExtreme temaları; açık/koyu mod ve + tema yapılandırıcı (`ThemeConfigurator`). +- Dil metinleri veritabanından gelir (`Sozsoft.Languages`); dil seçici header'dadır. + Tenant bazlı çeviri geçersiz kılma desteklenir. +- Layout seçenekleri: dikey menü, yatay menü, yığılmış yan menü, mobil navigasyon. -Mevcut işleyiş Havale/EFT ile çalışacak şekilde tarif edilmiştir. Kredi kartı entegrasyonu ileride eklenebilecek bir geliştirme alanıdır. Sipariş, tenant ve subdomain süreçleri birlikte ele alınmalıdır. +### 18.4. PWA ve sürüm yönetimi -### Dil ve Lokalizasyon +- Precache **yalnızca uygulama kabuğunu** kapsar (index.html + entry JS/CSS ≈ 2.8 MB); DevExtreme + temaları, lazy chunk'lar, font ve görseller runtime cache ile saklanır. Sebebi: + `dist` klasörü ~55 MB olduğundan tam precache, yavaş bağlantılarda service worker kurulumunun + timeout'a düşmesine yol açıyordu. +- `registerType: 'prompt'` — yeni sürüm indirilir, kullanıcı onayıyla devreye alınır + (`views/version/swRegistration.ts`, `useAppVersionNotice.tsx`). +- `version.json` her zaman ağdan tazelenir (NetworkFirst); changelog `/admin/changeLog` + ekranında gösterilir. +- `scripts/generate-version.js` build sırasında sürüm bilgisini üretir. -#### Amaç +--- -Uygulama metinlerinin ve dil kayıtlarının yönetilmesini sağlar. ABP lokalizasyon yapısı genişletilerek dil, language key ve language text kayıtlarının dinamik olarak yönetilebildiği bir yapı sunulur. Uygulama 15'ten fazla dil ile çalışabilecek şekilde tasarlanmıştır. +## 19. API ve Swagger -#### Ekranda Görülen Alanlar +- Swagger UI, API kökünde yayınlanır; OAuth2 ile "Authorize" düğmesi üzerinden token alınabilir. +- Endpoint'ler ABP **conventional controller** mekanizmasıyla uygulama servislerinden üretilir: -| Ekran | Açıklama | +| Namespace | Kök yol | | --- | --- | -| Language | Dil kayıtları. | -| Language Text | Çeviri metinleri. | -| Language Key | Backend servis olarak doğrulanmıştır; menüde ayrı kayıt bu taramada doğrulanmadı. | +| `Sozsoft.Platform.ListForms.Administration` | `/api/admin/...` | +| Diğer `Sozsoft.Platform.*` servisleri | `/api/app/...` | +| Modül servisleri (Languages, Settings, MailQueue, Notifications, SqlQueryManager) | Modül kendi kök yolunu kullanır | -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | -| --- | --- | -| Dil kaydı yönetme | Desteklenen diller eklenir veya düzenlenir. | -| Language key oluşturma | Uygulama içinde kullanılacak metin anahtarları tanımlanır. | -| Çeviri metni yönetme | Anahtar bazlı metinler güncellenir. | -| Çoklu dil yönetimi | Her dil için karşılık gelen text değerleri girilir. | -| Cache temizleme | `languageText.service.ts` içinde Redis cache temizleme servisi doğrulanmıştır. | +- DevExpress raporlama controller'ları (`CustomReportDesigner`, `CustomWebDocumentViewer`, + `QueryBuilder`) Swagger dokümanından hariç tutulur. +- Yanıtlar Brotli/Gzip ile sıkıştırılır; `X-Correlation-Id` başlığı istemciye açılır. +- Hata kodları: `PlatformConsts.AppErrorCodes` (`Error:0001` yetkisiz, `App.NoResults` kayıt yok, + `Error:0003` parametre geçersiz, `Error:0005` iç hata). -#### İş Akışı +**Token örneği** (yer tutucularla): -1. Yönetici Dil menüsünü açar. -2. Dil veya çeviri metni kaydını seçer. -3. Değeri günceller. -4. Gerekirse cache temizlenir. +```http +POST /connect/token HTTP/1.1 +Content-Type: application/x-www-form-urlencoded -#### Dikkat Edilmesi Gerekenler +grant_type=password +&username= +&password= +&client_id=Platform_PublicApi +&scope=offline_access%20Platform +``` -Lokalizasyon veritabanı üzerinden kullanılmaktadır. Yanlış çeviri anahtarı veya eksik metin kullanıcı arayüzünde ham anahtar görünmesine neden olabilir. Yeni dinamik ekranlar deploy edilirken menü, alan ve permission metinleri için dil kayıtlarının da oluşturulması önemlidir. +--- -### Raporlama +## 20. Dağıtım (Deployment) -#### Amaç +`configs/deployment` altında üç ortam için compose dosyaları ve numaralandırılmış scriptler bulunur. -Rapor kategori ve rapor template kayıtlarını yönetmek, DevExpress report viewer ve report designer altyapısını kullanmak için hazırlanmıştır. Yetkili kullanıcılar tanımlı raporları görüntüleyebilir, mevcut raporları düzenleyebilir ve rapor tasarımlarını güncelleyebilir. - -#### Ekranda Görülen Alanlar - -| Ekran | Açıklama | -| --- | --- | -| Report Categories | Rapor kategori tanımları. | -| Report Templates | Rapor template kayıtları. | -| Report Viewer | DevExpress rapor görüntüleyici. | -| Report Designer | DevExpress rapor tasarım ekranı. | - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | +| Dosya | Amaç | | --- | --- | -| Rapor kategorisi yönetme | Kategori ekleme/düzenleme/silme. | -| Template yönetme | Rapor şablonu kayıtlarını yönetme. | -| Rapor görüntüleme | Yetkili raporu viewer ile açma. | -| Rapor tasarlama | Designer üzerinden rapor tasarımını düzenleme. | -| Rapor düzenleme | Tanımlanan bir rapor açılarak DevExpress designer ile değiştirilebilir. | +| `docker-compose-data.yml` | Redis, SQL Server / PostgreSQL, yedek volume'ları | +| `docker-compose-app.yml` / `.dev.yml` / `.production.yml` | API, UI, migrator, nginx | +| `docker-compose-devops.yml` | Forgejo + runner (CI/CD) | +| `Dockerfile`, `api/*.Dockerfile`, `ui/Sozsoft.Platform.Ui.Dockerfile` | İmaj tanımları | +| `configs/nginx.conf`, `redis.conf`, `turnserver.conf`, `elasticsearch.yml` | Servis yapılandırmaları | -#### İş Akışı +Script sırası (`configs/deployment/scripts`): + +``` +1-devops.sh → DevOps makinesi (Forgejo, runner) +2-data-postgres.sh / +3-data-sql.sh → Veri katmanı +4-production-build.sh → İmaj build +5-production-deploy.sh → Yayın +6-production-migrator-true.sh → Migration çalıştırma +7-setup_ssl.sh → Sertifika +8-dev-build.sh / 9-dev-deploy.sh / 10-dev-migrator-true.sh → Dev ortamı +``` -1. Yönetici rapor kategorisi oluşturur. -2. Rapor template kaydını ilgili kategori altında tanımlar. -3. Kullanıcı rapor görüntüleme route'u ile raporu açar. -4. Yetkili teknik kullanıcı DevExpress report designer ekranında tasarımı günceller. -5. Değiştirilen rapor yeniden görüntülenebilir hale gelir. +Ayrıntılı kurulum notları: [`configs/deployment/README.md`](configs/deployment/README.md), +[`Readme_Production.md`](configs/deployment/Readme_Production.md), +[`Readme_Devops.md`](configs/deployment/Readme_Devops.md). -#### Dikkat Edilmesi Gerekenler +> Bu dokümanlarda geçmişte örnek amaçlı bırakılmış kimlik bilgileri bulunabilir; üretimde +> tümü döndürülmeli ve secret yöneticisine taşınmalıdır. -DevExpress controllerları Swagger dokümantasyonundan hariç tutulmuştur. Rapor tasarımı teknik bilgi gerektirir. +--- -### Background Worker ve Mail Queue +## 21. Geliştirme Standartları ve Sık Kullanılan Komutlar -#### Amaç +### 21.1. Çözüm kuralları -Zamanlanmış veya arka plan işleriyle ilgili kayıtları yönetir. ABP Background Workers ve Hangfire altyapısı kullanılır. Cron zamanına göre tanımlanan worker kayıtları belirli aralıklarla çalıştırılabilir, izlenebilir ve detayları incelenebilir. +1. Her istek şu sırayla değerlendirilir: **(1)** ListForm konfigürasyonu → **(2)** SQL Query + Manager + Custom Endpoint → **(3)** Dynamic Service → **(4)** kod değişikliği (son çare, + gerekçe zorunlu). +2. Standart iş ihtiyaçları için yeni özel React sayfası/bileşeni geliştirilmez. +3. Her öneri tenant ve yetki tasarımını içerir. +4. Platform yetkilendirme desenleri atlanmaz. +5. Secret, tenant id veya bağlantı dizesi koda gömülmez. -#### Ekranda Görülen Alanlar +### 21.2. Frontend -| Ekran | Açıklama | -| --- | --- | -| Recurring Jobs | Tekrarlı job kayıtları. | -| Jobs | Çalışan veya geçmiş job kayıtları. | -| MailQueue modülü | Mail queue altyapısı backend modülü olarak doğrulanmıştır. | +```powershell +cd ui +npm start # geliştirme sunucusu +npm run build # üretim derlemesi (metadata + version + vite build) +npm run lint # ESLint +npm run format # prettier --write + eslint --fix +``` -Tanımlanabilen veya kodda altyapısı bulunan worker örnekleri: +### 21.3. Backend -| Worker | Açıklama | -| --- | --- | -| BackupWorker | Yedekleme işlemleri için kullanılabilir. | -| MailQueueWorker | Kuyruktaki e-posta işlemlerini yürütür. | -| NotificationWorker | Bildirim üretme/gönderme süreçlerini çalıştırır. | -| SqlWorker | Belirlenen SQL işlemlerini zamanlanmış olarak çalıştırır. | -| SessionCleanupWorker | Kullanıcı oturum temizleme süreçlerini yürütür. | +```powershell +cd api +dotnet build +dotnet run --project src/Sozsoft.Platform.HttpApi.Host +dotnet format --include .\modules\Sozsoft.Notifications\ --folder -#### Kullanıcı İşlemleri +# Migration (EntityFrameworkCore projesi içinde) +dotnet ef migrations add +dotnet ef database update +dotnet ef migrations remove +``` -| İşlem | Açıklama | -| --- | --- | -| Job listeleme | Arka plan iş kayıtları görüntülenir. | -| Job üretme | `generateBackgroundWorkers` servisi doğrulanmıştır. | -| Hangfire dashboard görüntüleme | Geliştirme ortamında veya yetkili üretim route'u ile dashboard açılır. | -| Cron izleme | Çalıştırılan cron kayıtları ve detayları incelenebilir. | +Yeni ABP modülü eklemek için: +`abp new Sozsoft. -t module --no-ui -m none --database-provider ef` -#### İş Akışı +### 21.4. Seed dosyaları -1. Yönetici background worker ekranını açar. -2. Worker tipini, cron zamanını ve gerekli parametreleri tanımlar. -3. Hangfire zamanı geldiğinde worker'ı çalıştırır. -4. Yönetici çalışan veya geçmiş job kayıtlarını kontrol eder. -5. Gerekirse worker üretme veya düzeltme işlemleri yapılır. +Yeni bir modülün nasıl kurulacağını öğrenmek için başvurulacak dosyalar: -#### Dikkat Edilmesi Gerekenler +- `api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs` +- `.../Seeds/ListFormSeeder_Administration.cs` +- `.../Seeds/MenusData.json` +- `.../Seeds/PermissionsData.json` +- `.../Seeds/HostData.json` +- `.../Seeds/LanguagesData.json` -Hangfire veritabanı bağlantısı hazır değilse başlatılmaz. Üretim ortamında dashboard yetkilendirme ile korunur. +--- -### Tanımlar +## 22. Sorun Giderme -#### Amaç - -Sistem genelinde kullanılan referans verilerin yönetilmesini sağlar. Genel tanımlar platformun içinde hazır olarak bulunur; yetkili kullanıcılar bu kayıtları dinamik listeler üzerinden görüntüleyebilir ve ihtiyaç halinde güncelleyebilir. - -#### Ekranda Görülen Alanlar - -Kodda doğrulanan tanım listeleri: - -| Grup | Listeler | +| Belirti | Olası neden ve çözüm | | --- | --- | -| SaaS Tanımlar | GlobalSearch, AiBot, ContactTitle, Currency, CountryGroup, Country, City, District, UomCategory, Uom, Sequence, SkillType, SkillLevel, Skill | -| Administration Tanımlar | Sector, Department, JobPosition, WorkHour | -| Kısıtlar | IpRestrictions, WorkHour | - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | -| --- | --- | -| Kayıt listeleme | Tanım kayıtları görüntülenir. | -| Kayıt ekleme/düzenleme/silme | Yetkiye göre CRUD işlemleri yapılır. | -| Lookup kullanımı | Bu tanımlar başka formlarda seçim alanı olarak kullanılabilir. | -| Otomatik tanım kullanımı | Sistem içinde hazır gelen genel tanımlar dinamik ekranlarda lookup veya filtre kaynağı olarak kullanılabilir. | -| Çalışma saati tanımlama | Kullanıcıların hangi gün ve saat aralıklarında uygulamayı kullanabileceği belirlenebilir. | -| IP restriction tanımlama | Kullanıcının yalnızca izin verilen IP adreslerinden bağlantı kurması sağlanabilir. | -| Tenant bazlı tanım yönetimi | Sector, Department, JobPosition ve WorkHour gibi tanımlar tenant ihtiyacına göre yönetilebilir. | - -#### İş Akışı +| `/api/abp/application-configuration` 503 dönüyor | Veritabanına erişilemiyor. Bağlantı dizesini ve veri konteynerini kontrol edin; `/api/setup/application-status` ile durumu görün. | +| UI `/setup` ekranına düşüyor | Şema oluşmamış veya bekleyen migration var. Setup ekranından migration'ı tetikleyin ya da DbMigrator'ı çalıştırın. | +| Giriş "UserCannotSignIn..." hatası veriyor | [11.3](#113-giriş-engelleri) tablosuna bakın: IP, çalışma saati, doğrulama, şube/eş zamanlı kullanıcı limiti. | +| Yeni sürüm tarayıcıda görünmüyor | Service worker `prompt` modundadır; güncelleme bildirimini onaylayın veya sekmeyi tamamen kapatıp açın. | +| DevExtreme teması yüklenmiyor | `public/css` altındaki tema dosyaları runtime cache'tedir (StaleWhileRevalidate); hard refresh yeterlidir. | +| Hangfire başlangıçta hata veriyor | Bağlantı dizesi boşsa Hangfire kaydı atlanır ama ABP modülleri yüklenir. Bağlantı dizesini doğrulayın. | +| Dinamik servis yayınlanmıyor | Önce `TestCompile` çalıştırın; derleme hatası mesajı UI'da döner. `Publish` için ilgili yetki gerekir. | +| Raporlarda font/karakter bozukluğu (Linux) | `DevExpressFontConfiguration.InstallLinuxFonts` loglarını kontrol edin; konteynerde font paketi eksik olabilir. | -1. Yönetici ilgili tanım ekranını açar. -2. Yeni kayıt ekler veya mevcut kaydı düzenler. -3. Kaydedilen tanım, bağlı formlarda lookup olarak kullanılabilir. -4. WorkHour tanımları kullanıcı kartları veya erişim kısıtlarıyla ilişkilendirilir. -5. IpRestriction tanımları güvenli bağlantı kuralları için kullanılır. +--- -#### Dikkat Edilmesi Gerekenler +## 23. Bilinen Sınırlar ve Notlar -Tanım kayıtları başka formlara bağlı olabilir. Silme işleminden önce bağlı kayıt etkileri kontrol edilmelidir. Hazır gelen genel tanımlar sistem davranışını etkileyebileceği için gereksiz değişiklik yapılmamalıdır. WorkHour ve IP restriction kayıtları kullanıcıların sisteme erişimini doğrudan etkileyebileceği için değişiklikler kontrollü yapılmalıdır. +- `route.constant.ts` içindeki `supplychain`, `maintenance`, `warehouse`, `projects`, `crm`, + `mrp`, `accounting` ve `hr` yol sabitleri **yol sözleşmesidir**; bu alanların ekranları fiziksel + React sayfaları olarak değil, ListForm konfigürasyonu ile üretilir. `hr` altında yalnızca + organizasyon şeması (`OrgChart.tsx`) fiziksel bir view'dır. +- `Telegram` bildirim kanalı altyapıda tanımlıdır ancak UI'da kapalıdır. +- Depodaki `appsettings*.json` ve deployment dokümanları geliştirme değerleri/örnek kimlik + bilgileri içerir; üretim öncesi tümü değiştirilmelidir. +- `.github/instructions/ai.instructions.md` içinde stack ".NET 9 + ABP 9" olarak yazılıdır; + kod tabanı **.NET 10 + ABP 10** üzerindedir. Kural bölümleri geçerliliğini korur. +- Rol–yetki eşleşmeleri veritabanında müşteri ortamına göre değişir; buradaki yetki kodları + tanımın kendisidir, atama değil. + +--- + +## Lisans ve Sahiplik -### AI Assistant ve AiBot Tanımları - -#### Amaç - -AI asistan ekranı ve AiBot tanımlarını yönetmek için kullanılır. - -#### Ekranda Görülen Alanlar - -| Ekran | Açıklama | -| --- | --- | -| Assistant | `ui/src/views/ai/Assistant.tsx` ile doğrulanmıştır. | -| AiBot Tanımları | `App.Definitions.AiBot` list formu ve `AiBotAppService`. | - -#### Kullanıcı İşlemleri - -| İşlem | Açıklama | -| --- | --- | -| AI asistanı kullanma | Asistan ekranında sorgu/yanıt akışı kullanılır. | -| Bot tanımı yönetme | AiBot kayıtları dinamik liste ile yönetilir. | - -#### İş Akışı - -1. Kullanıcı AI ekranını açar. -2. Asistanla etkileşime girer. -3. Yönetici bot tanımlarını güncelleyerek davranışı yapılandırır. - -#### Dikkat Edilmesi Gerekenler - -AI servis sağlayıcı ayrıntıları bu statik taramada doğrulanmamıştır. Entegrasyon ayarları ayrıca kontrol edilmelidir. - -## 5. Ortak Komponentler - -### DataGrid / Dinamik Tablo - -| Özellik | Kullanıcı Açısından Açıklama | -| --- | --- | -| Nerelerde kullanılır | `/admin/list/:listFormCode` route'u ile açılan dinamik listelerde. | -| Nasıl kullanılır | Kayıtlar satır-sütun yapısında görüntülenir; arama, filtre, grup, sıralama ve sayfalama yapılır. | -| Özel davranış | Kolon yetkileri, state kaydı, conditional styling, summary, alt form ve workflow butonları list form tanımına göre değişir. | - -### Form Komponentleri - -| Özellik | Kullanıcı Açısından Açıklama | -| --- | --- | -| Nerelerde kullanılır | Yeni kayıt, düzenleme ve görüntüleme ekranlarında. | -| Nasıl kullanılır | Alanlar doldurulur; zorunlu alanlar tamamlanır; kaydet butonu ile işlem tamamlanır. | -| Özel davranış | View modunda alanlar read-only olur. Ekleme/düzenleme modunda alan bazlı permission ve script davranışı uygulanır. | - -### Modal / Popup - -| Özellik | Kullanıcı Açısından Açıklama | -| --- | --- | -| Nerelerde kullanılır | Düzenleme popup'ı, import modalı, not paneli, silme onayı, dosya upload, forum konu/gönderi oluşturma, video katılımcı çıkarma. | -| Nasıl kullanılır | Kullanıcı modal içindeki formu doldurur veya onay verir. | -| Özel davranış | Mobil/dokunmatik cihazlarda bazı popup'lar tam genişlikte açılır. | - -### Lookup / Select Alanları - -| Özellik | Kullanıcı Açısından Açıklama | -| --- | --- | -| Nerelerde kullanılır | Tanım seçimi, tenant seçimi, ayar listeleri, dinamik form lookup alanları. | -| Nasıl kullanılır | Açılır listeden değer seçilir; temizleme butonu varsa seçim kaldırılabilir. | -| Özel davranış | Cascade lookup alanlarında üst alan seçilmeden alt alan pasif kalabilir. | - -### Date Picker / DateBox - -| Özellik | Kullanıcı Açısından Açıklama | -| --- | --- | -| Nerelerde kullanılır | Tarih ve saat alanlarında. | -| Nasıl kullanılır | Takvimden tarih seçilir veya maskeli giriş yapılır. | -| Özel davranış | `useMaskBehavior`, `openOnFieldClick` ve `showClearButton` davranışları uygulanır. | - -### Dosya Yükleme - -| Özellik | Kullanıcı Açısından Açıklama | -| --- | --- | -| Nerelerde kullanılır | Dosya yöneticisi, not dosyaları, image upload editor, import manager. | -| Nasıl kullanılır | Dosya seçilir veya yükleme alanına bırakılır. | -| Özel davranış | Import manager Excel/CSV önizleme ve aktarım süreci sunar. | - -### Bildirim / Toast - -| Özellik | Kullanıcı Açısından Açıklama | -| --- | --- | -| Nerelerde kullanılır | Kayıt, silme, yükleme, bağlantı, hata ve uyarı işlemlerinde. | -| Nasıl kullanılır | Kullanıcı bildirimleri ekranın köşesinde kısa süreli olarak görür. | -| Özel davranış | Başarı, bilgi, uyarı ve hata tipleri vardır. | - -### Onay Pencereleri - -| Özellik | Kullanıcı Açısından Açıklama | -| --- | --- | -| Nerelerde kullanılır | Silme, developer kit silme, dosya silme, workflow veya özel aksiyonlarda. | -| Nasıl kullanılır | Kullanıcı işlemi onaylar veya vazgeçer. | -| Özel davranış | Bazı eski ekranlarda `window.confirm`, bazı ekranlarda özel `UiDialog` kullanılır. | - -### Sayfalama, Sıralama ve Filtreleme - -| Özellik | Kullanıcı Açısından Açıklama | -| --- | --- | -| Nerelerde kullanılır | Dinamik listeler ve dosya yöneticisi. | -| Nasıl kullanılır | Sayfa boyutu, ileri/geri butonları, kolon sıralama ve filtre alanları kullanılır. | -| Özel davranış | Liste form ayarına göre standart, virtual veya infinite scrolling kullanılabilir. | - -## 6. API ve Swagger Açıklaması - -Swagger, teknik kullanıcıların ve sistem yöneticilerinin API endpointlerini görmesi, test etmesi ve model şemalarını incelemesi için kullanılır. - -Kodda doğrulanan Swagger özellikleri: - -| Özellik | Açıklama | -| --- | --- | -| Swagger UI | `/swagger` üzerinden ABP Swagger UI etkinleştirilmiştir. | -| Swagger JSON | `/swagger/v1/swagger.json` endpointi yapılandırılmıştır. | -| OAuth | Swagger OAuth client ve `Platform` scope ile yapılandırılmıştır. | -| API adı | `Sozsoft Platform Api`. | -| Hariç tutulan controllerlar | DevExpress `CustomReportDesigner`, `CustomWebDocumentViewer`, `QueryBuilder` Swagger'dan hariç tutulmuştur. | - -### Ana API Grupları - -| API Grubu | Amaç | -| --- | --- | -| `/api/app/*` | Genel uygulama servisleri: public, identity extension, list form data, dashboard, forum, intranet, file management, video room vb. | -| `/api/admin/*` | List form administration servisleri. | -| ABP Identity/Tenant API'leri | Kullanıcı, rol, permission, tenant ve hesap işlemleri. | -| Languages API | Dil ve çeviri metinleri. | -| Settings API | Ayar grupları ve ayar değerleri. | -| Notifications API | Bildirim, bildirim kuralı ve kullanıcı bildirim tercihleri. | -| SqlQueryManager API | SQL nesne ve sorgu yönetimi. | -| Dynamic Services | Runtime derlenen servislerin controller olarak kaydedilmesi. | - -### Önemli Endpoint Kategorileri - -| Kategori | Örnek Kullanım | -| --- | --- | -| Kimlik | Login, refresh token, logout, kayıt, şifre sıfırlama, hesap doğrulama. | -| Profil | Profil görüntüleme/güncelleme, şifre değiştirme, avatar güncelleme. | -| List Form | Liste konfigürasyonu alma, veri listeleme, CRUD, import, customization, workflow. | -| Datasource | Dinamik liste, endpoint ve SQL işlemleri için veri kaynağı tanımı. | -| Menü/Route | Menüleri alma, route kayıtları, custom component kayıtları. | -| Dosya | Dosya listeleme, yükleme, indirme, silme, taşıma, kopyalama. | -| Forum | Kategori, konu, gönderi, beğeni, sabitleme, kilitleme, çözüm işaretleme. | -| Video Oda | Oda listeleme, oluşturma, güncelleme, başlatma, bitirme, katılımcı ve sohbet. | -| Ayarlar | Ayarları listeleme ve değer güncelleme. | -| Bildirim | Bildirim listeleme, okundu bilgisi güncelleme, bildirim kuralı yönetimi. | - -### Yetkilendirme Gerektiren API'ler - -Admin ve dinamik list form API'lerinin çoğu authentication ve permission gerektirir. Kodda örnek olarak: - -| API/Servis | Yetki | -| --- | --- | -| Tenant update | `TenantManagementPermissions.Tenants.Update` | -| Identity user create/update | `IdentityPermissions.Users.Create`, `IdentityPermissions.Users.Update` | -| Identity role create/update | `IdentityPermissions.Roles.Create`, `IdentityPermissions.Roles.Update` | -| SQL execute | `SqlQueryManager.SqlExecution.Execute` | -| Dinamik liste işlemleri | İlgili list formun CRUD permissionları. | - -### API Kullanırken Dikkat Edilmesi Gerekenler - -| Konu | Açıklama | -| --- | --- | -| Token | Bearer token gerekir. Token süresi dolduğunda refresh veya yeniden giriş gerekir. | -| Tenant | Çok kiracılı yapıda tenant bağlamı veri erişimini etkiler. | -| Permission | Endpoint görünse bile permission yoksa 401/403 döner. | -| Correlation Id | Hata takibi için `X-Correlation-Id` header'ı expose edilmiştir. | -| Dynamic API | Runtime üretilen servisler canlı sistem davranışını değiştirebilir. | -| Datasource seçimi | Custom endpoint, CRUD endpoint ve list form işlemlerinde doğru datasource seçilmelidir. | -| Dynamic service | Yayınlanan C# servisleri Swagger üzerinde görünür hale gelebilir; permission ve güvenlik kontrolleri yapılmalıdır. | - -## 7. Kullanıcı Rolleri ve Yetkiler - -Kodda kullanıcıya atanmış hazır rol listesi değil, permission odaklı yetki modeli doğrulanmıştır. Bu nedenle tablo permission grupları üzerinden hazırlanmıştır. - -| Permission Grubu | Örnek Permission | Kullanıcıya Etkisi | -| --- | --- | --- | -| `AbpIdentity.Users` | `Create`, `Update`, `Delete`, `Export`, `Import`, `Note`, `ManagePermissions`, `ManageRoles` | Kullanıcı kayıtlarını ve kullanıcı rol/permissionlarını yönetir. | -| `AbpIdentity.Roles` | `Create`, `Update`, `Delete`, `ManagePermissions` | Rol kayıtlarını ve rol izinlerini yönetir. | -| `Abp.Identity.PermissionGroups` | `Create`, `Update`, `Delete`, `Export`, `Import`, `Note` | Permission group kayıtlarını yönetir. | -| `Abp.Identity.Permissions` | `Create`, `Update`, `Delete`, `Export`, `Import`, `Note` | Permission kayıtlarını yönetir. | -| `AbpTenantManagement.Tenants` | `Create`, `Update`, `Delete` | Tenant kayıtlarını yönetir. | -| `App.Menus.*` | Menü ve route permissionları | Menü ve route kayıtlarını yönetir. | -| `App.Listforms.*` | List form permissionları | Dinamik liste/form ekranları oluşturma ve düzenleme. | -| `App.Settings` / ayar permissionları | Ayar grubu permissionları | Ayarları görme ve güncelleme. | -| `App.Notifications.*` | Notification type/rule/notification | Bildirim altyapısını yönetir. | -| `App.Files` | Dosya ekranı | Dosya yöneticisini açar. | -| Branch bazlı yetkiler | Kullanıcı-şube ilişkileri | Kullanıcının hangi şube kayıtlarını görebileceğini veya yönetebileceğini sınırlar. | -| Organization Unit yetkileri | OU kullanıcı/rol ilişkileri | Organizasyon ağacı üzerinden kullanıcı ve rol erişimlerini yönetir. | -| `App.ForumManagement.*` | Forum yönetimi | Forum admin işlemleri. | -| `App.Videoroom.*` | Video oda ekranları | Video oda yönetim işlemleri. | -| `SqlQueryManager.SqlExecution.Execute` | SQL çalıştırma | SQL Query Manager üzerinde sorgu çalıştırma. | - -Doğrulanması Gereken Alan: Kuruma özel rol adları ve hangi role hangi permissionların verildiği veritabanındaki rol-permission eşleştirmesine bağlıdır. - -## 8. Formlar ve Zorunlu Alanlar - -### Statik Formlar - -| Form Adı | Kullanıldığı Ekran | Zorunlu Alanlar | Opsiyonel Alanlar | Validasyon | Kaydetme Sonrası | -| --- | --- | --- | --- | --- | --- | -| Login | `/login` | Kullanıcı adı, şifre; iki aşamalı doğrulama aktifse kod | Beni hatırla | Alanlar boş olamaz | Başarılıysa oturum açılır. | -| Register | `/register` | E-posta, şifre, şifre tekrarı, ad, soyad, captcha | - | E-posta formatı, şifre tekrarı eşleşmesi | Hesap oluşturma sonucu gösterilir. | -| Forgot Password | `/forgot-password` | E-posta, captcha | - | E-posta boş olamaz | Sıfırlama kodu gönderilir. | -| Reset Password | `/reset-password` | Şifre, şifre tekrarı | - | Şifre tekrarı eşleşmesi | Şifre güncellenir. | -| Confirmation Code | `/confirm` | E-posta, captcha | - | Alanlar boş olamaz | Doğrulama kodu gönderilir. | -| Extend Login | `/extend-login` | E-posta, captcha | - | Alanlar boş olamaz | Talep gönderilir. | -| Settings | `/admin/settings` | Ayar tipine göre değişir | Ayar tipine göre değişir | Tip bazlı giriş | Ayarlar kaydedilir, config yenilenir. | -| File Upload | `/admin/files` | En az bir dosya | Klasör seçimi | Dosya seçilmelidir | Dosya listesi yenilenir. | -| Create Folder | `/admin/files` | Klasör adı | Parent klasör | Ad boş olmamalıdır | Klasör listesi yenilenir. | -| Rename Item | `/admin/files` | Yeni ad | - | Ad boş olmamalıdır | Liste yenilenir. | - -### Dinamik Formlar - -Dinamik formlarda zorunlu alanlar list form field tanımlarından gelir. Kodda desteklenen validasyon tipleri: - -| Validasyon Tipi | Açıklama | -| --- | --- | -| `required` | Alan boş bırakılamaz. | -| `numeric` | Sayısal değer beklenir. | -| `range` | Belirli aralıkta değer beklenir. | -| `stringLength` | Metin uzunluğu kontrol edilir. | -| `custom` | Özel validasyon kuralı çalışabilir. | -| `compare` | Başka alanla karşılaştırma yapılabilir. | -| `pattern` | Regex/pattern kontrolü yapılır. | -| `email` | E-posta formatı kontrol edilir. | -| `async` | Asenkron validasyon yapılabilir. | - -Dinamik form alan tipleri: - -| Editor Tipi | Kullanım | -| --- | --- | -| `dxTextBox`, `dxTextArea` | Metin girişi. | -| `dxNumberBox` | Sayı girişi. | -| `dxDateBox`, `dxDateRangeBox`, `dxCalendar` | Tarih/tarih aralığı. | -| `dxSelectBox`, `dxLookup`, `dxAutocomplete` | Tekli seçim/arama. | -| `dxTagBox` | Çoklu seçim. | -| `dxDropDownBox`, `dxGridBox` | Gelişmiş seçim. | -| `dxCheckBox`, `dxSwitch`, `dxRadioGroup` | Boolean veya seçenekli alanlar. | -| `dxHtmlEditor` | Zengin metin. | -| `dxImageUpload` | Görsel yükleme. | - -## 9. Listeleme, Filtreleme ve Raporlama Özellikleri - -### Tablolar - -Dinamik listelerde DevExtreme DataGrid kullanılır. Tablo özellikleri list form ayarına göre değişir. - -| Özellik | Açıklama | -| --- | --- | -| Sıralama | Kolon başlığından veya list form sort ayarıyla yapılır. | -| Filtre satırı | Kolon altında filtre girişi sağlar. | -| Header filter | Kolon değerlerinden seçim yapılır. | -| Arama paneli | Tüm liste içinde arama sağlar. | -| Grup paneli | Kolon bazlı gruplama yapılabilir. | -| Sayfalama | Sayfa boyutu ve gezinme butonları desteklenir. | -| Kolon seçici | Kolon görünürlüğü kullanıcı tarafından değiştirilebilir. | -| State storing | Kullanıcı tablo düzenini kaydedebilir. | -| Summary | Toplam, ortalama, minimum, maksimum ve adet gösterilebilir. | - -### Filtreler - -| Filtre Tipi | Açıklama | -| --- | --- | -| Standart grid filtresi | Kolon değerlerine göre filtreleme. | -| Extra filter toolbar | List form özel filtre alanları. | -| URL filtresi | Route query parametresinden gelen filtre yeni kayıt default değerlerini etkileyebilir. | -| Lookup filtreleri | Seçim alanları için datasource bazlı filtre. | - -### Import / Export - -| İşlem | Açıklama | -| --- | --- | -| Template üretme | Excel veya CSV template alınabilir. | -| Dosya yükleme | Excel/CSV dosyası import session olarak yüklenir. | -| Önizleme | Aktarılacak kayıtlar ve validasyon durumu gösterilir. | -| Aktarım | Eşleştirme ve validasyon sonrası kayıtlar sisteme alınır. | -| Log görüntüleme | Import log kayıtları görüntülenebilir. | - -### Raporlama ve Grafik - -| Özellik | Açıklama | -| --- | --- | -| Chart route | `/admin/chart/:listFormCode` olarak tanımlanmıştır. | -| Pivot route | `/admin/pivot/:listFormCode` olarak tanımlanmıştır. | -| Gantt/Scheduler/Tree | List form ayar sekmelerinde desteklenir. | -| DevExpress Reporting | Report viewer ve designer bileşenleri vardır. | - -## 10. Hata Mesajları ve Kullanıcı Bildirimleri - -Kodda doğrulanan yaygın mesaj davranışları: - -| Mesaj / Durum | Ne Zaman Görünür | Kullanıcı Ne Yapmalı | -| --- | --- | --- | -| `Hata!` | Genel API veya render hatasında. | İşlemi tekrar deneyin; devam ederse yöneticiden correlation id ile destek isteyin. | -| 401 / 403 | Oturum geçersiz veya yetki yok. | Yeniden giriş yapın veya yetki talep edin. | -| `GridStateSaved` | Tablo görünüm düzeni kaydedildiğinde. | İşleme devam edebilirsiniz. | -| `GridStateSaveError` | Tablo görünüm ayarı kaydedilemediğinde. | Tekrar deneyin. | -| `Files uploaded successfully` | Dosya yükleme tamamlandığında. | Dosya listede görünür. | -| `Failed to upload files` | Dosya yükleme başarısız olduğunda. | Dosya boyutu, bağlantı veya yetkiyi kontrol edin. | -| `Folder created successfully` | Klasör oluşturulduğunda. | Klasörü kullanabilirsiniz. | -| `Failed to create folder` | Klasör oluşturulamadığında. | Adı ve yetkiyi kontrol edin. | -| `Protected system folders cannot be deleted` | Korumalı sistem klasörü silinmek istendiğinde. | Bu klasörlerde işlem yapmayın. | -| `Only files can be downloaded` | Klasör indirilmek istendiğinde. | Dosya tipinde bir öğe seçin. | -| `Cannot move items to the same folder` | Aynı klasöre taşıma denendiğinde. | Farklı hedef klasör seçin. | -| `Sınıfa katılamadı` | Video odaya katılım başarısız olduğunda. | Bağlantı, oda durumu ve yetkiyi kontrol edin. | -| `Kamera açılamadı` | Tarayıcı kamera izni veya cihaz erişimi yoksa. | Kamera izni verin veya cihazı kontrol edin. | -| `Mikrofon açılamadı` | Mikrofon izni veya cihaz erişimi yoksa. | Mikrofon izni verin veya cihazı kontrol edin. | -| `Bağlantı koptu, yeniden bağlanılıyor...` | SignalR bağlantısı kesildiğinde. | Bağlantının yeniden kurulmasını bekleyin. | -| `Your passwords do not match` | Şifre tekrarı eşleşmediğinde. | İki şifre alanını aynı girin. | -| `SuccessfullySaved` | Ayarlar başarıyla kaydedildiğinde. | Değişiklikler uygulanmıştır. | - -## 11. Sık Kullanılan Senaryolar - -### Senaryo: Yeni kayıt oluşturma - -1. Kullanıcı ilgili liste menüsüne girer. -2. Yeni kayıt butonuna basar. -3. Zorunlu alanları doldurur. -4. Lookup veya tarih alanlarını seçer. -5. Kaydet butonuna basar. -6. Sistem kaydı oluşturur ve listeyi yeniler. - -### Senaryo: Kayıt düzenleme - -1. Kullanıcı ilgili liste ekranında kaydı bulur. -2. Düzenleme aksiyonunu seçer. -3. Yetkili olduğu alanları günceller. -4. Kaydet butonuna basar. -5. Sistem değişiklikleri kaydeder. - -### Senaryo: Kayıt filtreleme - -1. Kullanıcı liste ekranını açar. -2. Arama paneline kelime yazar veya kolon filtresini kullanır. -3. Gerekirse extra filter alanlarını doldurur. -4. Liste filtreye göre güncellenir. - -### Senaryo: Excel/CSV içe aktarma - -1. Kullanıcı ilgili liste ekranında import yöneticisini açar. -2. Gerekirse template indirir. -3. Excel veya CSV dosyasını yükler. -4. Önizleme ekranında eşleşme ve validasyonları kontrol eder. -5. Aktarımı başlatır. -6. Sistem aktarım sonucunu ve logları gösterir. - -### Senaryo: Dosya yükleme - -1. Kullanıcı Dosyalar ekranını açar. -2. Gerekirse tenant seçer. -3. Hedef klasöre gider. -4. Dosya yükle butonuna basar. -5. Dosyaları seçer. -6. Sistem dosyaları yükler ve listeyi yeniler. - -### Senaryo: Kullanıcı yetkisi verme - -1. Yönetici kullanıcı veya rol ekranını açar. -2. İlgili kullanıcıyı veya rolü seçer. -3. Permission yönetimi ekranını açar. -4. Gerekli permissionları işaretler. -5. Kaydeder. -6. Kullanıcının menü ve işlem yetkileri güncellenir. - -### Senaryo: Ayar değiştirme - -1. Yönetici Ayarlar ekranına girer. -2. Sol menüden ayar grubunu seçer. -3. İlgili değeri değiştirir. -4. Kaydet butonuna basar. -5. Sistem başarı bildirimi gösterir ve konfigürasyonu yeniler. - -### Senaryo: Video odaya katılma - -1. Kullanıcı video oda ekranından odayı açar. -2. Tarayıcı kamera ve mikrofon izinlerini verir. -3. Sistem SignalR bağlantısını kurar. -4. Kullanıcı sohbet, kamera, mikrofon ve ekran paylaşımı özelliklerini kullanır. -5. İşlem sonunda odadan ayrılır veya oda sonlandırılır. - -### Senaryo: Forumda konu açma - -1. Kullanıcı Forum ekranını açar. -2. Kategori seçer. -3. Yeni konu oluşturma modalını açar. -4. Başlık ve içerik bilgilerini girer. -5. Kaydeder. -6. Konu forum listesinde görünür. - -### Senaryo: Tenant ve branch bazlı kullanım hazırlama - -1. Yönetici tenant kaydını oluşturur veya mevcut tenantı seçer. -2. Gerekirse tenant için ayrı connection string tanımlar. -3. Tenant seed işlemiyle başlangıç verilerini oluşturur. -4. Tenant altında branch kayıtlarını tanımlar. -5. Kullanıcıları ilgili branch veya organization unit yapısıyla ilişkilendirir. -6. Kullanıcılar yalnızca yetkili oldukları tenant/branch kapsamındaki kayıtları görür. - -### Senaryo: Low-code ile yeni liste ekranı oluşturma - -1. Teknik kullanıcı Datasource kaydını seçer veya oluşturur. -2. Wizard Manager ekranında yeni menü ya da alt menü bilgisini girer. -3. SQL tablo ve listede gösterilecek sütunları seçer. -4. Her sütun için uygun editor tipini belirler. -5. Gerekirse sub list, widget ve workflow adımlarını tanımlar. -6. Deploy işlemini çalıştırır. -7. Sistem menü, route, dil metni, permission group, permission, grid/form ve ilgili component ayarlarını oluşturur. - -### Senaryo: Custom endpoint ile veri sağlama - -1. Teknik kullanıcı Custom Endpoints ekranını açar. -2. Datasource, SQL, HTTP metodu, URL, parametre ve permission bilgilerini tanımlar. -3. Endpointi aktif hale getirir. -4. Dinamik veya statik component bu endpointten veri alacak şekilde yapılandırılır. -5. Yetkili kullanıcılar endpoint üzerinden beslenen ekranı kullanır. - -### Senaryo: Background worker tanımlama ve izleme - -1. Yönetici worker tanımını açar. -2. Worker tipini ve cron zamanını belirler. -3. Gerekli parametreleri kaydeder. -4. Hangfire zamanı geldiğinde worker'ı çalıştırır. -5. Yönetici çalışan job kayıtlarını ve detaylarını izler. - -### Senaryo: Organizasyon şeması oluşturma - -1. Yönetici tenant bazında sector, department ve job position tanımlarını hazırlar. -2. Kullanıcı kartlarında departman ve job position alanlarını doldurur. -3. Organization Unit ekranında takım veya birim oluşturur. -4. Kullanıcıları ilgili birimlere atar. -5. Organizasyon şeması departman, pozisyon ve kullanıcı bilgileriyle görüntülenir. - -### Senaryo: Intranet anketi veya duyurusu yayınlama - -1. Yönetici tenant kapsamında duyuru veya anket kaydı oluşturur. -2. Anket için soru ve seçenekleri tanımlar. -3. Gerekirse ilgili dosyaları File Manager üzerinden hazırlar. -4. Kullanıcı intranet dashboard veya social wall ekranında içeriği görür. -5. Kullanıcı ankete oy verir, duyuruyu görüntüler veya etkinliklere katılır. - -### Senaryo: Kullanıcı erişimini saat ve IP ile kısıtlama - -1. Yönetici WorkHour tanımında gün ve saat aralıklarını belirler. -2. Gerekirse IpRestriction kaydı ile izin verilen IP adreslerini tanımlar. -3. Kullanıcı kartında çalışma saati veya restriction ilişkisi belirlenir. -4. Kullanıcı yalnızca izin verilen zaman ve bağlantı koşullarında uygulamaya erişebilir. - -### Senaryo: Aktif kullanıcı oturumunu sonlandırma - -1. Yetkili kullanıcı aktif oturum veya session listesini açar. -2. Concurrent lisans kapsamında aktif kullanıcıları inceler. -3. Sonlandırılması gereken kullanıcı oturumunu seçer. -4. Kick işlemini çalıştırır. -5. Kullanıcının aktif oturumu düşürülür. - -### Senaryo: Rapor düzenleme - -1. Yönetici rapor kategorisini ve rapor template kaydını açar. -2. İlgili raporu DevExpress report designer ile düzenler. -3. Tasarımı kaydeder. -4. Kullanıcı report viewer ile güncellenen raporu görüntüler. - -## 12. Sık Sorulan Sorular - -### Menüyü neden göremiyorum? - -Menü görünürlüğü permissiona bağlıdır. Yetkiniz yoksa menü gösterilmeyebilir. Yöneticinizden ilgili permissionı talep edin. - -### Ekrana girdiğimde yetkisiz erişim sayfası açılıyor. Ne yapmalıyım? - -Oturumunuz bitmiş veya ilgili route için yetkiniz olmayabilir. Önce tekrar giriş yapın; sorun devam ederse yöneticinize başvurun. - -### Listedeki bazı butonlar neden görünmüyor? - -Ekleme, düzenleme, silme, export, import, note ve workflow butonları hem ekran ayarına hem de kullanıcı permissionına bağlıdır. - -### Tablo düzenimi kaydedebilir miyim? - -Evet. İlgili list formda state storing açıksa kolon, filtre ve görünüm düzeniniz kaydedilebilir. - -### Dosya yöneticisinde bazı klasörleri neden silemiyorum? - -Sistem klasörleri korumalıdır. Ayrıca içinde dosya bulunan klasörler güvenlik nedeniyle silinmez. - -### İçe aktarma yaparken zorunlu alan hatası alıyorum. Ne yapmalıyım? - -Template dosyasını kullanın ve zorunlu kolonları boş bırakmadığınızdan emin olun. Önizleme ekranındaki hata satırlarını kontrol edin. - -### Şifre sıfırlama linkim çalışmıyor. Ne yapmalıyım? - -Token süresi dolmuş olabilir. Şifremi unuttum ekranından yeni sıfırlama talebi oluşturun. - -### Video odada kamera veya mikrofon açılmıyor. Ne yapmalıyım? - -Tarayıcı izinlerini, cihaz bağlantısını ve başka bir uygulamanın kamerayı kullanıp kullanmadığını kontrol edin. - -### Swagger ne için kullanılır? - -Swagger teknik kullanıcıların API'leri incelemesi ve yetkili ise test etmesi için kullanılır. Son kullanıcı işlemleri için admin paneli tercih edilmelidir. - -### Tenant, branch ve organization unit arasındaki fark nedir? - -Tenant kiracı/kurum alanını, branch tenant içindeki şube ayrımını, organization unit ise kullanıcı ve rol ilişkilerinin ağaç yapısında yönetildiği organizasyon yapısını ifade eder. - -### Her tenant ayrı veritabanı kullanabilir mi? - -Evet. Tenant bazlı connection string tanımlanarak tenantların ayrı veritabanı üzerinde çalışması sağlanabilir. - -### Dinamik liste farklı veritabanından veri gösterebilir mi? - -Evet. Datasource tanımları sayesinde MSSQL veya PostgreSQL kaynakları seçilebilir ve dinamik listeler bu kaynaklardan beslenebilir. - -### Public ürünler nasıl kiralanır? - -Public ürün ve ödeme ekranları üzerinden aylık veya yıllık sipariş oluşturulabilir. Mevcut akış Havale/EFT odaklıdır; kredi kartı entegrasyonu ileride eklenebilir. - -### Developer Kit ile kod yazmadan ekran oluşturulabilir mi? - -Evet. Wizard Manager, datasource, tablo, sütun, editor, menü, permission, widget, sub list ve workflow seçimleriyle low-code şekilde yeni liste/form ekranı oluşturmayı destekler. - -### Kullanıcıların hangi saatlerde uygulamaya gireceği yönetilebilir mi? - -Evet. WorkHour tanımlarıyla kullanıcıların hangi gün ve saat aralıklarında uygulamayı kullanabileceği belirlenebilir. - -### Kullanıcı girişi belirli IP adresleriyle sınırlandırılabilir mi? - -Evet. IP restriction tanımlarıyla sadece izin verilen IP adreslerinden bağlantı kurulması sağlanabilir. - -### Organizasyon şeması hangi bilgilerden oluşur? - -Organizasyon şeması tenant bazında tanımlanan department, job position ve kullanıcı ilişkileriyle oluşturulur. - -### Aktif kullanıcılar izlenebilir mi? - -Evet. Concurrent lisans yönetimi nedeniyle aktif kullanıcı/session kayıtları izlenebilir ve yetkili kullanıcı gerektiğinde kick işlemiyle oturumu sonlandırabilir. - -### Raporlar sonradan değiştirilebilir mi? - -Evet. Yetkili kullanıcı DevExpress report designer ile tanımlı raporları düzenleyebilir ve report viewer ile görüntüleyebilir. - -## 13. Ekler - -### Terimler Sözlüğü - -| Terim | Açıklama | -| --- | --- | -| Tenant | Çok kiracılı yapıda ayrı müşteri/kurum alanı. | -| Permission | Bir ekranı veya işlemi yapma izni. | -| Role | Birden fazla permission içerebilen kullanıcı grubu. | -| List Form | Dinamik liste ve form ekranı tanımı. | -| Lookup | Başka kayıtlardan seçim yapılan alan. | -| Workflow | Kayıt üzerinde onay/red gibi durum geçişlerini yöneten akış. | -| Import | Excel/CSV dosyasından veri alma işlemi. | -| Export | Liste verisini dışa aktarma işlemi. | -| State Storing | Tablo görünüm ayarlarının kullanıcı için saklanması. | -| Swagger | API dokümantasyonu ve test arayüzü. | -| SignalR | Canlı bağlantı ve anlık iletişim altyapısı. | -| WebRTC | Tarayıcı üzerinden ses/görüntü iletişimi altyapısı. | - -### Kısaltmalar - -| Kısaltma | Açıklama | -| --- | --- | -| API | Application Programming Interface. | -| CRUD | Create, Read, Update, Delete. | -| UI | User Interface. | -| JWT | JSON Web Token. | -| CDN | Content Delivery Network veya dosya sunum alanı. | -| SQL | Structured Query Language. | - -### Teknik Notlar - -| Konu | Not | -| --- | --- | -| Backend | C#, ABP Framework, OpenIddict, Hangfire, DevExpress Reporting, SignalR. | -| Frontend | React, Vite, Tailwind CSS, DevExtreme, Formik, React Router. | -| Çok kiracılık | `PlatformConsts.IsMultiTenant` aktiftir. | -| Cache | Redis cache yapılandırması bulunur. | -| Blob/File | ABP BlobStoring FileSystem ile CDN path üzerinden çalışır. | -| Swagger | OAuth destekli ABP Swagger UI kullanılır. | -| Dynamic Services | Runtime derleme ve controller registration altyapısı vardır. | - -### Modül Listesi - -| Modül | Açıklama | -| --- | --- | -| Platform | Ana uygulama modülü. | -| Languages | Dil ve lokalizasyon yönetimi. | -| Settings | Ayar yönetimi. | -| Notifications | Bildirim yönetimi. | -| MailQueue | Mail queue altyapısı. | -| Sender | SMS, Rocket, WhatsApp gibi gönderim ayarları/altyapısı. | -| SqlQueryManager | SQL nesne ve sorgu yönetimi. | -| DataSource | MSSQL/PostgreSQL kaynaklarının tenant ve dinamik liste/endpoint bazında yönetimi. | -| FileManagement | Dosya ve klasör yönetimi. | -| DeveloperKit | SQL Query Manager, CRUD endpoint, custom endpoint, dynamic service, custom component ve Wizard Manager ile low-code geliştirme. | -| Intranet | Duyuru, anket, sosyal gönderi, etkinlik. | -| Forum | Kategori, konu, gönderi. | -| Videoroom | Canlı oda/toplantı yönetimi. | - -### Ekran Listesi - -| Ekran | Route veya Kod | -| --- | --- | -| Login | `/login` | -| Register | `/register` | -| Forgot Password | `/forgot-password` | -| Reset Password | `/reset-password` | -| Setup | `/setup` | -| Dashboard | `/admin/dashboard` | -| Dynamic List | `/admin/list/:listFormCode` | -| Form New | `/admin/form/:listFormCode` | -| Form View | `/admin/form/:listFormCode/:id` | -| Form Edit | `/admin/form/:listFormCode/:id/edit` | -| Chart | `/admin/chart/:listFormCode` | -| Pivot | `/admin/pivot/:listFormCode` | -| Settings | `/admin/settings` | -| Files | `/admin/files` | -| Forum | `/admin/forum` | -| Forum Management | `/admin/forumManagement` | -| Video Room Dashboard | `/admin/videoroom/dashboard` | -| Video Room List | `/admin/videoroom/list` | -| Video Room Detail | `/admin/videoroom/room/:id` | -| Organization Units | `/admin/ous` | -| Menu Manager | `/admin/menuManager` | -| Developer Components | `/admin/developerkit/components` | -| Developer Endpoints | `/admin/developerkit/endpoints` | -| Dynamic Services | `/admin/developerkit/dynamic-services` | -| Public Home | `/home` | -| Public About | `/about` | -| Public Services | `/services` | -| Public Products | `/products` | -| Public Checkout | `/checkout` | -| Public Payment | `/payment` | -| Public Blog | `/blog` | -| Public Demo | `/demo` | -| Public Contact | `/contact` | - -## Eksik / Netleştirilmesi Gereken Konular - -| Konu | Açıklama | -| --- | --- | -| Kuruma özel rol adları | Kodda permission modeli doğrulandı; rol-permission eşleştirmeleri veritabanından doğrulanmalıdır. | -| Canlı menü sırası ve görünürlük | Seed dosyası incelendi; canlı ortamda menü kayıtları değişmiş olabilir. | -| Dinamik component içerikleri | Backend custom component kayıtları runtime veritabanından geldiği için canlı kayıtlar ayrıca kontrol edilmelidir. | -| Route sabitlerinde geçen ERP alanları | Supply chain, maintenance, warehouse, projects, CRM, MRP ve accounting route sabitleri var; ancak bu taramada fiziksel ekran veya seed menü kanıtı bulunmadı. | -| Ödeme sağlayıcı ayrıntıları | Sipariş oluşturma akışı doğrulandı; ödeme sağlayıcı entegrasyon ayrıntısı statik koddan netleşmedi. | -| AI entegrasyonu | AI ekranı ve AiBot tanımları doğrulandı; sağlayıcı/model ayarları ayrıca kontrol edilmelidir. | -| Swagger endpointlerinin tam listesi | Uygulama çalıştırılarak `/swagger/v1/swagger.json` çıktısı alınırsa endpoint listesi daha ayrıntılı tabloya dönüştürülebilir. | +Bu depo Sozsoft'a aittir. DevExpress/DevExtreme bileşenleri ticari lisans gerektirir +(`api/DevExpress_License.txt`). diff --git a/claude.md b/claude.md index 5d8016eb..d3b31dda 100644 --- a/claude.md +++ b/claude.md @@ -7,6 +7,21 @@ Primary source of truth for platform behavior is: If there is any conflict, follow `.github/instructions/ai.instructions.md`. +## Communication Rules (apply to every response) + +1. **Never introduce or summarize the platform.** Assume the user knows what this + application is, how it is built, and which technologies it uses. Do not open a response + with "Sozsoft Platform is a multi-tenant low-code engine…" or any equivalent framing. +2. **Answer the request, nothing more.** No unsolicited architecture overviews, no + restating the decision order, no re-explaining ListForm/DeveloperKit concepts unless + the user actually asked about them. +3. **No preamble, no epilogue.** Skip "Great question", "I reviewed the codebase", + summaries of what you are about to do, and closing recaps of what you just did. +4. **Match the question's size.** A yes/no question gets a sentence. A config question + gets the config. Only a full feature request gets a full proposal. +5. **Respond in the language the user writes in** (Turkish → Turkish). +6. Mention a rule from this file only when it changes the answer — never as boilerplate. + ## Purpose - Maximize delivery through runtime configuration. @@ -17,7 +32,9 @@ Primary principle: Configuration first, code last. ## Mandatory Decision Order -For every request, evaluate and propose in this order: +For every request, evaluate in this order — internally. State the chosen step only when the +user is asking how to build something, and state it in one line, not as a walkthrough of the +options you rejected. 1. Dynamic configuration with existing ListForm ecosystem 2. SQL Query Manager + Custom Endpoint @@ -28,14 +45,15 @@ For every request, evaluate and propose in this order: 1. Do not propose new custom React component/page development for standard feature requests. 2. Build new screens using platform configuration mechanisms. -3. Every proposal must include tenant and permission design. +3. Every implementation proposal must include tenant and permission design. 4. Never bypass platform authorization patterns. 5. Never hardcode secrets, tenant IDs, or connection strings. Exception: -- Custom React/backend code is allowed only when the user explicitly requests implementation and configuration is insufficient. -- In such cases, explain why configuration-first options are not enough. +- Custom React/backend code is allowed only when the user explicitly requests implementation + and configuration is insufficient. +- In such cases, explain why configuration-first options are not enough — in a sentence or two. ## Architecture Guardrails @@ -58,7 +76,12 @@ Exception: ## Response Contract -When producing an implementation proposal, include: +The list below applies **only** to a full implementation proposal for a new screen, module, +or integration. It is not a template for questions, debugging, code review, refactors, +explanations, or small changes. + +Even for a full proposal: include only the items that carry real content for that request, +and drop the rest. An empty or obvious heading is noise. 1. Goal 2. Decision flow result (which step used) diff --git a/ui/src/components/scriptBuilder/ScriptBuilderDialog.tsx b/ui/src/components/scriptBuilder/ScriptBuilderDialog.tsx new file mode 100644 index 00000000..4684875c --- /dev/null +++ b/ui/src/components/scriptBuilder/ScriptBuilderDialog.tsx @@ -0,0 +1,924 @@ +import { Button, Dialog } from '@/components/ui' +import Editor, { type Monaco } from '@monaco-editor/react' +import type * as monacoApi from 'monaco-editor' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + FaArrowDown, + FaArrowUp, + FaBook, + FaCheck, + FaChevronDown, + FaChevronRight, + FaCode, + FaCopy, + FaExclamationTriangle, + FaMagic, + FaPlus, + FaSearch, + FaTimes, + FaTrash, +} from 'react-icons/fa' +import { apiEntryName, attachScriptIntelliSense, insertAtCursor } from './scriptIntelliSense' +import { + scriptRuleConditions, + type ScriptDialect, + type ScriptRecipeParam, + type ScriptRule, + type ScriptRuleCondition, +} from './types' + +export type ScriptBuilderDialogProps = { + isOpen: boolean + value?: string + dialect: ScriptDialect + onClose: () => void + onApply: (value: string) => void + /** Kaydet/Vazgeç etiketleri; çağıran kendi çevirisini geçebilir. */ + saveLabel?: string + cancelLabel?: string +} + +const controlClass = + 'w-full min-w-0 h-9 px-2 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-100 focus:outline-none focus:border-indigo-400' + +const cardClass = + 'rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900' + +const editorOptions = { + automaticLayout: true, + fontSize: 12, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + tabSize: 2, + wordWrap: 'on', + quickSuggestions: { other: true, comments: false, strings: true }, + suggestOnTriggerCharacters: true, + padding: { top: 8, bottom: 8 }, +} as const + +const emptyCondition = (kind?: string): ScriptRuleCondition => ({ + operator: 'equals', + source: '', + value: '', + ...(kind ? { kind } : {}), +}) + +function ScriptBuilderDialog({ + isOpen, + value, + dialect, + onClose, + onApply, + saveLabel = 'Kaydet', + cancelLabel = 'Vazgeç', +}: ScriptBuilderDialogProps) { + const [rules, setRules] = useState([]) + const [code, setCode] = useState('') + // Sihirbaz bir butona basılarak açılır; varsayılan her zaman kod editörüdür. + const [view, setView] = useState<'code' | 'wizard'>('code') + const [openRules, setOpenRules] = useState>({}) + const [paletteOpen, setPaletteOpen] = useState(true) + const [helpOpen, setHelpOpen] = useState(true) + const [helpSearch, setHelpSearch] = useState('') + const [copied, setCopied] = useState(false) + + const editorRef = useRef(null) + const disposeIntelliSense = useRef<(() => void) | null>(null) + /** Sihirbazda ilk düzenleme yapılana kadar kod'a dokunulmaz. */ + const wizardTouched = useRef(false) + /** + * Lehçe nesnesi çağıranın prop'larından türetildiği için her render'da yeniden + * oluşabilir. Sıfırlama efektinin ona bağlanması, kullanıcı yazarken editörü + * boşaltırdı — o yüzden efekt lehçeyi ref üzerinden okur. + */ + const dialectRef = useRef(dialect) + dialectRef.current = dialect + + const generatedScript = useMemo(() => dialect.buildScript(rules), [dialect, rules]) + const completeRuleCount = rules.filter((rule) => dialect.isRuleComplete(rule)).length + const codeDiverged = code.trim() !== '' && code.trim() !== generatedScript.trim() + + useEffect(() => { + if (!isOpen) return + const parsed = dialectRef.current.parseScript(value) + setRules(parsed.rules) + setCode(value?.trim() ?? '') + setOpenRules(Object.fromEntries(parsed.rules.map((rule) => [rule.id, false]))) + setPaletteOpen(parsed.rules.length === 0) + setView('code') + setCopied(false) + setHelpSearch('') + wizardTouched.current = false + }, [isOpen, value]) + + // Kural eklemek, çıkarmak veya değiştirmek script'e anında yansır: sihirbazda + // yapılan her düzenleme kod editörünü de günceller. + useEffect(() => { + if (view !== 'wizard' || !wizardTouched.current) return + setCode(generatedScript) + }, [generatedScript, view]) + + useEffect( + () => () => { + disposeIntelliSense.current?.() + disposeIntelliSense.current = null + }, + [], + ) + + const optionSources = useMemo(() => { + const sources = new Set() + dialect.conditionKinds.forEach((kind) => kind.optionSource && sources.add(kind.optionSource)) + dialect.recipes.forEach((recipe) => + recipe.params.forEach((param) => param.optionSource && sources.add(param.optionSource)), + ) + return [...sources] + }, [dialect]) + + const handleEditorMount = useCallback( + (editor: monacoApi.editor.IStandaloneCodeEditor, monaco: Monaco) => { + editorRef.current = editor + disposeIntelliSense.current?.() + disposeIntelliSense.current = attachScriptIntelliSense( + monaco, + editor, + () => dialectRef.current, + { optionSources }, + ) + }, + [optionSources], + ) + + const touchRules = (next: (current: ScriptRule[]) => ScriptRule[]) => { + wizardTouched.current = true + setRules(next) + } + + const updateRule = (id: string, patch: Partial) => + touchRules((current) => current.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule))) + + const updateParam = (id: string, key: string, next: string) => + touchRules((current) => + current.map((rule) => + rule.id === id ? { ...rule, params: { ...rule.params, [key]: next } } : rule, + ), + ) + + /** + * Koşul güncellemesi: `index` 0 ise ana koşul, değilse ek koşullardan biri. + * Ek koşul kalmadığında `conditions` alanı tamamen kaldırılır — eski tek + * koşullu kurallarla aynı JSON'a serileşmesi buna bağlı. + */ + const updateCondition = (rule: ScriptRule, index: number, patch: Partial) => { + if (index === 0) { + updateRule(rule.id, { condition: { ...rule.condition, ...patch } }) + return + } + const conditions = [...(rule.conditions ?? [])] + conditions[index - 1] = { ...conditions[index - 1], ...patch } + updateRule(rule.id, { conditions }) + } + + const addCondition = (rule: ScriptRule) => + updateRule(rule.id, { + conditions: [ + ...(rule.conditions ?? []), + // Tek kaynak türü olan lehçelerde `kind` taşımak gereksiz gürültü. + emptyCondition(dialect.conditionKinds.length > 1 ? rule.condition.kind : undefined), + ], + join: rule.join ?? 'and', + }) + + const removeCondition = (rule: ScriptRule, index: number) => { + const conditions = (rule.conditions ?? []).filter((_, item) => item !== index - 1) + updateRule(rule.id, conditions.length ? { conditions } : { conditions: undefined, join: undefined }) + } + + const moveRule = (index: number, delta: number) => + touchRules((current) => { + const target = index + delta + if (target < 0 || target >= current.length) return current + const next = [...current] + const [moved] = next.splice(index, 1) + next.splice(target, 0, moved) + return next + }) + + const addRule = (recipeId: string) => { + const rule = dialect.createRule(recipeId) + touchRules((current) => [...current, rule]) + setOpenRules((current) => ({ ...current, [rule.id]: true })) + } + + const copyScript = () => { + navigator.clipboard?.writeText(view === 'wizard' ? generatedScript : code) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + const openWizard = () => { + // Elle yazılmış bir script'i kurallar okuyamıyorsa, kullanıcı bir kural + // değiştirene kadar kod olduğu gibi durur; banner bunu söyler. + const parsed = dialect.parseScript(code) + if (parsed.rules.length) { + setRules(parsed.rules) + setOpenRules(Object.fromEntries(parsed.rules.map((rule) => [rule.id, false]))) + } + setView('wizard') + } + + const filteredApi = useMemo(() => { + const query = helpSearch.trim().toLocaleLowerCase('tr') + if (!query) return dialect.apiReference + return dialect.apiReference.filter((entry) => + `${entry.signature} ${entry.description}`.toLocaleLowerCase('tr').includes(query), + ) + }, [dialect.apiReference, helpSearch]) + + const filteredSnippets = useMemo(() => { + const query = helpSearch.trim().toLocaleLowerCase('tr') + if (!query) return dialect.snippets + return dialect.snippets.filter((snippet) => + `${snippet.label} ${snippet.group} ${snippet.description} ${snippet.code}` + .toLocaleLowerCase('tr') + .includes(query), + ) + }, [dialect.snippets, helpSearch]) + + const renderOptionInput = ( + optionSource: string | undefined, + selectedValue: string, + onChange: (next: string) => void, + placeholder: string, + ) => { + const options = optionSource ? dialect.getOptions(optionSource) : [] + if (!options.length) { + return ( + onChange(event.target.value)} + /> + ) + } + return ( + + ) + } + + const renderParam = (rule: ScriptRule, param: ScriptRecipeParam) => { + if (param.type === 'fieldList') { + const selectedFields = rule.fields ?? [] + const options = dialect.getOptions(param.optionSource || '') + return ( +
+ {param.label} +
+ {selectedFields.map((fieldName) => ( + + {fieldName} + + + ))} + +
+
+ ) + } + + const currentValue = rule.params[param.key] ?? '' + + return ( + + ) + } + + const renderCondition = (rule: ScriptRule, condition: ScriptRuleCondition, index: number) => { + const operator = dialect.operators.find((item) => item.value === condition.operator) + const kind = + dialect.conditionKinds.find((item) => item.value === condition.kind) || + dialect.conditionKinds[0] + + return ( +
+ {index > 0 ? ( + + ) : ( +
+ Koşul +
EĞER
+
+ )} + + + + {operator?.needsSource && dialect.conditionKinds.length > 1 && ( + + )} + + {operator?.needsSource && ( + + )} + + {operator?.needsValue && ( + + )} + + {index > 0 && ( +
+
+ )} +
+ ) + } + + const renderRule = (rule: ScriptRule, index: number) => { + const recipe = dialect.getRecipe(rule.recipe) + if (!recipe) return null + + const open = openRules[rule.id] ?? true + const complete = dialect.isRuleComplete(rule) + const group = dialect.groups.find((item) => item.key === recipe.group) + const Icon = group?.icon ?? FaCode + const conditions = scriptRuleConditions(rule) + const trigger = dialect.getTrigger?.(rule) + const triggerInfo = dialect.triggers?.find((item) => item.value === trigger) + + return ( +
+
+ + +
+
+ {index + 1}. {recipe.label} +
+
+ {dialect.describeRule(rule)} +
+
+ {conditions.length > 1 && ( + + {conditions.length} koşul · {rule.join === 'or' ? 'VEYA' : 'VE'} + + )} + {triggerInfo && trigger !== dialect.triggers?.[0]?.value && ( + + {triggerInfo.label} + + )} + {!complete && ( + + eksik + + )} +
+
+
+ + {open && ( +
+

{recipe.summary}

+ +
+ {dialect.triggers && ( + + )} + + {conditions.map((condition, conditionIndex) => + renderCondition(rule, condition, conditionIndex), + )} + +
+ +
+
+ +
+ {recipe.params.map((param) => renderParam(rule, param))} +
+ +
+              {dialect.buildRuleLine(rule, rules) || recipe.example}
+            
+
+ )} +
+ ) + } + + const helpPanel = ( +
+
+ + setHelpSearch(event.target.value)} + /> +
+
+
+ Snippet +
+
+ {filteredSnippets.map((snippet) => ( + + ))} + {!filteredSnippets.length && ( + Eşleşen snippet yok. + )} +
+
+ Runtime API +
+
+ {filteredApi.map((entry) => ( + + ))} + {!filteredApi.length && ( + Eşleşen kısayol yok. + )} +
+
+
+ ) + + return ( + + {/* + Yükseklik zinciri iki yerden kopuyordu: Dialog'un `height` prop'u yalnızca + react-modal içeriğine uygulanıyor, ve `.dialog-body` global olarak `flex-1` + alıyor. `!flex-none` ile flex boyutlandırmasını kapatıp yüksekliği + doğrudan veriyoruz. + */} + +
+ +
{dialect.title}
+ + {dialect.subject} + + {view === 'wizard' && ( + + {completeRuleCount}/{rules.length} kural + + )} + +
+ {view === 'code' && !helpOpen && ( + + )} + +
+ + +
+
+
+ + {view === 'wizard' && codeDiverged && ( +
+ + + Koddaki script kurallardan farklı. Burada bir kuralı değiştirdiğin anda kod + kurallardan yeniden üretilir. + + +
+ )} + + {view === 'code' ? ( +
+
+
+ {dialect.codeHint} + {rules.length > 0 && ( + + )} +
+
+ setCode(next || '')} + /> +
+
+ {helpOpen && helpPanel} +
+ ) : ( +
+
+
+ + {paletteOpen && ( +
+ {dialect.groups.map((group) => { + const Icon = group.icon + const groupRecipes = dialect.recipes.filter( + (recipe) => recipe.group === group.key, + ) + if (!groupRecipes.length) return null + return ( +
+
+ {group.title} +
+
+ {groupRecipes.map((recipe) => ( + + ))} +
+
+ ) + })} +
+ )} +
+ + {rules.map(renderRule)} + + {!rules.length && ( +
+ Henüz kural yok. Yukarıdan bir kural ekle; her kural tek satırlık script üretir ve + kod sekmesine anında yansır. +
+ )} +
+ +
+
+ + + Script Önizleme + + + {generatedScript ? `${generatedScript.split('\n').length - 1} satır` : 'boş'} + +
+
+ +
+

+ İlk satırdaki {'// @builder'} yorumu kuralları saklar; dialog yeniden + açıldığında sihirbaz kuralları buradan geri yükler. +

+
+
+ )} +
+ + + + + +
+ ) +} + +export default ScriptBuilderDialog diff --git a/ui/src/components/scriptBuilder/scriptIntelliSense.ts b/ui/src/components/scriptBuilder/scriptIntelliSense.ts new file mode 100644 index 00000000..1a7e560f --- /dev/null +++ b/ui/src/components/scriptBuilder/scriptIntelliSense.ts @@ -0,0 +1,167 @@ +/** + * Script editörünün yazarken yardım eden kısmı. + * + * Monaco'ya lehçeye özel tamamlama, snippet ve hover dokümantasyonu bağlar. + * Sağlayıcılar dil geneline kaydedilir — Monaco başka türlü izin vermiyor — ama + * her çağrının başında model karşılaştırılır, böylece sayfadaki diğer JavaScript + * editörleri bu önerileri görmez. + */ + +import type { Monaco } from '@monaco-editor/react' +import type * as monacoApi from 'monaco-editor' +import type { ScriptApiEntry, ScriptDialect } from './types' + +type Editor = monacoApi.editor.IStandaloneCodeEditor + +/** `set('Alan', deger)` → `set`. Tamamlama ve hover eşleşmesi bu ad üzerinden. */ +export const apiEntryName = (entry: ScriptApiEntry) => + entry.name || (entry.signature.match(/[A-Za-z_$][\w$.]*/)?.[0] ?? entry.signature) + +/** + * İmzayı snippet'e çevirir: `set('Alan', deger)` → `set(${1:'Alan'}, ${2:deger})`. + * Böylece seçildiğinde imleç ilk argümanın üzerinde durur. + */ +const signatureToSnippet = (signature: string) => { + const match = signature.match(/^([A-Za-z_$][\w$.]*)\s*\((.*)\)\s*$/) + if (!match) return signature + const [, name, rawArgs] = match + if (!rawArgs.trim()) return `${name}()` + // Argümanlar virgülle ayrılır; içinde nesne/dizi olan imzalarda bölmeye + // çalışmak yerine tamamını tek yer tutucu yaparız. + const args = rawArgs.includes('{') || rawArgs.includes('[') ? [rawArgs] : rawArgs.split(',') + const body = args.map((arg, index) => `\${${index + 1}:${arg.trim()}}`).join(', ') + return `${name}(${body})` +} + +/** İmleç bir string literalinin içinde mi? Alan adları yalnızca orada önerilir. */ +const isInsideString = (line: string, column: number) => { + const before = line.slice(0, column - 1) + const single = (before.match(/'/g) || []).length + const double = (before.match(/"/g) || []).length + return single % 2 === 1 || double % 2 === 1 +} + +export type ScriptIntelliSenseOptions = { + /** Kaynak listeleri; sırayla tamamlamaya eklenir. */ + optionSources: string[] +} + +/** + * Editöre lehçenin API'sini bağlar; dönen fonksiyon tüm sağlayıcıları kaldırır. + */ +export const attachScriptIntelliSense = ( + monaco: Monaco, + editor: Editor, + // Lehçe alan listesi değiştikçe yeniden kurulur; sağlayıcılar her çağrıda + // güncel nesneyi okusun diye referans değil getter alıyoruz. + getDialect: () => ScriptDialect, + options: ScriptIntelliSenseOptions, +) => { + const modelId = editor.getModel()?.uri.toString() + const owns = (model: monacoApi.editor.ITextModel) => model.uri.toString() === modelId + + // Script bir handler gövdesi ya da tek satırlık parça; `return`/`await` gibi + // ifadeler tek başına geçersiz görünür. Kırmızı altı çizgiler yardımcı olmak + // yerine kafa karıştırırdı. + monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({ + noSemanticValidation: true, + noSyntaxValidation: true, + }) + + const completion = monaco.languages.registerCompletionItemProvider('javascript', { + triggerCharacters: ['.', "'", '"', '('], + provideCompletionItems: ( + model: monacoApi.editor.ITextModel, + position: monacoApi.Position, + ) => { + if (!owns(model)) return { suggestions: [] } + + const dialect = getDialect() + const word = model.getWordUntilPosition(position) + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + } + const line = model.getLineContent(position.lineNumber) + const inString = isInsideString(line, position.column) + + const names = options.optionSources.flatMap((source) => + dialect.getOptions(source).map((option) => ({ source, option })), + ) + const seen = new Set() + const nameSuggestions = names + .filter(({ option }) => (seen.has(option) ? false : seen.add(option))) + .map(({ source, option }) => ({ + label: option, + kind: monaco.languages.CompletionItemKind.Field, + detail: source, + // String içindeyken tırnak eklemek çift tırnağa yol açardı. + insertText: inString ? option : `'${option}'`, + range, + sortText: `0_${option}`, + })) + + // String içinde yalnızca ad önerilir; fonksiyon adı orada anlamsızdır. + if (inString) return { suggestions: nameSuggestions } + + const apiSuggestions = dialect.apiReference.map((entry) => ({ + label: apiEntryName(entry), + kind: monaco.languages.CompletionItemKind.Function, + detail: entry.signature, + documentation: entry.description, + insertText: entry.insertText ?? signatureToSnippet(entry.signature), + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range, + sortText: `1_${apiEntryName(entry)}`, + })) + + const snippetSuggestions = dialect.snippets.map((snippet) => ({ + label: snippet.label, + kind: monaco.languages.CompletionItemKind.Snippet, + detail: snippet.group, + documentation: { value: `${snippet.description}\n\n\`\`\`js\n${snippet.code}\n\`\`\`` }, + insertText: snippet.code, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range, + sortText: `2_${snippet.label}`, + })) + + return { suggestions: [...apiSuggestions, ...snippetSuggestions, ...nameSuggestions] } + }, + }) + + const hover = monaco.languages.registerHoverProvider('javascript', { + provideHover: (model: monacoApi.editor.ITextModel, position: monacoApi.Position) => { + if (!owns(model)) return null + const word = model.getWordAtPosition(position) + if (!word) return null + const entry = getDialect().apiReference.find((item) => apiEntryName(item) === word.word) + if (!entry) return null + return { + range: new monaco.Range( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn, + ), + contents: [{ value: `\`\`\`js\n${entry.signature}\n\`\`\`` }, { value: entry.description }], + } + }, + }) + + return () => { + completion.dispose() + hover.dispose() + } +} + +/** Editörde imlecin bulunduğu yere metin ekler ve odağı geri verir. */ +export const insertAtCursor = (editor: Editor | null, text: string) => { + if (!editor) return + const selection = editor.getSelection() + if (!selection) return + editor.executeEdits('script-builder', [{ range: selection, text, forceMoveMarkers: true }]) + editor.focus() +} diff --git a/ui/src/components/scriptBuilder/types.ts b/ui/src/components/scriptBuilder/types.ts new file mode 100644 index 00000000..29352039 --- /dev/null +++ b/ui/src/components/scriptBuilder/types.ts @@ -0,0 +1,173 @@ +/** + * Script Builder'ın ortak sözleşmesi. + * + * Dialog'un kendisi hangi runtime için script yazıldığını bilmez; her şey bir + * "lehçe" (dialect) nesnesinden gelir. Bugün iki lehçe var: + * + * - `form` → ListForm editor script'i (`get`/`set`/`notify`, alan bazlı) + * - `designer` → Visual Designer event script'i (`refs`/`api`/`event`) + * + * Lehçeler kendi tarif sözlüklerini sarmalar; ortak katman yalnızca kuralları + * listeler, koşulları düzenler ve üretilen script'i gösterir. + */ + +import type { IconType } from 'react-icons' + +export type ScriptRuleCondition = { + operator: string + source: string + value: string + /** Kaynağın türü — yalnızca birden fazla tür sunan lehçelerde anlamlı. */ + kind?: string +} + +export type ScriptConditionJoin = 'and' | 'or' + +/** + * Kural modeli iki lehçe için de aynıdır; `params`/`fields` içeriğinin anlamını + * tarif belirler. `conditions` yalnızca ikinci koşul eklendiğinde doldurulur — + * tek koşullu kurallar eskisiyle birebir aynı JSON'a serileşir. + */ +export type ScriptRule = { + id: string + recipe: string + condition: ScriptRuleCondition + conditions?: ScriptRuleCondition[] + join?: ScriptConditionJoin + params: Record + fields?: string[] + trigger?: string +} + +export type ScriptParamType = 'option' | 'text' | 'number' | 'select' | 'fieldList' + +export type ScriptRecipeParam = { + key: string + label: string + type: ScriptParamType + /** `option` ve `fieldList` için: seçenekleri hangi listeden okuyacağı. */ + optionSource?: string + placeholder?: string + help?: string + choices?: { value: string; label: string }[] + optional?: boolean +} + +export type ScriptRecipeView = { + id: string + label: string + group: string + summary: string + example: string + params: ScriptRecipeParam[] + /** Koşul aksiyonun argümanıdır; kural `if (...)` ile sarmalanmaz. */ + conditionIsArgument?: boolean +} + +export type ScriptRecipeGroup = { + key: string + title: string + description: string + icon: IconType +} + +export type ScriptOperator = { + value: string + label: string + /** Karşılaştırma değeri ister mi? */ + needsValue?: boolean + /** Kaynak alan ister mi? `always` istemez. */ + needsSource?: boolean +} + +export type ScriptConditionKind = { + value: string + label: string + /** Bu türde kaynak seçicisinin okuyacağı liste; boşsa serbest metin. */ + optionSource?: string + placeholder?: string +} + +export type ScriptTrigger = { value: string; label: string; help: string } + +export type ScriptApiEntry = { + signature: string + description: string + /** Tamamlama listesinde kullanılan ad; verilmezse imzadan türetilir. */ + name?: string + /** Monaco'ya verilen snippet gövdesi; verilmezse imza olduğu gibi eklenir. */ + insertText?: string +} + +export type ScriptSnippet = { + label: string + group: string + description: string + /** Monaco snippet sözdizimi (`${1:alan}`) desteklenir. */ + code: string +} + +export type ScriptParseResult = { + rules: ScriptRule[] + /** Builder tarafından üretilmiş ve o gün bugündür elle değiştirilmemiş mi? */ + inSync: boolean + hasHeader: boolean +} + +/** + * Dialog'un lehçeden beklediği her şey. Fonksiyonlar lehçe kurulurken verilen + * bağlamı (alan listesi, ref adları, event bilgisi) kapatır; ortak katman + * bunların hiçbirini görmez. + */ +export interface ScriptDialect { + id: string + /** Başlıkta görünen ad. */ + title: string + /** Başlıktaki rozet — düzenlenen alan ya da event. */ + subject: string + subjectHint?: string + /** Kod modunda üstte gösterilen tek satırlık açıklama. */ + codeHint: string + groups: ScriptRecipeGroup[] + recipes: ScriptRecipeView[] + operators: ScriptOperator[] + /** Birden fazlaysa kural kartında kaynak türü seçicisi çıkar. */ + conditionKinds: ScriptConditionKind[] + triggers?: ScriptTrigger[] + apiReference: ScriptApiEntry[] + snippets: ScriptSnippet[] + /** Tamamlama listesine eklenecek adlar (alan, ref, event yolu…). */ + getOptions: (optionSource: string) => string[] + getRecipe: (recipeId: string) => ScriptRecipeView | undefined + createRule: (recipeId: string) => ScriptRule + isRuleComplete: (rule: ScriptRule) => boolean + /** Kuralın tek satırlık kodu; `rules` tetikleyici guard'ları için gerekir. */ + buildRuleLine: (rule: ScriptRule, rules: ScriptRule[]) => string + buildScript: (rules: ScriptRule[]) => string + parseScript: (script?: string) => ScriptParseResult + describeCondition: (condition: ScriptRuleCondition) => string + describeRule: (rule: ScriptRule) => string + /** Kuralın etkin tetikleyicisi; tetikleyicisiz lehçelerde tanımsız. */ + getTrigger?: (rule: ScriptRule) => string + /** Kural kartında gösterilecek uyarı; örneğin eksik bağlam. */ + getRuleWarning?: (rule: ScriptRule) => string +} + +/** Kuralın bütün koşulları; ilki her zaman `condition`. */ +export const scriptRuleConditions = (rule: ScriptRule): ScriptRuleCondition[] => [ + rule.condition, + ...(rule.conditions ?? []), +] + +export const DEFAULT_SCRIPT_OPERATORS: ScriptOperator[] = [ + { value: 'always', label: 'Her zaman' }, + { value: 'equals', label: 'Eşitse', needsValue: true, needsSource: true }, + { value: 'notEquals', label: 'Eşit değilse', needsValue: true, needsSource: true }, + { value: 'contains', label: 'İçeriyorsa', needsValue: true, needsSource: true }, + { value: 'empty', label: 'Boşsa', needsSource: true }, + { value: 'notEmpty', label: 'Doluysa', needsSource: true }, + { value: 'greaterThan', label: 'Büyükse', needsValue: true, needsSource: true }, + { value: 'lessThan', label: 'Küçükse', needsValue: true, needsSource: true }, + { value: 'isTrue', label: 'İşaretliyse', needsSource: true }, + { value: 'isFalse', label: 'İşaretli değilse', needsSource: true }, +] diff --git a/ui/src/components/visualDesigner/DesignerScriptBuilderDialog.tsx b/ui/src/components/visualDesigner/DesignerScriptBuilderDialog.tsx new file mode 100644 index 00000000..fef6f81e --- /dev/null +++ b/ui/src/components/visualDesigner/DesignerScriptBuilderDialog.tsx @@ -0,0 +1,68 @@ +import ScriptBuilderDialog from '@/components/scriptBuilder/ScriptBuilderDialog' +import { useMemo } from 'react' +import { createDesignerScriptDialect } from './designerScriptDialect' +import type { SqlDataSourceEvent } from './types' + +export type DesignerScriptBuilderDialogProps = { + isOpen: boolean + value?: string + /** Düzenlenen event: başlıkta ve event yolu seçicilerinde kullanılır. */ + eventName: string + eventInfo?: SqlDataSourceEvent + /** Event'in sahibi komponentin adı, yalnızca başlık için. */ + componentLabel?: string + /** Kayıt tariflerinin yazacağı SqlDataSource ref adı. */ + sqlRef: string + /** SqlDataSource Select sonucundaki sütun adları. */ + recordFields: string[] + /** Sayfadaki tüm ref adları. */ + refNames: string[] + onClose: () => void + onApply: (value: string) => void +} + +/** + * Designer event'lerinin script editörü. Ortak Script Builder'ı designer + * lehçesiyle kurar; dialog'un kendisi paylaşılan bileşendir. + */ +function DesignerScriptBuilderDialog({ + isOpen, + value, + eventName, + eventInfo, + componentLabel, + sqlRef, + recordFields, + refNames, + onClose, + onApply, +}: DesignerScriptBuilderDialogProps) { + // Çağıran her render'da yeni diziler üretiyor; lehçe içeriklerine göre + // sabitlenmezse editör yazarken sıfırlanırdı. + const recordFieldKey = recordFields.join('|') + const refNameKey = refNames.join('|') + const dialect = useMemo( + () => + createDesignerScriptDialect({ + componentLabel, + eventInfo, + eventName, + recordFields: recordFieldKey ? recordFieldKey.split('|') : [], + refNames: refNameKey ? refNameKey.split('|') : [], + sqlRef, + }), + [componentLabel, eventInfo, eventName, recordFieldKey, refNameKey, sqlRef], + ) + + return ( + + ) +} + +export default DesignerScriptBuilderDialog diff --git a/ui/src/components/visualDesigner/VisualCanvas.tsx b/ui/src/components/visualDesigner/VisualCanvas.tsx index 6534c51d..817e3e6c 100644 --- a/ui/src/components/visualDesigner/VisualCanvas.tsx +++ b/ui/src/components/visualDesigner/VisualCanvas.tsx @@ -7,8 +7,10 @@ import apiService from '@/services/api.service' import { useLocalization } from '@/utils/hooks/useLocalization' import { formatLocaleValue } from '@/utils/localeFormat' import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa' +import { beginDesignerDragSoon, endDesignerDrag, useDesignerDrag } from './designerDrag' import { fromDesignerDate, + getDesignerBindingColumns, getDesignerCollectionProperty, getDesignerTabSlot, getDesignerValueByPath, @@ -68,6 +70,52 @@ const acceptDesignerDrag = (event: React.DragEvent) => { event.stopPropagation() event.dataTransfer.dropEffect = event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move' } + +/** + * "Sürüklenen düğümün içindeyiz" bilgisi ağaçtan aşağı akar. Bir bileşen kendi + * alt ağacının içine taşınamaz; onun dışındaki her hedef — kardeşler, üst + * konteynerler, kök — geçerlidir ve toolbox'tan bırakmayla birebir aynı geri + * bildirimi alır. + */ +const DesignerDraggedSubtreeContext = React.createContext(false) + +/** + * Açık bir bırakma bölgesi: üzerine gelindiğinde kendini vurgular. Boş konteyner + * yer tutucuları ve Tabs sekme gövdeleri bunu kullanır. + */ +const useDesignerDropZone = ( + interactive: boolean, + onDropPayload: (payload: DesignerDragPayload) => void, +) => { + const [over, setOver] = React.useState(false) + const drag = useDesignerDrag() + + React.useEffect(() => { + if (!drag) setOver(false) + }, [drag]) + + return { + over: over && Boolean(drag), + dragging: Boolean(drag) && interactive, + props: { + onDragOver: (event: React.DragEvent) => { + if (!interactive) return + acceptDesignerDrag(event) + setOver(true) + }, + onDragLeave: () => setOver(false), + onDrop: (event: React.DragEvent) => { + if (!interactive) return + event.preventDefault() + event.stopPropagation() + setOver(false) + const payload = readDesignerDragPayload(event) + if (payload) onDropPayload(payload) + endDesignerDrag() + }, + }, + } +} const getTableDimension = (value: unknown, fallback: number) => Math.min(20, Math.max(1, Math.floor(Number(value) || fallback))) const resolveStaticLanguageKeys = (value: unknown, translate: (key: string) => string): unknown => { @@ -204,13 +252,70 @@ const buildDesignerRefs = (store: DesignerRefStore | null) => { ...(isSqlDataSourceNode(node.type) ? { getRecord: () => ({}), + setRecord: designTimeOnly('setRecord'), + getField: () => undefined, setField: designTimeOnly('setField'), + setFields: designTimeOnly('setFields'), + clearFields: designTimeOnly('clearFields'), + getOriginal: () => ({}), + getChanges: () => ({}), + hasChanges: () => false, + getKey: () => undefined, + getKeyField: () => getSqlDataSourceKeyField(node), + getRows: () => [], + getRowCount: () => 0, + getIndex: () => 0, + getMode: () => 'edit', + isNew: () => false, + isBusy: () => false, + getError: () => '', + setError: designTimeOnly('setError'), + goToRow: designTimeOnly('goToRow'), + nextRow: designTimeOnly('nextRow'), + prevRow: designTimeOnly('prevRow'), + firstRow: designTimeOnly('firstRow'), + lastRow: designTimeOnly('lastRow'), reload: designTimeOnly('reload'), newRecord: designTimeOnly('newRecord'), save: designTimeOnly('save'), remove: designTimeOnly('remove'), } : {}), + // Select/AutoComplete/Menu: seçilen kaydın görünmeyen sütunları. Tasarım + // ekranında yalnızca statik seçenekler ve önizleme değeri elde olduğu için + // erişimciler bunlarla çalışır; çalışma zamanında aynı isimler endpoint + // sonucundaki tüm sütunları okur. + ...(isDesignerOptionComponent(node.type) + ? (() => { + const currentOption = () => { + const value = overrideProps()[valueProperty] ?? node.props[valueProperty] + if (value && typeof value === 'object') return value as Record + const options = node.props[getDesignerCollectionProperty(node.type)] + if (!Array.isArray(options)) return null + return ( + (options.find( + (option) => + option && + typeof option === 'object' && + (option as Record).value === value, + ) as Record) ?? null + ) + } + return { + getOptions: () => { + const options = node.props[getDesignerCollectionProperty(node.type)] + return Array.isArray(options) ? options : [] + }, + getSelectedOption: () => currentOption(), + getColumn: (path: string) => getDesignerValueByPath(currentOption(), String(path || '')), + getLabel: () => String(currentOption()?.label ?? ''), + getColumnNames: () => + getDesignerBindingColumns( + node.bindings?.[getDesignerCollectionProperty(node.type)], + ), + } + })() + : {}), } }) return refs @@ -1216,6 +1321,95 @@ const NodeView = ({ 'Tabs', 'SqlDataSource', ].includes(node.type) + + /* ---- Sürükleme geri bildirimi ---------------------------------- */ + const drag = useDesignerDrag() + const [dropHint, setDropHint] = React.useState<'before' | 'inside' | 'after' | null>(null) + /** Tabs sekmesi / tablo hücresi gibi slot hedeflerinden hangisinin üzerindeyiz. */ + const [overSlot, setOverSlot] = React.useState(null) + React.useEffect(() => { + if (drag) return + setDropHint(null) + setOverSlot(null) + }, [drag]) + /** Slot hedeflerinin ortak sürükleme davranışı: kabul et ve kendini işaretle. */ + const slotDropProps = (slot: string) => ({ + onDragOver: (event: React.DragEvent) => { + if (!interactive) return + acceptDesignerDrag(event) + setOverSlot(slot) + }, + onDragLeave: () => setOverSlot((current) => (current === slot ? null : current)), + }) + const dragging = interactive && Boolean(drag) + const isDragSource = drag?.nodeId === node.id + // Kendisi ve alt ağacı dışında her hedef geçerli: kardeşinin yanına, bir üst + // konteynerin içine ya da köke taşımak hep anlamlı işlemler. + const insideDraggedSubtree = React.useContext(DesignerDraggedSubtreeContext) || isDragSource + const canDropInside = acceptsDroppedChildren && !insideDraggedSubtree + const canDropBeside = !insideDraggedSubtree + const dragLabel = drag?.label || drag?.name || 'Bileşen' + /** + * Taşımayı başlatan ortak kod. Hem düğüm gövdesi hem de tutamak bunu kullanır: + * Input/Select/Textarea gibi canlı önizleme kontrolleri Chrome'da üst öğenin + * sürüklenmesini başlatmadığı için, gövdesi tamamen bir kontrolle kaplı bir + * bileşenin tutulabileceği tek yer tutamaktır. + */ + const startNodeDrag = (event: React.DragEvent) => { + event.stopPropagation() + const payload = JSON.stringify({ source: 'canvas', nodeId: node.id }) + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData(DESIGNER_DRAG_TYPE, payload) + event.dataTransfer.setData('text/plain', payload) + // `dragstart` içinde senkron bir state güncellemesi sürüklenen düğümü hemen + // yeniden render eder ve tarayıcı sürüklemeyi iptal eder. Vurguları bir tık + // sonraya bırakınca sürükleme sağlam başlar, geri bildirim de kaybolmaz. + beginDesignerDragSoon({ source: 'canvas', nodeId: node.id, label: node.ref || node.type }) + } + /** Pointer'ın dikey konumu bırakmanın anlamını belirler. */ + const resolveDropHint = (event: React.DragEvent) => { + const bounds = event.currentTarget.getBoundingClientRect() + const offset = event.clientY - bounds.top + // Konteynerlerde orta bant "içine", dış çeyrekler "yanına" demektir. + if (canDropInside && offset > bounds.height * 0.25 && offset < bounds.height * 0.75) { + return 'inside' as const + } + return offset < bounds.height / 2 ? ('before' as const) : ('after' as const) + } + /** Bırakmanın karşılığı olan ağaç işlemi. Kenar şeritleri de bunu kullanır. */ + const applyDrop = (payload: DesignerDragPayload, hint: 'before' | 'inside' | 'after') => { + const placement = hint === 'before' ? 'before' : 'after' + if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) { + if (hint === 'inside') onMoveIntoContainer?.(payload.nodeId, node.id) + else onReorder?.(payload.nodeId, node.id, placement) + } else if (payload.source === 'library' && payload.name) { + if (hint === 'inside') onDropComponent?.(payload.name, node.id) + else onDropComponentBeside?.(payload.name, node.id, placement) + } + } + /** + * Düğümler arasındaki boşluk hiçbir düğüme ait değildi; oraya bırakılan bileşen + * canvas'ın köküne, yani sayfanın en altına gidiyordu. Sürükleme sürerken her + * düğüm kendi üst ve alt boşluğunu kaplayan görünmez birer şerit açar, böylece + * "araya bırak" tam olarak nişan alınan yere düşer. + */ + const edgeStripProps = (placement: 'before' | 'after') => ({ + onDragOver: (event: React.DragEvent) => { + if (!interactive) return + acceptDesignerDrag(event) + setDropHint(canDropBeside ? placement : null) + }, + onDragLeave: () => setDropHint(null), + onDrop: (event: React.DragEvent) => { + if (!interactive) return + event.preventDefault() + event.stopPropagation() + setDropHint(null) + endDesignerDrag() + const payload = readDesignerDragPayload(event) + if (payload) applyDrop(payload, placement) + }, + }) const staticChildren = node.props.children const hasStaticChildren = staticChildren !== undefined && @@ -1317,31 +1511,26 @@ const NodeView = ({ return (
{ - if (!interactive) return - event.preventDefault() - event.stopPropagation() - event.dataTransfer.dropEffect = - event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move' - }} + className={`min-h-14 rounded py-2 transition ${ + overSlot === slot + ? 'bg-emerald-50 outline outline-2 outline-emerald-500 dark:bg-emerald-950/40' + : dragging + ? 'outline-dashed outline-1 outline-sky-400/70' + : '' + }`} + {...slotDropProps(slot)} onDrop={(event) => { if (!interactive) return event.preventDefault() event.stopPropagation() - const raw = - event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || - event.dataTransfer.getData('text/plain') - if (!raw) return - try { - const payload = JSON.parse(raw) - if (payload.source === 'library') { - onDropComponent?.(payload.name, node.id, slot) - } else if (payload.source === 'canvas' && payload.nodeId) { - onMoveIntoContainer?.(payload.nodeId, node.id, slot) - } - } catch { - // Ignore payloads that do not belong to the visual designer. + setOverSlot(null) + endDesignerDrag() + const payload = readDesignerDragPayload(event) + if (!payload) return + if (payload.source === 'library' && payload.name) { + onDropComponent?.(payload.name, node.id, slot) + } else if (payload.source === 'canvas' && payload.nodeId) { + onMoveIntoContainer?.(payload.nodeId, node.id, slot) } }} > @@ -1414,38 +1603,29 @@ const NodeView = ({ { - if (!interactive) return - event.preventDefault() - event.stopPropagation() - // Must match the source's effectAllowed, otherwise the - // browser rejects the drop and never fires onDrop. - event.dataTransfer.dropEffect = - event.dataTransfer.effectAllowed === 'move' ? 'move' : 'copy' - }} + {...slotDropProps(slot)} onDrop={(event) => { if (!interactive) return event.preventDefault() event.stopPropagation() - const raw = - event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || - event.dataTransfer.getData('text/plain') - if (!raw) return - try { - const payload = JSON.parse(raw) - if (payload.source === 'library') { - onDropComponent?.(payload.name, node.id, slot) - } else if (payload.source === 'canvas' && payload.nodeId) { - // Lets a component be dragged back into a cell, from - // another cell or from anywhere else on the canvas. - onMoveIntoContainer?.(payload.nodeId, node.id, slot) - } - } catch { - // Ignore payloads that do not belong to the visual designer. + setOverSlot(null) + endDesignerDrag() + const payload = readDesignerDragPayload(event) + if (!payload) return + if (payload.source === 'library' && payload.name) { + onDropComponent?.(payload.name, node.id, slot) + } else if (payload.source === 'canvas' && payload.nodeId) { + // Lets a component be dragged back into a cell, from + // another cell or from anywhere else on the canvas. + onMoveIntoContainer?.(payload.nodeId, node.id, slot) } }} > @@ -1571,22 +1751,13 @@ const NodeView = ({ : children // An empty container's placeholder is an explicit "inside" target, so it never // depends on where the pointer happens to sit within the node. - const containerDropZoneProps = { - onDragOver: (event: React.DragEvent) => { - if (interactive) acceptDesignerDrag(event) - }, - onDrop: (event: React.DragEvent) => { - if (!interactive) return - event.preventDefault() - event.stopPropagation() - const payload = readDesignerDragPayload(event) - if (!payload) return - if (payload.source === 'library' && payload.name) onDropComponent?.(payload.name, node.id) - else if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) { - onMoveIntoContainer?.(payload.nodeId, node.id) - } - }, - } + const containerDropZone = useDesignerDropZone(interactive, (payload) => { + if (payload.source === 'library' && payload.name) onDropComponent?.(payload.name, node.id) + else if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) { + onMoveIntoContainer?.(payload.nodeId, node.id) + } + }) + const containerDropZoneProps = containerDropZone.props // While a Card holds no child node the drop target stays visible below its own // content — otherwise a Card filled by its `children` text looks like it takes // no components. renderElement keeps that text above whatever is passed here. @@ -1595,12 +1766,16 @@ const NodeView = ({ ? [
- Bileşeni buraya bırakın + {containerDropZone.over ? `${dragLabel} buraya bırakılacak` : 'Bileşeni buraya bırakın'}
, ] : renderedChildren @@ -1610,13 +1785,23 @@ const NodeView = ({ if (refHidden && !interactive) return null return ( +
{ if (!interactive) return - event.stopPropagation() - const payload = JSON.stringify({ source: 'canvas', nodeId: node.id }) - event.dataTransfer.effectAllowed = 'move' - event.dataTransfer.setData(DESIGNER_DRAG_TYPE, payload) - event.dataTransfer.setData('text/plain', payload) + startNodeDrag(event) }} + onDragEnd={() => endDesignerDrag()} onDragOver={(event) => { if (!interactive) return event.preventDefault() @@ -1644,37 +1826,79 @@ const NodeView = ({ // the drop and never fires onDrop. event.dataTransfer.dropEffect = event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move' + setDropHint(canDropBeside || canDropInside ? resolveDropHint(event) : null) }} + onDragLeave={() => setDropHint(null)} onDrop={(event) => { if (!interactive) return event.preventDefault() event.stopPropagation() + setDropHint(null) + endDesignerDrag() const payload = readDesignerDragPayload(event) if (!payload) return - - const bounds = event.currentTarget.getBoundingClientRect() - const offset = event.clientY - bounds.top - // A container claims its middle band as "drop inside"; the outer quarters - // stay reserved for placing the node next to it, which is the only - // meaningful option on a component that cannot host children. - const inside = - acceptsDroppedChildren && offset > bounds.height * 0.25 && offset < bounds.height * 0.75 - const placement = offset < bounds.height / 2 ? 'before' : 'after' - - if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) { - if (inside) onMoveIntoContainer?.(payload.nodeId, node.id) - else onReorder?.(payload.nodeId, node.id, placement) - } else if (payload.source === 'library' && payload.name) { - if (inside) onDropComponent?.(payload.name, node.id) - else onDropComponentBeside?.(payload.name, node.id, placement) - } + // Bırakma noktası vurguyla birebir aynı kuraldan hesaplanır; kullanıcı + // ekranda ne gördüyse o olur. + applyDrop(payload, resolveDropHint(event)) }} > + {/* Üst ve alt boşluğu kaplayan görünmez "araya bırak" şeritleri. */} + {dragging && canDropBeside && ( + <> +
+
+ + )} + {/* + Taşıma tutamağı. Bir Input ya da Select'in gövdesi tamamen kontrolle + kaplıdır ve tarayıcı oradan üst öğenin sürüklenmesini başlatmaz; tutamak + her bileşen için garanti bir tutma noktası verir. Sürükleme sürerken + gizlenir, aksi halde komşu düğümün bırakma alanını kapatırdı. + */} + {/* Yanına bırakma çizgisi: bileşen tam olarak buraya girer. */} + {dropHint === 'before' && ( +
+ + {dragLabel} üstüne + + +
+ )} + {dropHint === 'after' && ( +
+ + {dragLabel} altına + + +
+ )} + {dropHint === 'inside' && ( + + {node.type} içine + + )} + {/* Sürükleme sürerken bırakılamayan hedef açıkça söylenir. */} + {dragging && insideDraggedSubtree && !isDragSource && ( + + buraya taşınamaz + + )} {interactive && (
- + {/* + Taşıma tutamağı. Bir Input ya da Select'in gövdesi tamamen canlı + kontrolle kaplıdır ve tarayıcı oradan üst öğenin sürüklenmesini + başlatmaz; bu rozet her bileşen için garanti bir tutma noktası verir. + */} + endDesignerDrag()} + > {node.type}
+ ) } @@ -1801,6 +2026,26 @@ const VisualCanvas = ({ // leaves whatever the last event script set in place. const refFingerprint = Object.keys(nodesByRef).sort().join('|') React.useEffect(() => setRefState({}), [refFingerprint]) + const drag = useDesignerDrag() + // Sürükleme canvas dışında bırakılırsa da vurgular temizlenmeli. + React.useEffect(() => { + if (typeof window === 'undefined') return + const clear = () => endDesignerDrag() + window.addEventListener('dragend', clear) + window.addEventListener('drop', clear) + return () => { + window.removeEventListener('dragend', clear) + window.removeEventListener('drop', clear) + } + }, []) + const rootDropZone = useDesignerDropZone(interactive, (payload) => { + if (payload.source === 'library' && payload.name) onDropComponent?.(payload.name, null) + // Kök alana bırakmak düğümü içinde bulunduğu konteynerden çıkarır. + else if (payload.source === 'canvas' && payload.nodeId) { + onMoveIntoContainer?.(payload.nodeId, null) + } + }) + const refStore = React.useMemo( () => ({ state: refState, @@ -1825,12 +2070,15 @@ const VisualCanvas = ({ return (
interactive && onSelect?.('')} onDragOver={(event) => interactive && event.preventDefault()} onDrop={(event) => { if (!interactive) return event.preventDefault() + endDesignerDrag() const raw = event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData('text/plain') if (!raw) return @@ -1843,6 +2091,14 @@ const VisualCanvas = ({ } }} > + {drag && interactive && ( +
+ + {drag.label || drag.name} sürükleniyor · kesikli çerçeveli alanlar içine alır, + yeşil çizgi araya yerleştirir + +
+ )} {nodes.length ? (
{nodes.map((node, index) => ( @@ -1867,9 +2123,32 @@ const VisualCanvas = ({ onNodePropChange={onNodePropChange} /> ))} + {/* Sürükleme sürerken kök seviye açık bir hedef olur; aksi halde bir + konteynerin dışına çıkmanın görünür bir yolu yok. */} + {drag && interactive && ( +
+ Sayfanın en altına bırak +
+ )}
) : ( -
+
+ diff --git a/ui/src/components/visualDesigner/catalog.ts b/ui/src/components/visualDesigner/catalog.ts index 6cfb62a1..600a8211 100644 --- a/ui/src/components/visualDesigner/catalog.ts +++ b/ui/src/components/visualDesigner/catalog.ts @@ -2,6 +2,7 @@ import { CUSTOM_COMPONENTS, HTML_ELEMENTS } from '@/components/codeLayout/data/c import generatedComponentProps from './generated/componentProps.json' import { DESIGNER_DATA_COMPONENT_NAMES, + SQL_DATA_SOURCE_EVENTS, SQL_DATA_SOURCE_TYPE, getDesignerCollectionProperty, isDesignerOptionComponent, @@ -133,6 +134,41 @@ const selectedValue = event?.value ?? null const selectedLabel = event?.label ?? '' console.log({ selectedValue, selectedLabel })`, }, + [SQL_DATA_SOURCE_TYPE]: { + onLoad: `// Select cevabı forma yerleştikten sonra çalışır. +// event: { rows, count, record, index } +if (!event.count) notify('Kayıt bulunamadı', 'warning')`, + onRecordChange: `// Aktif kayıt her değiştiğinde çalışır. +// event: { record, index, rows, count } +console.log('Aktif kayıt:', event.record)`, + onFieldChange: `// Kayda değer yazıldığında çalışır. +// event: { field, value, previous, record } +if (event.field === 'Quantity' || event.field === 'UnitPrice') { + const total = Number(event.record.Quantity || 0) * Number(event.record.UnitPrice || 0) + refs.{{sql}}.setField('TotalAmount', Math.round(total * 100) / 100) +}`, + onNewRecord: `// Yeni kayıt moduna geçildiğinde varsayılanları yaz. +refs.{{sql}}.setField('Status', 'Taslak')`, + onModeChange: `// event: { mode, record } +console.log('Mod:', event.mode)`, + onBeforeSave: `// false döndürülürse kayıt iptal edilir. +// event: { record, original, payload, mode, isNew } +if (!event.record.Name) { + notify('Ad alanı zorunludur', 'warning') + return false +}`, + onAfterSave: `// event: { record, response, mode, isNew } +notify(event.isNew ? 'Kayıt eklendi' : 'Kayıt güncellendi', 'success')`, + onBeforeDelete: `// false döndürülürse silme iptal edilir. +// event: { record, key } +if (event.record.Status === 'Onaylı') { + notify('Onaylı kayıt silinemez', 'danger') + return false +}`, + onAfterDelete: `notify('Kayıt silindi', 'success')`, + onError: `// event: { message, action, error, record } +notify(event.message, 'danger')`, + }, } /** @@ -272,6 +308,23 @@ try { params: { skipCount: 0 }, }) console.log(result)`, + }, + { + group: 'Komponent erişimi', + title: 'Select’in görünmeyen sütunlarını okuma', + description: + 'Select yalnızca Label gösterir; Data sekmesinde eklenen sütunlar seçenekle birlikte taşınır.', + code: `// onChange içinde: seçilen kaydın diğer sütunları +const fiyat = refs.{{ref}}.getColumn('unitPrice') +const stok = refs.{{ref}}.getColumn('stockQuantity') +refs.{{sql}}.setFields({ UnitPrice: fiyat, Stock: stok })`, + }, + { + group: 'Komponent erişimi', + title: 'Seçilen kaydın tamamı', + description: 'Çoklu seçimde dizi döner.', + code: `const secilen = refs.{{ref}}.getSelectedOption() +console.log(secilen, refs.{{ref}}.getLabel())`, }, { group: 'SqlDataSource', @@ -787,6 +840,16 @@ export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = { category: 'styling', }, { name: 'className', type: 'string', value: '', category: 'styling' }, + // Record lifecycle. `category: 'events'` keeps them out of the Properties + // panel and moves them into the JavaScript tab, where they are edited as + // scripts rather than as values. + ...SQL_DATA_SOURCE_EVENTS.map((event) => ({ + name: event.name, + type: 'function' as const, + value: '', + category: 'events' as const, + description: event.cancelable ? `${event.description} (false → iptal)` : event.description, + })), ], hooks: [], } diff --git a/ui/src/components/visualDesigner/codeGenerator.ts b/ui/src/components/visualDesigner/codeGenerator.ts index 069c938d..95a54df5 100644 --- a/ui/src/components/visualDesigner/codeGenerator.ts +++ b/ui/src/components/visualDesigner/codeGenerator.ts @@ -1,10 +1,12 @@ import { + getDesignerBindingColumns, getDesignerCollectionProperty, getDesignerTabSlotValue, getSqlDataSourceEndpointId, getSqlDataSourceKeyField, getSqlDataSourceKeyParam, getSqlDataSourceKeySource, + getSqlDataSourceScriptedEvents, hasSqlDataSourceUrlParams, isDesignerDateComponent, isDesignerDateOnlyComponent, @@ -316,10 +318,47 @@ const sqlIdentifiers = (node: DesignerNode) => { remove: `sqlDelete_${identifier}`, previous: `sqlPrev_${identifier}`, next: `sqlNext_${identifier}`, + // Mirrors of the record/mode state, so an event handler declared further down + // the component body reads the current value instead of a closed over one. + recordRef: `sqlRecordRef_${identifier}`, + modeRef: `sqlModeRef_${identifier}`, slot: (property: string) => `sql${property.replace(/Endpoint$/, '')}_${identifier}`, } } +/** Handler name of a scripted SqlDataSource event, or `''` when it has no script. */ +const sqlEventHandler = (node: DesignerNode, eventName: string) => + node.events?.[eventName]?.trim() ? `handle_${safeIdentifier(node.id)}_${eventName}` : '' + +const hasSqlEvents = (node: DesignerNode) => getSqlDataSourceScriptedEvents(node).length > 0 + +/** + * `await`ed call of an event script, emitted only when the event carries one. + * `payload` is the object literal the script sees as `event`. + */ +const sqlEventCall = (node: DesignerNode, eventName: string, payload: string) => { + const handler = sqlEventHandler(node, eventName) + return handler ? `await ${handler}(${payload})` : '' +} + +/** + * Cancelable variant: the command stops when the script returns exactly `false`, + * so a script that returns nothing (the common case) never blocks anything. + */ +const sqlCancelableEventCall = (node: DesignerNode, eventName: string, payload: string) => { + const handler = sqlEventHandler(node, eventName) + return handler ? `if ((await ${handler}(${payload})) === false) return` : '' +} + +/** Same, but inside a `try` that already owns the busy flag — needs a reset. */ +const sqlErrorEventCall = (node: DesignerNode, action: string) => { + const handler = sqlEventHandler(node, 'onError') + const names = sqlIdentifiers(node) + return handler + ? `\n await ${handler}({ message: toSqlErrorMessage(error), action: ${JSON.stringify(action)}, error, record: ${names.recordRef}.current })` + : '' +} + /** `{ url, method }` literal of an endpoint slot, or `null` when it is unset. */ const sqlSlotLiteral = ( node: DesignerNode, @@ -364,7 +403,16 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[ const [${names.mode}, ${names.setMode}] = React.useState("edit") const [${names.index}, ${names.setIndex}] = React.useState(0) const [${names.busy}, ${names.setBusy}] = React.useState(false) - const [${names.error}, ${names.setError}] = React.useState("") + const [${names.error}, ${names.setError}] = React.useState("")${ + hasSqlEvents(node) + ? ` + // Event scripts are declared after these hooks, so they read the record and the + // mode through refs rather than through the closure they were created in. + const ${names.recordRef} = React.useRef(${names.record}) + ${names.recordRef}.current = ${names.record} + const ${names.modeRef} = React.useRef(${names.mode})` + : '' + } // The Select key can come from the page URL, which is how a detail page reads // /api/app/orders/{id} or /api/app/orders?id=… for a single record. const ${names.urlKey} = readUrlKey(${JSON.stringify(keySource)}, ${names.keyParam}) @@ -385,19 +433,54 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[ // unsaved draft is not overwritten by a re-render. React.useEffect(() => { ${names.setIndex}((current) => (current < ${names.rows}.length ? current : 0)) - }, [${names.rows}]) + }, [${names.rows}])${ + sqlEventHandler(node, 'onLoad') + ? ` + // Fires once the Select result has settled, whether it carries rows or not — + // an empty result is exactly the case a script wants to react to. + React.useEffect(() => { + if (${selectData} === null || ${selectData} === undefined) return + void ${sqlEventHandler(node, 'onLoad')}({ rows: ${names.rows}, count: ${names.rows}.length, record: ${names.rows}[${names.index}] ?? {}, index: ${names.index} }) + }, [${names.rows}])` + : '' + } React.useEffect(() => { const row = ${names.rows}[${names.index}] if (!row) return ${names.setRecord}(row) ${names.setOriginal}(row) - ${names.setMode}("edit") - }, [${names.rows}, ${names.index}]) + ${names.setMode}("edit")${ + sqlEventHandler(node, 'onRecordChange') + ? ` + void ${sqlEventHandler(node, 'onRecordChange')}({ record: row, index: ${names.index}, rows: ${names.rows}, count: ${names.rows}.length })` + : '' + } + }, [${names.rows}, ${names.index}])${ + sqlEventHandler(node, 'onModeChange') + ? ` + // The ref starts at the initial mode, so the first render is not reported as a + // change; only real new/edit transitions reach the script. + React.useEffect(() => { + if (${names.modeRef}.current === ${names.mode}) return + ${names.modeRef}.current = ${names.mode} + void ${sqlEventHandler(node, 'onModeChange')}({ mode: ${names.mode}, record: ${names.recordRef}.current }) + }, [${names.mode}])` + : '' + } const ${names.hasKey} = (() => { const value = readSqlField(${names.record}, ${names.key}) return value !== undefined && value !== null && value !== "" })() - const ${names.setField} = React.useCallback((path, value) => ${names.setRecord}((current) => setSqlField(current, path, value)), []) + const ${names.setField} = React.useCallback(${ + sqlEventHandler(node, 'onFieldChange') + ? `(path, value) => { + ${names.setRecord}((current) => setSqlField(current, path, value)) + // The script is handed the record with the write already applied, so a + // calculation can read the sibling columns without re-deriving the change. + void ${sqlEventHandler(node, 'onFieldChange')}({ field: path, value, previous: getByPath(${names.recordRef}.current, path), record: setSqlField(${names.recordRef}.current, path, value) }) + }` + : `(path, value) => ${names.setRecord}((current) => setSqlField(current, path, value))` + }, []) const ${names.reload} = React.useCallback(async () => {${ setSelectData ? ` @@ -420,16 +503,21 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[ try { await ${names.reload}() } catch (error) { - ${names.setError}(toSqlErrorMessage(error)) + ${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'reload')} } finally { ${names.setBusy}(false) } }, [${names.reload}]) - const ${names.create} = React.useCallback(() => { + const ${names.create} = React.useCallback(${sqlEventHandler(node, 'onNewRecord') ? 'async ' : ''}() => { ${names.setError}("") ${names.setRecord}({}) ${names.setOriginal}({}) - ${names.setMode}("new") + ${names.setMode}("new")${ + sqlEventHandler(node, 'onNewRecord') + ? ` + ${sqlEventCall(node, 'onNewRecord', '{ record: {} }')}` + : '' + } }, []) const ${names.previous} = React.useCallback(() => ${names.setIndex}((current) => Math.max(0, current - 1)), []) const ${names.next} = React.useCallback(() => ${names.setIndex}((current) => Math.min(${names.rows}.length - 1, current + 1)), [${names.rows}]) @@ -445,6 +533,13 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[ if (!isNew && Object.keys(payload).filter((column) => column.toLowerCase() !== String(${names.key}).toLowerCase()).length === 0) { ${names.setError}("Kaydedilecek bir değişiklik yok.") return + }${ + sqlEventHandler(node, 'onBeforeSave') + ? ` + // Runs before the busy flag is raised: a vetoed save must leave the form in + // exactly the state the user left it in. + ${sqlCancelableEventCall(node, 'onBeforeSave', `{ record: ${names.record}, original: ${names.original}, payload, mode: ${names.mode}, isNew }`)}` + : '' } ${names.setBusy}(true) ${names.setError}("") @@ -461,28 +556,50 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[ ${names.setOriginal}(saved) } } - ${names.setMode}("edit") + ${names.setMode}("edit")${ + sqlEventHandler(node, 'onAfterSave') + ? ` + ${sqlEventCall(node, 'onAfterSave', `{ record: ${names.record}, response: response?.data, mode: ${names.mode}, isNew }`)}` + : '' + } } catch (error) { - ${names.setError}(toSqlErrorMessage(error)) + ${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'save')} } finally { ${names.setBusy}(false) } } const ${names.remove} = async () => { - if (!${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new") return + if (!${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new") return${ + sqlEventHandler(node, 'onBeforeDelete') + ? ` + // Asked before the confirm dialog, so a script can block the delete without + // the user ever being prompted. + ${sqlCancelableEventCall(node, 'onBeforeDelete', `{ record: ${names.record}, key: readSqlField(${names.record}, ${names.key}) }`)}` + : '' + } // Deleting cannot be undone from the form, so it always asks first. const confirmMessage = ${names.key} + " = " + readSqlField(${names.record}, ${names.key}) + " kaydı silinecek.\\n\\nOnaylıyor musunuz?" if (typeof window !== "undefined" && !window.confirm(confirmMessage)) return ${names.setBusy}(true) ${names.setError}("") - try { + try {${ + sqlEventHandler(node, 'onAfterDelete') + ? ` + const deleted = ${names.record}` + : '' + } await callSqlEndpoint(${names.slot('deleteEndpoint')}, ${names.record}, ${names.key}) ${names.setRecord}({}) ${names.setOriginal}({}) ${names.setMode}("new") - await ${names.reload}() + await ${names.reload}()${ + sqlEventHandler(node, 'onAfterDelete') + ? ` + ${sqlEventCall(node, 'onAfterDelete', '{ record: deleted }')}` + : '' + } } catch (error) { - ${names.setError}(toSqlErrorMessage(error)) + ${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'delete')} } finally { ${names.setBusy}(false) } @@ -940,12 +1057,31 @@ const refTargetFor = ( const names = sqlIdentifiers(node) extras.push( ` getRecord: () => ${names.record}`, + ` setRecord: (record) => ${names.setRecord}(record || {})`, + ` getField: (path) => getByPath(${names.record}, path)`, ` setField: (path, value) => ${names.setField}(path, value)`, + // A patch is the readable way to write several columns at once; each write + // still goes through setField so onFieldChange fires for every column. + ` setFields: (patch) => Object.entries(patch || {}).forEach(([path, value]) => ${names.setField}(path, value))`, + ` clearFields: (...paths) => paths.flat().forEach((path) => ${names.setField}(path, null))`, + ` getOriginal: () => ${names.original}`, + ` getChanges: () => toSqlChanges(${names.original}, ${names.record}, ${names.key})`, + ` hasChanges: () => Object.keys(toSqlChanges(${names.original}, ${names.record}, ${names.key})).filter((column) => column.toLowerCase() !== String(${names.key}).toLowerCase()).length > 0`, + ` getKey: () => readSqlField(${names.record}, ${names.key})`, + ` getKeyField: () => ${names.key}`, ` getRows: () => ${names.rows}`, + ` getRowCount: () => ${names.rows}.length`, + ` getIndex: () => ${names.index}`, ` getMode: () => ${names.mode}`, + ` isNew: () => ${names.mode} === "new"`, ` isBusy: () => ${names.busy}`, ` getError: () => ${names.error}`, + ` setError: (message) => ${names.setError}(message == null ? "" : String(message))`, ` goToRow: (index) => ${names.setIndex}(Number(index) || 0)`, + ` nextRow: () => ${names.next}()`, + ` prevRow: () => ${names.previous}()`, + ` firstRow: () => ${names.setIndex}(0)`, + ` lastRow: () => ${names.setIndex}(Math.max(0, ${names.rows}.length - 1))`, ` reload: () => ${names.refresh}()`, ` newRecord: () => ${names.create}()`, ` save: () => ${names.save}()`, @@ -958,30 +1094,42 @@ const refTargetFor = ( extras, } } - if (formField) { - return { - node, - getExpression: `getByPath(data_${safeIdentifier(formField.sourceId)}, ${JSON.stringify(formField.path)})`, - setStatement: `${formField.setterName}(${JSON.stringify(formField.path)}, value)`, - extras, - } - } - if (runtimeState) { - return { - node, - getExpression: runtimeState.stateName, - setStatement: `${runtimeState.setterName}(value)`, - extras, - } - } const valueProperty = 'checked' in node.props ? 'checked' : 'value' in node.props ? 'value' : 'children' - return { - node, - getExpression: `${refPropsExpression(ref)}.${valueProperty} ?? ${staticValueExpression(node.props[valueProperty])}`, - setStatement: `patchDesignerRef(${JSON.stringify(ref)}, { props: { ${valueProperty}: value } })`, - extras, + const base = formField + ? { + getExpression: `getByPath(data_${safeIdentifier(formField.sourceId)}, ${JSON.stringify(formField.path)})`, + setStatement: `${formField.setterName}(${JSON.stringify(formField.path)}, value)`, + } + : runtimeState + ? { + getExpression: runtimeState.stateName, + setStatement: `${runtimeState.setterName}(value)`, + } + : { + getExpression: `${refPropsExpression(ref)}.${valueProperty} ?? ${staticValueExpression(node.props[valueProperty])}`, + setStatement: `patchDesignerRef(${JSON.stringify(ref)}, { props: { ${valueProperty}: value } })`, + } + + // Select/AutoComplete/Menu ekranda yalnızca Label gösterir, ama seçenek nesnesi + // kaynak satırın bütün sütunlarını taşır. Bu erişimciler sayesinde bir script + // görünmeyen sütunları okuyup başka bir komponente ya da kayda yazabilir. + if (isDesignerOptionComponent(node.type)) { + const optionsExpression = optionCollectionExpression(node) + const selected = `findDesignerOption(${optionsExpression}, ${base.getExpression})` + const columns = getDesignerBindingColumns(node.bindings?.[getDesignerCollectionProperty(node.type)]) + extras.push( + ` getOptions: () => ${optionsExpression}`, + ` getSelectedOption: () => ${selected}`, + ` getColumn: (path) => readDesignerOptionColumn(${selected}, path)`, + ` getLabel: () => readDesignerOptionColumn(${selected}, "label") ?? ""`, + // Tasarımda bildirilen ek sütunlar; bir script hangi sütunların garanti + // altında olduğunu buradan öğrenebilir. + ` getColumnNames: () => ${JSON.stringify(columns)}`, + ) } + + return { node, ...base, extras } } /** @@ -1018,7 +1166,26 @@ const refRuntimeCode = (targets: RefTarget[]) => { }) .join(',\n') - return ` // Component references. Every node on the canvas is addressable by its ref, + // Only emitted when an option component is actually addressable by ref. + const optionHelpers = targets.some((target) => isDesignerOptionComponent(target.node.type)) + ? ` // Seçili option nesnesi: bir Select değeri kimi zaman option'ın kendisi, kimi + // zaman kayda yazılan ham value olur; ikisi de aynı nesneye çözülür. + const findDesignerOption = (options, value) => { + const list = Array.isArray(options) ? options : [] + if (Array.isArray(value)) return value.map((item) => findDesignerOption(list, item)).filter(Boolean) + if (value && typeof value === "object") return value + return list.find((option) => option?.value === value) + ?? list.find((option) => String(option?.value ?? "") === String(value ?? "")) + ?? null + } + const readDesignerOptionColumn = (option, path) => { + if (Array.isArray(option)) return option.map((item) => readDesignerOptionColumn(item, path)) + return String(path || "").split(".").filter(Boolean).reduce((current, key) => current?.[key], option) + } +` + : '' + + return `${optionHelpers} // Component references. Every node on the canvas is addressable by its ref, // so an event script can read or change another component without prop drilling. const [designerRefState, setDesignerRefState] = React.useState({}) const designerRefProps = (name) => designerRefState[name]?.props || {} @@ -1110,6 +1277,19 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) = ` const [${runtimeState.stateName}, ${runtimeState.setterName}] = React.useState(${initialExpression})`, ) } + // A SqlDataSource event is not a React prop: it is called by the container's + // own runtime with a single payload object, and always awaited so a script + // can veto a command or finish an API call before the save continues. + if (isSqlDataSourceNode(node.type)) { + getSqlDataSourceScriptedEvents(node).forEach((event) => { + const script = node.events[event.name].trim() + handlers.push( + ` const handle_${safeIdentifier(node.id)}_${event.name} = async (event) => {\n${indent(script, 2)}\n }`, + ) + }) + visit(node.children, { sourceId: node.id, setterName: sqlIdentifiers(node).setField }) + return + } const eventNames = new Set( Object.entries(node.events) .filter(([, script]) => script.trim()) diff --git a/ui/src/components/visualDesigner/designerDrag.ts b/ui/src/components/visualDesigner/designerDrag.ts new file mode 100644 index 00000000..55185911 --- /dev/null +++ b/ui/src/components/visualDesigner/designerDrag.ts @@ -0,0 +1,66 @@ +/** + * Designer sürükleme durumu. + * + * Bırakılabilecek yerleri vurgulayabilmek için canvas'ın, sürüklemenin ne zaman + * başladığını bilmesi gerekir. Kaynak toolbox olduğunda bu bilgi başka bir React + * ağacından gelir ve `dataTransfer` yalnızca drop anında okunabilir — bu yüzden + * sürükleme modül seviyesinde tutulup ilgilenen her bileşene yayınlanır. + */ + +import { useSyncExternalStore } from 'react' + +export interface DesignerDragInfo { + source: 'library' | 'canvas' + /** Toolbox'tan sürüklenen komponentin adı. */ + name?: string + /** Canvas'tan sürüklenen düğümün id'si. */ + nodeId?: string + /** Rozetlerde gösterilen etiket. */ + label?: string +} + +let designerDrag: DesignerDragInfo | null = null +let pendingStart: ReturnType | null = null +const listeners = new Set<() => void>() +const emit = () => listeners.forEach((listener) => listener()) + +export const beginDesignerDrag = (info: DesignerDragInfo) => { + designerDrag = info + emit() +} + +/** + * `dragstart` sırasında yapılan senkron bir state güncellemesi sürüklenen DOM + * düğümünü hemen yeniden render eder ve tarayıcı sürüklemeyi iptal eder — bu, + * HTML5 sürükleme API'sinin bilinen tuzağı. Vurguları bir sonraki tik'e + * bırakınca sürükleme sağlam başlar ve geri bildirim yine anında görünür. + */ +export const beginDesignerDragSoon = (info: DesignerDragInfo) => { + if (pendingStart) clearTimeout(pendingStart) + pendingStart = setTimeout(() => { + pendingStart = null + beginDesignerDrag(info) + }, 0) +} + +export const endDesignerDrag = () => { + if (pendingStart) { + clearTimeout(pendingStart) + pendingStart = null + } + if (!designerDrag) return + designerDrag = null + emit() +} + +const subscribe = (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +const getSnapshot = () => designerDrag +const getServerSnapshot = () => null + +export const useDesignerDrag = () => useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) diff --git a/ui/src/components/visualDesigner/designerScriptDialect.ts b/ui/src/components/visualDesigner/designerScriptDialect.ts new file mode 100644 index 00000000..e3db755b --- /dev/null +++ b/ui/src/components/visualDesigner/designerScriptDialect.ts @@ -0,0 +1,245 @@ +/** + * Visual Designer event script'inin ortak Script Builder lehçesi. + * + * `designerScriptRecipes.ts` içindeki sözlüğü ortak dialog'un sözleşmesine + * çevirir; kural motoru ve `// @builder` başlığı değişmez. + */ + +import { + DEFAULT_SCRIPT_OPERATORS, + type ScriptDialect, + type ScriptRecipeGroup, + type ScriptRecipeParam, + type ScriptRecipeView, + type ScriptRule, + type ScriptSnippet, +} from '@/components/scriptBuilder/types' +import { FaBell, FaDatabase, FaMousePointer, FaPlug, FaSignOutAlt } from 'react-icons/fa' +import { + buildDesignerRuleLine, + buildDesignerScript, + createDesignerRule, + describeDesignerCondition, + designerRecipeGroups, + designerRecipes, + designerRecipesById, + designerRuntimeApiReference, + isDesignerRuleComplete, + parseDesignerScript, + type DesignerRecipeGroupKey, + type DesignerRecipeId, + type DesignerRecipeParam, + type DesignerRuleCondition, + type DesignerScriptContext, + type DesignerScriptRule, +} from './designerScriptRecipes' +import type { SqlDataSourceEvent } from './types' + +const groupIcons: Record = { + record: FaDatabase, + component: FaMousePointer, + interaction: FaBell, + integration: FaPlug, + flow: FaSignOutAlt, +} + +const RECORD_SOURCE = 'recordField' +const REF_SOURCE = 'refName' +const EVENT_SOURCE = 'eventPath' + +const toParam = (param: DesignerRecipeParam): ScriptRecipeParam => ({ + key: param.key, + label: param.label, + type: + param.type === 'recordField' || param.type === 'refName' || param.type === 'eventPath' + ? 'option' + : param.type, + optionSource: + param.type === 'recordField' + ? RECORD_SOURCE + : param.type === 'refName' + ? REF_SOURCE + : param.type === 'eventPath' + ? EVENT_SOURCE + : param.type === 'fieldList' + ? RECORD_SOURCE + : undefined, + placeholder: param.placeholder, + help: param.help, + choices: param.choices, + optional: param.optional, +}) + +/** Kod editöründe tek tıkla eklenebilen çok satırlı kalıplar. */ +const snippets = (sqlRef: string): ScriptSnippet[] => { + const sql = sqlRef || 'sqlDataSource1' + return [ + { + label: 'Alan değişimine göre hesapla', + group: 'Kayıt', + description: 'Sadece ilgili sütunlar değişince hesaplar.', + code: `if (['\${1:Quantity}', '\${2:UnitPrice}'].includes(event.field)) {\n const total = Number(event.record.\${1:Quantity} || 0) * Number(event.record.\${2:UnitPrice} || 0)\n refs.${sql}.setField('\${3:TotalAmount}', Math.round(total * 100) / 100)\n}`, + }, + { + label: 'Kaydetmeden önce doğrula', + group: 'Akış', + description: 'Eksik alan varsa işlemi iptal eder.', + code: `const eksik = ['\${1:Name}', '\${2:Code}'].filter((column) => !event.record[column])\nif (eksik.length) {\n notify('Zorunlu alanlar: ' + eksik.join(', '), 'warning')\n return false\n}`, + }, + { + label: 'Çok koşullu blok', + group: 'Akış', + description: 'VE / VEYA ile birleşen koşullar.', + code: `if (refs.${sql}.getMode() === 'new' || Number(refs.${sql}.getField('\${1:Total}') || 0) > \${2:1000}) {\n \${3:notify('Kontrol edin')}\n}`, + }, + { + label: 'Toplu alan yazma', + group: 'Kayıt', + description: 'Birden fazla sütunu tek çağrıda doldurur.', + code: `refs.${sql}.setFields({ \${1:Status}: '\${2:Taslak}', \${3:Currency}: '\${4:TRY}' })`, + }, + { + label: 'Komponent görünürlüğü', + group: 'Komponent', + description: 'Koşula göre gösterir veya gizler.', + code: `refs.\${1:card1}.setVisible(refs.${sql}.getMode() === '\${2:edit}')`, + }, + { + label: 'API çağrısı ve hata yakalama', + group: 'Entegrasyon', + description: 'Çağrı başarısızsa bildirim gösterir.', + code: `try {\n const sonuc = await api.get('\${1:/api/app/customer/1}')\n refs.${sql}.setField('\${2:Title}', sonuc?.\${3:name})\n} catch (error) {\n notify(api.errorMessage(error), 'danger')\n}`, + }, + { + label: 'Satırlar arasında dolaş', + group: 'Kayıt', + description: 'Select sonucundaki tüm satırları gezer.', + code: `refs.${sql}.getRows().forEach((row, index) => {\n \${1:console.log(index, row)}\n})`, + }, + { + label: 'Değişiklik varsa kaydet', + group: 'Kayıt', + description: 'Boşuna istek atmaz.', + code: `if (refs.${sql}.hasChanges()) await refs.${sql}.save()`, + }, + { + label: 'Event içeriğini incele', + group: 'Akış', + description: 'Geliştirirken event nesnesinin ne taşıdığını görmek için.', + code: `console.log('\${1:event}', JSON.parse(JSON.stringify(event ?? null)))`, + }, + ] +} + +export type DesignerScriptDialectContext = { + /** Kayıt tariflerinin yazacağı SqlDataSource ref adı. */ + sqlRef: string + /** Select sonucundaki sütun adları. */ + recordFields: string[] + /** Sayfadaki tüm ref adları. */ + refNames: string[] + /** Düzenlenen event. */ + eventName: string + eventInfo?: SqlDataSourceEvent + /** Event'in sahibi komponentin adı; başlıkta gösterilir. */ + componentLabel?: string +} + +export const createDesignerScriptDialect = ( + context: DesignerScriptDialectContext, +): ScriptDialect => { + const sqlContext: DesignerScriptContext = { + sqlRef: context.sqlRef, + cancelable: Boolean(context.eventInfo?.cancelable), + } + const sorted = (values: string[]) => + Array.from(new Set(values.filter(Boolean))).sort((left, right) => left.localeCompare(right)) + const recordFields = sorted(context.recordFields) + const refNames = sorted(context.refNames) + const eventPaths = (context.eventInfo?.payload ?? []).map((item) => item.key) + + // "İşlemi iptal et" yalnızca false döndürebilen event'lerde anlamlı; diğer + // event'lerde üretilen `return false` sessizce hiçbir şey yapmazdı. + const usableRecipes = designerRecipes.filter( + (recipe) => !recipe.cancelableOnly || context.eventInfo?.cancelable, + ) + + const recipeViews: ScriptRecipeView[] = usableRecipes.map((recipe) => ({ + id: recipe.id, + label: recipe.label, + group: recipe.group, + summary: recipe.summary, + example: recipe.example, + conditionIsArgument: recipe.conditionIsArgument, + params: recipe.params.map(toParam), + })) + + const asDesignerRule = (rule: ScriptRule) => rule as unknown as DesignerScriptRule + + return { + id: 'designer', + title: 'Event Script', + subject: `${context.componentLabel ? `${context.componentLabel}.` : ''}${context.eventName}`, + subjectHint: context.eventInfo?.description, + codeHint: context.eventInfo?.cancelable + ? 'event / refs / api / notify hazır. false döndürürsen işlem iptal edilir.' + : 'event / refs / api / notify hazır. Ctrl+Space tamamlama listesini açar.', + groups: designerRecipeGroups.map((group) => ({ + key: group.key, + title: group.title, + description: group.description, + icon: groupIcons[group.key], + })), + recipes: recipeViews, + operators: DEFAULT_SCRIPT_OPERATORS, + conditionKinds: [ + { value: 'record', label: 'Kayıt sütunu', optionSource: RECORD_SOURCE }, + { value: 'ref', label: 'Komponent değeri', optionSource: REF_SOURCE }, + { value: 'event', label: 'Event verisi', optionSource: EVENT_SOURCE }, + { value: 'expression', label: 'Serbest ifade', placeholder: `refs.${context.sqlRef}.getMode()` }, + ], + apiReference: designerRuntimeApiReference, + snippets: snippets(context.sqlRef), + getOptions: (optionSource) => + optionSource === RECORD_SOURCE + ? recordFields + : optionSource === REF_SOURCE + ? refNames + : optionSource === EVENT_SOURCE + ? eventPaths + : [], + getRecipe: (recipeId) => recipeViews.find((recipe) => recipe.id === recipeId), + createRule: (recipeId) => + createDesignerRule(recipeId as DesignerRecipeId) as unknown as ScriptRule, + isRuleComplete: (rule) => isDesignerRuleComplete(asDesignerRule(rule), sqlContext), + buildRuleLine: (rule) => buildDesignerRuleLine(asDesignerRule(rule), sqlContext), + buildScript: (rules) => buildDesignerScript(rules.map(asDesignerRule), sqlContext), + parseScript: (script) => { + const parsed = parseDesignerScript(script, sqlContext) + return { ...parsed, rules: parsed.rules as unknown as ScriptRule[] } + }, + describeCondition: (condition) => + describeDesignerCondition(condition as unknown as DesignerRuleCondition), + describeRule: (rule) => { + const recipe = designerRecipesById.get(rule.recipe as DesignerRecipeId) + if (!recipe) return '' + const action = recipe.describe(asDesignerRule(rule)) + if (recipe.conditionIsArgument) return action + const conditions = [rule.condition, ...(rule.conditions ?? [])] + .map((condition) => + describeDesignerCondition(condition as unknown as DesignerRuleCondition), + ) + .filter((text) => text !== 'Her zaman') + const prefix = conditions.length + ? conditions.join(rule.join === 'or' ? ' veya ' : ' ve ') + : 'Her zaman' + return `${prefix} → ${action}` + }, + getRuleWarning: (rule) => { + const recipe = designerRecipesById.get(rule.recipe as DesignerRecipeId) + return recipe?.needsSqlRef && !context.sqlRef + ? 'Bu kural bir SqlDataSource ref adı gerektiriyor.' + : '' + }, + } +} diff --git a/ui/src/components/visualDesigner/designerScriptRecipes.ts b/ui/src/components/visualDesigner/designerScriptRecipes.ts new file mode 100644 index 00000000..d9bd78ed --- /dev/null +++ b/ui/src/components/visualDesigner/designerScriptRecipes.ts @@ -0,0 +1,1056 @@ +/** + * Visual Designer event script builder'ının veri sözlüğü. + * + * ListForm tarafındaki `editor-script/scriptRecipes.ts` ile aynı sözleşmeyi + * kullanır: her tarif tek satırlık kod üretir, üretilen script'in ilk satırındaki + * `// @builder {...}` başlığı kuralları saklar ve dialog yeniden açıldığında + * kurallar script'i regex ile çözmek yerine bu başlıktan geri okunur. + * + * Farkı çalışma ortamı: burada script bir React handler'ının gövdesinde çalışır + * ve `refs`, `api`, `notify`, `event` üzerinden konuşur — `get`/`set` gibi form + * yardımcıları yoktur. + */ + +export type DesignerConditionOperator = + | 'always' + | 'equals' + | 'notEquals' + | 'contains' + | 'empty' + | 'notEmpty' + | 'greaterThan' + | 'lessThan' + | 'isTrue' + | 'isFalse' + +/** Koşulun okunacağı yer; her biri farklı bir runtime ifadesine çevrilir. */ +export type DesignerConditionKind = 'record' | 'ref' | 'event' | 'expression' + +export type DesignerRuleCondition = { + operator: DesignerConditionOperator + kind: DesignerConditionKind + /** Sütun adı, ref adı, event yolu ya da ham ifade — `kind`'a göre. */ + source: string + value: string +} + +/** Ek koşullar nasıl birleşecek. */ +export type DesignerConditionJoin = 'and' | 'or' + +export type DesignerScriptRule = { + id: string + recipe: DesignerRecipeId + condition: DesignerRuleCondition + /** + * İkinci ve sonraki koşullar. Tek koşullu kurallar bu alanı taşımaz, böylece + * daha önce kaydedilmiş script'ler birebir aynı koda üretilmeye devam eder. + */ + conditions?: DesignerRuleCondition[] + join?: DesignerConditionJoin + params: Record + /** Çoklu alan alan tarifler için (toplama, temizleme). */ + fields?: string[] +} + +/** Kuralın bütün koşulları, ilki her zaman `condition`. */ +export const designerRuleConditions = (rule: DesignerScriptRule): DesignerRuleCondition[] => [ + rule.condition, + ...(rule.conditions ?? []), +] + +export type DesignerRecipeParam = { + key: string + label: string + type: 'recordField' | 'refName' | 'eventPath' | 'text' | 'number' | 'select' | 'fieldList' + placeholder?: string + help?: string + choices?: { value: string; label: string }[] + optional?: boolean +} + +export type DesignerRecipeGroupKey = 'record' | 'component' | 'interaction' | 'integration' | 'flow' + +/** + * Kuralın kod üretirken ihtiyaç duyduğu sayfa bilgisi. `sqlRef` düzenlenen + * event'in sahibi olan SqlDataSource'un ref adıdır; kayıt tarifleri bunun + * üzerinden yazar, böylece aynı tarif başka bir sayfada da çalışır. + */ +export type DesignerScriptContext = { + sqlRef: string + /** Event `false` döndürerek iptal edilebiliyor mu? "İptal et" tarifi buna bakar. */ + cancelable?: boolean +} + +export type DesignerRecipe = { + id: DesignerRecipeId + label: string + group: DesignerRecipeGroupKey + summary: string + /** Koşul aksiyonun argümanı olur; `if (...)` ile sarmalanmaz. */ + conditionIsArgument?: boolean + /** Yalnızca iptal edilebilir event'lerde (onBeforeSave/onBeforeDelete) anlamlı. */ + cancelableOnly?: boolean + /** SqlDataSource kaydına yazan tarifler bir sql ref'i olmadan tamamlanamaz. */ + needsSqlRef?: boolean + params: DesignerRecipeParam[] + build: (rule: DesignerScriptRule, context: DesignerScriptContext) => string + describe: (rule: DesignerScriptRule) => string + example: string +} + +export type DesignerRecipeId = + | 'setField' + | 'multiply' + | 'sumFields' + | 'formula' + | 'copyFromEvent' + | 'clearFields' + | 'dayDiff' + | 'save' + | 'remove' + | 'reload' + | 'newRecord' + | 'goToRow' + | 'selectColumnToField' + | 'selectColumnToRef' + | 'refSetValue' + | 'refVisible' + | 'refEnabled' + | 'refReadOnly' + | 'refSetText' + | 'refSetProp' + | 'notify' + | 'confirm' + | 'openUrl' + | 'apiToField' + | 'apiPost' + | 'cancel' + | 'stop' + | 'log' + | 'custom' + +export const designerRecipeGroups: { + key: DesignerRecipeGroupKey + title: string + description: string +}[] = [ + { key: 'record', title: 'Kayıt', description: 'SqlDataSource kaydını okur, yazar ve yönetir.' }, + { key: 'component', title: 'Komponent', description: 'Sayfadaki komponentleri ref ile yönetir.' }, + { key: 'interaction', title: 'Etkileşim', description: 'Mesaj, onay ve sayfa yönlendirmesi.' }, + { key: 'integration', title: 'Entegrasyon', description: 'API çağrıları ve serbest kod.' }, + { key: 'flow', title: 'Akış', description: 'İşlemi durdurur veya iptal eder.' }, +] + +/* ------------------------------------------------------------------ * + * Yardımcılar + * ------------------------------------------------------------------ */ + +const q = (input: string) => JSON.stringify(input ?? '') + +const label = (input: string, fallback: string) => input || fallback + +const isNumericLiteral = (input: string) => /^-?\d+(\.\d+)?$/.test(String(input).trim()) + +/** `a.b.c` → `event?.a?.b?.c`; boş yol `event`'in kendisidir. */ +export const eventPathExpression = (path: string) => { + const segments = String(path || '') + .split('.') + .map((segment) => segment.trim()) + .filter(Boolean) + return segments.length ? `event?.${segments.join('?.')}` : 'event' +} + +export const recordFieldExpression = (context: DesignerScriptContext, field: string) => + `refs.${context.sqlRef}.getField(${q(field)})` + +export const refValueExpression = (ref: string) => `refs.${ref}.getValue()` + +/** Koşulun okunacağı ifade. */ +export const conditionSourceExpression = ( + condition: DesignerRuleCondition, + context: DesignerScriptContext, +) => { + switch (condition.kind) { + case 'ref': + return condition.source ? refValueExpression(condition.source) : '' + case 'event': + return eventPathExpression(condition.source) + case 'expression': + return condition.source.trim() + default: + return condition.source ? recordFieldExpression(context, condition.source) : '' + } +} + +export const buildDesignerCondition = ( + condition: DesignerRuleCondition, + context: DesignerScriptContext, +): string => { + if (condition.operator === 'always') return '' + const source = conditionSourceExpression(condition, context) + if (!source) return '' + const { operator, value } = condition + + switch (operator) { + case 'empty': + return `!(${source})` + case 'notEmpty': + return `!!(${source})` + case 'isTrue': + return `(${source}) === true` + case 'isFalse': + return `(${source}) !== true` + case 'contains': + return `String((${source}) ?? "").includes(${q(value)})` + case 'greaterThan': + return `Number((${source}) || 0) > ${isNumericLiteral(value) ? value : `Number(${q(value)} || 0)`}` + case 'lessThan': + return `Number((${source}) || 0) < ${isNumericLiteral(value) ? value : `Number(${q(value)} || 0)`}` + case 'notEquals': + return `String((${source}) ?? "") !== ${q(value)}` + default: + return `String((${source}) ?? "") === ${q(value)}` + } +} + +/** + * Kuralın tüm koşullarını tek ifadeye çevirir. Tek koşulda çıktı eskisiyle + * birebir aynıdır; birden fazlasında parçalar parantezlenip birleştirilir. + */ +export const buildDesignerRuleCondition = ( + rule: DesignerScriptRule, + context: DesignerScriptContext, +): string => { + const parts = designerRuleConditions(rule) + .map((condition) => buildDesignerCondition(condition, context)) + .filter(Boolean) + if (parts.length <= 1) return parts[0] ?? '' + return parts.map((part) => `(${part})`).join(rule.join === 'or' ? ' || ' : ' && ') +} + +const conditionSourceLabel = (condition: DesignerRuleCondition) => { + switch (condition.kind) { + case 'ref': + return `${label(condition.source, 'komponent')} değeri` + case 'event': + return `event.${label(condition.source, 'alan')}` + case 'expression': + return label(condition.source, 'ifade') + default: + return label(condition.source, 'sütun') + } +} + +export const describeDesignerCondition = (condition: DesignerRuleCondition): string => { + if (condition.operator === 'always') return 'Her zaman' + const source = conditionSourceLabel(condition) + switch (condition.operator) { + case 'empty': + return `${source} boşsa` + case 'notEmpty': + return `${source} doluysa` + case 'isTrue': + return `${source} işaretliyse` + case 'isFalse': + return `${source} işaretli değilse` + case 'contains': + return `${source} içinde "${label(condition.value, 'değer')}" geçiyorsa` + case 'greaterThan': + return `${source} > ${label(condition.value, 'değer')} ise` + case 'lessThan': + return `${source} < ${label(condition.value, 'değer')} ise` + case 'notEquals': + return `${source} ≠ "${label(condition.value, 'değer')}" ise` + default: + return `${source} = "${label(condition.value, 'değer')}" ise` + } +} + +/** Serbest metin alanı: `ifade` modunda ham kod, aksi halde string literal. */ +const valueExpression = (raw: string, mode?: string) => { + const input = (raw ?? '').trim() + if (mode === 'expression') return input || 'null' + if (mode === 'number') return isNumericLiteral(input) ? input : `Number(${q(input)} || 0)` + if (mode === 'boolean') return input === 'false' ? 'false' : 'true' + if (mode === 'null') return 'null' + return q(raw ?? '') +} + +const VALUE_MODE_PARAM: DesignerRecipeParam = { + key: 'mode', + label: 'Değer türü', + type: 'select', + optional: true, + help: 'İfade seçilirse yazdığın kod olduğu gibi çalışır.', + choices: [ + { value: '', label: 'Metin' }, + { value: 'number', label: 'Sayı' }, + { value: 'boolean', label: 'Evet / Hayır' }, + { value: 'expression', label: 'İfade (kod)' }, + { value: 'null', label: 'Boş (null)' }, + ], +} + +const numberOfRecordField = (context: DesignerScriptContext, field: string) => + isNumericLiteral(field) ? field : `Number(${recordFieldExpression(context, field)} || 0)` + +/* ------------------------------------------------------------------ * + * Tarifler + * ------------------------------------------------------------------ */ + +export const designerRecipes: DesignerRecipe[] = [ + { + id: 'setField', + label: 'Kayda değer yaz', + group: 'record', + needsSqlRef: true, + summary: 'SqlDataSource kaydındaki bir sütuna değer yazar.', + example: "refs.sqlDataSource1.setField('Status', 'Taslak')", + params: [ + { key: 'target', label: 'Sütun', type: 'recordField' }, + { key: 'value', label: 'Değer', type: 'text', placeholder: 'Taslak' }, + VALUE_MODE_PARAM, + ], + build: ({ params }, context) => + `refs.${context.sqlRef}.setField(${q(params.target)}, ${valueExpression(params.value, params.mode)})`, + describe: ({ params }) => + `${label(params.target, 'sütun')} sütununa "${label(params.value, 'değer')}" yaz.`, + }, + { + id: 'multiply', + label: 'Çarpım (miktar × fiyat)', + group: 'record', + needsSqlRef: true, + summary: 'İki sütunu çarpar, yuvarlar ve hedef sütuna yazar.', + example: + "refs.sqlDataSource1.setField('TotalAmount', Math.round(Number(refs.sqlDataSource1.getField('Quantity') || 0) * Number(refs.sqlDataSource1.getField('UnitPrice') || 0) * 100) / 100)", + params: [ + { key: 'left', label: 'Birinci sütun', type: 'recordField' }, + { key: 'right', label: 'İkinci sütun', type: 'recordField' }, + { key: 'target', label: 'Sonuç sütunu', type: 'recordField' }, + { + key: 'digits', + label: 'Ondalık', + type: 'number', + optional: true, + placeholder: '2', + help: 'Boş bırakılırsa 2 kullanılır.', + }, + ], + build: ({ params }, context) => { + const factor = 10 ** (Number(params.digits) || 2) + return `refs.${context.sqlRef}.setField(${q(params.target)}, Math.round(${numberOfRecordField( + context, + params.left, + )} * ${numberOfRecordField(context, params.right)} * ${factor}) / ${factor})` + }, + describe: ({ params }) => + `${label(params.left, 'sütun')} × ${label(params.right, 'sütun')} sonucunu ${label( + params.target, + 'hedef', + )} sütununa yaz.`, + }, + { + id: 'sumFields', + label: 'Toplama', + group: 'record', + needsSqlRef: true, + summary: 'Seçilen sütunları toplar ve hedefe yazar.', + example: + "refs.sqlDataSource1.setField('Total', Number(refs.sqlDataSource1.getField('Net') || 0) + Number(refs.sqlDataSource1.getField('Tax') || 0))", + params: [ + { key: 'target', label: 'Sonuç sütunu', type: 'recordField' }, + { key: 'sources', label: 'Toplanacak sütunlar', type: 'fieldList' }, + { key: 'digits', label: 'Ondalık', type: 'number', optional: true, placeholder: '2' }, + ], + build: ({ params, fields }, context) => { + const total = (fields ?? []).map((field) => numberOfRecordField(context, field)).join(' + ') + const factor = 10 ** (Number(params.digits) || 2) + return `refs.${context.sqlRef}.setField(${q(params.target)}, Math.round((${total || '0'}) * ${factor}) / ${factor})` + }, + describe: ({ params, fields }) => + `${(fields ?? []).join(' + ') || 'sütunlar'} toplamını ${label(params.target, 'hedef')} sütununa yaz.`, + }, + { + id: 'formula', + label: 'Serbest formül', + group: 'record', + needsSqlRef: true, + summary: 'JavaScript ifadesi yazarsın; refs ve event kullanabilirsin.', + example: + "refs.sqlDataSource1.setField('Net', Number(refs.sqlDataSource1.getField('Gross') || 0) * 0.82)", + params: [ + { key: 'target', label: 'Sonuç sütunu', type: 'recordField' }, + { + key: 'expression', + label: 'İfade', + type: 'text', + placeholder: "Number(refs.sqlDataSource1.getField('Gross') || 0) * 0.18", + help: 'refs..getField(), refs..getValue(), event. kullanabilirsin.', + }, + ], + build: ({ params }, context) => + `refs.${context.sqlRef}.setField(${q(params.target)}, ${params.expression || 'null'})`, + describe: ({ params }) => + `${label(params.expression, 'ifade')} sonucunu ${label(params.target, 'hedef')} sütununa yaz.`, + }, + { + id: 'copyFromEvent', + label: 'Event verisini sütuna kopyala', + group: 'record', + needsSqlRef: true, + summary: 'Event nesnesindeki bir değeri kayıt sütununa taşır.', + example: "refs.sqlDataSource1.setField('OldValue', event?.previous)", + params: [ + { key: 'source', label: 'Event yolu', type: 'eventPath', placeholder: 'record.Name' }, + { key: 'target', label: 'Hedef sütun', type: 'recordField' }, + ], + build: ({ params }, context) => + `refs.${context.sqlRef}.setField(${q(params.target)}, ${eventPathExpression(params.source)})`, + describe: ({ params }) => + `event.${label(params.source, 'alan')} değerini ${label(params.target, 'hedef')} sütununa kopyala.`, + }, + { + id: 'clearFields', + label: 'Sütunları temizle', + group: 'record', + needsSqlRef: true, + summary: 'Seçilen sütunları null yapar.', + example: "refs.sqlDataSource1.clearFields('City', 'District')", + params: [{ key: 'sources', label: 'Temizlenecek sütunlar', type: 'fieldList' }], + build: ({ fields }, context) => + `refs.${context.sqlRef}.clearFields(${(fields ?? []).map(q).join(', ')})`, + describe: ({ fields }) => `${(fields ?? []).join(', ') || 'sütunları'} temizle.`, + }, + { + id: 'dayDiff', + label: 'Gün farkı', + group: 'record', + needsSqlRef: true, + summary: 'İki tarih sütunu arasındaki gün sayısını (bitiş dahil) yazar.', + example: + "refs.sqlDataSource1.setField('TotalDays', Math.round((new Date(refs.sqlDataSource1.getField('EndDate')) - new Date(refs.sqlDataSource1.getField('StartDate'))) / 86400000) + 1)", + params: [ + { key: 'start', label: 'Başlangıç sütunu', type: 'recordField' }, + { key: 'end', label: 'Bitiş sütunu', type: 'recordField' }, + { key: 'target', label: 'Sonuç sütunu', type: 'recordField' }, + ], + build: ({ params }, context) => + `refs.${context.sqlRef}.setField(${q(params.target)}, Math.round((new Date(${recordFieldExpression( + context, + params.end, + )}) - new Date(${recordFieldExpression(context, params.start)})) / 86400000) + 1)`, + describe: ({ params }) => + `${label(params.start, 'başlangıç')} → ${label(params.end, 'bitiş')} gün farkını ${label( + params.target, + 'hedef', + )} sütununa yaz.`, + }, + { + id: 'save', + label: 'Kaydet', + group: 'record', + needsSqlRef: true, + summary: 'Insert/Update endpointini çalıştırır.', + example: 'await refs.sqlDataSource1.save()', + params: [], + build: (_rule, context) => `await refs.${context.sqlRef}.save()`, + describe: () => 'Kaydı kaydet.', + }, + { + id: 'remove', + label: 'Sil', + group: 'record', + needsSqlRef: true, + summary: 'Delete endpointini çalıştırır.', + example: 'await refs.sqlDataSource1.remove()', + params: [], + build: (_rule, context) => `await refs.${context.sqlRef}.remove()`, + describe: () => 'Kaydı sil.', + }, + { + id: 'reload', + label: 'Yenile', + group: 'record', + needsSqlRef: true, + summary: 'Select endpointini yeniden çağırır.', + example: 'await refs.sqlDataSource1.reload()', + params: [], + build: (_rule, context) => `await refs.${context.sqlRef}.reload()`, + describe: () => 'Kaydı yeniden yükle.', + }, + { + id: 'newRecord', + label: 'Yeni kayıt', + group: 'record', + needsSqlRef: true, + summary: 'Formu boş kayıt moduna alır.', + example: 'refs.sqlDataSource1.newRecord()', + params: [], + build: (_rule, context) => `refs.${context.sqlRef}.newRecord()`, + describe: () => 'Yeni kayıt moduna geç.', + }, + { + id: 'goToRow', + label: 'Satıra git', + group: 'record', + needsSqlRef: true, + summary: 'Select sonucundaki başka bir satıra geçer.', + example: 'refs.sqlDataSource1.lastRow()', + params: [ + { + key: 'target', + label: 'Hedef', + type: 'select', + choices: [ + { value: 'next', label: 'Sonraki' }, + { value: 'prev', label: 'Önceki' }, + { value: 'first', label: 'İlk' }, + { value: 'last', label: 'Son' }, + { value: 'index', label: 'Sıra numarası' }, + ], + }, + { key: 'index', label: 'Sıra', type: 'number', optional: true, placeholder: '0' }, + ], + build: ({ params }, context) => { + const calls: Record = { + next: 'nextRow()', + prev: 'prevRow()', + first: 'firstRow()', + last: 'lastRow()', + } + return params.target === 'index' + ? `refs.${context.sqlRef}.goToRow(${Number(params.index) || 0})` + : `refs.${context.sqlRef}.${calls[params.target] || 'nextRow()'}` + }, + describe: ({ params }) => `${label(params.target, 'sonraki')} satıra git.`, + }, + { + id: 'selectColumnToField', + label: 'Select sütununu kayda yaz', + group: 'record', + needsSqlRef: true, + summary: + 'Select ekranda sadece Label gösterir; seçilen kaydın görünmeyen bir sütununu kayda taşır.', + example: + "refs.sqlDataSource1.setField('UnitPrice', refs.select1.getColumn('unitPrice'))", + params: [ + { key: 'ref', label: 'Select komponenti', type: 'refName' }, + { + key: 'column', + label: 'Okunacak sütun', + type: 'text', + placeholder: 'unitPrice', + help: 'Data sekmesinde “Ek sütunlar” altında tanımladığın sütun adı.', + }, + { key: 'target', label: 'Hedef kayıt sütunu', type: 'recordField' }, + ], + build: ({ params }, context) => + `refs.${context.sqlRef}.setField(${q(params.target)}, refs.${params.ref}.getColumn(${q( + params.column, + )}))`, + describe: ({ params }) => + `${label(params.ref, 'select')} seçiminin ${label(params.column, 'sütun')} değerini ${label( + params.target, + 'hedef', + )} sütununa yaz.`, + }, + { + id: 'selectColumnToRef', + label: 'Select sütununu komponente yaz', + group: 'component', + summary: 'Seçilen kaydın görünmeyen bir sütununu başka bir komponentin değerine yazar.', + example: "refs.input1.setValue(refs.select1.getColumn('unitPrice'))", + params: [ + { key: 'ref', label: 'Select komponenti', type: 'refName' }, + { key: 'column', label: 'Okunacak sütun', type: 'text', placeholder: 'unitPrice' }, + { key: 'target', label: 'Hedef komponent', type: 'refName' }, + ], + build: ({ params }) => + `refs.${params.target}.setValue(refs.${params.ref}.getColumn(${q(params.column)}))`, + describe: ({ params }) => + `${label(params.ref, 'select')} seçiminin ${label(params.column, 'sütun')} değerini ${label( + params.target, + 'komponent', + )} değerine yaz.`, + }, + { + id: 'refSetValue', + label: 'Komponente değer yaz', + group: 'component', + summary: 'Bir komponentin değerini değiştirir.', + example: "refs.input1.setValue('Merhaba')", + params: [ + { key: 'ref', label: 'Komponent', type: 'refName' }, + { key: 'value', label: 'Değer', type: 'text' }, + VALUE_MODE_PARAM, + ], + build: ({ params }) => + `refs.${params.ref}.setValue(${valueExpression(params.value, params.mode)})`, + describe: ({ params }) => + `${label(params.ref, 'komponent')} değerini "${label(params.value, 'değer')}" yap.`, + }, + { + id: 'refVisible', + label: 'Göster / gizle', + group: 'component', + conditionIsArgument: true, + summary: 'Koşul sağlandığında komponenti gösterir veya gizler.', + example: + "refs.card1.setVisible(String(refs.sqlDataSource1.getField('Status') ?? '') === 'Aktif')", + params: [ + { key: 'ref', label: 'Komponent', type: 'refName' }, + { + key: 'invert', + label: 'Koşul sağlanınca', + type: 'select', + optional: true, + choices: [ + { value: '', label: 'Göster' }, + { value: 'true', label: 'Gizle' }, + ], + }, + ], + build: (rule, context) => { + const expression = buildDesignerRuleCondition(rule, context) || 'true' + const flag = rule.params.invert === 'true' ? `!(${expression})` : expression + return `refs.${rule.params.ref}.setVisible(${flag})` + }, + describe: ({ params, condition }) => + `${describeDesignerCondition(condition)} ${label(params.ref, 'komponent')} ${ + params.invert === 'true' ? 'gizlensin' : 'görünsün' + }.`, + }, + { + id: 'refEnabled', + label: 'Aktif / pasif yap', + group: 'component', + conditionIsArgument: true, + summary: 'Koşul sağlandığında komponenti kullanılabilir veya pasif yapar.', + example: 'refs.button1.setEnabled(refs.sqlDataSource1.hasChanges())', + params: [ + { key: 'ref', label: 'Komponent', type: 'refName' }, + { + key: 'invert', + label: 'Koşul sağlanınca', + type: 'select', + optional: true, + choices: [ + { value: '', label: 'Aktif' }, + { value: 'true', label: 'Pasif' }, + ], + }, + ], + build: (rule, context) => { + const expression = buildDesignerRuleCondition(rule, context) || 'true' + const flag = rule.params.invert === 'true' ? `!(${expression})` : expression + return `refs.${rule.params.ref}.setEnabled(${flag})` + }, + describe: ({ params, condition }) => + `${describeDesignerCondition(condition)} ${label(params.ref, 'komponent')} ${ + params.invert === 'true' ? 'pasif' : 'aktif' + } olsun.`, + }, + { + id: 'refReadOnly', + label: 'Salt okunur yap', + group: 'component', + conditionIsArgument: true, + summary: 'Koşul sağlandığında alanı kilitler, sağlanmadığında açar.', + example: "refs.input1.setReadOnly(refs.sqlDataSource1.getMode() !== 'new')", + params: [ + { key: 'ref', label: 'Komponent', type: 'refName' }, + { + key: 'invert', + label: 'Tersine çevir', + type: 'select', + optional: true, + choices: [ + { value: '', label: 'Hayır' }, + { value: 'true', label: 'Evet' }, + ], + }, + ], + build: (rule, context) => { + const expression = buildDesignerRuleCondition(rule, context) || 'true' + const flag = rule.params.invert === 'true' ? `!(${expression})` : expression + return `refs.${rule.params.ref}.setReadOnly(${flag})` + }, + describe: ({ params, condition }) => + `${describeDesignerCondition(condition)} ${label(params.ref, 'komponent')} ${ + params.invert === 'true' ? 'düzenlenebilir' : 'salt okunur' + } olsun.`, + }, + { + id: 'refSetText', + label: 'Metnini değiştir', + group: 'component', + summary: 'Buton, etiket veya başlık metnini değiştirir.', + example: "refs.button1.setText('Güncelle')", + params: [ + { key: 'ref', label: 'Komponent', type: 'refName' }, + { key: 'text', label: 'Metin', type: 'text' }, + VALUE_MODE_PARAM, + ], + build: ({ params }) => + `refs.${params.ref}.setText(${valueExpression(params.text, params.mode)})`, + describe: ({ params }) => + `${label(params.ref, 'komponent')} metnini "${label(params.text, 'metin')}" yap.`, + }, + { + id: 'refSetProp', + label: 'Prop yaz', + group: 'component', + summary: 'Komponentin herhangi bir prop’unu değiştirir.', + example: "refs.input1.setProp('placeholder', 'Ad soyad')", + params: [ + { key: 'ref', label: 'Komponent', type: 'refName' }, + { key: 'property', label: 'Prop adı', type: 'text', placeholder: 'placeholder' }, + { key: 'value', label: 'Değer', type: 'text' }, + VALUE_MODE_PARAM, + ], + build: ({ params }) => + `refs.${params.ref}.setProp(${q(params.property)}, ${valueExpression(params.value, params.mode)})`, + describe: ({ params }) => + `${label(params.ref, 'komponent')} ${label(params.property, 'prop')} değerini yaz.`, + }, + { + id: 'notify', + label: 'Mesaj göster', + group: 'interaction', + summary: 'Kullanıcıya bildirim gösterir.', + example: "notify('Kayıt güncellendi', 'success')", + params: [ + { key: 'message', label: 'Mesaj', type: 'text', placeholder: 'Kayıt güncellendi' }, + { + key: 'type', + label: 'Tür', + type: 'select', + optional: true, + choices: [ + { value: 'info', label: 'Bilgi' }, + { value: 'success', label: 'Başarılı' }, + { value: 'warning', label: 'Uyarı' }, + { value: 'danger', label: 'Hata' }, + ], + }, + ], + build: ({ params }) => + params.type && params.type !== 'info' + ? `notify(${q(params.message)}, ${q(params.type)})` + : `notify(${q(params.message)})`, + describe: ({ params }) => `"${label(params.message, 'mesaj')}" mesajını göster.`, + }, + { + id: 'confirm', + label: 'Onay iste', + group: 'interaction', + summary: 'Onay ister; kullanıcı vazgeçerse script durur.', + example: "if (!window.confirm('Emin misiniz?')) return", + params: [ + { key: 'message', label: 'Mesaj', type: 'text', placeholder: 'Emin misiniz?' }, + { + key: 'cancel', + label: 'Vazgeçilirse', + type: 'select', + optional: true, + help: 'İptal et yalnızca onBeforeSave / onBeforeDelete için anlamlıdır.', + choices: [ + { value: '', label: 'Script’i durdur' }, + { value: 'false', label: 'İşlemi iptal et' }, + ], + }, + ], + build: ({ params }) => + `if (!window.confirm(${q(params.message)})) return${params.cancel === 'false' ? ' false' : ''}`, + describe: ({ params }) => `"${label(params.message, 'mesaj')}" onayını iste.`, + }, + { + id: 'openUrl', + label: 'Sayfa aç', + group: 'interaction', + summary: 'Yeni sekmede veya aynı sekmede adres açar.', + example: "window.open('/report?id=1', '_blank')", + params: [ + { key: 'url', label: 'Adres', type: 'text', placeholder: '/admin/list/orders' }, + { + key: 'target', + label: 'Hedef', + type: 'select', + optional: true, + choices: [ + { value: '_blank', label: 'Yeni sekme' }, + { value: '_self', label: 'Aynı sekme' }, + ], + }, + ], + build: ({ params }) => + params.target === '_self' + ? `window.location.assign(${q(params.url)})` + : `window.open(${q(params.url)}, "_blank")`, + describe: ({ params }) => `${label(params.url, 'adres')} adresini aç.`, + }, + { + id: 'apiToField', + label: 'API sonucunu sütuna yaz', + group: 'integration', + needsSqlRef: true, + summary: 'Adresi çağırır, cevaptaki değeri kayıt sütununa yazar.', + example: "refs.sqlDataSource1.setField('Title', (await api.get('/api/app/customer/1'))?.name)", + params: [ + { key: 'target', label: 'Hedef sütun', type: 'recordField' }, + { key: 'url', label: 'API adresi', type: 'text', placeholder: '/api/app/customer/1' }, + { + key: 'path', + label: 'Cevap yolu', + type: 'text', + optional: true, + placeholder: 'name', + help: 'Boş bırakılırsa tüm cevap yazılır.', + }, + ], + build: ({ params }, context) => { + const call = `await api.get(${q(params.url)})` + const path = (params.path || '') + .split('.') + .map((segment) => segment.trim()) + .filter(Boolean) + const value = path.length ? `(${call})?.${path.join('?.')}` : call + return `refs.${context.sqlRef}.setField(${q(params.target)}, ${value})` + }, + describe: ({ params }) => + `${label(params.url, 'api')} çağrısının sonucunu ${label(params.target, 'hedef')} sütununa yaz.`, + }, + { + id: 'apiPost', + label: 'API çağır', + group: 'integration', + summary: 'Gövdesiz veya kayıt gövdeli bir POST/PUT/DELETE isteği atar.', + example: "await api.post('/api/app/order/approve', refs.sqlDataSource1.getRecord())", + params: [ + { + key: 'method', + label: 'Metot', + type: 'select', + choices: [ + { value: 'post', label: 'POST' }, + { value: 'put', label: 'PUT' }, + { value: 'patch', label: 'PATCH' }, + { value: 'delete', label: 'DELETE' }, + ], + }, + { key: 'url', label: 'Adres', type: 'text', placeholder: '/api/app/order/approve' }, + { + key: 'body', + label: 'Gövde', + type: 'select', + optional: true, + choices: [ + { value: 'record', label: 'Kaydın tamamı' }, + { value: 'event', label: 'Event nesnesi' }, + { value: 'none', label: 'Gövdesiz' }, + ], + }, + ], + build: ({ params }, context) => { + const method = params.method || 'post' + if (method === 'delete' || params.body === 'none') { + return `await api.${method}(${q(params.url)})` + } + const body = params.body === 'event' ? 'event' : `refs.${context.sqlRef}.getRecord()` + return `await api.${method}(${q(params.url)}, ${body})` + }, + describe: ({ params }) => + `${(params.method || 'post').toUpperCase()} ${label(params.url, 'adres')} çağır.`, + }, + { + id: 'cancel', + label: 'İşlemi iptal et', + group: 'flow', + cancelableOnly: true, + summary: 'Koşul sağlandığında kaydetme/silme işlemini durdurur.', + example: 'return false', + params: [], + build: () => 'return false', + describe: ({ condition }) => `${describeDesignerCondition(condition)} işlemi iptal et.`, + }, + { + id: 'stop', + label: 'Script’i durdur', + group: 'flow', + summary: 'Koşul sağlandığında sonraki kurallar çalışmaz.', + example: 'return', + params: [], + build: () => 'return', + describe: ({ condition }) => `${describeDesignerCondition(condition)} script’i durdur.`, + }, + { + id: 'log', + label: 'Konsola yaz', + group: 'flow', + summary: 'Geliştirme sırasında event içeriğini görmek için.', + example: "console.log('event', event)", + params: [ + { key: 'label', label: 'Etiket', type: 'text', optional: true, placeholder: 'event' }, + { key: 'path', label: 'Event yolu', type: 'eventPath', optional: true }, + ], + build: ({ params }) => + `console.log(${q(params.label || 'event')}, ${eventPathExpression(params.path)})`, + describe: ({ params }) => `${label(params.label, 'event')} değerini konsola yaz.`, + }, + { + id: 'custom', + label: 'Serbest tek satır', + group: 'integration', + summary: 'Kendi yazdığın tek satırlık kod.', + example: 'refs.grid1.reset()', + params: [{ key: 'code', label: 'Kod', type: 'text', placeholder: 'refs.grid1.reset()' }], + build: ({ params }) => (params.code || '').trim().replace(/;$/, ''), + describe: ({ params }) => `${label(params.code, 'kod')} çalıştır.`, + }, +] + +export const designerRecipesById = new Map(designerRecipes.map((recipe) => [recipe.id, recipe])) + +/* ------------------------------------------------------------------ * + * Script üretimi ve geri okuma + * ------------------------------------------------------------------ */ + +const BUILDER_HEADER = '// @builder ' + +export const isDesignerRuleComplete = ( + rule: DesignerScriptRule, + context: DesignerScriptContext, +): boolean => { + const recipe = designerRecipesById.get(rule.recipe) + if (!recipe) return false + if (recipe.needsSqlRef && !context.sqlRef) return false + + const conditionReady = designerRuleConditions(rule).every( + (condition) => + condition.operator === 'always' || + (!!condition.source.trim() && + (['empty', 'notEmpty', 'isTrue', 'isFalse'].includes(condition.operator) || + condition.value.trim() !== '')), + ) + if (!conditionReady) return false + + return recipe.params.every((param) => { + if (param.optional) return true + if (param.type === 'fieldList') return (rule.fields ?? []).length > 0 + return (rule.params[param.key] ?? '').trim() !== '' + }) +} + +export const buildDesignerRuleLine = ( + rule: DesignerScriptRule, + context: DesignerScriptContext, +): string => { + const recipe = designerRecipesById.get(rule.recipe) + if (!recipe) return '' + + const body = recipe.build(rule, context) + if (!body) return '' + if (recipe.conditionIsArgument) return body + + const condition = buildDesignerRuleCondition(rule, context) + return condition ? `if (${condition}) ${body}` : body +} + +export const buildDesignerScript = ( + rules: DesignerScriptRule[], + context: DesignerScriptContext, +): string => { + const usable = rules.filter((rule) => isDesignerRuleComplete(rule, context)) + if (!usable.length) return '' + + return [ + `${BUILDER_HEADER}${JSON.stringify({ v: 1, sqlRef: context.sqlRef, rules: usable })}`, + ...usable.map((rule) => buildDesignerRuleLine(rule, context)).filter(Boolean), + ].join('\n') +} + +export type ParsedDesignerScript = { + rules: DesignerScriptRule[] + /** Builder tarafından üretilmiş ve o günden beri elle değiştirilmemiş mi? */ + inSync: boolean + hasHeader: boolean +} + +export const parseDesignerScript = ( + script: string | undefined, + context: DesignerScriptContext, +): ParsedDesignerScript => { + const source = script?.trim() + if (!source || !source.startsWith(BUILDER_HEADER)) { + return { rules: [], inSync: false, hasHeader: false } + } + + const [headerLine] = source.split('\n') + try { + const parsed = JSON.parse(headerLine.slice(BUILDER_HEADER.length)) + const rules: DesignerScriptRule[] = Array.isArray(parsed?.rules) ? parsed.rules : [] + // Kurallar hangi sql ref'iyle üretildiyse onunla yeniden üretilmeli; ref adı + // sonradan değiştiyse script farklı çıkar ve elle düzenlenmiş sayılırdı. + const savedContext: DesignerScriptContext = { + ...context, + sqlRef: typeof parsed?.sqlRef === 'string' && parsed.sqlRef ? parsed.sqlRef : context.sqlRef, + } + return { + rules, + inSync: buildDesignerScript(rules, savedContext).trim() === source, + hasHeader: true, + } + } catch { + return { rules: [], inSync: false, hasHeader: true } + } +} + +export const createDesignerRule = (recipe: DesignerRecipeId): DesignerScriptRule => ({ + id: `${Date.now()}_${Math.random().toString(36).slice(2)}`, + recipe, + condition: { operator: 'always', kind: 'record', source: '', value: '' }, + params: {}, + fields: [], +}) + +/** Cheatsheet olarak gösterilen runtime API özeti. */ +export const designerRuntimeApiReference: { signature: string; description: string }[] = [ + { signature: 'event', description: 'Event’in taşıdığı veri; alanları event listesinde yazar.' }, + { + signature: 'refs..getValue() / setValue(x)', + description: 'Komponent değerini okur/yazar.', + }, + { signature: 'refs..show() / hide()', description: 'Komponenti gösterir veya gizler.' }, + { signature: 'refs..setEnabled(b)', description: 'Aktif/pasif yapar.' }, + { signature: 'refs..setReadOnly(b)', description: 'Salt okunur yapar.' }, + { signature: 'refs..setProp(ad, deger)', description: 'Herhangi bir prop’u değiştirir.' }, + { + signature: 'refs..getSelectedOption()', + description: 'Seçilen kaydın tamamı; çoklu seçimde dizi döner.', + }, + { signature: 'refs..getOptions()', description: 'Seçenek listesinin tamamı.' }, + { signature: 'refs..getRecord() / setRecord(o)', description: 'Aktif kaydı okur/yazar.' }, + { signature: 'refs..getField(ad) / setField(ad, x)', description: 'Sütun okur/yazar.' }, + { signature: 'refs..setFields({ A: 1 })', description: 'Birden fazla sütunu yazar.' }, + { signature: 'refs..clearFields("A", "B")', description: 'Sütunları null yapar.' }, + { signature: 'refs..getChanges() / hasChanges()', description: 'Değişen sütunlar.' }, + { signature: 'refs..getMode() / isNew()', description: 'Yeni kayıt mı, düzenleme mi?' }, + { + signature: 'refs..getRows() / getIndex()', + description: 'Satırlar ve aktif satır sırası.', + }, + { signature: 'refs..nextRow() / prevRow()', description: 'Satırlar arasında gezinir.' }, + { signature: 'await refs..save() / remove() / reload()', description: 'CRUD komutları.' }, + { signature: 'await api.get(url) / post(url, body)', description: 'JSON API çağrısı.' }, + { signature: 'notify(mesaj, "success")', description: 'Bildirim gösterir.' }, + { + signature: 'return false', + description: 'Yalnızca onBeforeSave / onBeforeDelete: işlemi iptal eder.', + }, +] diff --git a/ui/src/components/visualDesigner/types.ts b/ui/src/components/visualDesigner/types.ts index eb0f636d..41b6ad71 100644 --- a/ui/src/components/visualDesigner/types.ts +++ b/ui/src/components/visualDesigner/types.ts @@ -8,8 +8,20 @@ export interface DesignerBinding { path: string labelPath?: string valuePath?: string + /** + * Label ve Value dışında, seçilen kaydın script ile okunabilmesi istenen + * sütunlar. Ekranda hiçbiri görünmez; `refs..getColumn('sutun')` ile + * okunup başka bir komponentin değerine ya da SqlDataSource kaydına yazılır. + */ + columns?: string[] } +/** Bir option bağlantısının bildirilmiş ek sütunları. */ +export const getDesignerBindingColumns = (binding?: DesignerBinding): string[] => + Array.isArray(binding?.columns) + ? binding.columns.filter((column): column is string => typeof column === 'string' && !!column) + : [] + /** * Data sources are no longer read-only: an endpoint can also be attached as the * insert/update/delete command of a SqlDataSource, mirroring ASP.NET's @@ -457,6 +469,136 @@ export const SQL_DATA_SOURCE_SLOTS: readonly SqlDataSourceSlot[] = [ export const getSqlDataSourceSlot = (property: string) => SQL_DATA_SOURCE_SLOTS.find((slot) => slot.property === property) +/** + * Lifecycle of the record the container owns. Every entry is emitted by the code + * generator as a handler that receives a single `event` object, so a script never + * has to know which piece of React state actually fired it. + * + * A `cancelable` event stops the command when the script returns `false` — that + * is the only way a Save or a Delete can be vetoed from configuration. + */ +export interface SqlDataSourceEvent { + name: string + label: string + description: string + cancelable?: boolean + /** Fields of the `event` object; drives the builder's condition/param pickers. */ + payload: { key: string; description: string }[] +} + +export const SQL_DATA_SOURCE_EVENTS: readonly SqlDataSourceEvent[] = [ + { + name: 'onLoad', + label: 'Select tamamlandı', + description: 'Select endpointi cevap verdiğinde, satırlar forma yerleştikten sonra çalışır.', + payload: [ + { key: 'rows', description: 'Select sonucundaki tüm satırlar' }, + { key: 'count', description: 'Satır sayısı' }, + { key: 'record', description: 'Aktif satır' }, + { key: 'index', description: 'Aktif satırın sırası' }, + ], + }, + { + name: 'onRecordChange', + label: 'Aktif kayıt değişti', + description: 'Satırlar arasında gezinildiğinde veya yeni kayıt yüklendiğinde çalışır.', + payload: [ + { key: 'record', description: 'Yeni aktif kayıt' }, + { key: 'index', description: 'Satır sırası' }, + { key: 'rows', description: 'Tüm satırlar' }, + { key: 'count', description: 'Satır sayısı' }, + ], + }, + { + name: 'onFieldChange', + label: 'Alan değişti', + description: 'İçerideki bir komponent kayda değer yazdığında çalışır; hesaplama için idealdir.', + payload: [ + { key: 'field', description: 'Değişen sütun adı' }, + { key: 'value', description: 'Yeni değer' }, + { key: 'previous', description: 'Önceki değer' }, + { key: 'record', description: 'Değişiklik uygulanmış kayıt' }, + ], + }, + { + name: 'onNewRecord', + label: 'Yeni kayıt', + description: 'Yeni butonuyla boş kayda geçildiğinde çalışır; varsayılan değer yazmak için.', + payload: [{ key: 'record', description: 'Boş kayıt' }], + }, + { + name: 'onModeChange', + label: 'Mod değişti', + description: 'Kayıt new/edit modları arasında geçtiğinde çalışır.', + payload: [ + { key: 'mode', description: '"new" veya "edit"' }, + { key: 'record', description: 'Güncel kayıt' }, + ], + }, + { + name: 'onBeforeSave', + label: 'Kaydetmeden önce', + description: 'Save çağrılmadan önce çalışır. Script false döndürürse kayıt iptal edilir.', + cancelable: true, + payload: [ + { key: 'record', description: 'Kaydedilecek kayıt' }, + { key: 'original', description: 'Yüklendiği andaki hali' }, + { key: 'payload', description: 'Endpointe gidecek gövde (Update ise sadece değişenler)' }, + { key: 'mode', description: '"new" veya "edit"' }, + { key: 'isNew', description: 'Insert mi çalışacak?' }, + ], + }, + { + name: 'onAfterSave', + label: 'Kaydettikten sonra', + description: 'Insert/Update başarıyla tamamlandığında çalışır.', + payload: [ + { key: 'record', description: 'Gönderilen kayıt' }, + { key: 'response', description: 'Endpoint cevabı' }, + { key: 'mode', description: 'Kaydetme anındaki mod' }, + { key: 'isNew', description: 'Insert mi çalıştı?' }, + ], + }, + { + name: 'onBeforeDelete', + label: 'Silmeden önce', + description: 'Onay kutusundan önce çalışır. Script false döndürürse silme iptal edilir.', + cancelable: true, + payload: [ + { key: 'record', description: 'Silinecek kayıt' }, + { key: 'key', description: 'Key sütunundaki değer' }, + ], + }, + { + name: 'onAfterDelete', + label: 'Sildikten sonra', + description: 'Delete endpointi başarıyla tamamlandığında çalışır.', + payload: [{ key: 'record', description: 'Silinen kayıt' }], + }, + { + name: 'onError', + label: 'Hata oluştu', + description: 'Select/Insert/Update/Delete sırasında hata alındığında çalışır.', + payload: [ + { key: 'message', description: 'Kullanıcıya gösterilen hata metni' }, + { key: 'action', description: '"reload" | "save" | "delete"' }, + { key: 'error', description: 'Ham hata nesnesi' }, + { key: 'record', description: 'Hata anındaki kayıt' }, + ], + }, +] as const + +export const SQL_DATA_SOURCE_EVENT_NAMES: readonly string[] = SQL_DATA_SOURCE_EVENTS.map( + (event) => event.name, +) + +export const getSqlDataSourceEvent = (name: string) => + SQL_DATA_SOURCE_EVENTS.find((event) => event.name === name) + +/** Scripted events of a SqlDataSource node, in lifecycle order. */ +export const getSqlDataSourceScriptedEvents = (node: DesignerNode) => + SQL_DATA_SOURCE_EVENTS.filter((event) => Boolean(node.events?.[event.name]?.trim())) + export const getSqlDataSourceEndpointId = (node: DesignerNode, property: string) => String(node.props?.[property] ?? '').trim() diff --git a/ui/src/routes/route.constant.ts b/ui/src/routes/route.constant.ts index 79c827d9..a31e5cdb 100644 --- a/ui/src/routes/route.constant.ts +++ b/ui/src/routes/route.constant.ts @@ -91,159 +91,159 @@ export const ROUTES_ENUM = { accessDenied: '/admin/access-denied', - coordinator: { - videoroom: { - dashboard: '/admin/coordinator/videoroom/dashboard', - roomList: '/admin/coordinator/videoroom/rooms', - roomDetail: '/admin/coordinator/videoroom/room/:id', - planning: '/admin/coordinator/videoroom/planning/:id', - }, - exams: '/admin/coordinator/exams', - examDetail: '/admin/coordinator/exam/:id', - assignments: '/admin/coordinator/assignments', - assignmentDetail: '/admin/coordinator/assignment/:id', - tests: '/admin/coordinator/tests', - testDetail: '/admin/coordinator/test/:id', - }, + // coordinator: { + // videoroom: { + // dashboard: '/admin/coordinator/videoroom/dashboard', + // roomList: '/admin/coordinator/videoroom/rooms', + // roomDetail: '/admin/coordinator/videoroom/room/:id', + // planning: '/admin/coordinator/videoroom/planning/:id', + // }, + // exams: '/admin/coordinator/exams', + // examDetail: '/admin/coordinator/exam/:id', + // assignments: '/admin/coordinator/assignments', + // assignmentDetail: '/admin/coordinator/assignment/:id', + // tests: '/admin/coordinator/tests', + // testDetail: '/admin/coordinator/test/:id', + // }, - supplychain: { - materialTypes: '/admin/supplychain/materials/types', - materialGroups: '/admin/supplychain/materials/groups', - materials: '/admin/supplychain/materials', - materialsNew: '/admin/supplychain/materials/new', - materialsEdit: '/admin/supplychain/materials/edit/:id', - materialsDetail: '/admin/supplychain/materials/detail/:id', - suppliers: '/admin/supplychain/suppliers', - suppliersNew: '/admin/supplychain/suppliers/new', - suppliersEdit: '/admin/supplychain/suppliers/edit/:id', - suppliersDetail: '/admin/supplychain/suppliers/:id', - requests: '/admin/supplychain/requests', - requestsNew: '/admin/supplychain/requests/new', - requestsEdit: '/admin/supplychain/requests/edit/:id', - requestsView: '/admin/supplychain/requests/view/:id', - requisitions: '/admin/supplychain/requisitions', - requisitionsNew: '/admin/supplychain/requisitions/new', - requisitionsEdit: '/admin/supplychain/requisitions/edit/:id', - requisitionsDetail: '/admin/supplychain/requisitions/:id', - quotations: '/admin/supplychain/quotations', - quotationsNew: '/admin/supplychain/quotations/new', - quotationsEdit: '/admin/supplychain/quotations/edit/:id', - quotationsView: '/admin/supplychain/quotations/view/:id', - approvals: '/admin/supplychain/approvals', - orders: '/admin/supplychain/orders', - ordersNew: '/admin/supplychain/orders/new', - ordersEdit: '/admin/supplychain/orders/edit/:id', - ordersView: '/admin/supplychain/orders/view/:id', - delivery: '/admin/supplychain/delivery', - }, + // supplychain: { + // materialTypes: '/admin/supplychain/materials/types', + // materialGroups: '/admin/supplychain/materials/groups', + // materials: '/admin/supplychain/materials', + // materialsNew: '/admin/supplychain/materials/new', + // materialsEdit: '/admin/supplychain/materials/edit/:id', + // materialsDetail: '/admin/supplychain/materials/detail/:id', + // suppliers: '/admin/supplychain/suppliers', + // suppliersNew: '/admin/supplychain/suppliers/new', + // suppliersEdit: '/admin/supplychain/suppliers/edit/:id', + // suppliersDetail: '/admin/supplychain/suppliers/:id', + // requests: '/admin/supplychain/requests', + // requestsNew: '/admin/supplychain/requests/new', + // requestsEdit: '/admin/supplychain/requests/edit/:id', + // requestsView: '/admin/supplychain/requests/view/:id', + // requisitions: '/admin/supplychain/requisitions', + // requisitionsNew: '/admin/supplychain/requisitions/new', + // requisitionsEdit: '/admin/supplychain/requisitions/edit/:id', + // requisitionsDetail: '/admin/supplychain/requisitions/:id', + // quotations: '/admin/supplychain/quotations', + // quotationsNew: '/admin/supplychain/quotations/new', + // quotationsEdit: '/admin/supplychain/quotations/edit/:id', + // quotationsView: '/admin/supplychain/quotations/view/:id', + // approvals: '/admin/supplychain/approvals', + // orders: '/admin/supplychain/orders', + // ordersNew: '/admin/supplychain/orders/new', + // ordersEdit: '/admin/supplychain/orders/edit/:id', + // ordersView: '/admin/supplychain/orders/view/:id', + // delivery: '/admin/supplychain/delivery', + // }, - maintenance: { - equipment: '/admin/maintenance/equipment', - equipmentNew: '/admin/maintenance/equipment/new', - equipmentEdit: '/admin/maintenance/equipment/edit/:id', - equipmentDetail: '/admin/maintenance/equipment/:id', - workcenters: '/admin/maintenance/workcenters', - plans: '/admin/maintenance/plans', - calendar: '/admin/maintenance/calendar', - teams: '/admin/maintenance/teams', - faults: '/admin/maintenance/faults', - workorders: '/admin/maintenance/workorders', - workordersNew: '/admin/maintenance/workorders/new', - workordersDetail: '/admin/maintenance/workorders/:id', - }, + // maintenance: { + // equipment: '/admin/maintenance/equipment', + // equipmentNew: '/admin/maintenance/equipment/new', + // equipmentEdit: '/admin/maintenance/equipment/edit/:id', + // equipmentDetail: '/admin/maintenance/equipment/:id', + // workcenters: '/admin/maintenance/workcenters', + // plans: '/admin/maintenance/plans', + // calendar: '/admin/maintenance/calendar', + // teams: '/admin/maintenance/teams', + // faults: '/admin/maintenance/faults', + // workorders: '/admin/maintenance/workorders', + // workordersNew: '/admin/maintenance/workorders/new', + // workordersDetail: '/admin/maintenance/workorders/:id', + // }, - warehouse: { - definitions: '/admin/warehouse/definitions', - tracking: '/admin/warehouse/tracking', - putaway: '/admin/warehouse/putaway', - receipt: '/admin/warehouse/receipt', - issue: '/admin/warehouse/issue', - transfer: '/admin/warehouse/transfer', - inventory: '/admin/warehouse/inventory', - movements: '/admin/warehouse/movements', - movementDetail: '/admin/warehouse/movements/:id', - stocklevel: '/admin/warehouse/stocklevel', - warehouses: '/admin/warehouse/warehouses', - warehouseNew: '/admin/warehouse/new', - warehouseEdit: '/admin/warehouse/edit/:id', - warehouseDetail: '/admin/warehouse/warehouses/:id', - }, + // warehouse: { + // definitions: '/admin/warehouse/definitions', + // tracking: '/admin/warehouse/tracking', + // putaway: '/admin/warehouse/putaway', + // receipt: '/admin/warehouse/receipt', + // issue: '/admin/warehouse/issue', + // transfer: '/admin/warehouse/transfer', + // inventory: '/admin/warehouse/inventory', + // movements: '/admin/warehouse/movements', + // movementDetail: '/admin/warehouse/movements/:id', + // stocklevel: '/admin/warehouse/stocklevel', + // warehouses: '/admin/warehouse/warehouses', + // warehouseNew: '/admin/warehouse/new', + // warehouseEdit: '/admin/warehouse/edit/:id', + // warehouseDetail: '/admin/warehouse/warehouses/:id', + // }, - projects: { - list: '/admin/projects', - new: '/admin/projects/new', - edit: '/admin/projects/edit/:id', - detail: '/admin/projects/:id', - tasks: '/admin/projects/tasks', - phases: '/admin/projects/phases', - activities: '/admin/projects/activities', - workload: '/admin/projects/workload', - costTracking: '/admin/projects/cost-tracking', - dailyUpdates: '/admin/projects/daily-updates', - }, + // projects: { + // list: '/admin/projects', + // new: '/admin/projects/new', + // edit: '/admin/projects/edit/:id', + // detail: '/admin/projects/:id', + // tasks: '/admin/projects/tasks', + // phases: '/admin/projects/phases', + // activities: '/admin/projects/activities', + // workload: '/admin/projects/workload', + // costTracking: '/admin/projects/cost-tracking', + // dailyUpdates: '/admin/projects/daily-updates', + // }, - hr: { - employees: '/admin/hr/employees', - employeesNew: '/admin/hr/employees/new', - employeesEdit: '/admin/hr/employees/edit/:id', - employeesDetail: '/admin/hr/employees/:id', - departments: '/admin/hr/departments', - jobPositions: '/admin/hr/job-positions', - employmentTypes: '/admin/hr/employment-types', - organization: '/admin/hr/organization', - badges: '/admin/hr/badges', - leaveManagement: '/admin/hr/leave-management', - overtimes: '/admin/hr/overtimes-management', - payroll: '/admin/hr/payroll', - costCenters: '/admin/hr/cost-centers', - evaluationTemplates: '/admin/hr/360-templates', - evaluation: '/admin/hr/360-evaluation', - }, + // hr: { + // employees: '/admin/hr/employees', + // employeesNew: '/admin/hr/employees/new', + // employeesEdit: '/admin/hr/employees/edit/:id', + // employeesDetail: '/admin/hr/employees/:id', + // departments: '/admin/hr/departments', + // jobPositions: '/admin/hr/job-positions', + // employmentTypes: '/admin/hr/employment-types', + // organization: '/admin/hr/organization', + // badges: '/admin/hr/badges', + // leaveManagement: '/admin/hr/leave-management', + // overtimes: '/admin/hr/overtimes-management', + // payroll: '/admin/hr/payroll', + // costCenters: '/admin/hr/cost-centers', + // evaluationTemplates: '/admin/hr/360-templates', + // evaluation: '/admin/hr/360-evaluation', + // }, - crm: { - customers: '/admin/crm/customers', - customersNew: '/admin/crm/customers/new', - customersEdit: '/admin/crm/customers/edit/:id', - customersDetail: '/admin/crm/customers/:id', - salesTeams: '/admin/crm/sales-teams', - salesTeamsNew: '/admin/crm/sales-teams/new', - salesTeamsEdit: '/admin/crm/sales-teams/edit/:id', - salesTeamsDetail: '/admin/crm/sales-teams/:id', - lossReasons: '/admin/crm/loss-reasons', - opportunities: '/admin/crm/opportunities', - opportunitiesNew: '/admin/crm/opportunities/new', - opportunitiesEdit: '/admin/crm/opportunities/edit/:id', - opportunitiesDetail: '/admin/crm/opportunities/:id', - activities: '/admin/crm/activities', - salesOrders: '/admin/crm/sales-orders', - salesOrdersNew: '/admin/crm/sales-orders/new', - salesOrdersEdit: '/admin/crm/sales-orders/edit/:id', - salesOrdersDetail: '/admin/crm/sales-orders/:id', - }, + // crm: { + // customers: '/admin/crm/customers', + // customersNew: '/admin/crm/customers/new', + // customersEdit: '/admin/crm/customers/edit/:id', + // customersDetail: '/admin/crm/customers/:id', + // salesTeams: '/admin/crm/sales-teams', + // salesTeamsNew: '/admin/crm/sales-teams/new', + // salesTeamsEdit: '/admin/crm/sales-teams/edit/:id', + // salesTeamsDetail: '/admin/crm/sales-teams/:id', + // lossReasons: '/admin/crm/loss-reasons', + // opportunities: '/admin/crm/opportunities', + // opportunitiesNew: '/admin/crm/opportunities/new', + // opportunitiesEdit: '/admin/crm/opportunities/edit/:id', + // opportunitiesDetail: '/admin/crm/opportunities/:id', + // activities: '/admin/crm/activities', + // salesOrders: '/admin/crm/sales-orders', + // salesOrdersNew: '/admin/crm/sales-orders/new', + // salesOrdersEdit: '/admin/crm/sales-orders/edit/:id', + // salesOrdersDetail: '/admin/crm/sales-orders/:id', + // }, - mrp: { - operationTypes: '/admin/mrp/operation-types', - workcenters: '/admin/mrp/workcenters', - operations: '/admin/mrp/operations', - bom: '/admin/mrp/bom', - productionOrders: '/admin/mrp/production-orders', - productionOrderNew: '/admin/mrp/production-orders/new', - productionOrderEdit: '/admin/mrp/production-orders/:id/edit', - productionOrderDetail: '/admin/mrp/production-orders/:id', - workOrders: '/admin/mrp/work-orders', - demandPlanning: '/admin/mrp/demand-planning', - materialRequirements: '/admin/mrp/material-requirements', - planningGantt: '/admin/mrp/planning-gantt', - }, + // mrp: { + // operationTypes: '/admin/mrp/operation-types', + // workcenters: '/admin/mrp/workcenters', + // operations: '/admin/mrp/operations', + // bom: '/admin/mrp/bom', + // productionOrders: '/admin/mrp/production-orders', + // productionOrderNew: '/admin/mrp/production-orders/new', + // productionOrderEdit: '/admin/mrp/production-orders/:id/edit', + // productionOrderDetail: '/admin/mrp/production-orders/:id', + // workOrders: '/admin/mrp/work-orders', + // demandPlanning: '/admin/mrp/demand-planning', + // materialRequirements: '/admin/mrp/material-requirements', + // planningGantt: '/admin/mrp/planning-gantt', + // }, - accounting: { - currentAccounts: '/admin/accounting/current-accounts', - waybills: '/admin/accounting/waybills', - invoices: '/admin/accounting/invoices', - invoicesNew: '/admin/accounting/invoices/new', - cash: '/admin/accounting/cash', - bank: '/admin/accounting/bank', - checkNote: '/admin/accounting/check-note', - }, + // accounting: { + // currentAccounts: '/admin/accounting/current-accounts', + // waybills: '/admin/accounting/waybills', + // invoices: '/admin/accounting/invoices', + // invoicesNew: '/admin/accounting/invoices/new', + // cash: '/admin/accounting/cash', + // bank: '/admin/accounting/bank', + // checkNote: '/admin/accounting/check-note', + // }, }, } diff --git a/ui/src/views/admin/listForm/edit/json-row-operations/EditorOptionsBuilderDialog.tsx b/ui/src/views/admin/listForm/edit/json-row-operations/EditorOptionsBuilderDialog.tsx index dc542e4d..f21c867e 100644 --- a/ui/src/views/admin/listForm/edit/json-row-operations/EditorOptionsBuilderDialog.tsx +++ b/ui/src/views/admin/listForm/edit/json-row-operations/EditorOptionsBuilderDialog.tsx @@ -319,7 +319,7 @@ function EditorOptionsBuilderDialog({ `height` yok sayılıyor. `!flex-none` ile flex boyutlandırmasını kapatıp yüksekliği doğrudan veriyoruz. */} - + {/* ── Başlık ─────────────────────────────────────────────── */}
diff --git a/ui/src/views/admin/listForm/edit/json-row-operations/EditorScriptBuilderDialog.tsx b/ui/src/views/admin/listForm/edit/json-row-operations/EditorScriptBuilderDialog.tsx index ff69407c..b694cc16 100644 --- a/ui/src/views/admin/listForm/edit/json-row-operations/EditorScriptBuilderDialog.tsx +++ b/ui/src/views/admin/listForm/edit/json-row-operations/EditorScriptBuilderDialog.tsx @@ -1,49 +1,8 @@ -import { Button, Dialog } from '@/components/ui' +import ScriptBuilderDialog from '@/components/scriptBuilder/ScriptBuilderDialog' import { SelectBoxOption } from '@/types/shared' import { useLocalization } from '@/utils/hooks/useLocalization' -import Editor from '@monaco-editor/react' -import { useEffect, useMemo, useState } from 'react' -import type { IconType } from 'react-icons' -import { - FaArrowDown, - FaArrowUp, - FaBell, - FaBook, - FaCalculator, - FaCheck, - FaChevronDown, - FaChevronRight, - FaCode, - FaCopy, - FaDatabase, - FaExclamationTriangle, - FaEye, - FaMagic, - FaPlug, - FaPlus, - FaTimes, - FaTrash, -} from 'react-icons/fa' -import { - buildRuleLine, - buildScriptFromRules, - createRule, - describeCondition, - effectiveTrigger, - isRuleComplete, - parseScript, - recipeGroups, - recipes, - recipesById, - runtimeApiReference, - scriptRunsOnOpen, - triggerLabels, - type ConditionOperator, - type RecipeGroupKey, - type RecipeParam, - type RuleTrigger, - type ScriptRule, -} from './editor-script/scriptRecipes' +import { useMemo } from 'react' +import { createFormScriptDialect } from './editor-script/formScriptDialect' type EditorScriptBuilderDialogProps = { isOpen: boolean @@ -54,36 +13,10 @@ type EditorScriptBuilderDialogProps = { onApply: (value: string) => void } -const controlClass = - 'w-full min-w-0 h-9 px-2 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-100 focus:outline-none focus:border-indigo-400' - -const cardClass = - 'rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900' - -const groupIcons: Record = { - calc: FaCalculator, - data: FaDatabase, - view: FaEye, - interaction: FaBell, - integration: FaPlug, -} - -const operatorLabels: { value: ConditionOperator; label: string }[] = [ - { value: 'always', label: 'Her zaman' }, - { value: 'equals', label: 'Eşitse' }, - { value: 'notEquals', label: 'Eşit değilse' }, - { value: 'contains', label: 'İçeriyorsa' }, - { value: 'empty', label: 'Boşsa' }, - { value: 'notEmpty', label: 'Doluysa' }, - { value: 'greaterThan', label: 'Büyükse' }, - { value: 'lessThan', label: 'Küçükse' }, - { value: 'isTrue', label: 'İşaretliyse' }, - { value: 'isFalse', label: 'İşaretli değilse' }, -] - -const operatorNeedsValue = (operator: ConditionOperator) => - ['equals', 'notEquals', 'contains', 'greaterThan', 'lessThan'].includes(operator) - +/** + * ListForm editor script'inin düzenleyicisi. Ortak Script Builder'ı form + * lehçesiyle kurar; kural motoru ve üretilen script değişmedi. + */ function EditorScriptBuilderDialog({ isOpen, value, @@ -94,624 +27,24 @@ function EditorScriptBuilderDialog({ }: EditorScriptBuilderDialogProps) { const { translate } = useLocalization() - const availableFields = useMemo( - () => - Array.from( - new Set(fields.map((field) => String(field.value || field.label || '')).filter(Boolean)), - ).sort((left, right) => left.localeCompare(right)), - [fields], + // Çağıran her render'da yeni bir dizi verebilir; lehçe alan adlarına göre + // sabitlenmezse editör yazarken sıfırlanırdı. + const fieldKey = fields.map((field) => String(field.value || field.label || '')).join('|') + const dialect = useMemo( + () => createFormScriptDialect({ currentField, fields: fieldKey.split('|') }), + [currentField, fieldKey], ) - const [rules, setRules] = useState([]) - const [manualScript, setManualScript] = useState('') - const [mode, setMode] = useState<'rules' | 'manual'>('rules') - const [openRules, setOpenRules] = useState>({}) - const [paletteOpen, setPaletteOpen] = useState(true) - const [helpOpen, setHelpOpen] = useState(false) - const [copied, setCopied] = useState(false) - - useEffect(() => { - if (!isOpen) return - - const parsed = parseScript(value) - const existing = value?.trim() ?? '' - - setRules(parsed.rules) - setManualScript(existing) - setCopied(false) - setHelpOpen(false) - setPaletteOpen(parsed.rules.length === 0) - setOpenRules(Object.fromEntries(parsed.rules.map((rule) => [rule.id, false]))) - // Elle yazılmış ya da builder dışında değiştirilmiş script'i kurallara - // zorlamıyoruz; kullanıcı kendisi geçmedikçe olduğu gibi korunur. - setMode(!existing || parsed.inSync ? 'rules' : 'manual') - }, [isOpen, value]) - - const generatedScript = useMemo(() => buildScriptFromRules(rules), [rules]) - const runsOnOpen = useMemo(() => scriptRunsOnOpen(rules.filter(isRuleComplete)), [rules]) - const activeScript = mode === 'rules' ? generatedScript : manualScript - const completeRuleCount = rules.filter(isRuleComplete).length - const manualDiffers = manualScript.trim() !== '' && manualScript.trim() !== generatedScript.trim() - - const updateRule = (id: string, patch: Partial) => - setRules((current) => current.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule))) - - const updateParam = (id: string, key: string, next: string) => - setRules((current) => - current.map((rule) => - rule.id === id ? { ...rule, params: { ...rule.params, [key]: next } } : rule, - ), - ) - - const moveRule = (index: number, delta: number) => - setRules((current) => { - const target = index + delta - if (target < 0 || target >= current.length) return current - const next = [...current] - const [moved] = next.splice(index, 1) - next.splice(target, 0, moved) - return next - }) - - const addRule = (recipeId: ScriptRule['recipe']) => { - const rule = createRule(recipeId) - setRules((current) => [...current, rule]) - setOpenRules((current) => ({ ...current, [rule.id]: true })) - } - - const copyScript = () => { - navigator.clipboard?.writeText(activeScript) - setCopied(true) - setTimeout(() => setCopied(false), 1500) - } - - const renderFieldSelect = ( - selectedValue: string, - onChange: (next: string) => void, - placeholder = 'Alan seç', - ) => ( - - ) - - const renderParam = (rule: ScriptRule, param: RecipeParam) => { - if (param.type === 'fieldList') { - const selectedFields = rule.fields ?? [] - return ( -
- {param.label} -
- {selectedFields.map((fieldName) => ( - - {fieldName} - - - ))} - -
-
- ) - } - - const currentValue = rule.params[param.key] ?? '' - - return ( - - ) - } - - const renderRule = (rule: ScriptRule, index: number) => { - const recipe = recipesById.get(rule.recipe) - if (!recipe) return null - - const open = openRules[rule.id] ?? true - const complete = isRuleComplete(rule) - const Icon = groupIcons[recipe.group] - - return ( -
-
- - -
-
- {index + 1}. {recipe.label} -
-
- {recipe.conditionIsArgument - ? recipe.describe(rule) - : `${describeCondition(rule.condition)} → ${recipe.describe(rule)}`} -
-
- {effectiveTrigger(rule) !== 'change' && ( - - {effectiveTrigger(rule) === 'open' ? 'açılışta' : 'açılış + değişim'} - - )} - {!complete && ( - - eksik - - )} -
-
-
- - {open && ( -
-

{recipe.summary}

- -
- - - - - {rule.condition.operator !== 'always' && ( - - )} - - {operatorNeedsValue(rule.condition.operator) && ( - - )} -
- -
- {recipe.params.map((param) => renderParam(rule, param))} -
- -
-              {buildRuleLine(rule, runsOnOpen) || recipe.example}
-            
-
- )} -
- ) - } - return ( - - {/* - Yükseklik zinciri iki yerden kopuyordu: Dialog'un `height` prop'u yalnızca - react-modal içeriğine uygulanıyor (iç panel `height: auto`), ve - `.dialog-body` global olarak `flex-1` alıyor — flex-basis 0% olduğu için - `height` yok sayılıyor. `!flex-none` ile flex boyutlandırmasını kapatıp - yüksekliği doğrudan veriyoruz. - */} - - {/* ── Başlık ─────────────────────────────────────────────── */} -
- -
Editor Script
- - {currentField || 'alan seçilmemiş'} - - - {completeRuleCount}/{rules.length} kural - - -
- -
- - -
-
-
- - {mode === 'rules' && manualDiffers && ( -
- - - Kayıtlı script kurallardan farklı. Kurallar sekmesinde kaydedersen elle yazılmış hali - değişir. - - -
- )} - - {helpOpen && ( -
-
Script içinde kullanabileceklerin
-
- {runtimeApiReference.map((item) => ( -
- - {item.signature} - - {item.description} -
- ))} -
-
- )} - - {mode === 'manual' ? ( -
-
- - Tek satırlık yardımcılarla yaz. Eski uzun script'ler de çalışmaya devam eder. - -
- - -
-
-
- setManualScript(next || '')} - /> -
-
- ) : ( - // flex-1 olmadan bu ızgara içeriğe göre büzülür ve sağdaki Monaco - // (height="100%") yüksekliğini kaybeder. -
- {/* ── Kurallar ─────────────────────────────────────────── */} -
-
- - {paletteOpen && ( -
- {recipeGroups.map((group) => { - const Icon = groupIcons[group.key] - return ( -
-
- {group.title} -
-
- {recipes - .filter((recipe) => recipe.group === group.key) - .map((recipe) => ( - - ))} -
-
- ) - })} -
- )} -
- - {rules.map(renderRule)} - - {!rules.length && ( -
- Henüz kural yok. Yukarıdan bir kural ekle; her kural tek satırlık script üretir. -
- )} -
- - {/* ── Önizleme ─────────────────────────────────────────── */} -
-
- - - Script Önizleme - -
- - {generatedScript ? `${generatedScript.split('\n').length - 1} satır` : 'boş'} - -
-
-
- -
-

- İlk satırdaki {'// @builder'} yorumu kuralları saklar; dialog yeniden - açıldığında kurallar buradan geri yüklenir. -

-
-
- )} -
- - - - - -
+ /> ) } diff --git a/ui/src/views/admin/listForm/edit/json-row-operations/editor-script/formScriptDialect.ts b/ui/src/views/admin/listForm/edit/json-row-operations/editor-script/formScriptDialect.ts new file mode 100644 index 00000000..b53b497b --- /dev/null +++ b/ui/src/views/admin/listForm/edit/json-row-operations/editor-script/formScriptDialect.ts @@ -0,0 +1,203 @@ +/** + * ListForm editor script'inin ortak Script Builder lehçesi. + * + * Bu dosya yeni bir kural motoru getirmez; `scriptRecipes.ts` içindeki mevcut + * sözlüğü ortak dialog'un beklediği sözleşmeye çevirir. Üretilen script ve + * `// @builder` başlığı bire bir aynı kaldığı için eskiden kaydedilmiş bütün + * script'ler değişmeden okunmaya devam eder. + */ + +import { + DEFAULT_SCRIPT_OPERATORS, + type ScriptApiEntry, + type ScriptDialect, + type ScriptRecipeGroup, + type ScriptRecipeParam, + type ScriptRecipeView, + type ScriptRule, + type ScriptSnippet, +} from '@/components/scriptBuilder/types' +import { FaBell, FaCalculator, FaDatabase, FaEye, FaPlug } from 'react-icons/fa' +import { + buildRuleLine, + buildScriptFromRules, + createRule, + describeCondition, + effectiveTrigger, + isRuleComplete, + parseScript, + recipeGroups, + recipes, + recipesById, + runtimeApiReference, + scriptRunsOnOpen, + triggerLabels, + type RecipeGroupKey, + type RecipeId, + type RecipeParam, + type ScriptRule as FormScriptRule, +} from './scriptRecipes' + +const groupIcons: Record = { + calc: FaCalculator, + data: FaDatabase, + view: FaEye, + interaction: FaBell, + integration: FaPlug, +} + +/** Alan seçicisi tek bir listeden beslenir: form kolonları. */ +const FIELD_SOURCE = 'field' + +const toParam = (param: RecipeParam): ScriptRecipeParam => ({ + key: param.key, + label: param.label, + type: + param.type === 'field' ? 'option' : param.type === 'fieldList' ? 'fieldList' : param.type, + optionSource: param.type === 'field' || param.type === 'fieldList' ? FIELD_SOURCE : undefined, + placeholder: param.placeholder, + help: param.help, + choices: param.choices, + optional: param.optional, +}) + +const recipeViews: ScriptRecipeView[] = recipes.map((recipe) => ({ + id: recipe.id, + label: recipe.label, + group: recipe.group, + summary: recipe.summary, + example: recipe.example, + conditionIsArgument: recipe.conditionIsArgument, + params: recipe.params.map(toParam), +})) + +const apiReference: ScriptApiEntry[] = runtimeApiReference + +/** + * Kod editöründe tek tıkla eklenebilen kalıplar. Tarifler tek satır üretir; + * buradakiler ise elle yazarken en çok ihtiyaç duyulan çok satırlı iskeletler. + */ +const snippets: ScriptSnippet[] = [ + { + label: 'Koşullu değer yaz', + group: 'Akış', + description: 'Bir alan doluysa hedefe yaz.', + code: "if (get('${1:Kaynak}')) set('${2:Hedef}', get('${1:Kaynak}'))", + }, + { + label: 'Çok koşullu blok', + group: 'Akış', + description: 'VE / VEYA ile birleşen iki koşul.', + code: "if (str('${1:Durum}') === '${2:Aktif}' && num('${3:Tutar}') > ${4:1000}) {\n ${5:notify('Limit aşıldı')}\n}", + }, + { + label: 'Açılışta / değişimde ayrımı', + group: 'Akış', + description: 'isReady form açılışında true gelir.', + code: "if (isReady) {\n ${1:// form açılırken}\n} else {\n ${2:// değer değişince}\n}", + }, + { + label: 'Toplu alan yazma', + group: 'Veri', + description: 'Tek çağrıda birden fazla alan.', + code: "set({ ${1:Alan1}: ${2:null}, ${3:Alan2}: ${4:null} })", + }, + { + label: 'Seçili kayıttan doldur', + group: 'Veri', + description: 'Lookup seçiminden birkaç alanı birden taşı.', + code: "copy('${1:kolon}', '${2:Hedef}')\ncopy('${3:kolon2}', '${4:Hedef2}')", + }, + { + label: 'Onay iste, vazgeçilirse geri al', + group: 'Etkileşim', + description: 'ask false dönerse alan eski değerine döner.', + code: "if (!ask('${1:Emin misiniz?}')) return", + }, + { + label: 'API çağrısı ve hata yakalama', + group: 'Entegrasyon', + description: 'Çağrı başarısızsa uyarı gösterir.', + code: "try {\n set('${1:Hedef}', await api('${2:/api/app/musteri/{value}}', '${3:data.name}'))\n} catch (error) {\n notify('${4:Servise ulaşılamadı}')\n}", + }, + { + label: 'Hesaplama ve yuvarlama', + group: 'Hesaplama', + description: 'İki alandan türetilmiş değer.', + code: "set('${1:Toplam}', round(num('${2:Miktar}') * num('${3:Fiyat}'), ${4:2}))", + }, + { + label: 'Alanı koşula göre kilitle', + group: 'Görünüm', + description: 'Koşul doğruyken salt okunur olur.', + code: "readOnly('${1:Alan}', str('${2:Durum}') === '${3:Kapali}')", + }, +] + +export type FormScriptDialectContext = { + /** Form kolonları. */ + fields: string[] + /** Script'in bağlı olduğu alan; başlıkta gösterilir. */ + currentField?: string +} + +export const createFormScriptDialect = (context: FormScriptDialectContext): ScriptDialect => { + const fields = Array.from(new Set(context.fields.filter(Boolean))).sort((left, right) => + left.localeCompare(right), + ) + + const asFormRule = (rule: ScriptRule) => rule as unknown as FormScriptRule + + return { + id: 'form', + title: 'Editor Script', + subject: context.currentField || 'alan seçilmemiş', + subjectHint: 'Bu alan değiştiğinde çalışacak script.', + codeHint: + 'Tek satırlık yardımcılarla yaz; get/set/notify hazır. Ctrl+Space tamamlama listesini açar.', + groups: recipeGroups.map((group) => ({ + key: group.key, + title: group.title, + description: group.description, + icon: groupIcons[group.key], + })), + recipes: recipeViews, + operators: DEFAULT_SCRIPT_OPERATORS, + conditionKinds: [{ value: FIELD_SOURCE, label: 'Form alanı', optionSource: FIELD_SOURCE }], + triggers: triggerLabels, + apiReference, + snippets, + getOptions: (optionSource) => (optionSource === FIELD_SOURCE ? fields : []), + getRecipe: (recipeId) => recipeViews.find((recipe) => recipe.id === recipeId), + createRule: (recipeId) => createRule(recipeId as RecipeId) as unknown as ScriptRule, + isRuleComplete: (rule) => isRuleComplete(asFormRule(rule)), + // Guard'ların gerekip gerekmediği script'in tamamına bağlı, o yüzden tek bir + // kuralın önizlemesi de bütün kurallara bakılarak üretilir. + buildRuleLine: (rule, rules) => + buildRuleLine( + asFormRule(rule), + scriptRunsOnOpen(rules.map(asFormRule).filter(isRuleComplete)), + ), + buildScript: (rules) => buildScriptFromRules(rules.map(asFormRule)), + parseScript: (script) => { + const parsed = parseScript(script) + return { ...parsed, rules: parsed.rules as unknown as ScriptRule[] } + }, + describeCondition: (condition) => + describeCondition(condition as unknown as FormScriptRule['condition']), + describeRule: (rule) => { + const recipe = recipesById.get(rule.recipe as RecipeId) + if (!recipe) return '' + const action = recipe.describe(asFormRule(rule)) + if (recipe.conditionIsArgument) return action + const conditions = [rule.condition, ...(rule.conditions ?? [])] + .map((condition) => describeCondition(condition as unknown as FormScriptRule['condition'])) + .filter((text) => text !== 'Her zaman') + const prefix = conditions.length + ? conditions.join(rule.join === 'or' ? ' veya ' : ' ve ') + : 'Her zaman' + return `${prefix} → ${action}` + }, + getTrigger: (rule) => effectiveTrigger(asFormRule(rule)), + } +} diff --git a/ui/src/views/admin/listForm/edit/json-row-operations/editor-script/scriptRecipes.ts b/ui/src/views/admin/listForm/edit/json-row-operations/editor-script/scriptRecipes.ts index 011ba98c..51e94c0c 100644 --- a/ui/src/views/admin/listForm/edit/json-row-operations/editor-script/scriptRecipes.ts +++ b/ui/src/views/admin/listForm/edit/json-row-operations/editor-script/scriptRecipes.ts @@ -43,10 +43,20 @@ export const triggerLabels: { value: RuleTrigger; label: string; help: string }[ { value: 'both', label: 'Her ikisi', help: 'Hem açılışta hem değer değişiminde çalışır.' }, ] +/** Ek koşullar nasıl birleşecek. */ +export type ConditionJoin = 'and' | 'or' + export type ScriptRule = { id: string recipe: RecipeId condition: RuleCondition + /** + * İkinci ve sonraki koşullar. Tek koşullu kurallar bu alanı hiç taşımaz — + * böylece eskiden kaydedilmiş script'ler birebir aynı JSON'a ve aynı koda + * üretilmeye devam eder. + */ + conditions?: RuleCondition[] + join?: ConditionJoin /** Tarife göre anlamı değişen serbest parametreler. */ params: Record /** Çoklu alan alan tarifler için (sum, clear). */ @@ -55,6 +65,12 @@ export type ScriptRule = { trigger?: RuleTrigger } +/** Kuralın bütün koşulları, ilki her zaman `condition`. */ +export const ruleConditions = (rule: ScriptRule): RuleCondition[] => [ + rule.condition, + ...(rule.conditions ?? []), +] + export type RecipeParam = { key: string label: string @@ -83,8 +99,11 @@ export type Recipe = { export type RecipeId = | 'multiply' + | 'subtract' + | 'percent' | 'sum' | 'formula' + | 'today' | 'days' | 'hours' | 'copy' @@ -142,6 +161,17 @@ export const buildConditionExpression = (condition: RuleCondition): string => { } } +/** + * Kuralın tüm koşullarını tek ifadeye çevirir. Tek koşulda çıktı eskisiyle + * birebir aynıdır; birden fazlasında her parça parantezlenip `&&` / `||` ile + * birleştirilir. + */ +export const buildRuleConditionExpression = (rule: ScriptRule): string => { + const parts = ruleConditions(rule).map(buildConditionExpression).filter(Boolean) + if (parts.length <= 1) return parts[0] ?? '' + return parts.map((part) => `(${part})`).join(rule.join === 'or' ? ' || ' : ' && ') +} + export const describeCondition = (condition: RuleCondition): string => { if (condition.operator === 'always' || !condition.source) return 'Her zaman' const source = label(condition.source, 'alan') @@ -203,6 +233,104 @@ export const recipes: Recipe[] = [ 'hedef', )} alanına yaz.`, }, + { + id: 'subtract', + label: 'Fark (brüt − indirim)', + group: 'calc', + summary: 'Birinci alandan ikinciyi çıkarır, yuvarlar ve hedefe yazar.', + example: "set('Net', round(num('Gross') - num('Discount'), 2))", + params: [ + { key: 'left', label: 'Çıkarılan alan', type: 'field' }, + { key: 'right', label: 'Çıkan alan', type: 'field' }, + { key: 'target', label: 'Sonuç alanı', type: 'field' }, + { key: 'digits', label: 'Ondalık', type: 'number', optional: true, placeholder: '2' }, + ], + build: ({ params }) => + `set(${q(params.target)}, round(${numberOrField(params.left)} - ${numberOrField( + params.right, + )}, ${params.digits || 2}))`, + describe: ({ params }) => + `${label(params.left, 'alan')} − ${label(params.right, 'alan')} sonucunu ${label( + params.target, + 'hedef', + )} alanına yaz.`, + }, + { + id: 'percent', + label: 'Yüzde (KDV, iskonto)', + group: 'calc', + summary: 'Tutarın belirtilen yüzdesini hesaplar; oran alan ya da sabit olabilir.', + example: "set('Kdv', round(num('Tutar') * num('KdvOrani') / 100, 2))", + params: [ + { key: 'amount', label: 'Tutar alanı', type: 'field' }, + { + key: 'rate', + label: 'Oran', + type: 'text', + placeholder: '20 veya KdvOrani', + help: 'Sayı yazarsan sabit oran, alan adı yazarsan o alandan okunur.', + }, + { key: 'target', label: 'Sonuç alanı', type: 'field' }, + { key: 'digits', label: 'Ondalık', type: 'number', optional: true, placeholder: '2' }, + { + key: 'mode', + label: 'Sonuç', + type: 'select', + optional: true, + choices: [ + { value: '', label: 'Yalnızca yüzde tutarı' }, + { value: 'add', label: 'Tutar + yüzde' }, + { value: 'subtract', label: 'Tutar − yüzde' }, + ], + }, + ], + build: ({ params }) => { + const amount = numberOrField(params.amount) + const portion = `${amount} * ${numberOrField(params.rate)} / 100` + const expression = + params.mode === 'add' + ? `${amount} + ${portion}` + : params.mode === 'subtract' + ? `${amount} - ${portion}` + : portion + return `set(${q(params.target)}, round(${expression}, ${params.digits || 2}))` + }, + describe: ({ params }) => + `${label(params.amount, 'tutar')} üzerinden %${label(params.rate, 'oran')} hesabını ${label( + params.target, + 'hedef', + )} alanına yaz.`, + }, + { + id: 'today', + label: 'Bugünün tarihi', + group: 'calc', + summary: 'Bugünü (veya kaç gün sonrasını) tarih alanına yazar.', + example: "set('Tarih', new Date().toISOString().slice(0, 10))", + params: [ + { key: 'target', label: 'Hedef alan', type: 'field' }, + { + key: 'offset', + label: 'Gün ekle', + type: 'number', + optional: true, + placeholder: '0', + help: 'Negatif yazarsan geçmiş bir tarih üretir.', + }, + ], + build: ({ params }) => { + const offset = Number(params.offset) || 0 + const base = offset + ? `new Date(Date.now() + ${offset} * 86400000)` + : 'new Date()' + return `set(${q(params.target)}, ${base}.toISOString().slice(0, 10))` + }, + describe: ({ params }) => { + const offset = Number(params.offset) || 0 + const when = offset > 0 ? `${offset} gün sonrasını` : offset < 0 ? `${-offset} gün öncesini` : 'bugünü' + return `${when} ${label(params.target, 'hedef')} alanına yaz.` + }, + }, { id: 'sum', label: 'Toplama', @@ -355,10 +483,10 @@ export const recipes: Recipe[] = [ ], }, ], - build: ({ params, condition }) => { - const expression = buildConditionExpression(condition) || 'true' - const flag = params.invert === 'true' ? `!(${expression})` : expression - return `readOnly(${q(params.target)}, ${flag})` + build: (rule) => { + const expression = buildRuleConditionExpression(rule) || 'true' + const flag = rule.params.invert === 'true' ? `!(${expression})` : expression + return `readOnly(${q(rule.params.target)}, ${flag})` }, describe: ({ params, condition }) => `${describeCondition(condition)} ${label(params.target, 'alan')} alanını ${ @@ -505,11 +633,13 @@ export const isRuleComplete = (rule: ScriptRule): boolean => { const recipe = recipesById.get(rule.recipe) if (!recipe) return false - const conditionReady = - rule.condition.operator === 'always' || - (!!rule.condition.source && - (['empty', 'notEmpty', 'isTrue', 'isFalse'].includes(rule.condition.operator) || - rule.condition.value.trim() !== '')) + const conditionReady = ruleConditions(rule).every( + (condition) => + condition.operator === 'always' || + (!!condition.source && + (['empty', 'notEmpty', 'isTrue', 'isFalse'].includes(condition.operator) || + condition.value.trim() !== '')), + ) if (!conditionReady) return false return recipe.params.every((param) => { @@ -543,7 +673,7 @@ export const buildRuleLine = (rule: ScriptRule, runsOnOpen = false): string => { } if (!recipe.conditionIsArgument) { - const condition = buildConditionExpression(rule.condition) + const condition = buildRuleConditionExpression(rule) if (condition) guards.push(condition) } diff --git a/ui/src/views/developerKit/VisualComponentDesigner.tsx b/ui/src/views/developerKit/VisualComponentDesigner.tsx index b26fe400..e53bf13b 100644 --- a/ui/src/views/developerKit/VisualComponentDesigner.tsx +++ b/ui/src/views/developerKit/VisualComponentDesigner.tsx @@ -27,6 +27,7 @@ import { FaSlidersH, FaSync, FaTabletAlt, + FaTimes, FaTrash, FaUndo, } from 'react-icons/fa' @@ -39,6 +40,11 @@ import { developerKitService } from '@/services/developerKit.service' import { Button, Notification, toast } from '@/components/ui' import StyleModal from '@/components/codeLayout/StyleModal' import VisualCanvas, { DESIGNER_DRAG_TYPE } from '@/components/visualDesigner/VisualCanvas' +import { + beginDesignerDragSoon, + endDesignerDrag, + useDesignerDrag, +} from '@/components/visualDesigner/designerDrag' import { DESIGNER_PRIMARY_EVENTS, DESIGNER_SCRIPT_RECIPES, @@ -47,8 +53,10 @@ import { getDesignerEventSnippet, } from '@/components/visualDesigner/catalog' import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator' +import DesignerScriptBuilderDialog from '@/components/visualDesigner/DesignerScriptBuilderDialog' import { collectDesignerRefs, + getSqlDataSourceEvent, createDesignerId, createEmptyDesignerDocument, findDesignerNode, @@ -56,6 +64,7 @@ import { renameDesignerRefInNodes, renameDesignerRefInScript, withDesignerRefs, + getDesignerBindingColumns, getDesignerCollectionProperty, getDesignerValueByPath, getSqlDataSourceEndpointId, @@ -1171,6 +1180,13 @@ const VisualComponentDesigner = () => { null, ) const [endpointResultModal, setEndpointResultModal] = useState(null) + /** Toolbox'ta hangi komponentin sürüklendiğini işaretlemek için. */ + const activeDrag = useDesignerDrag() + /** Kural tabanlı script builder'ın düzenlediği event. */ + const [scriptBuilderTarget, setScriptBuilderTarget] = useState<{ + nodeId: string + eventName: string + } | null>(null) const [isCatalogSourceSaving, setIsCatalogSourceSaving] = useState(false) const undoStack = useRef([]) const redoStack = useRef([]) @@ -2341,6 +2357,7 @@ const VisualComponentDesigner = () => { const selectOptionsBinding = isOptionDataComponent(selectedNode?.type) ? selectedNode?.bindings?.[optionDataProperty] : undefined + const selectOptionColumns = getDesignerBindingColumns(selectOptionsBinding) const tabularItemsBinding = isTabularDataComponent(selectedNode?.type) ? selectedNode?.bindings?.items : undefined @@ -2882,6 +2899,69 @@ const VisualComponentDesigner = () => { ))}
)} + {/* + Label ve Value ekranı sürer; buradaki sütunlar görünmez ama + seçenek nesnesiyle birlikte taşınır, böylece bir script onları + başka bir komponente ya da SqlDataSource kaydına yazabilir. + */} + {selectCollectionSample && selectColumnFields.length > 0 && ( +
+ + Ek sütunlar + + refs.{selectedNode?.ref}.getColumn('…') + + +
+ {selectOptionColumns.map((column) => ( + + {column} + + + ))} + +
+

+ Ekranda yalnızca Label görünür. Buraya eklediğin sütunlar seçenekle birlikte + taşınır; script içinde{' '} + refs.{selectedNode?.ref || 'select1'}.getColumn('sutun'){' '} + ile okunur. +

+
+ )} {selectCollectionSample && selectColumnFields.length > 0 && (!selectOptionsBinding.labelPath || !selectOptionsBinding.valuePath) && ( @@ -3668,6 +3748,27 @@ const VisualComponentDesigner = () => { current.trim() ? `${current.replace(/\s+$/, '')}\n\n${snippet}` : snippet, ) } + /** + * Builder context of the event being edited: the SqlDataSource that owns the + * record (the node itself, or the nearest one above it) plus the columns and + * ref names its rules can address. + */ + const scriptBuilderNode = scriptBuilderTarget + ? findDesignerNode(document.nodes, scriptBuilderTarget.nodeId) + : null + const scriptBuilderSqlNode = scriptBuilderNode + ? isSqlDataSourceNode(scriptBuilderNode.type) + ? scriptBuilderNode + : [...(findDesignerAncestors(document.nodes, scriptBuilderNode.id) || [])] + .reverse() + .find((node) => isSqlDataSourceNode(node.type)) + : undefined + const scriptBuilderRecordFields = scriptBuilderSqlNode + ? Object.keys( + (sqlDataSourceRecords[scriptBuilderSqlNode.id] as Record) || {}, + ) + : [] + const endpointResultText = endpointResultModal ? typeof endpointResultModal.result === 'string' ? endpointResultModal.result @@ -3817,8 +3918,16 @@ const VisualComponentDesigner = () => {
)} + + {scriptBuilderTarget && scriptBuilderNode && ( + + updateNodeEvent(scriptBuilderTarget.nodeId, scriptBuilderTarget.eventName, next) + } + onClose={() => setScriptBuilderTarget(null)} + /> + )}
) }