Commit graph

117 commits

Author SHA1 Message Date
nurdotnet 8eceff0bb5 fix(phone): сохранять позицию курсора после нормализации
Some checks failed
CI / Backend (.NET 8) (push) Successful in 1m3s
CI / Web (React + Vite) (push) Successful in 41s
Docker Public / Build + push Public (push) Successful in 26s
Docker Web / Build + push Web (push) Successful in 32s
Docker Public / Deploy Public on stage (push) Successful in 10s
Docker Web / Deploy Web on stage (push) Successful in 11s
CI / POS (WPF, Windows) (push) Has been cancelled
Курсор не должен прыгать в конец после редактирования — он должен оставаться там, где юзер только что внёс изменение.

Корень проблемы: после onChange я вызываю onChange с каноничным «+77…», parent перерендеривает с этим значением, React переписывает input.value на новый display — а нативное поведение браузера в этом случае: при programmatic value-set курсор сбрасывается в конец.

Фикс: сохраняем «сколько цифр стояло до курсора» в момент редактирования, после ре-рендера в useLayoutEffect ставим курсор сразу после той же по счёту цифры в новом display. Учёт по цифрам (а не по абсолютной позиции) корректен даже когда format добавляет/убирает пробелы (например при переходе между «+7 700 1» и «+7 700 12»).

useLayoutEffect (а не useEffect) — чтобы курсор восстанавливался синхронно до paint, без визуального флика.
2026-05-03 18:25:56 +05:00
nurdotnet 1264a91e2c fix(phone): нативное редактирование, фильтр не-цифр через onBeforeInput
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m7s
CI / Web (React + Vite) (push) Successful in 41s
Docker Public / Build + push Public (push) Successful in 27s
Docker Web / Build + push Web (push) Successful in 33s
Docker Public / Deploy Public on stage (push) Successful in 10s
Docker Web / Deploy Web on stage (push) Successful in 11s
Прошлая попытка вручную обрабатывать каждую клавишу через onKeyDown ломала привычное поведение: selection + Backspace удалял только 1 цифру, нельзя было редактировать в середине, и т.д.

Новая модель совсем простая:
- Префикс «+7 7» (4 символа) залочен — onSelect клампит курсор/selection на digit-зону.
- Не-цифры блокируются через onBeforeInput (e.preventDefault если data содержит \D). Это покрывает и печать, и paste, и IME, и drag-drop.
- Всё остальное — нативное поведение браузера: selection + delete, click anywhere, arrow keys, Cmd+A, Cmd+V, drag-drop. После любого изменения onChange нормализует значение (rawToUserDigits извлекает 9 пользовательских цифр, отрезая залоченные «+7 7»).
- Display всегда «+7 7» + format(userDigits) — если юзер случайно стер часть префикса, она восстанавливается на ре-рендере.

Канон наружу: «+77XXXXXXXXX» (12 chars при полном номере), пустая строка если ничего не введено. Совместимо с существующим validatePhone (^77\d{9}$).
2026-05-03 17:21:50 +05:00
nurdotnet 3beaec214a fix(phone): редактирование на месте курсора, как в обычном поле
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m3s
CI / Web (React + Vite) (push) Successful in 39s
Docker Public / Build + push Public (push) Successful in 25s
Docker Web / Build + push Web (push) Successful in 31s
Docker Public / Deploy Public on stage (push) Successful in 10s
Docker Web / Deploy Web on stage (push) Successful in 11s
Курсор больше не «прыгает в конец» — его можно ставить куда угодно в digit-зоне (после префикса «+7 »), а Backspace/Delete/ввод цифры работают относительно позиции курсора, как в любом нормальном текстовом поле.

Реализация:
- cursorToDigitIdx — мапит позицию курсора в display к индексу в массиве цифр (пробелы форматирования пропускаются).
- digitIdxToCursor — обратное: после операции ставит курсор сразу после последней изменённой цифры (через пробел если он там есть, чтобы не было визуального скачка).
- Backspace на позиции idx удаляет цифру (idx-1); Delete удаляет цифру idx; ввод цифры вставляет в позицию idx.
- Префикс «+7 » защищён: onSelect ловит попытку курсора влезть в позиции 0..2 и мягко переставляет на 3.
2026-05-03 17:01:20 +05:00
nurdotnet a7130f3116 fix(phone): полностью переписать на простую модель — цифры как single source of truth
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m5s
CI / Web (React + Vite) (push) Successful in 38s
Docker Public / Build + push Public (push) Successful in 27s
Docker Web / Build + push Web (push) Successful in 32s
Docker Public / Deploy Public on stage (push) Successful in 10s
Docker Web / Deploy Web on stage (push) Successful in 11s
Предыдущие итерации пытались парсить промежуточный raw input от браузера и постоянно ловили баги: курсор не там → парсер путал префикс с введёнными цифрами; Backspace на странной позиции → блокировался не там где надо.

Новая модель: фронт полностью контролирует input, нативный ввод запрещён. Все клавиши обрабатываются вручную в onKeyDown:
- Цифра → если набрано <10, добавить в конец.
- Backspace/Delete → если есть цифры, удалить последнюю.
- Любой другой символ → игнор (preventDefault).
- Шорткаты (Cmd/Ctrl+что-то), стрелки, Tab, Enter, Esc → пропускаем.

Курсор автоматически держим в конце display'а (после фокуса/клика/изменения). Paste обрабатывается отдельно через onPaste с нормализацией. onChange = no-op (React-warning заглушка).

Бенефиты: Backspace теперь всегда удаляет цифру, без оглядки на позицию. Не-цифры физически невозможно ввести. Префикс «+7 » никогда не повреждается.
2026-05-03 16:56:52 +05:00
nurdotnet 16fe7580af fix(phone): блокировать ввод не-цифр на уровне keyDown
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m4s
CI / Web (React + Vite) (push) Failing after 37s
Docker Public / Build + push Public (push) Successful in 26s
Docker Web / Build + push Web (push) Failing after 26s
Docker Web / Deploy Web on stage (push) Has been skipped
Docker Public / Deploy Public on stage (push) Successful in 10s
Предыдущий fix не покрывал кейс когда курсор оказывался не сразу после «+7 » (в начале строки или в середине). parseRawInput не находил префикс на нужной позиции и «7» снова попадала в подсчёт.

Корректное решение: блокировать любой не-цифровой символ через preventDefault в onKeyDown — тогда он вообще не доходит до input, парсер видит только валидные цифры. Сохранены: Backspace/Delete (с защитой префикса), стрелки и навигация, Cmd/Ctrl+A/C/V/X для копи-пасты, Enter для submit. Paste произвольной строки по-прежнему работает через onChange.
2026-05-03 16:15:27 +05:00
nurdotnet 47c349818f fix(phone): не считать «7» из префикса как введённую цифру
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m5s
CI / Web (React + Vite) (push) Failing after 37s
Docker Public / Build + push Public (push) Successful in 27s
Docker Web / Build + push Web (push) Failing after 27s
Docker Web / Deploy Web on stage (push) Has been skipped
Docker Public / Deploy Public on stage (push) Successful in 11s
Баг: при нажатии любой не-цифры (буквы, пробела) поле подставляло «7». Корень: extractDigits парсил полное e.target.value включая префикс «+7», и его «7» попадала в d, добавляясь как введённый юзером символ. Backspace на пустом поле не работал по той же причине.

Фикс: новая функция parseRawInput отделяет префикс «+7 » ДО извлечения цифр. Применена в handleChange обоих PhoneInput (web + public). Кейс paste без префикса (8XXXXXXXXXX / 7XXXXXXXXXX из 11 цифр) сохранён.
2026-05-03 15:56:27 +05:00
nurdotnet 2301446b06 feat(phone): единый PhoneInput с зашитым «+7» и ФЛК Казахстана
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m1s
CI / Web (React + Vite) (push) Failing after 37s
Docker Public / Build + push Public (push) Successful in 27s
Docker Web / Build + push Web (push) Failing after 25s
Docker Web / Deploy Web on stage (push) Has been skipped
Docker Public / Deploy Public on stage (push) Successful in 10s
Поле телефона во всех формах (web + public) теперь использует общий компонент:
- Префикс «+7 » всегда виден и не удаляется (Backspace/Delete на позиции ≤3 блокируется).
- Принимаются только цифры (буквы и спецсимволы автоматически отфильтровываются).
- Авто-форматирование при вводе: «+7 7XX XXX XX XX».
- Paste произвольного формата нормализуется (поддерживается ведущая «8», «+7…», скобки, дефисы).
- Наружу через onChange отдаётся каноничное «+7XXXXXXXXXX».

Подключено в:
- food-market.public/SignupForm
- food-market.web/CounterpartiesPage (контрагенты)
- food-market.web/EmployeesPage (сотрудники)
- food-market.web/SuperAdminOrgEmployeesPage (управление сотрудниками SuperAdmin)
- food-market.web/SuperAdminOrgCreatePage (создание организации SuperAdmin)
2026-05-03 11:01:55 +05:00
nurdotnet 36b4fb1b31 fix(super-admin): убрать «моргание» при клике на орг — переход теперь по double-click
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m7s
CI / Web (React + Vite) (push) Successful in 39s
Docker Web / Build + push Web (push) Successful in 33s
Docker Web / Deploy Web on stage (push) Successful in 11s
В списке организаций SuperAdmin одиночный клик навигировал на /super-admin/organizations/{id} и страница успевала отрисоваться → визуальное моргание. Изменил поведение:

- DataTable теперь поддерживает onRowDoubleClick параллельно onRowClick.
- SuperAdminOrganizationsPage: убран single-click navigate; double-click → setOrgOverride + переход на /dashboard этой орги (вход в её личный кабинет).
- Кнопки в колонке действий не задеты — у них уже stopPropagation.
2026-05-03 02:50:55 +05:00
nns 0f59dfee69 feat(brand): новый логотип food-market wordmark + apple mark
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m23s
CI / Web (React + Vite) (push) Successful in 38s
Docker Public / Build + push Public (push) Successful in 28s
Docker Web / Build + push Web (push) Successful in 33s
Docker Public / Deploy Public on stage (push) Successful in 10s
Docker Web / Deploy Web on stage (push) Successful in 11s
Источник — assets/brand/food-market-source.svg (1080x1080, FOOD #0DB70D
с яблоком вместо O, MARKET #4C4F54). Из source извлечены векторные
пути (Adobe-preview PNG-image теги вырезаны), собраны три варианта,
оптимизированы svgo.

Варианты в public/ обоих пакетов (food-market.web, food-market.public):
- logo.svg          (2.1 KB) — full wordmark для светлого фона
- logo-light.svg    (2.1 KB) — full wordmark белым (для тёмного фона)
- favicon.svg       (1.0 KB) — mark-only: яблоко в зелёном круге,
                              читается с 16px (Astro favicon, web favicon)

Дополнительно (PWA maskable, food-market.web):
- logo-bg.svg       (140 B) — solid #0DB70D 456x456 фон
- logo-fg.svg       (1.0 KB) — белое яблоко на прозрачном, в safe-zone

Logo-компоненты переписаны:
- Logo.tsx (web) — было div+span с FOOD/MARKET буквами; теперь
  <img src=/logo.svg|/logo-light.svg /> с одним пропом variant.
  Сохранён API: variant=light|dark, className.
- Logo.astro (public) — то же; добавил пропс class для override-а.

Header.astro public-пакета использует <Logo />, не содержит
hardcoded текста — менять не нужно.

Все файлы под 2.2 KB после svgo.
2026-05-02 00:26:42 +05:00
nns 79406e304e revert(domains): публичный сайт → test.food-market.kz, apex 404 до релиза
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 53s
CI / Web (React + Vite) (push) Successful in 38s
Docker API / Build + push API (push) Successful in 1m9s
Docker Public / Build + push Public (push) Successful in 28s
Docker Web / Build + push Web (push) Successful in 33s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Public / Deploy Public on stage (push) Successful in 10s
Docker Web / Deploy Web on stage (push) Successful in 11s
Публичный сайт ещё в разработке — выносим его с food-market.kz на
test.food-market.kz. На корневом домене food-market.kz пока 404.
admin.food-market.kz остаётся как есть.

— Заменены https-URL в src/** и deploy/:
  https://food-market.kzhttps://test.food-market.kz
  (admin.food-market.kz, app.food-market.kz и emails @food-market.kz
  не трогаем — sed строго по https-префиксу).
— public Dockerfile ARG PUBLIC_SITE_URL → test.food-market.kz.
— SignupForm/Header/NoOrganizationPage указывают на admin.food-market.kz
  для API (без изменений с прошлого коммита).
— appsettings.json CORS: test + admin.food-market.kz.

Nginx (на сервере):
- /etc/nginx/conf.d/test.food-market.kz.conf — новый, серт LE issued.
- food-market.kz.conf — apex теперь 404 (HTTPS), серт переиспользует
  пару (food-market.kz + admin.food-market.kz).
- food-market.zat.kz и app.food-market.zat.kz — 301 на test/admin
  соответственно.

Smoke: test/, /signup/, admin/health, admin/login = 200; apex = 404;
zat → test/admin 301; sitemap.xml отдаёт https://test.food-market.kz/.
2026-05-01 18:06:31 +05:00
nns 58df887f1c feat(domains): миграция на food-market.kz / admin.food-market.kz
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 58s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m38s
Docker Public / Build + push Public (push) Successful in 49s
Docker Web / Build + push Web (push) Successful in 37s
Docker API / Deploy API on stage (push) Successful in 18s
Docker Public / Deploy Public on stage (push) Successful in 9s
Docker Web / Deploy Web on stage (push) Successful in 11s
- Заменил все хардкоды URL в src/** и deploy/:
  food-market.zat.kz       → food-market.kz       (публичный сайт)
  app.food-market.zat.kz   → admin.food-market.kz (админ-API + SPA)
- public/SignupForm и Header: дефолт PUBLIC_APP_URL теперь
  https://admin.food-market.kz (раньше указывал на сам публичный домен,
  что было багом — фронт стучался не туда после переезда зон).
- public/Dockerfile ARG PUBLIC_APP_URL → admin.food-market.kz.
- API appsettings.json CORS — оставил только два прода-origin (localhost
  для dev живёт там же).
- Program.cs: добавил opts.SetIssuer(uri) если задан OpenIddict:Issuer
  в конфиге — иначе iss вычислялся из текущего HTTP-запроса и ломался
  при nginx-прокси без X-Forwarded-Proto.
- docker-compose стейджа: env OpenIddict__Issuer=https://admin.food-market.kz/
  + Cors__AllowedOrigins[0,1].

Nginx (на сервере, не в репе):
- /etc/nginx/conf.d/food-market.kz.conf, admin.food-market.kz.conf —
  новые конфиги с certbot-выданными сертификатами на оба домена
  (LetsEncrypt --webroot, действителен до 2026-07-29).
- Старые food-market.zat.kz / app.food-market.zat.kz переведены в
  301-редирект на новые домены (HTTP+HTTPS), серты zat.kz пока
  оставлены чтобы handshake шёл нормально.
2026-04-30 13:56:51 +05:00
nns 3849cb3547 feat(super-admin): полное управление сотрудниками любой орги
Some checks failed
CI / Backend (.NET 8) (push) Successful in 47s
CI / Web (React + Vite) (push) Successful in 41s
Docker API / Build + push API (push) Successful in 1m10s
Docker Web / Build + push Web (push) Successful in 32s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 12s
CI / POS (WPF, Windows) (push) Has been cancelled
API: SuperAdminEmployeesController на /api/super-admin/organizations/{orgId}/employees
- GET (с пагинацией, поиском, includeInactive=true по умолчанию)
- GET /{id} — детали + флаги isOwner/hasAccount/accountActive
- POST — создать сотрудника, опционально с учёткой (генерация temp password)
- PUT /{id} — изменить ФИО/контакты/роль/активность БЕЗ tenant-гардов
  (можно править главного администратора)
- DELETE /{id}?reason=… — soft-delete; если удаляем главного администратора —
  снимаем org.AccountOwnerUserId
- POST /{id}/toggle-active — активировать/деактивировать запись Employee
- POST /{id}/account/toggle-active — заблокировать/разблокировать AppUser
  (revoke valid OpenIddictTokens при блокировке)
- POST /{id}/reset-password — сгенерировать новый temp password,
  revoke все active токены, вернуть one-shot

Все мутации требуют reason ≥ 10 символов и пишутся в SuperAdminAuditLog
(actionType: SA_CreateEmployee / SA_EditEmployee / SA_ActivateEmployee /
SA_DeactivateEmployee / SA_ActivateAccount / SA_DeactivateAccount /
SA_ResetPassword / SA_DeleteEmployee). Эндпойнты [Authorize(Roles=SuperAdmin)],
обходят tenant-фильтр через IgnoreQueryFilters().

Web: новая страница SuperAdminOrgEmployeesPage по
`/super-admin/organizations/:id/employees`. Таблица сотрудников орги
(включая неактивных), бейдж «Главный администратор», статус учётки
(активна/заблокирована/нет). Иконки: редактировать, сбросить пароль,
блокировка учётки, активность сотрудника. Каждое действие открывает
модалку с обязательным полем «Причина» (≥10 символов) — она уходит
в audit-log. Сгенерированный пароль показывается one-shot с copy-кнопкой.

Кнопка «Сотрудники» (Users-icon) добавлена в actions колонку
SuperAdminOrganizationsPage — переход на страницу прямо из списка орг.
2026-04-28 14:21:14 +05:00
nurdotnet f54c8bb5b7 feat(ui): AsyncSelect — серверный поиск в дропдаунах вместо pageSize=500
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m5s
CI / Web (React + Vite) (push) Successful in 41s
Docker Web / Build + push Web (push) Successful in 32s
Docker Web / Deploy Web on stage (push) Successful in 12s
Добавлен AsyncSelect в Field.tsx: дебаунс 300ms, fetch ?search=&pageSize=20,
поддержка onCreate, getLabel для кастомного отображения (path у групп товаров).

Переведены на AsyncSelect:
- SupplyEditPage: поставщик
- RetailSaleEditPage: покупатель
- ProductEditPage: группа товара
- ProductQuickCreateModal: группа товара

useLookups/useOrgInfra: pageSize 500→100 для малых справочников.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 14:13:29 +05:00
nns c0824518ab feat(employees): главный администратор — терминология + защита роли/активности
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 45s
CI / Web (React + Vite) (push) Successful in 40s
Docker API / Build + push API (push) Successful in 1m7s
Docker Web / Build + push Web (push) Successful in 32s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 12s
— «Владелец» переименован в «Главный администратор» — терминологически
  у нас не «собственник», а тот кто управляет организацией.
  Бейдж в таблице, тексты модалок, серверные сообщения 403 — везде
  единая формулировка.

— PUT /api/organization/employees/{id}: добавлен гард для главного
  администратора:
  · Смена RoleId на не-«Администратор» → 403 «Нельзя сменить роль…»
  · IsActive=false → 403 «Нельзя деактивировать…»
  Раньше юзер мог поменять себе роль на Кладовщик и получить бейдж
  «Владелец» с ролью кладовщика — несостыковка.

— EmployeesPage: при редактировании главного администратора
  · Селект ролей disabled + amber-плашка-объяснение «роль фиксирована»
  · Чекбокс «Активен» disabled + текст «нельзя деактивировать»
  · save() ловит ошибки и показывает их в общей модалке (раньше 403
    «тихо проваливалось» — модалка зависала).

— recovery-restore-orphan-owners.sql добавлен блок: для всех
  Organizations где главный администратор имеет роль не-«Администратор»
  или IsActive=false → восстанавливает «Администратор» и активирует.
  Идемпотентен. Применён на стейдже (0 пострадавших — текущая БД ОК).

Все изменения главного администратора (роль, ФИО, удаление, передача
управления) по архитектурному решению юзера должны идти через очередь
запросов к Супер-администратору платформы. Эта подсистема — отдельная
большая фича (RequestType / RequestQueue / SuperAdmin approval UI),
её план описан в TG-ответе.
2026-04-27 19:12:33 +05:00
nns 2691b7d78b feat(employees): бейдж «Владелец» + блокировка удаления с объяснением
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 45s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 1m8s
Docker Web / Build + push Web (push) Successful in 32s
Docker API / Deploy API on stage (push) Successful in 18s
Docker Web / Deploy Web on stage (push) Successful in 12s
Жалоба юзера: «нажимаю удалить владельца магазина — диалог "удалить
сотрудника?" — нажимаю — ничего не происходит». Раньше кнопка «Удалить»
для Owner была доступна, на сервере отвечала 403 с понятным сообщением,
но фронт ошибку не ловил — модалка зависала.

— EmployeeDto теперь возвращает isOwner (Org.AccountOwnerUserId ==
  Employee.UserId) и isSelf (UserId текущего залогиненного юзера).
  List + Get обновлены: подгружают AccountOwnerUserId и текущий sub
  из JWT, проставляют флаги в проекции.

— Таблица сотрудников: рядом с ФИО владельца — бейдж «Владелец»
  (amber-100/800).

— Кнопка «Удалить» в модалке редактирования:
  · disabled для Owner и для self с tooltip-объяснением;
  · клик по disabled-кнопке через onClick-handler показывает спец-
    модалку: «Нельзя удалить владельца магазина — сначала передайте
    управление другому пользователю. Организация не может остаться
    без владельца. Удалить или деактивировать саму организацию может
    только Супер-администратор платформы.»;
  · self-delete объясняется отдельным текстом (Настройки → Аккаунт →
    Покинуть организацию);
  · обычное удаление — confirm с именем сотрудника и пояснением что
    это soft-delete (деактивация).
  · 403/любая ошибка от сервера ловится в try/catch и показывается
    в той же модалке «Не удалось удалить» — больше не «ничего не
    происходит».

Smoke: API эмплоя возвращает isOwner=true,isSelf=false для Owner'а в
override-режиме SuperAdmin'а.
2026-04-27 18:58:08 +05:00
nns 691448201d fix: пароль/orphan signup/tenant-guard toast/dashboard счётчик
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 48s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 1m9s
Docker Public / Build + push Public (push) Successful in 41s
Docker Web / Build + push Web (push) Successful in 31s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Public / Deploy Public on stage (push) Successful in 11s
Docker Web / Deploy Web on stage (push) Successful in 12s
- validation: убрана клиентская проверка «должна быть заглавная/цифра»
  — она расходилась с серверной политикой Identity и блокировала
  валидные пароли. Серверный Identity сам валидирует и возвращает
  конкретное сообщение в общий error-bar формы. Клиент проверяет
  только длину 8.

- /api/auth/signup: если AppUser с таким email уже есть, но он
  orphan (org удалена / архивирована / IsActive=false) — реактивируем
  его и привязываем к новой org вместо отказа «уже зарегистрирован».
  SuperAdmin не реактивируется (ему сценарий неактуален).
  Пароль перезаписывается на тот, что юзер ввёл сейчас.

- TenantRouteGuard: убран alert «Откройте конкретную организацию через
  Открыть как…». На каждом редиректе SuperAdmin'а из tenant-роута
  всплывал window.alert — раздражал. Поведение редиректа сохранено.
  AppLayout чистит legacy ключ из sessionStorage.

- SuperAdmin dashboard: KPI «Пользователей» теперь показывает количество
  активных, а в подсказке — сколько деактивированных. Раньше показывал
  total (включая orphan) — юзер видел «2», но в реальных списках их не
  было, потому что orphan-юзеры уже деактивированы.
2026-04-27 18:38:56 +05:00
nns 633bdf3ef0 fix(auth): закрыть критические дыры — orphan login, self-delete, owner-delete, override-баннер
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 46s
CI / Web (React + Vite) (push) Successful in 41s
Docker API / Build + push API (push) Successful in 1m12s
Docker Web / Build + push Web (push) Successful in 31s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 12s
Аудит 2026-04-27. Полный отчёт — docs/audit-2026-04-27.md.

Что закрыто:

— /connect/token (AuthorizationController) теперь отказывает в login если
  AppUser привязан к удалённой/архивной Organization. SuperAdmin обходит
  проверку (ему org не нужна). Жалоба: nurnetps@gmail.com мог логиниться
  после удаления своей org из SuperAdmin консоли.

— SuperAdminOrganizationsController.Delete (DELETE org) каскадно
  деактивирует всех AppUser привязанных к этой org (IsActive=false,
  OrganizationId=null) и помечает Status='revoked' для всех их
  OpenIddictTokens. Раньше Org удалялась, а юзеры оставались валидными
  с активными refresh-tokens на 30 дней.

— EmployeesController.Delete теперь soft-delete (IsActive=false,
  FiredAt). Запрещены: 403 если попытка удалить себя; 403 если
  попытка удалить Owner (Organization.AccountOwnerUserId ==
  employee.UserId). Сообщения с инструкцией («передайте права»,
  «покинуть через настройки»).

— /api/me возвращает hasLiveOrg и hasActiveEmployee — frontend
  использует это для редиректа на /no-organization вместо белого экрана.

— Новая страница /no-organization (NoOrganizationPage) — fallback для
  orphan AppUser. CTA: создать новую org через публичный /signup
  или попросить инвайт. Кнопка «выйти». TenantRouteGuard редиректит
  orphan юзеров туда.

— SuperAdminAsOrgBanner: добавлена проверка через useMe — баннер
  рендерится только если у текущего юзера есть Identity-роль
  SuperAdmin. Lingering localStorage override от прошлой сессии
  (другой юзер логинился до этого) автоматически чистится.

— auth.ts: clearTokens() теперь сбрасывает superAdminAsOrg и
  superAdminEditMode. login() вызывает clearTokens() ПЕРЕД запросом
  чтобы новый юзер не унаследовал override-состояние от предыдущего.

— deploy/recovery-restore-orphan-owners.sql — идемпотентный скрипт
  деактивирующий уже накопленных orphan AppUser (как nurnetps) и
  revoke их токены. Применён на стейдже: 1 user деактивирован,
  9 токенов revoked.

— deploy/Dockerfile.api: убран `--no-restore` из publish — два
  раздельных шага роняли build с NETSDK1064 на свежих analyzer-
  зависимостях, теперь restore идёт внутри publish.

Smoke (стейдж):
- nurnetps@gmail.com /connect/token → invalid_grant.
- admin@food-market.local /connect/token → access_token выдан.
- food-market.zat.kz/, /signup/, app.../login, /health → 200.
2026-04-27 09:28:18 +05:00
nns ff991a7101 feat(validation): русская локализация и строгий email с TLD
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m2s
CI / Web (React + Vite) (push) Successful in 39s
Docker Public / Build + push Public (push) Successful in 31s
Docker Web / Build + push Web (push) Successful in 33s
Docker Public / Deploy Public on stage (push) Successful in 11s
Docker Web / Deploy Web on stage (push) Successful in 12s
— Общий модуль `src/lib/validation.ts` в обоих пакетах (public + web):
  validateEmail (требует TLD ≥2 букв), validatePassword (8+, буква+цифра),
  validatePhone (+7/8, 10 цифр), validateRequired, и
  localizeNativeValidation — слушатель invalid/input для setCustomValidity
  на русском (required, typeMismatch, patternMismatch, tooShort и т.д.).

— public SignupForm: noValidate, валидация на onSubmit с русскими ошибками
  под каждым полем; placeholder email→ "name@example.kz", phone→ "+7 700
  123 45 67". Старый HTML5 required+minLength+type=email убран —
  теперь всё через нашу схему.

— web LoginPage: noValidate + локальная проверка email/password перед
  login(); красная подсказка появляется в самом поле.

— web Field/TextInput: useEffect с localizeNativeValidation на ref —
  все остальные админские формы (SuperAdminSetup, SuperAdminOrgCreate,
  EmployeesPage, CounterpartiesPage) автоматически получают русские
  нативные подсказки через общий компонент, без правки каждой страницы.

Acceptance: на /signup/ ввод "name@domain" без TLD блокируется
сообщением «Email должен содержать доменную зону…». Нативные браузерные
подсказки больше не на английском.
2026-04-27 08:29:22 +05:00
nns ad09d48a89 feat(public): Phase 6 — публичный маркетинговый сайт food-market.public на Astro
Новый пакет src/food-market.public/ — отдельный фронт для маркетинга и
самостоятельной регистрации магазинов-клиентов в SaaS Food Market.
Существующая админка food-market.web НЕ затронута.

Стек: Astro 4 + React 19 islands + Tailwind v3 (палитра идентична
food-market.web — единый бренд), TypeScript 6, content collections для
юр.документов. Static-сайт через nginx, gzip + immutable cache на assets.

Карта страниц (23):
- /                            — главная (Hero + 3 выгоды + скриншот +
                                   6 вертикалей + 6 модулей + Касса +
                                   Интеграции + 3 тарифа + соцпруф +
                                   FAQ + финальный CTA)
- /features                    — модули по сценариям
- /pricing                     — тарифы + интерактивный конструктор
                                   «Бизнес» (per-unit: 10000 база +
                                   2000/магазин + 500/касса + 500/склад,
                                   слайдеры, передача params в /signup)
- /pos                         — УТП лендинг кассы для Windows + весов
- /migration-from-other-system     — УТП лендинг миграции с сторонняя система
                                   (сравнительная таблица + 3 шага)
- /integrations                — список интеграций
- /for-grocery|pharmacy|cafe|alcohol|clothing|household — 6 вертикалей
                                   с уникальными фишками (весовой,
                                   серии/сроки, модификаторы и комбо,
                                   акцизные марки, размерные сетки,
                                   гарантийные сроки)
- /signup                      — регистрация (React-island форма)
- /about /contacts /kb /blog /status /changelog — компания + ресурсы
- /legal/{offer,privacy,consent,requisites} — реальные юр.документы
                                   из /tmp/legal/ как Astro content
                                   collection (markdown с frontmatter,
                                   динамический [slug].astro template,
                                   720px max-width, line-height 1.7,
                                   prose-legal стили)
- /sitemap.xml                 — ручной генератор (sitemap-плагин
                                   конфликтует с Astro 4.16, заменён
                                   на простой APIRoute)

React-острова (3):
- BusinessTariffBuilder — слайдеры + расчёт total + ссылка на signup
- SignupForm — email/password/orgName/phone/plan + валидация + agree
- FAQ — accordion 7 вопросов

API: новый POST /api/auth/signup создаёт Organization + AppUser
(Identity Admin role) + Owner Employee + полный bootstrap через
DevDataSeeder.SeedTenantReferencesAsync (units, price-types, store,
cassa, 6 ролей). Токены НЕ выпускает — фронт сразу делает обычный
/connect/token (password grant) и получает access/refresh без
дублирования OpenIddict-логики. На signup-форме — auth-bridge:
токены передаются через URL fragment в админку
APP_URL/auth-bridge#access=...&refresh=...&welcome=1, AuthBridgePage
кладёт в localStorage и редиректит на /?welcome=signup.

URL-домены через env-переменные (юзер ещё выбирает финальный):
- PUBLIC_SITE_URL — canonical/OG/sitemap (default https://food-market.kz)
- PUBLIC_APP_URL — admin/API endpoint (default https://food-market.zat.kz)
Nginx-конфиг для деплоя сайта — заготовка-template в
deploy/nginx/food-market-public.conf.template, не применён —
ждёт решения по домену.

Dockerfile multi-stage (node:20-alpine build → nginx:1.27 runtime),
build-args PUBLIC_SITE_URL/PUBLIC_APP_URL, deploy/nginx.conf gzip +
immutable cache + try_files для pretty URLs.

SEO: OG-теги, twitter-card, canonical, JSON-LD SoftwareApplication
схема, robots.txt → sitemap-index, lang=ru-KZ.

Admin-side: /auth-bridge route в food-market.web — принимает токены
из URL fragment, кладёт в localStorage (fm.access_token / fm.refresh_token),
редиректит на /. Fragment чтобы access_token не попадал в Referer.

23 страницы билдятся без ошибок. Контейнер собирается. Деплой на
конкретный домен — отдельным шагом после решения юзера.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:11:34 +05:00
nns fc3f63c49a feat(super-admin): настраиваемый retention period для архивных орг
Раньше «удалить орг навсегда» было захардкожено на 30 дней архива.
Теперь — глобальная системная настройка SuperAdmin'а.

Domain/DB:
- SystemSettings : Entity (single-row table system_settings).
  Поле ArchiveRetentionDays (int, default 30). Структура расширяется
  именованными полями по мере необходимости — без key-value generic'а.
- Migration Phase4e_SystemSettings создаёт таблицу с default 30.
- DevDataSeeder: при первом старте создаёт single-row дефолт.

API:
- GET /api/super-admin/settings — текущие настройки.
- PUT /api/super-admin/settings — обновить с валидацией [0..3650].
  Audit-log запись ActionType=EditSystemSettings с before/after.
- SuperAdminOrganizationsController.Delete: хардкод 30 заменён
  чтением SystemSettings.ArchiveRetentionDays. При retention=0 —
  удаление доступно сразу после архивации.

UI:
- /super-admin/settings — страница «Системные настройки».
  Select из 6 опций (0/1/3/7/14/30), warning-баннер при выборе
  «Немедленно». Кнопка «Сохранить» disabled пока нет изменений.
- В SuperAdminLayout убрана пометка «скоро» с пункта «Системные
  настройки» — раздел активен.
- SuperAdminOrganizationsPage: кнопка «Удалить навсегда» теперь
  читает retentionDays из API; tooltip показывает оставшиеся дни
  «Доступно через X дн. (retention N)»; при retention=0 — всегда
  active для архивных орг.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:59:24 +05:00
nns 2cadb6e5d9 fix(super-admin): убрать цикл редиректа, регресс override после пакета задач
КОРНЕВАЯ ПРИЧИНА: SuperAdminLayout имел useEffect

  if (getOrgOverride()) navigate('/dashboard', { replace: true })

который автоматически выкидывал в tenant-режим при любом заходе на
/super-admin/* если в localStorage остался override (например с прошлой
сессии или после нечистого выхода). Это создавало цикл: SuperAdmin
кликает «Системная консоль» в меню → попадает на /super-admin/... →
useEffect видит override → navigate /dashboard → tenant TenantRouteGuard
проверяет: override есть → пропускает, но юзер видит tenant с баннером
вместо системной консоли.

Если override содержал «зависший» orgId с прошлой сессии и юзер ожидал
вернуться в SuperAdmin консоль — он попадал в tenant-режим, а попытки
вернуться через ссылки sidebar лечились этим же useEffect снова в /
dashboard. Юзер видел тост от TenantRouteGuard в редких случаях когда
localStorage не успевал прочитаться (race с rAF).

Фикс:
- Удалён useEffect (+ unused useNavigate импорт). SuperAdmin может
  свободно перемещаться между системной консолью и tenant-режимом
  при активном override; снимает override только баннером «Выйти из
  режима» либо переключает другую орг через picker.
- TenantRouteGuard: повторная проверка getOrgOverride() через
  requestAnimationFrame (защита от любых race с localStorage), и
  разовый guard-флаг (useRef) чтобы не редиректить дважды на одной
  странице. Console.warn перед редиректом — облегчает диагностику
  если регресс повторится.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:54:37 +05:00
nns 5c5b231157 feat(directories): двухуровневые справочники Группы и Ед.измерения (системные + tenant)
Концепция: ProductGroup и UnitOfMeasure становятся двухуровневыми
справочниками. Системные эталонные записи (OrganizationId=NULL,
управляются SuperAdmin'ом) видны всем tenant'ам как «Эталон»
и read-only. Tenant'овские (OrganizationId=<orgId>) — обычная изоляция,
полный CRUD у админа орги.

Архитектура:
- IOptionalTenantEntity { Guid? OrganizationId } — новый интерфейс
  в Domain/Common. ProductGroup и UnitOfMeasure отнаследованы от
  Entity и реализуют его.
- AppDbContext.ApplyOptionalTenantFilter<T>: query-filter для
  IOptionalTenantEntity пропускает запись с OrganizationId=NULL для
  всех tenant'ов + tenant'овские по выбранной orgId. SuperAdmin без
  override видит всё, в override — только NULL+своё.
- StampTenant: при Add для IOptionalTenantEntity — null оставляется
  если SuperAdmin без override (системная), иначе подставляется
  tenant.OrganizationId.
- Миграция Phase4d_OptionalTenantOnDirectories: ALTER COLUMN
  OrganizationId DROP NOT NULL на product_groups и units_of_measure.
  Existing данные FOOD MARKET (11 групп, 5 единиц) сохраняются как
  tenant'овские — additive change, ничего не теряется.
- DTO: UnitOfMeasureDto и ProductGroupDto получили nullable
  OrganizationId; фронт читает его для показа badge «Эталон».
- Защита мутаций: PUT/DELETE контроллеры теперь возвращают Forbid()
  если запись OrganizationId=null и юзер не SuperAdmin (только
  суперадмин может править/удалять системные).

Frontend:
- Badge «Эталон» (indigo) рядом с именем системной записи в обеих
  страницах.
- Клик по строке системной записи → alert «Изменения недоступны…».
- SuperAdmin sidebar: новые пункты «Группы (эталон)» (FolderTree)
  и «Ед. измерения (эталон)» (Ruler) под «Справочники». Страницы
  реиспользуют существующие компоненты — для SuperAdmin без override
  фильтр возвращает все записи, что в Phase 4+ можно ужесточить
  отдельным эндпоинтом «только системные» (?orgId=null).

Decision (нонстоп-выбор по ТЗ): nullable OrganizationId через
IOptionalTenantEntity, не sentinel Guid.Empty — чище, безопаснее,
ясная семантика. Существующие группы FOOD MARKET НЕ мигрированы в
системные (как просил юзер) — пусть SuperAdmin сам создаст эталоны.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:20:47 +05:00
nns bd2800837f feat(roles): системные роли read-only + русские имена + чистка дубликата у admin
Концепция: «Супер администратор» — платформенная Identity-роль
SuperAdmin. «Администратор» — организационная роль внутри Employee
(IsSystem=true в EmployeeRole). Они НЕ должны дублироваться у
одного юзера.

- Сидер: admin@food-market.local получает только Identity-роль
  SuperAdmin. Догоняющая ветка для существующих стендов:
  если есть Identity-роль Admin — RemoveFromRoleAsync. На стенде
  AspNetUserRoles почищен SQL'ом.
- AppLayout: translateRoles() переводит SuperAdmin → «Супер
  администратор», скрывает Identity-роль Admin (org-уровень
  показывается через Employee/Role, не через Identity).
- EmployeeRolesPage: клик по строке системной роли → alert
  «Системная роль, изменения недоступны». Edit-модалка для
  системных была частично defensive (disabled чекбоксы Phase 2c),
  теперь точка входа закрыта целиком. Кастомные роли — без изменений.

EmployeeRole.IsSystem поле уже было — миграция не нужна.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:11:14 +05:00
nns 8d72e9da2d feat(super-admin): перенести справочник Стран в системную консоль
Country — глобальный справочник (Entity, не TenantEntity), магазины-
клиенты выбирают страны из готового списка но не управляют ими.
Управление переносится в SuperAdmin консоль.

Изменения:
- API: POST/PUT /api/catalog/countries теперь Authorize(Roles=SuperAdmin)
  (раньше был SuperAdmin,Admin). DELETE и так был SuperAdmin.
- GET остаётся [Authorize] без роли — нужен tenant'у для селектов в
  формах создания орги/контрагентов/товаров.
- Tenant AppLayout: убран блок «Справочники» с пунктом «Страны».
  Иконка Globe больше не импортируется в tenant-меню.
- Tenant роут /catalog/countries удалён из App.tsx.
- В OrganizationSettingsPage ссылка «откройте справочник Страны»
  заменена на текст «обратитесь к администратору платформы».
- SuperAdminLayout: новый блок «Справочники» с пунктом «Страны»
  (/super-admin/countries). Иконка Globe.
- Роут /super-admin/countries использует существующий CountriesPage —
  компонент unchanged, страница теперь рендерится в SuperAdminLayout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:09:02 +05:00
nns 21c2ca89fe ui(super-admin): SaaS-метрики на главной системной консоли (placeholders)
Food Market — SaaS для розницы, SuperAdmin это владелец платформы,
а не сотрудник магазина. Операционные метрики магазинов («Товаров
29540», «Приёмок за месяц») для него бесполезны — это для tenant
dashboard'а конкретной орги.

KPI блок на /super-admin переработан под кабинет SaaS-провайдера:

Top row (4 карточки):
- Организаций (как было — клиентская база)
- Платящих клиентов — placeholder, accent emerald, muted
- MRR (₸ / мес) — placeholder, accent violet, muted
- Должники — placeholder, accent rose, muted

Second row (2 карточки):
- Пользователей (всего/активных)
- Регистраций за 30 дней — реальное значение
  (COUNT Organizations WHERE CreatedAt >= now-30d)

Заглушки получили проп muted=true: фон чуть серее (slate-50/60),
значение «—» более бледным slate-400, иконка остаётся полноцветной
чтобы было видно «здесь будут данные после Phase 4». В hint —
«Скоро · после внедрения биллинга».

API: DashboardStats потерял TotalProducts/TotalSuppliesThisMonth,
получил RegistrationsLast30Days.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:47:13 +05:00
nns 7c161a138b feat(super-admin): рабочий quick-switch + UI-блокировка мутаций в read-only
БАГ: dropdown «Открыть организацию» в topbar системной консоли вёл
сначала на reload текущего /super-admin, а оттуда внутренний редирект
конкурировал с TenantRouteGuard'ом, и юзер видел alert «Откройте
через "Открыть как…"». Фикс — setOrgOverride получил опциональный
{ redirectTo }, делает window.location.assign(target) вместо reload.
Точки вызова обновлены:
- dropdown в SuperAdminLayout topbar → redirectTo='/dashboard'
- кнопка «Открыть как» в строке таблицы орг → redirectTo='/dashboard'
- кнопка «Выйти из режима» в баннере → redirectTo='/super-admin/organizations'

Хук useReadOnly() централизует «override активен И edit-mode не
включён» в один { readOnly, reason }. Button по умолчанию считает
variant='primary' и variant='danger' мутирующими (опт-аут через
mutating={false} для редких primary-без-мутаций) — в read-only они
автоматически disabled с tooltip «Только просмотр. Включите
редактирование в баннере…». Variant='secondary' и 'ghost' не
блокируются — Cancel/Назад/Закрыть остаются кликабельны. Серверный
403 (ReadonlyOverrideMiddleware) остаётся как safety-net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:32:29 +05:00
nns 9d8fd2cb53 ui(super-admin): hero-блок и KPI карточки на главной + три полезных секции
/super-admin переработан:
- Hero на indigo-градиенте: badge «Super Admin Console» + крупный H1
  «Системная консоль» + подзаголовок + env+build справа.
- 4 KPI карточки с цветным круглым фоном иконки (indigo/sky/
  emerald/amber), label uppercase tracking, значение крупно,
  hint мелко серым; hover-shadow.
- Три карточки в ряд (lg:3 cols) вместо дублирующих ссылок-CTA:
  «Здоровье системы» (заглушки /Activity для скорых проверок),
  «Активные организации» (топ-3 по объёму, ссылка «все»),
  «Свежие события» (6 последних audit-записей или empty state
  с Inbox-иконкой и текстом «Журнал пуст. Здесь появятся события»).
- Кнопка «Создать организацию» убрана с главной — оставлена
  только на /super-admin/organizations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:32:29 +05:00
nns 4d4a3a4786 ui(super-admin): читаемый логотип на тёмном sidebar
<Logo> получил пропс variant="dark": в нём «FOOD» рендерится белым
(slate-50) вместо чёрного, «MARKET» — emerald-400 (вместо var brand)
для контраста на indigo-950 фоне SuperAdminLayout. SuperAdminLayout
прокидывает variant="dark" в обоих местах (desktop sidebar + mobile
header). Светлый вариант остался по умолчанию для tenant AppLayout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:32:29 +05:00
nns 8ae9f68119 feat(super-admin): Phase 2b — отдельный SuperAdminLayout и разделение от tenant-админки
SuperAdmin при логине больше не попадает на tenant Dashboard
(Каталог/Товары/Контрагенты/Выручка) — он стоит над всеми органами,
у него отдельная Системная консоль с системным sidebar и системным
дашбордом. Каждый из четырёх кейсов теперь визуально различим:

1. SuperAdmin → /super-admin (индиго-сайдбар, системные разделы:
   Главная / Организации / Пользователи (скоро) / Журнал /
   Здоровье/Бэкапы/Настройки (скоро)). В topbar — компактный
   «Открыть организацию ▼» dropdown для быстрого override.
   Title-suffix « · Super Admin» в browser-tab.
2. Обычный tenant-админ → /dashboard (как раньше).
3. SuperAdmin в режиме «открыть как…» → /dashboard с tenant
   sidebar'ом + amber-баннер сверху + слабая жёлтая тонировка фона
   body чтобы периферийным зрением было ясно «не моя админка».
4. SuperAdmin без override руками вбивает /products → TenantRouteGuard
   редиректит на /super-admin/organizations + alert «Откройте
   конкретную организацию через "Открыть как…"».

Изменения:
- SuperAdminLayout.tsx: индиго-палитра, Logo + badge «Super» + подпись
  «Системная консоль», 7 пунктов меню (5 active + 4 «скоро»),
  user-карточка с «Super Admin», topbar с org-picker dropdown,
  redirect на /dashboard если активен override.
- TenantRouteGuard.tsx: в useEffect проверяет (SuperAdmin && !override)
  и редиректит. Сообщение в sessionStorage для AppLayout, alert один раз.
- App.tsx: setup wizard вне layout'а; /super-admin/* через
  SuperAdminLayout (nested routes); остальное через TenantRouteGuard +
  AppLayout.
- AppLayout: убрана 3-пунктовая группа «Супер-админ» — оставил один
  пункт «Системная консоль» как точку возврата. Тонировка фона
  amber-50/60 при override.
- LoginPage: после успешного login — если me.roles.SuperAdmin и target
  это /, /dashboard → redirect на /super-admin.
- SuperAdminDashboardPage: блок «Последние события» — 10 записей
  audit-log + ссылка на полный журнал.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:13:47 +05:00
nns 6f61bbd974 ui(roles): warning «изменения применятся ко всем сотрудникам» при edit
Edit модалка для кастомной роли (не системной, существующей) теперь
показывает amber-плашку перед матрицей прав — напоминает что галка
тут касается N людей сразу. Системные роли — без плашки (там
disabled чекбоксы).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:08:13 +05:00
nns 4dd6b16eed feat(super-admin): Phase 3 — edit-mode с reason + audit-trail
В режиме «открыть как…» SuperAdmin может временно (на 30 минут)
включить редактирование с обязательной причиной — каждая успешная
мутация пишется в SuperAdminAuditLog. Чтобы лог был полезным:

API:
- Header X-Org-Override-Reason. Если присутствует и trimmed >= 10
  символов — ReadonlyOverrideMiddleware пропускает мутации (вместо 403).
- SuperAdminEditAuditFilter — глобальный IAsyncActionFilter после
  controller'а: при наличии обоих headers + успешном статусе 2xx +
  методе POST/PUT/PATCH/DELETE пишет запись ActionType=EditEntity
  с reason, описанием «METHOD /path → 200», IP и SuperAdminUserId.
  Регистрируется как Scoped + AddService<>() в AddControllers.

Web:
- enableEditMode(reason)/disableEditMode/getEditMode в lib/api.ts —
  хранение в localStorage с expiresAt = now + 30мин. Axios interceptor
  добавляет header только пока edit активен и не истёк.
- SuperAdminAsOrgBanner расширен: цвет меняется amber→red в edit-mode,
  кнопка «Включить редактирование» открывает модалку с textarea
  reason (≥10 символов) + чекбокс согласия на аудит. После активации
  баннер показывает «EDIT-MODE (N мин)», кнопка «Снять edit» отключает
  до истечения таймера.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:07:10 +05:00
nns b2d6584f09 feat(super-admin): Phase 2 — read-only «открыть как…» context switch
SuperAdmin может зайти в данные конкретной орги в режиме просмотра
(аналог «view as customer» в SaaS). Все запросы летят с tenant'ом
выбранной орги, но любая мутация запрещена — Phase 3 (edit-mode +
audit-trail) будет ослаблять ограничение по reason'у.

API:
- HttpContextTenantContext.OrganizationId: если у юзера роль SuperAdmin
  И header X-Org-Override присутствует — возвращаем его как tenant
  (вместо org_id из JWT).
- ReadonlyOverrideMiddleware (после UseAuthorization): когда заголовок
  активен, отбивает 403 любую non-GET операцию, кроме /api/super-admin/*
  (управление орг) и /connect/* (refresh tokens).
- Безопасность: проверка SuperAdmin-роли — без неё header игнорируется,
  обычный юзер ничего подменить не может.

Web:
- api.ts: localStorage 'superAdminAsOrg' = {id, name}; axios interceptor
  добавляет X-Org-Override на каждый запрос. setOrgOverride(...) делает
  hard reload чтобы сбросить TanStack Query-кэш.
- SuperAdminAsOrgBanner — жёлтая полоса сверху main-area с названием
  орги и кнопкой «Выйти». Подключена в AppLayout перед <Outlet/>.
- В таблице /super-admin/organizations добавлена кнопка LogIn (синяя)
  в actions; клик → setOrgOverride → reload в режим просмотра.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:03:48 +05:00
nns e3bc5cacfc feat(web): super-admin section + setup wizard + auto-redirect
Раздел /super-admin/* доступный только пользователю с ролью SuperAdmin.
В сайдбаре отдельная группа «Супер-админ» появляется только если
me.roles содержит SuperAdmin (UI-guard, серверная авторизация
параллельно проверяется через [Authorize(Roles=\"SuperAdmin\")]).

Страницы:
- /super-admin            — KPI-дашборд (orgs/users/products/supplies)
- /super-admin/organizations — таблица всех org с фильтром Активные/
  Архив/Все, поиском по Name/BIN. Действия в строке: Архивировать
  (модалка с вводом названия), Восстановить, Удалить навсегда (только
  если в архиве >30 дней + повторное подтверждение).
- /super-admin/organizations/new — двух-секционная форма создания
  (реквизиты + первый админ); в response получаем generatedPassword
  и показываем в одноразовой модалке с copy-кнопками.
- /super-admin/audit-log  — таблица журнала с экспортом в CSV.
- /super-admin/setup      — 3-шаговый wizard (welcome → org → admin →
  done с временным паролем). Авто-редирект на этот URL для SuperAdmin
  если /api/super-admin/setup-status возвращает needsSetup=true (в
  системе ноль организаций) — нельзя пропустить пока не создадут.

useMe()/useIsSuperAdmin() хуки в lib/useMe.ts — единая точка чтения
текущего юзера и его ролей для UI-гейтов.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:59:07 +05:00
nns bf6880fe0f feat(employee): add Salary, TaxNumber, Description, ImageUrl + radio role picker
Domain Employee расширен 4 nullable-полями (по образу сторонняя система):
- Salary numeric(18,2) — оклад в валюте организации
- TaxNumber varchar(20) — ИИН/ИНН
- Description varchar(2000) — комментарий HR'а
- ImageUrl varchar(500) — аватар (на будущее: загрузка через images endpoint
  как у товаров; пока поле для прямой ссылки)

Migration Phase4c_EmployeeExtraFields добавляет 4 nullable колонки
(существующие записи не ломаются). EF config + snapshot обновлены.

API EmployeesController: DTO/Input/Create/Update пробрасывают новые
поля сквозь.

Frontend EmployeesPage:
- Поля «Оклад» и «ИИН/ИНН» рядом, ниже — «Описание» textarea.
- Селект роли заменён на radio-список с описанием каждой роли
  (системные сначала, затем кастомные). Под радио — ссылка
  «Настроить права ролей →» на /settings/employee-roles. Это
  по образу МС — пользователь сразу видит за что отвечает каждая
  роль и куда идти если нужно подкрутить.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:48:27 +05:00
nns 372ca9ec16 feat(roles): permissions matrix grouped by section + clone-from-template flow
RolePermissions расширен с 21 до 32 флагов: добавлены UnitsManage,
DemandsView/Edit/Post (отгрузка контрагенту, не путать с RetailSales),
CounterpartiesDelete, InventoryEdit, LossEdit, EnterEdit (склад-операции),
ReportsFinanceView, ReportsStockView (тонкие отчётные права),
CashRegistersManage и IntegrationsManage (отдельно от OrgSettingsManage).

UI EmployeeRolesPage:
- 7 групп вместо 6: Каталог / Закупки / Продажи / Контрагенты /
  Склад-Остатки / Отчёты / Настройки. Все секции аккордеоном внутри
  модалки (как было — flex-col), но с правильным грануляр-списком.
- Системные роли — чекбоксы disabled (только просмотр; имя/описание
  редактируются).
- При [+ Добавить роль] — сначала открывается модалка выбора шаблона:
  Пустой / Копия Администратора / Копия любой существующей. Дальше
  открывается основная модалка с предзаполненной матрицей.

allPerms() помощник на фронте — зеркало RolePermissions.All() с бэка,
для шаблона «Копия Администратора» в clone-flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:44:53 +05:00
nns d3bcbee8b9 feat(onboarding): welcome dashboard with first-steps cards
Дефолтная страница после логина (/) — OnboardingPage по образу
сторонняя система «Первые шаги». Старый DashboardPage с KPI и графиком
переехал на /dashboard, в меню «Главная» теперь два пункта:
«Главная» (онбординг) и «Аналитика» (KPI/графики).

useOnboardingProgress() — хук, считает 4 шага:
- orgConfigured: country + defaultCurrency установлены
- hasEmployees: > 1 сотрудник (помимо админа)
- hasProducts: > 0 товаров
- hasSupplies: > 0 приёмок

OnboardingPage:
- Прогресс-бар «N из 4 шагов» с процентом
- 4 карточки задач: Настройки → Сотрудники → Каталог/Импорт → Приёмка
- Каждая показывает иконку (CheckCircle2 если done) + бэйдж
  категории + заголовок + описание + CTA-кнопка с ArrowRight,
  меняющая текст и ссылку в зависимости от done.
- Когда все 4 шага сделаны — плашка «🎉 Готово!» + переход на
  /dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:08:03 +05:00
nns f5cccb6f10 feat(web): Employees + Roles pages with permissions matrix
EmployeesPage (/settings/employees):
- Таблица: ФИО + должность, Роль, Email, Телефон, Учётка (есть/нет),
  Статус (Активен/Уволен).
- Модалка добавления: ФИО + Position + Email + Phone + Role.
  Если выбрана роль «Кассир» — появляется блок «Кассы» с чекбоксами
  привязки к RetailPoint'ам (multi-select).
- Чекбокс «Создать учётную запись» (по умолчанию ✓): сервер
  возвращает generatedPassword один раз, показываем в отдельной
  модалке с copy-кнопками логина и временного пароля.
- Update/Delete как обычно. Снять Активен → серверная установка FiredAt.

EmployeeRolesPage (/settings/employee-roles):
- Таблица системных + кастомных ролей с счётчиком активных прав
  (N/21). Системные помечены бэйджем «Системная».
- Модалка edit: имя, описание, матрица прав сгруппированная по 6
  блокам (Каталог/Закупки/Продажи/Контрагенты/Отчёты/Настройки).
  Удаление кнопка только для кастомных.

Меню «Настройки организации» дополнено пунктами «Сотрудники»
(иконка UserCog) и «Роли» (иконка Shield).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:06:03 +05:00
nns cec76ecaaf refactor(retail-points): rename «Точка продаж» → «Касса» + перенос
складов и касс в раздел «Настройки организации»; useOrgInfra хук

UI-переименование:
- RetailPointsPage: title «Кассы», description обновлён, лейблы
  «Новая касса» / «Удалить кассу?»; доменная сущность RetailPoint
  и URL /api/catalog/retail-points сохранены — DTO/БД не трогаем.
- В сайдбаре пункты «Склады» и «Кассы» перенесены из бывшей
  группы «Склады» в группу «Настройки организации» (рядом с
  «Общие»). Старые пункты верхнего уровня убраны.

useOrgInfra() — общий хук:
- возвращает stores, cashRegisters, defaultStoreId, defaultCashId
- showStorePicker / showCashPicker = length > 1 (умное скрытие
  селекторов в формах документов когда инфра одна).
В SupplyEditPage скрытие склада уже работало через
(stores.data?.length ?? 0) > 1 — оставил как есть, новый хук
для будущих документов (продажи, инвентаризации).

Сидер default Store + RetailPoint per Organization уже есть в
DevDataSeeder.cs (Основной склад MAIN + Касса 1 POS-1) —
дополнять не нужно.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:53:49 +05:00
nns dc4b5360b9 fix(searchable-select): dropdown opens as floating overlay (Portal + absolute)
Старый dropdown использовал position:absolute внутри Section (а у того
overflow-hidden для скруглений), из-за чего он клипался границами
карточки. На некоторых страницах визуально это смотрелось так, будто
список «раздвигает» layout.

Решение — тот же Portal-паттерн что у SupplyLineQuickAdd и DateField:
- dropdown рендерится через createPortal в document.body
- position: fixed, координаты по getBoundingClientRect() trigger'а
- z-[100], sticky-headers/секции не перекроют
- Auto-flip: если внизу <240px и сверху больше — открываем вверх
  (anchor через bottom: window.innerHeight - rect.top)
- Outside-click учитывает обе ноды (wrap + dropdown в portal)
- На window.scroll/resize dropdown закрывается чтобы не «уплывать»
  относительно trigger'а

Фиксит сразу все Select'ы в проекте (тип штрихкода, валюта, страна,
группа товара, тип цены и т.д.) — компонент один.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:56:03 +05:00
nns e96f1cdc86 fix(date-field): theme styles + default to today for new docs
Тема react-datepicker подогнана под Tailwind/slate-стиль остального UI:
- font-family inherit, шрифт системный (не Helvetica дефолт);
- header bg-slate-50, border slate-200, скругления 0.5rem;
- выбранный день — заливка var(--color-brand) (фирменный зелёный);
- сегодня — bold + brand color;
- день под клавиатурой — slate-100 без заливки brand;
- стрелки навигации серые slate-500 (без bootstrap-синего);
- footer «Сегодня» — приглушённый slate-50 с brand-цветом текста;
- треугольник-указатель скрыт;
- крестик clear — slate-400 с hover slate-500;
- весь dark-вариант через prefers-color-scheme — slate-800/900.

todayIso() в SupplyEditPage переписан с toISOString() (UTC) на
локальный YYYY-MM-DD — иначе в часовом поясе KZ (UTC+5) с 03:00
до 05:00 утра новая приёмка получала бы вчерашнюю дату.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:51:19 +05:00
nns 39da12edec feat(date-field): replace native input with react-datepicker — polished UX
Нативный <input type=\"date\"> рендерил американский MM/DD/YYYY и
тонкий браузерный popup, выглядел криво рядом с другими полями.

Используем готовый react-datepicker (10M downloads/week) — никакой
кастомизации, всё из коробки:
- dateFormat=\"dd.MM.yyyy\" + locale=\"ru\" → «25.04.2026», русские
  Январь/Понедельник
- showMonthDropdown + showYearDropdown + dropdownMode=\"select\" →
  быстрый прыжок на любой месяц/год
- todayButton=\"Сегодня\" → кнопка под календарём
- isClearable → крестик в input для очистки
- popperClassName z-[100] чтобы попап не резался z-стеком

ISO YYYY-MM-DD внутрь/наружу собираем вручную из локальных Y/M/D —
не toISOString(), чтобы вечерние даты в часовом поясе KZ (UTC+5)
не сдвигались на день назад.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:39:51 +05:00
nns dc162a6c06 revert(date-field): drop custom react-day-picker — use native input type=date
Кастомный DateField на react-day-picker оказался хрупким (стрелки
навигации не реагировали, dropdown лет вылазил за popover). В проекте
нет shadcn/ui-обёртки над day-picker, а пилить её с нуля под одно
поле — overkill.

Откатил на нативный <input type=\"date\"> с max-w-[180px], чтобы
поле не растягивалось на всю колонку. Браузер сам подтягивает
локаль из ОС/настроек — у пользователя с RU-локалью календарь
будет на русском, формат DD.MM.YYYY (как в его референс-скриншоте).

- Удалён components/DateField.tsx.
- В SupplyEditPage возвращён <TextInput type=\"date\"> с
  className=\"max-w-[180px]\".
- Сняты зависимости react-day-picker и date-fns.

Если когда-нибудь вернёмся к кастомному picker'у — будем ставить
shadcn/ui calendar+popover целиком, не вручную.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:32:41 +05:00
nns c54c26cf2b fix(date-field): polish calendar UX — dropdown nav, today/clear footer, ru weekdays
Календарь приведён к виду нативного date-picker macOS:

- captionLayout="dropdown" + startMonth/endMonth — в шапке
  селекты «Апрель» и «2026» (можно прыгнуть на любой месяц/год
  без 12 кликов next).
- Стрелки навигации справа в шапке (absolute right-1 top-1),
  компактные 28×28, hover-bg, без ярко-синего акцента.
- footer prop — две inline-ссылки «Очистить» (сбрасывает) и
  «Сегодня» (ставит сегодняшнюю дату); border-t над ними.
- Сегодня = синяя заливка bg-brand text-white (как на референсе);
  выбранная дата = ring-2 ring-brand; если сегодня = выбранный —
  применяются обе (сначала заливка, потом ring).
- Ширина popover'а w-[340px], ячейки 36×36, weekday и dropdown
  capitalize → «Пн Вт Ср …», «Апрель 2026».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:22:48 +05:00
nns 6a673e536e fix(date-field): compact calendar popup — shadcn-style sizing
Календарь react-day-picker раздувался на ~700px из-за дефолтных
стилей. Теперь plотный shadcn-style ~280–290px:

- CSS-переменные v9 на враппере: --rdp-day-height/width=2rem,
  --rdp-day_button-height/width=2rem, --rdp-weekday-padding=0,
  --rdp-accent-color=brand. Контейнер p-3 text-sm.
- classNames переписаны: ячейка дня h-8 w-8 (32×32) с rounded-md,
  weekday w-8 capitalize, caption_label text-sm font-medium.
  Кнопки навигации 24×24 с hover-bg, chevron 16×16.
- Сегодня: bg-slate-100 + font-semibold (без жирной обводки).
  Выбранная дата: bg-brand text-white. Outside-дни выцветшие.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:19:13 +05:00
nns c31611d6c4 fix(date-fields): cap width + ru locale + DD.MM.YYYY format
Новый компонент <DateField/>:
- Ширина зафиксирована (по умолчанию w-40 = 160px) — раньше нативный
  <input type="date"> растягивался на всю колонку, хотя содержимое
  всегда 10 символов.
- Ввод в формате DD.MM.YYYY с авто-вставкой точек после dd и mm,
  inputMode=numeric для мобилы. Хранит/отдаёт ISO YYYY-MM-DD —
  API-контракт не меняется.
- Иконка календаря справа открывает попап (через portal в body,
  position fixed) с react-day-picker: locale=ru, weekStartsOn=1,
  ISOWeek; caption_label/weekday с capitalize CSS — «Апр 2026»,
  «Пн Вт Ср …». Outside-click закрывает.

Подключено в SupplyEditPage (поле «Дата»). Ставка на единый
компонент DateField — все будущие даты в системе через него.

Зависимости: + react-day-picker ^9, + date-fns ^4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:13:35 +05:00
nns 7d09873be2 fix(supply-lines): show both article and barcode in line subtitle
В таблице позиций приёмки под названием товара теперь выводится
и артикул, и основной штрихкод сразу — раньше показывалось что-то
одно (артикул или ничего, без штрихкода).

Формат: «Арт: 17933 · ШК: 4870144022958». Если только одно из двух
— префикс соответствующий, без точки-разделителя. Если ни того ни
другого — subtitle не рендерится. Шрифт мелкий моно серый.

API:
- SupplyLineDto расширен полем ProductBarcode (основной по
  IsPrimary, иначе первый по порядку).
- В проекции GetInternal штрихкод подтягивается через
  p.Barcodes.OrderByDescending(IsPrimary).Select(Code).First().

Frontend:
- types.ts.SupplyLineDto, LineRow в SupplyEditPage и AddedProduct
  в SupplyLineQuickAdd получили поле productBarcode/barcode.
- При добавлении строки через ProductPicker, sticky-input или
  quick-create — primary barcode достаётся из p.barcodes одинаковой
  логикой (sort by IsPrimary desc, [0]).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:03:43 +05:00
nns 970a9baec3 fix(supply-quick-add): sticky input at viewport bottom + auto-scroll on add
Quick-add bar теперь не sticky-внутри-Section, а отдельный flex-sibling
формы — всегда прибит к нижнему краю viewport независимо от высоты
содержимого и overflow-hidden у Section'а:

  <form flex flex-col h-full>
    <topbar />
    <body flex-1 overflow-auto>     ← скроллится
    <quick-add bar flex-shrink-0>   ← всегда виден
  </form>

После каждого добавления строки скролл-контейнер тела документа
автоскроллится к низу (smooth, через requestAnimationFrame чтобы
дождаться рендера новой строки) — новая строка всегда появляется
прямо над input'ом и пользователь видит подтверждение скана.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:42:24 +05:00
nns b56c499b45 fix(supply-quick-add): dropdown opens upward + show only N results + create-new at bottom
UX как в сторонняя системае:
- Dropdown открывается ВВЕРХ от input'а (anchor по input.top, fixed
  bottom). Max-height 60vh — не перекрывает шапку, внутри overflow-y.
  Раньше выпадал вниз и при добавлении строк визуально не оставалось
  места.
- Показываем первые 10 матчей (VISIBLE_LIMIT=10), под ними ссылка
  «Ещё N товаров» которая раскрывает полный список (limit=50 на
  сервере). На запрос приходит до 50 — этого достаточно для
  подавляющего большинства поисков.
- Под списком (через тонкий разделитель) — пункт «+ Создать новый
  товар: «{q}»» / «Создать товар со штрихкодом «…»». Всегда последний
  визуально, ближайший к input'у — самый удобный для быстрого клика.

Стрелки ↑↓ работают по видимой части (1..10 или весь список после
expand). Enter подбирает подсвеченный из видимой части.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:33:57 +05:00
nns 95bf2188c6 feat(product-card+list): drop supplier field, reorder sections, add cost column
Карточка товара:
- убрано поле «Основной поставщик» из секции «Классификация» (домен/DTO
  оставлены без изменений; в payload отправляется null);
- порядок секций: Основное → Цены → Классификация → Изображения →
  Штрихкоды (раньше Цены шли после Классификации). Цены — самое важное,
  должны быть ближе к названию товара.

Список товаров:
- добавлена колонка «Себестоимость» перед колонкой системной розничной
  цены. Источник — Product.Cost (скользящее среднее, обновляется при
  проведении приёмки). Cost = 0 (приёмок не было) показывается как «—»,
  чтобы визуально отличать «не накопилось» от реальной себестоимости 0.
- API: добавлен сортировочный case sort=cost,asc/desc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:13:57 +05:00
nns 94d3c4687b fix(supply-quick-add): keep input focused after scan / clear on add
Сценарий — приёмщик подряд сканирует 50 штрихкодов без клика мышью:

- Sticky-bar с input'ом теперь ВНЕ Section'а (overflow-hidden родителя
  ломал sticky), прибит к низу скроллируемого тела документа. После
  любого добавления строки — input всегда виден.
- Очистка query и refocus вызываются СРАЗУ после клика/Enter, до
  await на /products/{id}: пока сеть в полёте, юзер уже может начать
  следующий скан. После завершения запроса — повторный refocus
  (двойной guarded focus + requestAnimationFrame), чтобы перебить
  любые ререндеры родителя, которые могут увести фокус.
- Поиск по quick-search теперь через AbortController — устаревший
  ответ при быстром вводе подряд не подменяет свежий список.
- Закрытие ProductQuickCreateModal тоже возвращает фокус в input
  (и при «Создать», и при «Отмена»).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:08:49 +05:00