178 lines
11 KiB
Markdown
178 lines
11 KiB
Markdown
# Sözsoft Platform UI
|
||
|
||
React 19 + Vite 8 (rolldown) + DevExtreme + easy-peasy + Tailwind 4. Ekranların tamamı
|
||
veritabanından gelen ListForm/menü/route tanımlarıyla çizilir; bu klasör yalnızca çekirdek
|
||
motoru taşır. Platformun geneli için [README.md](../README.md), çalışma kuralları için
|
||
[CLAUDE.md](../CLAUDE.md).
|
||
|
||
## Komutlar
|
||
|
||
| Komut | Ne yapar |
|
||
| --- | --- |
|
||
| `npm start` | Dev sunucusu (3000). `prestart` DevExtreme lisans dosyasını üretir. |
|
||
| `npm run build` | Üretim derlemesi → `dist/`. `--mode dev|production` ile `.env.{mode}` okunur. |
|
||
| `npm run preview` | Yerel önizleme; üretim sunucusu **değildir** (bkz. Konteyner). |
|
||
| `npm run typecheck` / `npm run lint` | `tsc --noEmit` / `oxlint`. Her değişiklikten sonra ikisi de 0 hata vermeli. |
|
||
| `npm run format` | prettier + oxlint --fix. |
|
||
|
||
Ortam değişkenleri `.env` (yerel), `.env.dev`, `.env.production`: `VITE_API_URL`, `VITE_CDN_URL`.
|
||
Node/npm sürümü `package.json` → `volta`.
|
||
|
||
## Derleme çıktısı
|
||
|
||
`vite.config.ts` üç şey üretir:
|
||
|
||
- **Chunk'lar** `assets/js/entry-*.js` (giriş), `assets/js/vendor-react-*.js`,
|
||
`vendor-router-*.js` (her açılışta yüklenen, sürümler arası değişmeyen çekirdek) ve rolldown'un
|
||
otomatik böldüğü lazy chunk'lar; CSS `assets/css/`, görsel/font `assets/img|fonts/`.
|
||
Adlar hash'lidir: aynı ad = aynı içerik.
|
||
- **`sw.js`** — `src/sw.ts`'ten (`vite-plugin-pwa`, `injectManifest`). Precache manifest'i
|
||
yalnızca kabuğu (`PRECACHE_GLOBS`) taşır.
|
||
- **`warmup.json`** — `warmupManifest()` eklentisi: bütün JS/CSS chunk'larının adı/boyutu ve
|
||
her `views/**` modülünün açılması için gereken dosya zinciri (`routes`). Service worker ısıtma ve
|
||
eski sürüm temizliği için okur.
|
||
|
||
`twSafelistGenerator.ts`: custom component kodu veritabanında yaşadığı için Tailwind'in
|
||
göremediği sınıflar `safelist.txt`'ten derlemeye eklenir.
|
||
|
||
## Rota ve bileşen çözümü
|
||
|
||
`src/routes/dynamicRouteLoader.ts` `import.meta.glob('../views/**/*.tsx')` ile bütün view
|
||
modüllerini lazy olarak kaydeder; `Sas.Menus.Routes` kaydındaki `componentPath` bu haritadan
|
||
çözülür (`hasPhysicalComponent`), karşılığı olmayan rota router'a alınmaz. Runtime bileşenler
|
||
(`Custom Components`) `ComponentContext` üzerinden çizilir ve aynı yolu paylaşan fiziksel rotayı
|
||
gölgeler. Ayrıntı: [README.md §18.1](../README.md).
|
||
|
||
## PWA ve sürüm geçişi
|
||
|
||
Kod: `src/sw.ts` (service worker), `src/views/version/swRegistration.ts` (kayıt/güncelleme
|
||
motoru, entry chunk'ta, React'a bağımlı değil), `AppUpdateDialog.tsx` (lazy, kendi React
|
||
kökünde mount edilir), `installPrompt.ts` (PWA kurulum teklifi).
|
||
|
||
1. **Kurulum = kabuk.** `install` olayı yalnızca precache manifest'indeki dosyaları indirir
|
||
(index.html, entry JS/CSS, vendor chunk'ları). Biri inmezse kurulum düşer; yarım sürümle
|
||
aktive olunmaz. İndirme ilerlemesi dosya dosya istemciye yayınlanır.
|
||
2. **Isıtma = bileşenler.** Oturum açılınca store, yetkili `protected` rotaların view
|
||
modüllerini `setWarmupRoutes` ile bildirir; worker `ENSURE_WARMUP` ile `warmup.json`'daki
|
||
zinciri arka planda indirir. Ziyaretçi ve giriş ekranı hiçbir bileşen indirmez. Best-effort:
|
||
inemeyen dosya atlanır, süre bütçesi dolunca kalanlar runtime cache'e kalır.
|
||
3. **Güncelleme.** Deploy'da herhangi bir chunk değişince entry hash'i, index.html ve `sw.js`
|
||
manifest'i değişir; `registration.update()` (kayıt, sekmeye dönüş, bağlantı, 30 dk yoklama)
|
||
yeni worker'ı görür → kurulum → `SKIP_WAITING` → `controllerchange` → yenileme. Pencere geçiş
|
||
bitene kadar kapanmaz; ilk kurulumda (controller yok) kapatılabilir.
|
||
4. **Sürüm numarası ve notlar backend'dedir** (`extraProperties.changeLogs`,
|
||
`/api/app/change-log`); UI'da sürüm dosyası yoktur. Son görülen sürüm store'un `locale`
|
||
diliminde tutulur; sürüm değişince yazılan bayrağı `ProtectedRoute` okur ve oturum açık
|
||
kullanıcıyı (login şartı olmadan) `/admin/changeLog`'a yönlendirir, ekran bayrağı tüketir.
|
||
5. **Runtime cache.** Hash'li dosyalar cache-first (`sozsoft-assets-v1`, aktivasyonda
|
||
`warmup.json`'da olmayan eski chunk'lar silinir), DevExtreme temaları stale-while-revalidate,
|
||
font/görsel cache-first. `/api`, `/connect`, `/swagger`, `/signalr`, `/hangfire` worker'a
|
||
girmez. Gezinmeler kabuk cache'indeki index.html ile anında açılır, kopya arka planda tazelenir.
|
||
|
||
### Deploy sonrası eksik / değişmiş chunk
|
||
|
||
İki katman birlikte çalışır:
|
||
|
||
- **Sunucu tarafı (asıl çözüm):** konteyner nginx'i hash'li `assets/` dosyalarını `/srv/assets`
|
||
volume'undan servis eder; `nginx/40-archive-assets.sh` her açılışta yeni sürümün dosyalarını
|
||
arşive ekler (var olanı ezmez), imajda artık olmayan ve 30 günden eski dosyaları siler. Açık
|
||
sekmeler deploy'dan sonra da kendi sürümlerinin chunk'larını bulur.
|
||
- **İstemci tarafı (yedek):** bir chunk yine de bulunamazsa Vite `vite:preloadError`
|
||
fırlatır; `handleChunkLoadFailures` kabuk cache'ini silip sayfayı **bir kez** yeniler
|
||
(`reloadWithFreshShell`; düz `reload` eski index.html'i geri getirirdi). Koruma bayrağı
|
||
(`localStorage.chunkReloadGuard`, 10 s) döngüyü keser; ikinci başarısızlık hata sınırına
|
||
düşer, `Views.tsx` `isChunkLoadError` ile "uygulama güncellendi, yenileyin" mesajı ve
|
||
`reloadApp` butonu gösterir. Hata sınırı rota değişince kendini sıfırlar (`resetKeys`).
|
||
|
||
Dev'de service worker kapalıdır (`VITE_PWA_DEV=true` ile açılır); kapalıyken önceki kayıtlar
|
||
temizlenir, yoksa `sw.js` yerine index.html döner ve "unsupported MIME type" hatası üretirdi.
|
||
|
||
## Konteyner
|
||
|
||
`Sozsoft.Platform.Ui.Dockerfile` iki aşamalıdır:
|
||
|
||
1. `node:24.22-alpine` — `npm run build:production -- --mode $ENV`; DevExtreme lisansı yalnızca
|
||
bu aşamada, tek `RUN` içinde yazılıp silinir. `.env.$ENV`'deki `VITE_API_URL` `/tmp/api-url`
|
||
olarak ikinci aşamaya geçer.
|
||
2. `nginx:1.27-alpine` — `dist/` + `nginx/default.conf` (`${API_URL}` envsubst ile build'de
|
||
doldurulur, `nginx -t` ile doğrulanır) + `/docker-entrypoint.d/40-archive-assets.sh`.
|
||
|
||
`nginx/default.conf` sözleşmesi:
|
||
|
||
| Yol | Cache-Control | Not |
|
||
| --- | --- | --- |
|
||
| `/assets/**` | `public, max-age=31536000, immutable` | `/srv/assets` (imaj + arşiv) |
|
||
| `/index.html`, `/sw.js`, `/warmup.json`, `/manifest.webmanifest` | `no-store, no-cache, must-revalidate` | sürüm kapısı |
|
||
| `/css/**` (DevExtreme temaları) | `public, max-age=86400` | hash'siz, SW tazeler |
|
||
| `/img/**` | `public, max-age=604800` | |
|
||
| `/sitemap.xml`, `/robots.txt` | — | API'ye proxy (`SeoController`) |
|
||
| diğer | — | SPA fallback → `index.html` |
|
||
|
||
Compose (`configs/deployment/docker-compose-app.yml`): `ui` servisi `3002:80`, volume
|
||
`ui-assets:/srv/assets`. Deploy (`scripts/deploy/app.sh`) konteynerleri önceden durdurmaz:
|
||
`pull` → `up -d`; kesinti yalnızca konteyner değişimi kadardır. Ters vekil `nginx.conf`
|
||
Cache-Control başlıklarını ezmez; başlıklar konteynerden gelir, tenant alt alan adları da aynı
|
||
başlıkları alır.
|
||
|
||
## Kod düzeni
|
||
|
||
```
|
||
src/
|
||
components/ paylaşılan bileşenler (ui/, shared/, template/, layouts/, list/form editörleri…)
|
||
views/ rota bileşenleri (import.meta.glob ile lazy); views/shared/ hariç
|
||
routes/ dinamik router ve route çözümü
|
||
store/ easy-peasy modelleri (abpConfig, auth, locale, base…)
|
||
services/ axios istemcileri (authApi, platformApi) ve SignalR servisleri
|
||
proxy/ API DTO tipleri
|
||
utils/ yardımcılar ve hook'lar
|
||
sw.ts service worker
|
||
```
|
||
|
||
Grid/Tree popup'ı ile form sayfası editör yeteneklerini `views/list/shared/*` ve
|
||
`form/editors/*` üzerinden paylaşır; `scriptRecipes.ts` C# `Domain.Shared/Editors/*` ile
|
||
birebir aynı çıktıyı üretmek zorundadır (bkz. CLAUDE.md "Cross-cutting"). Grid ve Tree'nin ortak
|
||
iç seçenekleri (düzenleme, araç çubuğu, süzgeçler, hücre editörü şablonları) tek yerde çizilir:
|
||
`views/list/shared/listOptionChildren.tsx`.
|
||
|
||
Ortak yardımcılar; aynı işin ikinci bir kopyası yazılmaz:
|
||
|
||
| İş | Yardımcı |
|
||
| --- | --- |
|
||
| Kullanıcıya görünen metin | `useLocalization().translate('::Anahtar')`; React dışı kodda `utils/translate.ts` `translateStatic` |
|
||
| Toast bildirimi | `utils/notify.tsx` `notifySuccess/Info/Warning/Danger(başlık?, { message })` |
|
||
| API hatasının bildirimi | `services/apiError.ts` `pushApiError` |
|
||
| Kimlik üretimi | `utils/id.ts` `createId` (UUID), `createShortId` |
|
||
| Tarih/sayı/para/boyut | `utils/localeFormat.ts` (aşağıda) |
|
||
|
||
## Tarih ve sayı biçimi
|
||
|
||
Kullanıcıya görünen her tarih ve sayı **aktif kültürle** biçimlenir; kültür `useLocale` ile store'dan
|
||
`<html lang>`'e yazılır ve tek giriş `src/utils/localeFormat.ts`'tir:
|
||
|
||
| İhtiyaç | Çağrı |
|
||
| --- | --- |
|
||
| Tarih / saat / tarih+saat | `formatLocaleDate`, `formatLocaleTime`, `formatLocaleDateTime` (dayjs `L`, `LT`, `L LT`); uzun tarih `formatLocaleLongDate` (`LL`) |
|
||
| Sayı | `formatLocaleNumber(v, { minimumFractionDigits, maximumFractionDigits, useGrouping })` — varsayılan binlik ayracı **yok** (yıl, kimlik, adet), tutar için `useGrouping: true` |
|
||
| Sabit ondalık / dosya boyutu | `formatLocaleFixed(v, 2)`, `formatLocaleBytes(bytes)` |
|
||
| Para | `formatLocaleCurrency(v, currencyCode)` — kod veriden gelir; verisi olmayan fiyat `DEFAULT_CURRENCY` (`constants/app.constant.ts`) |
|
||
| Ham endpoint değeri | `formatLocaleValue(v)` (ISO tarih ve sayıyı çevirir, diğerini olduğu gibi bırakır) |
|
||
|
||
Hepsi boş/geçersiz değer için `''` döner ve `Date | string | number | Dayjs` alır. Tarih seçiciler
|
||
(`DatePicker`, `DateTimepicker`) `inputFormat` verilmeyince kültürün desenini kullanır; prop yalnızca
|
||
makine biçimi gerektiğinde verilir.
|
||
|
||
DevExtreme kolon/editör biçimleri **adlı** biçimdir: `shortDate`, `shortDateShortTime`, `shortTime`,
|
||
`decimal`, `fixedPoint`, `currency` (`EditorOptions.Date/DateTime/Time` ↔ `presets.ts`). Kültür
|
||
`DevExtremeBoundary` → `configureDevExtreme` ile verilir: `locale(cultureName)` tarih/sayıyı,
|
||
`loadMessages` ile yüklenen `devextreme/localization/messages/{dil}.json` arayüz metinlerini (Evet/Hayır,
|
||
"Veri yok", filtre satırı) çevirir; paket dışı dil İngilizce metinle kalır.
|
||
|
||
Yazılmayanlar: `'DD.MM.YYYY'`, `'HH:mm'` gibi sabit dayjs desenleri, `toLocaleString()` /
|
||
`toLocaleDateString('tr-TR')`, `Intl.*Format('tr-TR')`, görüntülenen ondalık için `toFixed`. Sunucu tarafı
|
||
karşılığı `CultureFormat` + `CultureInfo.CurrentCulture`'dır.
|
||
|
||
**Makine biçimi serbesttir** ve `localeFormat`'tan geçmez: `<input type="date|datetime-local">` değeri ve
|
||
API yüküne giden ISO metin (`YYYY-MM-DD`, `YYYY-MM-DDTHH:mm:ss`), gruplama/sorgu anahtarı, DevExtreme
|
||
`dateSerializationFormat`, yakınlaştırma yüzdesi gibi sayısal hesap yuvarlaması (`Number(x.toFixed(2))`),
|
||
metin karşılaştırması için `toLocaleLowerCase('tr-TR')`. `AppUpdateDialog` uygulama ağacının dışında,
|
||
kültür bilinmeden açıldığı için kendi `formatBytes` kopyasını taşır.
|