sozsoft-platform/ui/README.md

178 lines
11 KiB
Markdown
Raw Normal View History

2026-10-17 18:51:10 +00:00
# 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
2026-10-19 14:35:08 +00:00
modüllerini lazy olarak kaydeder; `Sas.Menus.Routes` kaydındaki `componentPath` bu haritadan
2026-10-17 18:51:10 +00:00
çö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, giriş sonrası changelog yönlendirmesi oradan okunur.
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.