# Ordinis Admin UI — баги и улучшения

Обзор по итогам чтения `package.json`, `main.tsx`, `api/{client,queries,mutations}.ts`, `auth/*`, `routes/__root.tsx`, `routes/dictionaries.$name.tsx`, `components/form/SchemaDrivenForm.tsx`, `components/version/useAppVersion.ts`, `lib/dates.ts`, `i18n.ts`.

---

## 🐞 Реальные баги

### 1. `time-travel`: повреждённое значение в `<input type="datetime-local">`
`routes/dictionaries.$name.tsx`:
```ts
value={timeTravelAt ? new Date(timeTravelAt).toISOString().slice(0,16) : ''}
```
`toISOString()` всегда отдаёт UTC. Если у пользователя часовой пояс `+03:00`, то значение в picker'е показывается на 3 часа раньше, чем выбрал пользователь, а `onChange` пишет обратно `new Date(value).toISOString()` (теперь уже трактует local) — день/время «уплывают» при каждом редактировании.
**Фикс:** использовать local-форматтер (есть `formatIsoDate`/`extractTime` в `lib/dates`), или
```ts
const d = new Date(timeTravelAt);
const pad = (n:number)=>String(n).padStart(2,'0');
const v = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
```

### 2. Дублирующиеся 401-интерсепторы → бесконечный цикл редиректов
В `api/client.ts` уже стоит response-interceptor, который при 401 удаляет токен. В `auth/TokenSync.tsx` навешивается **второй** interceptor, который при 401 дёргает `signinSilent/Redirect`. Оба регистрируются — оба отрабатывают на одном и том же ошибочном ответе. Если `signinSilent` упадёт, может уйти в `signinRedirect` параллельно с уже идущим redirect (race). Рекомендую:
- удалить response-handler из `api/client.ts`, оставить только в `TokenSync`;
- guard'ить флагом `redirecting` (модульная переменная), чтобы один 401 не вызвал N редиректов из разных одновременных запросов.

### 3. Race в `TokenSync` + `apiClient.defaults`
`apiClient.defaults.headers.common['Authorization']` ставится в `useEffect`, но request-interceptor в `api/client.ts` каждый раз сам читает токен из `localStorage`. Получается двойная синхронизация и при выходе из аккаунта в одной вкладке остаётся «зомби-хедер» в `defaults` до следующего ререндера. Оставьте **либо** interceptor (он самодостаточен), **либо** `defaults.headers` — не оба.

### 4. AJV: ошибки навешиваются на `data` (root), а не на конкретное поле
`SchemaDrivenForm.tsx` → `applyAjvErrors`:
```ts
const fieldPath = path ? (`data.${path}` as `data.${string}`) : 'data'
setError(fieldPath as 'data', { type: 'ajv', message: err.message })
```
Каст `as 'data'` ломает RHF: `setError` ожидает реальный path, иначе все ошибки складываются в один ключ `data` и `formState.errors.data?.[key]` **не находит их**, а tab-badge со счётчиком всегда показывает 0/1 случайно. Уберите каст и работайте через `setError(fieldPath as Path<FormValues>, ...)`. Также для `required` (AJV выдаёт `params.missingProperty`) instancePath пустой — нужно собирать путь из `missingProperty`.

### 5. `nowIsoLocal()` для `validFrom` в edit-modal
В `dictionaries.$name.tsx` при открытии edit-form `validFrom` дефолтится через `nowIsoLocal()`, но `defaultValues` в `useForm` берётся **только при mount**. Если пользователь подержит модалку открытой 5 минут и сохранит — `validFrom` всё ещё «момент открытия», что не совпадает с тем, что бы пользователь ожидал (бекенд проверяет `validFrom > existingFrom`). Лучше дефолтить пустым, а на submit подставлять `now()` если поле пустое.

### 6. `extraColumns` берёт первые 2 enum/FK без сортировки и без локализации
`Object.entries(props)` зависит от key insertion order, который у JSON-схемы непредсказуем (или придёт из бекенда в разном порядке между запросами). Лучше: либо явный `x-list-column` флажок в схеме, либо `schema.required` приоритет, либо стабильный alphabetical sort.

### 7. `useEffect([visibleKeys])` в дикт-роуте: новая ссылка каждую перерисовку
```ts
const visibleKeys = useMemo(() => visibleRecords.map(r => r.businessKey), [visibleRecords])
```
`visibleRecords` пересоздаётся при каждом рендере родителя, потому что `filteredRecords` зависит от `recordsResult.data` (стабильно), но `slice` всё равно даёт новый массив → `visibleKeys` тоже новая ссылка → effect для очистки selection срабатывает на любой ререндер. Безвредно, но создаёт лишние `setState`. Сравнивайте по содержимому (`useMemo` с deps на `JSON.stringify(visibleKeys)` или сравнение длин и первого/последнего ключа).

### 8. `useDictPendingDrafts(undefined)` всё равно вызывает `enabled: Boolean(...)`-query, но `queryKey` у него `['drafts','by-dict','']`
В кэше может застрять пустой ключ; при последующем переходе с разных дикторов один и тот же ключ `''` будет показываться. Передавайте `null`/исключайте полностью через `useQuery({...spread, enabled, queryKey: enabled ? key : ['noop']})`.

### 9. `schema.events.disclaimer` (i18n) ссылается на путь Java-файла
В строках интернационализации (ru/en) лежит абсолютный путь к Java-классу — попадёт пользователю в UI. Это похоже на ошибку при копировании комментария в i18n-resource. Уберите.

### 10. CSV-экспорт filename парсится небезопасным regex
`mutations.ts` → `useBulkExportRecords`:
```ts
const match = cd?.match(/filename="?([^";]+)"?/)
```
Не понимает `filename*=UTF-8''...` (RFC 5987) — всё, что отдаёт сервер с не-ASCII именем, окажется неправильно декодированным. Парсите оба варианта.

### 11. `apiClient` `timeout: 10_000` слишком жёсткий для CSV-экспорта и cascade-close
`bulk-close`/`cascade-close` на больших объёмах могут идти 20–30 c (по комментарию в `mutations.ts` бекенд сам падает по 30s timeout). Поставьте per-request `timeout` для тяжёлых эндпойнтов (axios `{ timeout: 60_000 }` в config).

### 12. `oidcConfig.userStore` использует `localStorage`
Это реальная XSS-уязвимость для access_token (и refresh-token, если он там окажется). Стандартная рекомендация oidc-client-ts — держать токен в **памяти** (по умолчанию) и хранить в `sessionStorage` максимум, а лучше использовать BFF (refresh httponly-cookie). Мин. шаг — переключиться на `sessionStorage`.

### 13. `apiClient` Authorization-header не добавляется на cross-origin/CORS, если `localStorage` пуст после reload
Token сохраняется в `localStorage` через `TokenSync` **только** после init React. Первые запросы, которые идут до того, как `useEffect` стрельнул, пойдут без `Authorization` → 401 → infinite redirect (см. п.2). Решение — читать токен напрямую из `userManager` синхронно в interceptor, или делать `<RequireAuth>` блокирующим до `auth.user.access_token`.

### 14. `RequireAuth` крутит `signinRedirect` в loop при `auth.error`
Условие на строчке `if (auth.isLoading || auth.activeNavigator) return` не учитывает `auth.error` — сперва рендерится «Не удалось подключиться» (правильно), но `useEffect` всё равно запустит `signinRedirect()` ниже. Добавьте `if (auth.error) return` в guard.

### 15. `apiClient` baseURL смешивает env-`VITE_API_BASE` и `/api/v1`
Если кто-то задаст `VITE_API_BASE=https://api.example.com` без `/v1`, то все запросы превратятся в `/dictionaries` относительно корня. Лучше `(import.meta.env.VITE_API_BASE ?? '') + '/api/v1'` или жёсткое требование trailing-slash.

### 16. Pagination для записей: client-side, но `recordsQuery` тащит **все** записи на каждый смену фильтра
Если справочник вырастет до 10k+, запрос будет тяжёлым, плюс `JSON.stringify(r.data)` для search-фильтра O(n*m). Перевести на server-side как минимум для search/scope-фильтра. Сейчас стоит TODO в комментарии «типичный словарь до 100 записей» — но никаких guard'ов нет.

### 17. `Checkbox` ref + `indeterminate` через colback ref, но `Checkbox` компонент из `@nstart/ui` может не пробрасывать ref на нативный input
Если `Checkbox` обёрнут в `forwardRef` и привязывает ref к **обёртке** — `el.indeterminate = …` упадёт молча. Проверить, что ref идёт на `<input>`. Чаще делают через prop `indeterminate`.

### 18. `setEdit({ kind: 'closed' })` в `onError` cascade-flow теряет `closeMut.error`
Сразу после ошибки 409 `setEdit` закрывает modal, но `closeMut.error` остаётся в state до следующего mutate, и в открытом cascade-dialog'е может промелькнуть alert с этим текстом из stale state. Делайте `closeMut.reset()` перед `setCascadeKey(...)`.

### 19. `searchQuery` отправляет min-length-проверку в hook, но и `useSearch` отправляет `q ?? ''`
Если кто-то в коде вызовет `searchQuery(...)` напрямую (для prefetch — обычный паттерн TanStack Router loader'а), он получит запрос `q=ab` → 400 от бекенда. Лучше вернуть `enabled` из самого `queryOptions` или `throw` в `queryFn`.

### 20. i18n plural-keys `_one/_few/_many/_other` для `en-US`
В русском есть `_few/_many`, в английском — только `_one/_other`. В английских ресурсах при `count=2` i18next выдаст fallback на `_other`. Норм. **Но**: ключи `myDrafts.total_few/_many` в `en-US` отсутствуют, а в `ru-RU` есть. На деплоях с `fallbackLng: 'ru-RU'` английский пользователь увидит русский plural при `count=2..4`. Завести явные `_one/_other` для всех ключей.

---

## ✨ Улучшения (не баги, но стоит сделать)

- **`api/client.ts`** — нет `axios-retry` или повтора `GET` идемпотентных запросов на 5xx; query-level `retry: 1` без backoff. Добавить `retryDelay: exponentialBackoff`.
- **`useCreateRecord` / `useUpdateRecord`** генерируют новый `Idempotency-Key` на каждом mutate-вызове. Если RHF сабмит дёрнет дважды (двойной клик / Enter), сервер увидит **два разных** ключа и создаст два состояния. Генерировать ключ один раз на форму (в `useState` инициализаторе) и сбрасывать после `onSuccess`.
- **`onSuccess` мутаций — слишком широкий invalidate.** В `useApproveDraft` инвалидируется `['records']` целиком — все справочники перезапросят свои listing'и. Достаточно `['records', dictName]`, который можно достать из draft-response.
- **`refetchInterval: 30_000` для outbox/reviews/version**: четыре одновременных интервала на одном экране дают 4 запроса в полминуты + tab-в-фоне. Добавьте `refetchIntervalInBackground: false` или объедините в один `useQuery` с `select`.
- **`UpdateBanner` через `__APP_COMMIT__`**: если build-time-define в `vite.config.ts` отсутствует, TS падает (`vite-env.d.ts` объявляет глобал, но рантайм будет `undefined`). Проверьте, что в `vite.config.ts` `define` установлен и для `dev`-режима тоже (часто забывают).
- **Доступность.** `<input type="datetime-local">` без `aria-describedby` для hint, `<button aria-pressed>` без видимой смены состояния помимо цвета (контраст 3:1 достаточен?). Скрин-ридеру ничего не озвучивается.
- **Плейн `<select>` в FK-полях** — потерял всю стилизацию модалки на мобильных Safari (родной picker), может «прорезать» modal. Сейчас в коде это аргументируется в комментарии, но при 5 опциях SingleSelect лучше — выбирать гибрид по `options.length`.
- **`humanize`** дублируется в `dictionaries.$name.tsx` и в `SchemaDrivenForm.tsx`. Вынесите в `lib/strings.ts`.
- **`scopeFilter` через URL** — если пользователь руками вобьёт `?scopes=public,internal` (lowercase), `validateSearch` отфильтрует. Но `toUpperCase()` применён только к scope-CSV, не к `q`. Нормализуйте всё на парсинге.
- **Типы.** `JsonSchema.properties: Record<string, JsonSchema>` — нет `nullable`, `oneOf`, `anyOf`. Поле сложного союза свалится в branch `schema.type === 'object'` и пойдёт в JSON-textarea. Добавить explicit unsupported branch + UI hint.
- **Файл `i18n.ts` — 899 строк/55 КБ ресурсов в одном модуле.** Это попадает в основной bundle. Разделите по namespace'ам и подгружайте `lazy` (i18next backend).
- **`AuthBadge` — `auth.removeUser()` после фейла logout** оставляет SSO-сессию в Keycloak активной. Если пользователь сразу логинится снова, он получит ту же сессию автоматически (что обычно не то, чего ждёт оператор после «Выйти»). Логируйте 5xx и показывайте toast.
- **`apiClient.interceptors.request` Accept-Language**: всегда отправляется `navigator.language,ru-RU;q=0.9,en-US;q=0.8`. Если пользователь переключил язык в `LanguageSwitch`, заголовок остаётся прежним до перезагрузки. Слушайте `i18n.on('languageChanged')` и обновляйте `apiClient.defaults.headers['Accept-Language']`.
- **`AoiPickerDialog`** парсится через regex `/^-?\d+(\.\d+)?(,...){3}$/` — не пропускает экспоненциальную форму, отрицательные `-0.5` без целой части (`-.5`) и т. п. `parseFloat`/`Number` стабильнее.
- **`bulkExportMut` не имеет timeout override** — при 10k записей десятисекундный axios-default обрежет.
- **`route.__root.tsx`**: navigation-меню — простой `<nav>` без `aria-label`, активная ссылка различима только цветом. Добавить `aria-current="page"` через `activeProps`.

---

## 📌 Топ-5 что чинить в первую очередь

1. Двойные 401-interceptor'ы → возможный redirect-loop (#2, #13, #14).
2. AJV-ошибки не привязаны к конкретным полям (#4) — у пользователя «непонятно почему форма красная».
3. Time-travel TZ-bug (#1) — пользователь видит не ту дату, что ввёл.
4. Idempotency-Key per-mutate, а не per-form (улучшение, но критично для retry-сценариев).
5. `localStorage` для access_token (#12) — security.

