Платформенные настройки: один row, не tenant-scoped, видны и меняются
только Супер-администратором. Хранят SMTP-креды для исходящей почты
(forgot-password, нотификации). IMAP к этому отношения не имеет — IMAP
для чтения входящей, нам нужен SMTP.
Поля:
- SmtpHost, SmtpPort
- SmtpUseSsl (implicit TLS / 465) и SmtpStartTls (587, по дефолту true)
- SmtpUsername, SmtpPasswordEncrypted (хранится зашифрованным через
DataProtection API; в API-ответах не выходит, только has-password флаг)
- FromEmail, FromName
Миграция Phase5b_PlatformSettings создаёт таблицу public.platform_settings.
Конфиг EF в AppDbContext: HasMaxLength для строк.
Раньше Sidebar строился только по флагу isSuperAdmin, и Кассир/
Кладовщик видели весь меню (включая «Сотрудники», «Контрагенты»,
«Настройки»), хотя серверные [Authorize(Roles = "Admin")] возвращали
бы 403 на каждом запросе.
Теперь:
- AppLayout.buildNav берёт roles[] из /api/me и собирает меню per-role:
· Каталог: Кассиру/Кладовщику только Товары; Admin — всё.
· Контрагенты: только Admin.
· Остатки: видят все три tenant-роли. Движения — Admin/Storekeeper.
· Закупки: Admin/Storekeeper.
· Продажи: Admin/Cashier.
· Импорт МойСклад, Настройки организации, Сотрудники, Роли,
Склады, Кассы — только Admin.
· Системная консоль — только SuperAdmin.
- Новый компонент RoleGuard (web/components/RoleGuard.tsx). Показывает
«Нет доступа» вместо страницы если у юзера нет нужной роли. Применён
в App.tsx для всех admin-only роутов: /settings/*, /catalog/{stores,
retail-points,counterparties}, /admin/import/moysklad. Защищает на
случай прямого ввода URL — sidebar их уже не показывает, но без
guard юзер увидел бы крутящееся колесо и 403.
Серверная авторизация ([Authorize(Roles=...)]) — основной слой защиты;
sidebar+RoleGuard — UX-слой.
Полное физическое удаление сотрудника невозможно — у него FK из
retail_sales и supplies. Поэтому теперь два шага:
IsActive=true → активный
IsActive=false + FiredAt → уволен (кнопка «Уволить»)
IsActive=false + IsDeleted=true + DeletedAt → удалён (кнопка «Удалить»)
— Domain: Employee получил поля IsDeleted/DeletedAt + миграция
Phase5a_EmployeeSoftDelete (drop column возможен через Down).
- API EmployeesController.Delete:
· если активен — переводит в Fired;
· если уже уволен — ставит IsDeleted=true + DeletedAt;
· если уже удалён — 409 Conflict;
· гарды Owner и self применяются на ОБОИХ шагах.
- API EmployeesController.List: новый query-param ?status=
active|fired|deleted|all (default: всё кроме deleted).
- DTO дополнен полями isDeleted, deletedAt, status (active/fired/deleted) —
фронтэнд использует для бейджа и логики кнопок.
- UI EmployeesPage:
· фильтр статуса в actions: «Активные и уволенные» (default),
«Только активные», «Только уволенные», «Только удалённые»,
«Все, включая удалённых».
· колонка «Статус» теперь с цветным бейджем (emerald/amber/rose).
· ФИО уволенного помечается «(уволен)», удалённого — line-through
+ «(удалён)».
· кнопка-действие в модалке: «Уволить» если active, «Удалить» если
fired, скрыта если уже deleted (заменена на pojaснение).
· confirm-текст обоих шагов разный — юзер понимает что произойдёт.
Существующие связанные документы (продажи, поставки) ссылаются на
employees по FK; имена для UI берутся из employee.LastName/FirstName +
status — отображение «Иванов И.И. (удалён)» работает автоматически.
Раньше явный validateEmail вызывался только в LoginPage и SignupForm.
Остальные формы (Counterparties, Employees, SuperAdminOrgCreate,
SuperAdminOrgEmployees, SuperAdminSetup) использовали голый
<TextInput type="email">, который пропускает «name@domain» без TLD.
Сделал общий TextInput строже: если type=email и pattern не задан явно,
автоматически проставляется pattern=[^@\s]+@[^@\s]+\.[A-Za-z]{2,}
(требует домен верхнего уровня минимум 2 буквы) + title с понятным
текстом подсказки. localizeNativeValidation (уже подключён к TextInput
через useEffect) переведёт patternMismatch в русское сообщение из
title-атрибута.
Это покрывает все формы с email-полем единообразно — отдельно править
каждую страницу не пришлось. Серверный AuthorizationController + signup
тоже проверяют email через свой validateEmail (food-market.web/lib/
validation.ts) — клиент и сервер консистентны.
Раньше Salary был <input type=number> с примитивной валидацией.
Заменено на MoneyInput (как в ценах товаров и др. денежных полях),
который:
- читает org-setting allowFractionalPrices (копейки или нет);
- показывает символ валюты (₸/$/€) справа;
- хранит draft-string для бесшовного ввода 100.50 без потери точки;
- onBlur нормализует значение.
Тип формы изменён: `salary: string` → `salary: number | null`,
сохранение payload берёт значение напрямую без Number() парсинга.
Других денежных полей в формах сотрудников/контрагентов (зарплаты,
авансы, штрафы, доплаты) сейчас НЕТ — есть только Salary в Employee
и MoneyInput уже используется в ProductEditPage (цены) и
SupplyEditPage (себестоимость и сумма по позициям). Поэтому пункт
закрыт одной правкой EmployeesPage.
ИНН — российское поле. В Казахстане физлица используют ИИН (12 цифр),
юрлица — БИН (12 цифр). Колонок «inn» в БД у нас нет (есть Bin для
юрлица + TaxNumber для ИИН физлица), миграция drop column не нужна —
только лейблы и комментарии.
Поправлено:
- EmployeesPage: «ИИН/ИНН» → «ИИН» (12 цифр, inputMode=numeric).
- CounterpartiesPage: убрано поле «ИНН / другой» — оставлены БИН и ИИН
с правильными ограничениями (12 цифр, numeric).
- SuperAdminOrgCreatePage / SuperAdminSetupPage: «БИН/ИНН» → «БИН».
- MoySkladImportPage: «(ИНН ...)» в ответе test → «(идентификатор ...)».
- Domain comments в Employee.cs / Counterparty.cs обновлены.
Внутренний MoySklad-DTO `Inn` оставлен — это входящий JSON из российского
API МойСклад, поле там действительно так называется. Маппится в Bin
при импорте контрагента (как и было).
Раньше клик по системной роли в списке выкидывал alert «Системная
роль, изменения недоступны». Теперь открывается обычная модалка с
правами, но: имя/описание disabled, все чекбоксы disabled, кнопка
«Сохранить» скрыта (вместо неё «Закрыть»). Юзер видит ровно какие
галки стоят у Администратора/Кладовщика/Кассира — это нужно как
шаблон при создании кастомной роли.
Также description страницы и заголовок модалки обновлены под новый
смысл: системные = только просмотр; кастомные = полный CRUD.
Менеджер/Закупщик/Бухгалтер сидились как кастомные шаблоны вместе с
организацией, но при этом числились системными в DevDataSeeder
(IsSystem=false), что путало UI (где-то нельзя было менять, где-то
можно было). Юзер хочет: при создании новой орги только три
системные роли (Admin, Storekeeper, Cashier), все остальные роли
администратор создаёт сам.
— SystemRoles.Manager убран. Identity-роли сидируются: SuperAdmin,
Admin, Cashier, Storekeeper.
— EmployeeRoles tenant-сидер создаёт только три записи (все IsSystem=true,
все три не редактируются и не удаляются обычным юзером — это правило
уже работало для Админа/Кассира, теперь покрывает Кладовщика).
— Authorize(Roles = ".. Manager ..") убрано из всех контроллеров (13 файлов):
Sales/RetailSales, Catalog/{Products,ProductImages,ProductGroups,
Counterparties,UnitsOfMeasure,RetailPoints,PriceTypes,Stores},
Purchases/Supplies, Organizations/{Employees,EmployeeRoles,
OrganizationSettings}.
Существующие организации с уже созданными «Менеджер/Закупщик/
Бухгалтер» записями НЕ затрагиваются — сидер пропускает org если в ней
уже есть роли (anyRole short-circuit). При желании админ может удалить
эти кастомные роли через UI.
Курсор не должен прыгать в конец после редактирования — он должен оставаться там, где юзер только что внёс изменение.
Корень проблемы: после onChange я вызываю onChange с каноничным «+77…», parent перерендеривает с этим значением, React переписывает input.value на новый display — а нативное поведение браузера в этом случае: при programmatic value-set курсор сбрасывается в конец.
Фикс: сохраняем «сколько цифр стояло до курсора» в момент редактирования, после ре-рендера в useLayoutEffect ставим курсор сразу после той же по счёту цифры в новом display. Учёт по цифрам (а не по абсолютной позиции) корректен даже когда format добавляет/убирает пробелы (например при переходе между «+7 700 1» и «+7 700 12»).
useLayoutEffect (а не useEffect) — чтобы курсор восстанавливался синхронно до paint, без визуального флика.
Прошлая попытка вручную обрабатывать каждую клавишу через 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}$).
Курсор больше не «прыгает в конец» — его можно ставить куда угодно в digit-зоне (после префикса «+7 »), а Backspace/Delete/ввод цифры работают относительно позиции курсора, как в любом нормальном текстовом поле.
Реализация:
- cursorToDigitIdx — мапит позицию курсора в display к индексу в массиве цифр (пробелы форматирования пропускаются).
- digitIdxToCursor — обратное: после операции ставит курсор сразу после последней изменённой цифры (через пробел если он там есть, чтобы не было визуального скачка).
- Backspace на позиции idx удаляет цифру (idx-1); Delete удаляет цифру idx; ввод цифры вставляет в позицию idx.
- Префикс «+7 » защищён: onSelect ловит попытку курсора влезть в позиции 0..2 и мягко переставляет на 3.
Предыдущие итерации пытались парсить промежуточный raw input от браузера и постоянно ловили баги: курсор не там → парсер путал префикс с введёнными цифрами; Backspace на странной позиции → блокировался не там где надо.
Новая модель: фронт полностью контролирует input, нативный ввод запрещён. Все клавиши обрабатываются вручную в onKeyDown:
- Цифра → если набрано <10, добавить в конец.
- Backspace/Delete → если есть цифры, удалить последнюю.
- Любой другой символ → игнор (preventDefault).
- Шорткаты (Cmd/Ctrl+что-то), стрелки, Tab, Enter, Esc → пропускаем.
Курсор автоматически держим в конце display'а (после фокуса/клика/изменения). Paste обрабатывается отдельно через onPaste с нормализацией. onChange = no-op (React-warning заглушка).
Бенефиты: Backspace теперь всегда удаляет цифру, без оглядки на позицию. Не-цифры физически невозможно ввести. Префикс «+7 » никогда не повреждается.
Предыдущий fix не покрывал кейс когда курсор оказывался не сразу после «+7 » (в начале строки или в середине). parseRawInput не находил префикс на нужной позиции и «7» снова попадала в подсчёт.
Корректное решение: блокировать любой не-цифровой символ через preventDefault в onKeyDown — тогда он вообще не доходит до input, парсер видит только валидные цифры. Сохранены: Backspace/Delete (с защитой префикса), стрелки и навигация, Cmd/Ctrl+A/C/V/X для копи-пасты, Enter для submit. Paste произвольной строки по-прежнему работает через onChange.
Баг: при нажатии любой не-цифры (буквы, пробела) поле подставляло «7». Корень: extractDigits парсил полное e.target.value включая префикс «+7», и его «7» попадала в d, добавляясь как введённый юзером символ. Backspace на пустом поле не работал по той же причине.
Фикс: новая функция parseRawInput отделяет префикс «+7 » ДО извлечения цифр. Применена в handleChange обоих PhoneInput (web + public). Кейс paste без префикса (8XXXXXXXXXX / 7XXXXXXXXXX из 11 цифр) сохранён.
Поле телефона во всех формах (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)
Фронт:
- SignupForm: убрал «(необязательно)» из лейбла, добавил required + autoComplete=tel.
- validation.ts: validatePhone теперь возвращает ошибку при пустом значении и валидирует строго KZ-мобильный (^77\d{9}$); ведущая «8» нормализуется в «7». «79…» (РФ) отвергается.
Бэк:
- AuthSignupController: SignupInput.Phone теперь string (не nullable). Добавлен NormalizeKzPhone — единая нормализация на сервере, защита от обхода фронтового валидатора. На запись в Organization.Phone уходит каноничная форма «+7XXXXXXXXXX».
В списке организаций SuperAdmin одиночный клик навигировал на /super-admin/organizations/{id} и страница успевала отрисоваться → визуальное моргание. Изменил поведение:
- DataTable теперь поддерживает onRowDoubleClick параллельно onRowClick.
- SuperAdminOrganizationsPage: убран single-click navigate; double-click → setOrgOverride + переход на /dashboard этой орги (вход в её личный кабинет).
- Кнопки в колонке действий не задеты — у них уже stopPropagation.
Bug: на test.food-market.kz href кнопки «Войти» показывал
https://app.food-market.zat.kz/login → 410 Gone (старый stage-домен
давно убран). Корни:
— Header.astro имел дефолт PUBLIC_APP_URL = https://test.food-market.kz
(после revert-коммита остался только public-домен, но Войти ведёт
на админку — должно быть admin.food-market.kz). Поправил, теперь
совпадает с SignupForm.tsx.
— Dockerfile ARG PUBLIC_APP_URL тоже был test.food-market.kz —
заменил на admin.food-market.kz.
— Добавил .dockerignore (node_modules / dist / .astro / .env / .git)
чтобы старый локальный dist/ не попадал в build-контекст и не
застревал в layer cache. Раньше это давало stale бандл с
app.food-market.zat.kz даже после изменений в src/.
Build/deploy: docker build --no-cache + compose pull --force-recreate.
Smoke на проде:
- href Войти → https://admin.food-market.kz/login
- Никаких .zat.kz упоминаний в /usr/share/nginx/html (grep пуст).
- og:image → https://test.food-market.kz/og/home.png
Источник — 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.
Публичный сайт ещё в разработке — выносим его с food-market.kz на
test.food-market.kz. На корневом домене food-market.kz пока 404.
admin.food-market.kz остаётся как есть.
— Заменены https-URL в src/** и deploy/:
https://food-market.kz → https://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/.
- Заменил все хардкоды 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 шёл нормально.
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 — переход на страницу прямо из списка орг.
Добавлен 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>
admin@food-market.local → SuperAdmin (OrganizationId=null, видит все орги)
admin@demo-market.local → Admin Demo Market (новый, для тестов орг-уровня)
Idempotent-фикс для существующих БД: исправляет роль и чистит OrganizationId.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
— «Владелец» переименован в «Главный администратор» — терминологически
у нас не «собственник», а тот кто управляет организацией.
Бейдж в таблице, тексты модалок, серверные сообщения 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-ответе.
Жалоба юзера: «нажимаю удалить владельца магазина — диалог "удалить
сотрудника?" — нажимаю — ничего не происходит». Раньше кнопка «Удалить»
для 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'а.
- 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. Полный отчёт — 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.
— Общий модуль `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 должен содержать доменную зону…». Нативные браузерные
подсказки больше не на английском.
В SignupForm.tsx плейсхолдер «ИП Иванов / Магазин у дома» →
«Например: Магазин «Береке»». Не используем выдуманных русских
персонажей в маркетинговых интерфейсах для KZ-аудитории.
Грэп всего публичного пакета и админки на типичные RU-фамилии
(Иванов/Петров/Сидоров и пр.), эл.почты mail.ru/yandex,
формы юр.лиц ОАО/ЗАО — других вхождений не найдено.
— Все материалы (главная, /pos, /about, FAQ, kb, blog) переведены на
нейтральные формулировки: «другие системы учёта», без имён.
— Новая страница /import — единая точка входа по миграции каталога;
описывает Excel/CSV, REST API и выгрузку 1С.
— Удалены публичные kb/blog-статьи, целиком построенные вокруг
миграции с конкретного продукта.
— /migration-from-other-system убран из sitemap; nginx делает 301 на /import.
— blog schema расширена optional-полями (author, category, cover_image),
чтобы новый frontmatter валидировался content collection.
— Грамматические правки: «Импорт из других систем».
Финальный grep по пакету по списку имён конкурирующих SaaS-учётных
продуктов — пусто. Smoke по 9 ключевым URL — 200; старый URL — 301.
Phase 6 контентная часть — частично:
Блог (3 поста из /tmp/content/blog-and-kb.md, content collection):
- launch — запуск Food Market
- import-other-system — пошаговый гид миграции (внутренняя инструкция, frontmatter)
- cash-with-scales — почему касса с весами Масса-К из коробки
База знаний (5 статей, content collection с category + order):
- quickstart, import-other-system, pos-setup, billing, faq
Страницы /blog и /kb перерисованы — рендерят список из коллекций
с группировкой по категориям, отдельные [slug].astro template'ы
рендерят markdown с типографикой prose-md.
/about и /contacts наполнены реальным контентом из
/tmp/content/about-contacts-pricing-features.md (без заглушек).
Контакты — 4 канала (email/phone/чат/реквизиты) с placeholder-ами
для будущих контактов после регистрации юр.лица.
Очистка от упоминаний сторонних систем (по правилу: не сравнивать с
конкретными системами и не выводить чужие цены публично):
- Hero/FAQ на главной — заменено «Импорт из сторонняя система» на «импорт
каталога одной кнопкой», убран FAQ-вопрос «Чем отличаетесь от...»,
заменён на общий «Чем хорош Food Market?».
- Footer: пункт «Импорт из сторонняя система» → «Импорт каталога».
- /migration-from-other-system удалён (содержал сравнительную таблицу
с ценами сторонняя система). Возможен возврат как KB-инструкция позже.
- features/pos/pricing/integrations/changelog/for-grocery/for-pharmacy
— sed-замена «сторонняя система» → «другие системы», ссылки на
/migration-from-other-system → /features.
- В content-collections блог/KB остались упоминания (юзер пришлёт
PATCH-версии текстов отдельно).
Контейнер пересобран и задеплоен на стенд: 30 страниц, smoke на /,
/blog, /kb, /about, /blog/launch — все 200.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Доменная схема (по решению юзера):
food-market.zat.kz → новый Astro public-сайт (порт 8082, контейнер food-market-public)
app.food-market.zat.kz → существующая админка (food-market.web, порт 8081)
API остаётся на app.* под /api/*.
Изменения:
- docker-compose: добавлен сервис public (image food-market-public:latest,
127.0.0.1:8082:80). На стенде .env дополнен PUBLIC_TAG=latest, контейнер
поднят, smoke на / и /pricing проходит.
- Forgejo workflow .forgejo/workflows/docker-public.yml — отдельный билд
при изменениях в src/food-market.public/**: docker build с
--build-arg PUBLIC_SITE_URL=https://food-market.zat.kz и
--build-arg PUBLIC_APP_URL=https://app.food-market.zat.kz, push в
локальный registry, deploy через docker compose pull+up. TG-пинг.
- Nginx (на стенде вручную, не через репо):
- Новый блок food-market-app.conf для app.food-market.zat.kz —
проксирует на :8081 (web), вместе с /api/admin/import/ и
/tg-webhook путями. Certbot --nginx выпустил SSL.
- Старый food-market-stage.conf переписан на public — проксирует на
:8082, использует существующий SSL для food-market.zat.kz.
- API CORS: добавлены food-market.zat.kz, app.food-market.zat.kz,
food-market.kz, app.food-market.kz в AllowedOrigins (publicу нужен
food-market.zat.kz для signup-запросов, админке нужен app.*).
- JWT cookie domain не настраиваем — проект использует localStorage,
cross-domain auth-bridge через URL fragment (см. AuthBridgePage),
что безопаснее cookie с .food-market.zat.kz.
- Хардкодов food-market.zat.kz в food-market.web/src не нашлось —
всё через относительные URL.
Существующие админ-сессии: токены в localStorage привязаны к
food-market.zat.kz origin. После переезда юзеры увидят на этом
домене публичный сайт без своих токенов — нужно перелогиниться
на app.food-market.zat.kz.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Раньше «удалить орг навсегда» было захардкожено на 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>
КОРНЕВАЯ ПРИЧИНА: 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>
Опечатка: в Up() миграции стояло table:\"units\" — реальное имя в БД
units_of_measure (по EF snapshot). API падал при старте на стенде:
«relation public.units does not exist», 502 на login.
На стенде SQL ALTER COLUMN накачен вручную + запись в
__EFMigrationsHistory; миграция fix только для будущих чистых баз.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Концепция: 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>
Концепция: «Супер администратор» — платформенная 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>
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>
🔴 КРИТИЧНЫЙ БАГ ИЗОЛЯЦИИ. SuperAdmin в режиме «открыть как Demo Market»
видел товары FOOD MARKET (29540 чужих записей вместо 0 своих).
Корень проблемы — query-filter в AppDbContext:
e => _tenant.IsSuperAdmin || e.OrganizationId == _tenant.OrganizationId
IsSuperAdmin → весь предикат становится true → все записи всех орг.
В режиме override OrganizationId уже корректно подменялся на
выбранную орг, НО bypass через IsSuperAdmin делал подмену
бессмысленной — фильтр всё равно пропускал всё.
Фикс — добавил IsTenantOverride флаг в ITenantContext и переписал:
e => (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|| e.OrganizationId == _tenant.OrganizationId
То есть SuperAdmin обходит фильтр ТОЛЬКО когда не в override. В
override-режиме он работает в контексте выбранной орги как обычный
юзер — фильтр применяется.
HttpContextTenantContext.IsTenantOverride возвращает true когда
текущий запрос — HTTP, юзер в роли SuperAdmin и присутствует header
X-Org-Override с валидным GUID. AsyncLocal-override (background-задачи
импорта/Hangfire) намеренно НЕ считается tenant-override — там
IsSuper=false по умолчанию и фильтр и так применяется.
Smoke-test ДО фикса (воспроизведение):
GET /products X-Org-Override=DemoId → total 29540 (баг: чужие)
GET /products X-Org-Override=FoodId → total 29540
GET /products без header → total 29540 (legit super)
После деплоя ожидается:
GET /products X-Org-Override=DemoId → 0 (Demo Market пуст)
GET /products X-Org-Override=FoodId → 29540 (своих)
GET /products без header → 29540 (legit super bypass)
Затронуты ВСЕ tenant-сущности (фильтр применяется через reflection
ко всем ITenantEntity): products, counterparties, supplies, stocks,
movements, retail-sales и т.д.
DesignTimeTenantContext получил IsTenantOverride=false (он только для
EF tooling).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
БАГ: 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>
/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>
<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>
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>
Edit модалка для кастомной роли (не системной, существующей) теперь
показывает amber-плашку перед матрицей прав — напоминает что галка
тут касается N людей сразу. Системные роли — без плашки (там
disabled чекбоксы).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
В режиме «открыть как…» 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>
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>
POST /api/super-admin/organizations создавал только Store + Admin role
в inline-коде — у новой орги не было единиц измерения, типов цен,
кастомных ролей шаблонов (Менеджер/Кладовщик/Закупщик/Бухгалтер),
кассы. Юзеру приходилось бы заводить всё руками.
Решение — переиспользовать DevDataSeeder.SeedTenantReferencesAsync,
который уже умеет всё это идемпотентно:
- 5 единиц измерения (штука/кг/л/м/упаковка по ОКЕИ)
- 2 типа цен (Розничная IsSystem+IsRequired+IsRetail / Оптовая)
- «Основной склад» MAIN
- «Касса 1» POS-1
- 6 ролей через SeedEmployeeRolesAsync (2 системных + 4 шаблона)
Helper повышен с private на public static. В контроллере убран
inline Store + AdminRole, после Add(org)+SaveChanges вызывается
seed, потом находим уже созданную «Администратор» роль для линковки
с Employee.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Раздел /super-admin в UI прячется за me.roles.includes('SuperAdmin').
Сидер при создании admin'а назначал только SystemRoles.Admin —
SuperAdmin висел как Identity-роль в роле-каталоге, но никому не был
выдан. Из-за этого SuperAdmin-консоль на стенде была не видна в меню.
Фикс: при создании admin'а сразу AddToRoleAsync(SuperAdmin). Для уже
развёрнутых стендов — догоняющая ветка else if (!IsInRoleAsync(SuperAdmin))
догоняет существующую учётку при следующем рестарте API.
На стенде роль уже выдана вручную через INSERT в AspNetUserRoles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Раздел /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>
SuperAdminOrganizationsController (/api/super-admin/organizations):
все методы используют IgnoreQueryFilters() для обхода tenant-фильтра.
- GET / — таблица с пагинацией, фильтр archived, поиск по Name/Bin,
возвращает счётчики (employees, products) + last login по users.
- GET /{id} — детали + статистика (employees, products, counterparties,
supplies за 30 дней) + AccountOwner данные.
- POST / — создание орга вместе с админом: Org + Store «Основной» +
EmployeeRole «Администратор» (IsSystem) + AppUser (random temp pwd
возвращается один раз) + Employee. Owner = созданный AppUser.
- PUT /{id} — правка базовых данных, лог EditOrg с before/after.
- POST /{id}/archive — требует ConfirmationName == Org.Name (ввод).
- POST /{id}/restore — снять архив.
- DELETE /{id} — только если в архиве >30 дней + повторное подтверждение.
- POST /{id}/change-owner — Reason обязателен, валидируем что user
принадлежит этой орге, лог ChangeOwner с from/to.
Все мутации пишут запись в SuperAdminAuditLog с ActionType,
Description, Reason, ChangesJson, IpAddress, SuperAdminUserId.
SuperAdminController (/api/super-admin):
- GET /setup-status — нужен ли wizard? (OrgCount == 0).
- GET /dashboard — total/active/archived orgs, users, products, supplies/month.
- GET /audit-log — фильтры organizationId/actionType/from/to + paged + join
на orgs для имени.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Базовый domain-каркас для SuperAdmin console (Phase 1):
Organization:
- IsArchived bool + ArchivedAt DateTime? — архивная орга не видна
юзерам, но данные сохраняются. Удалить навсегда можно только из
архива >30 дней (логика в API на следующем коммите).
- AccountOwnerUserId Guid? — главный владелец, не путать с админами
per-org. SuperAdmin может сменить через action c reason в audit-log.
- HasIndex(IsArchived) для быстрой фильтрации.
SuperAdminAuditLog (новая таблица super_admin_audit_log):
- Не tenant-scoped — лог общий по всей системе.
- ActionType (CreateOrg/EditOrg/ArchiveOrg/RestoreOrg/DeleteOrg/
ChangeOwner/EditEntity), OrganizationId, EntityType+EntityId,
Description, Reason, ChangesJson (jsonb), IpAddress.
- Индексы: CreatedAt, (SuperAdminUserId, CreatedAt),
(OrganizationId, CreatedAt) — типовые запросы фильтра.
Migration Phase4_SuperAdminConsole добавляет 3 колонки в organizations
+ создаёт super_admin_audit_log с тремя композитными индексами.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
После ревью UX оказалось что 6 системных ролей — перебор. Перешли на
схему «два системных + остальные шаблоны»:
- Администратор (IsSystem=true) — RolePermissions.All().
- Кассир (IsSystem=true) — POS-only набор:
ProductsView + StocksView + RetailSalesOperate. Без RetailSalesRefund
(админ включит при необходимости). Это маркер для будущего POS-app —
не имеет доступа к веб-админке.
- Менеджер / Кладовщик / Закупщик / Бухгалтер — IsSystem=false
(кастомные). Можно удалить если не нужны или подкрутить под себя.
Сидер на чистой БД сразу создаёт роли в правильных статусах. Для
существующих установок миграция Phase4b_RolesSimplify идемпотентно
делает UPDATE: демоутит лишние и приводит permissions Кассира к
правильному набору. Down() — no-op (юзер мог переименовать).
На стенде sql применил вручную + записал в __EFMigrationsHistory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
В snapshot/Designer я вручную добавил b.Navigation(\"RetailPointAssignments\")
в блоке Employee — но эта обратная навигация регистрируется через
WithMany(\"RetailPointAssignments\") у EmployeeRetailPointAssignment.HasOne(...),
который выполняется ПОЗЖЕ. Из-за этого BuildTargetModel падал с
«Navigation … was not found», и API на стенде не мог применить миграцию.
Убрал лишнюю строку в обоих местах. Свойство Employee.RetailPointAssignments
никуда не делось — обратную навигацию EF создаёт автоматически из конфига
EmployeeRetailPointAssignment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Дефолтная страница после логина (/) — 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>
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>
EmployeeRolesController (/api/organization/employee-roles):
- List/Get/Create/Update/Delete. Системные роли (IsSystem=true) — нельзя
удалить (409), но имя/описание/permissions редактируются (чтобы можно
было кастомизировать набор галок). Удаление 409 если роль уже
используется сотрудниками.
EmployeesController (/api/organization/employees):
- List с поиском по фамилии/имени/email/телефону.
- Create:
- LastName, FirstName, MiddleName, Position, Email, Phone, RoleId, IsActive
- RetailPointIds[] — для роли Кассир привязка к нескольким кассам;
хранится в employee_retail_point_assignments.
- CreateAccount=true → одновременно создаём User (Identity) с email и
случайным temp-паролем (12 символов, все классы), возвращаем в
response.GeneratedPassword один раз — UI покажет «выдайте сотруднику».
- Update — replace assignments wholesale; IsActive false → проставляем
FiredAt=now (восстановление обнуляет).
- Delete — без проверок на FK документов (на этом этапе нет других
ссылок на Employee, кроме CASCADE-связи с retail-point assignments).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DevDataSeeder.SeedEmployeeRolesAsync — 6 системных ролей с готовыми
наборами Permissions:
- Администратор — RolePermissions.All() (все 21 флаг)
- Менеджер — каталог + закупки + контрагенты + отчёты + остатки
- Кладовщик — приёмки + остатки + view товаров
- Кассир — продажи + view товаров (привязка к кассе на UI-этапе)
- Закупщик — закупки + контрагенты + view товаров
- Бухгалтер — все *View, никаких edit
IsSystem=true, SortOrder сохраняет порядок отображения в селектах.
Сидируется один раз per организацию (anyRole? skip) — чтобы кастомные
правки галок админа не сбрасывались на каждый старт.
SeedAdminEmployeeAsync — после создания admin@food-market.local
(SuperAdmin Identity user) заводит Employee-запись с ролью
«Администратор» в Demo Market организации, чтобы UI «Сотрудники»
сразу показывал учётку, а не пустой список.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Базовый каркас модуля «Сотрудники и Роли» (по образу сторонняя система):
Domain:
- Employee — сотрудник организации (UserId nullable: запись может
существовать без логина), ФИО + Position + Email/Phone + Role + IsActive
+ FiredAt + RetailPointAssignments.
- EmployeeRole — роль с IsSystem флагом и owned RolePermissions.
- RolePermissions — 21 булев флаг по группам (Каталог/Закупки/Продажи/
Контрагенты/Отчёты/Настройки) + helper All() для админа.
- EmployeeRetailPointAssignment — ассоциация сотрудника с RetailPoint
(для роли Кассир — к каким кассам привязан).
Infrastructure:
- OrganizationsHrConfigurations с OwnsOne(...).ToJson("permissions")
для permissions — JSONB-колонка вместо отдельной таблицы.
- DbSet<EmployeeRole/Employee/EmployeeRetailPointAssignment>.
- Уникальные индексы: (OrgId, RoleName), (OrgId, UserId) с filter
WHERE UserId IS NOT NULL, (EmployeeId, RetailPointId).
Migration Phase4_EmployeesAndRoles создаёт три таблицы. Сидер
системных ролей и привязка существующего admin'а к Employee —
следующим коммитом, контроллеры и UI — далее.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
складов и касс в раздел «Настройки организации»; 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>
Старый 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>
Нативный <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>
Кастомный 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>
Календарь приведён к виду нативного 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>
Новый компонент <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>
В таблице позиций приёмки под названием товара теперь выводится
и артикул, и основной штрихкод сразу — раньше показывалось что-то
одно (артикул или ничего, без штрихкода).
Формат: «Арт: 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>
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>
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>
Карточка товара:
- убрано поле «Основной поставщик» из секции «Классификация» (домен/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>
Сценарий — приёмщик подряд сканирует 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>
Корень проблемы: Section рендерится с класcом overflow-hidden (нужен
для скруглений углов) — absolute-позиционированный dropdown
обрезался границей карточки и был не виден совсем.
Решение: dropdown через React createPortal вынесен в document.body,
позиция вычисляется по getBoundingClientRect() input'а на каждый
open + window scroll/resize. position: fixed, z-[100] — выше любого
sticky-header. Outside-click handler теперь учитывает оба контейнера
(input wrap + portal-узел) — клик по элементу dropdown'а больше
не закрывает его как «снаружи».
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Валюты — закрытый сидируемый список (KZT, USD, EUR, RUB), пользователь
их не создаёт. Currency остаётся в БД как FK для Country и Organization;
GET /api/catalog/currencies продолжает работать для дропдаунов в форме
страны и настройках организации. Отдельная страница в меню избыточна.
- удалена pages/CurrenciesPage.tsx
- из роутера убран /catalog/currencies
- из сайдбара пропал пункт «Валюты», иконка Coins больше не импортируется
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UX как в сторонняя система: под таблицей строк единый input full-width с
автофокусом, на каждый ввод — debounce 200ms и quick-search в API.
Dropdown показывает артикул + название (с подсветкой матча) +
бэйдж текущего остатка по складу документа (зелёный/красный/серый).
Сценарии:
- Сканер (цифры 8/12/13/14 + Enter) → точный barcode lookup,
единственный матч добавляется мгновенно. Несколько → пользователь
выбирает из dropdown.
- Текст + Enter → берёт подсвеченный пункт автокомплита.
- Дубль того же товара → Quantity += 1 у существующей строки,
всплывает «Кол-во увеличено на 1».
- Ничего не нашлось → пункт «Создать новый товар: «{q}»» в дропдауне
и при Enter, открывает ProductQuickCreateModal с pre-fill в зависимости
от типа запроса (barcode/article/name).
- ↑↓ навигация, Esc/Tab закрывают, после добавления input очищается
и возвращает фокус — для сканирования партии подряд.
Кнопка «+ Добавить из справочника» (правый верх секции) — без изменений,
открывает ProductPicker с фильтрами и multi-select для bulk-добавления.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /api/catalog/products/quick-search?search=&storeId=&limit=20 —
лёгкий поиск для inline-добавления строк в документы. Ранжирует
по приоритету: точный barcode → точный article → префикс article →
префикс name → name contains. Возвращает QuickSearchItem с stockQty
по storeId (если передан) или сумме по всем складам.
GET /api/catalog/products/by-barcode/{value}?storeId= — точный поиск
для сканера. 404 если 0 совпадений, объект QuickSearchItem если 1,
{ items: [...] } если несколько (для диалога выбора).
Why: новый UX inline-добавления строк в приёмке требует быстрого
поиска по штрихкоду/артикулу/названию с показом остатков прямо в
дропдауне; полный /products endpoint слишком тяжёлый.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Внутри секции «Цены» теперь двухколоночная сетка (lg:grid-cols-2):
закупка слева, цены продажи справа, с вертикальным разделителем.
На узких экранах (<lg) колонки складываются вертикально, как раньше.
В правой колонке цены продажи переведены на стандартный <Field>
с label сверху, чтобы выравниваться с полями закупки.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
В таблице позиций приёмки заголовок «Розничная (карточка)» теперь берёт
имя из справочника типов цен (priceTypes.find(isSystem)) — так чтобы
название совпадало с тем, что отображается в карточке товара и в
самом справочнике. Если системного типа нет — фолбэк на IsRetail,
а затем «Розничная».
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UI:
- Чекбокс «Проведено» переехал из шапки в секцию «Реквизиты документа»,
чтобы было визуально как в сторонняя система. С хинтом «только проведённый
документ влияет на остатки и себестоимость».
- Поле «Дата» помечено как обязательное (звёздочка + required).
- canSave требует form.lines.length > 0; пустое состояние секции
«Позиции» теперь красное «должна быть хотя бы одна позиция».
- onError приёмки достаёт сообщение из response.data.error.
API:
- Create/Update приёмки 400-ят без позиций
(«Приёмка должна содержать хотя бы одну позицию.»).
- Post (проведение) уже валидирует это; теперь и на этапе сохранения.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Колонку organizations.ShowDescriptionOnProduct логически вводила миграция
Phase3b_DropProductShelfLifeDays, но я дописал её туда задним числом
после того как миграция уже применилась на стенде. EF проверяет только
__EFMigrationsHistory.MigrationId, не содержимое — поэтому колонка
не создалась, а после деплоя API падал в DevDataSeeder с
«column o.ShowDescriptionOnProduct does not exist».
Правильное лекарство — отдельная догоняющая миграция с idempotent
ALTER TABLE … ADD COLUMN IF NOT EXISTS. На стенде колонка уже добавлена
вручную и запись в истории миграций есть; пайплайн пройдёт мимо неё.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Опциональный onCreate(label) пропс — если задан и пользователь набрал
текст, не совпадающий ни с одним пунктом списка, в дропдауне появляется
кнопка «Создать «query»». По клику колбэк создаёт сущность на сервере
и возвращает id, который сразу подставляется как выбранное значение.
Enter в пустом результате тоже триггерит создание.
Подключено в приёмке для поля «Поставщик» — POST в counterparties с
дефолтами (Type=LegalEntity, остальные поля null), затем invalidate
лукапа. Полные реквизиты редактируются позже в справочнике.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Заменили нативный <select> на кастомный комбобокс с поисковой строкой:
кликабельная кнопка-триггер, dropdown с input «Поиск…» и фильтрацией
по подстроке, навигация ↑/↓/Enter/Esc. API совместим — onChange получает
synthetic event с e.target.value, поэтому все 22 существующих <Select>
работают без правок call site. Дочерние <option> парсятся в options
автоматически (поддержка <optgroup>).
Why: справочники быстро растут (поставщики, страны, типы цен) — выбор
из длинного списка через нативный select утомителен. Поиск нужен везде.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Product card:
- Barcodes moved inside «Основное», before description.
- Description hidden behind new ShowDescriptionOnProduct setting (default false).
- «Закупка» и «Цены продажи» объединены в один блок «Цены».
Supply (приёмка):
- Удалены поля «Дата накладной» и «№ накладной поставщика»
(избыточны: дата документа уже есть, номер можно положить в Notes).
- Поле «Склад *» скрывается если в системе всего один склад
— для большинства мелких магазинов лишний клик не нужен.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Удаление поля «Срок годности (дней)»:
• Domain.Product.ShelfLifeDays убран,
• миграция Phase3b_DropProductShelfLifeDays — DROP COLUMN,
• DTO/Input/UI/фильтр в списке товаров — выпилены.
- Перекомпоновка секции «Классификация» в карточке товара:
• ряд 1 (3 col): Группа * | Единица измерения * | Фасовка,
• ряд 2 (2 col): Основной поставщик | Страна происхождения,
• Страна происхождения видна только если включена настройка
Organization.ShowCountryOfOriginOnProduct (default false).
• Та же миграция добавляет колонку, в OrganizationSettingsPage
появляется галка «Показывать «Страну происхождения» на товаре»
с подсказкой про импорт.
- Артикул теперь обязательное поле с авто-генерацией:
• ProductEditPage: метка «Артикул *», required,
• генератор generateArticle() (timestamp[-6] + 3 random) — у нового
товара поле сразу заполнено,
• canSave требует непустой article. Уникальность подтверждает
сервер (он также имеет свой fallback-генератор max+1).
- Иконка корзины в секции «Штрихкоды» рендерится только при
form.barcodes.length > 1 — для единственной строки удаления нет
(минимум 1 штрихкод обязателен, удалять единственный нельзя).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Баг: переключение «Обязательная» в /catalog/price-types не приводило
к валидации в карточке товара — Save шёл и проходил, не требуя цену.
Корневая причина — два разных queryKey:
- useCatalogMutations в PriceTypesPage инвалидирует
['/api/catalog/price-types'] (для самой List-страницы),
- но usePriceTypes-хук, которым пользуется ProductEditPage и
ProductsPage, живёт под ключом ['lookup:price-types'].
В итоге свежий снапшот справочника не доходит до потребителей.
Фикс:
- useLookups: staleTime=0 + refetchOnMount: 'always' +
refetchOnWindowFocus=true. Любой переход на ProductEditPage
делает свежий GET и видит актуальный IsRequired/IsRetail.
- PriceTypesPage save/delete: дополнительно вызывают
qc.invalidateQueries({ queryKey: ['lookup:price-types'] }) —
потребители тут же перерендериваются, без необходимости F5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PriceType: убран флаг IsDefault — он семантически дублировал IsSystem
(защищённая запись «по умолчанию»). Остаются IsSystem / IsRequired /
IsRetail.
- Domain.Catalog.PriceType: удалено поле IsDefault.
- Миграция Phase3b_DropPriceTypeIsDefault: DROP COLUMN.
- DTO/Input (PriceTypeDto, PriceTypeInput) — без IsDefault.
- PriceTypesController:
• убрана логика uniqueness IsDefault на Create/Update,
• IsRetail теперь enforce'ит уникальность: при установке IsRetail=true
у других записей сбрасывается,
• при удалении единственной IsRetail записи (если она не системная)
IsRetail автоматически переезжает на IsSystem-запись — у организации
всегда остаётся один POS-кандидат.
- ProductsController.RecalcRetail и SuppliesController.SetDefaultRetail —
поиск дефолтной розничной идёт по IsSystem → IsRetail → SortOrder → Name
(ранее ThenByDescending(IsDefault) — выпилено).
- DevDataSeeder: поле IsDefault убрано.
- web types.ts: убрано isDefault из PriceType.
- PriceTypesPage:
• убран чекбокс «По умолчанию»,
• лейбл «Розничная (используется на кассе)» → «Используется на кассе»,
• Form/blankForm/onRowClick без isDefault.
- ProductsPage / ProductEditPage: фоллбэк дефолтной цены теперь
IsSystem → IsRetail → первая.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Сервер 400-нул сохранение товара когда обязательная цена пуста, а UI
показывал «Request failed with status code 400» без указания причины.
- onError save mutation: достаём response.data.error из axios-ответа
и кладём в setError; общий generic message остаётся как fallback.
- canSave дополнен проверкой что у каждого PriceType с IsRequired=true
есть строка в form.prices с amount > 0 (та же что делает бэкенд,
чтобы кнопка не отправляла запрос обречённый на 400).
- Под секцией цен — красная подсказка-список незаполненных обязательных
типов цен («Заполни обязательные цены: «Розничная2» (значение должно
быть больше 0).»).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase3b сидер ошибочно создавал НОВУЮ запись «Розничная цена» с
IsSystem=true в каждой организации, не проверяя что фактически
системной была другая запись (с реальными ценами у товаров).
В итоге IsSystem-замок оказывался не у той записи.
Миграция Phase3b_FixPriceTypeIsSystem (идемпотентная):
- Снимает IsSystem со всех записей.
- Помечает IsSystem=true + IsRequired=true тому PriceType, у которого
максимум связанных ProductPrice (приоритет — фактически
использующейся цене); при равенстве — самая старая (CreatedAt ASC).
- Если у организации вообще нет PriceType — создаёт «Розничная цена»
(IsSystem=true, IsRequired=true).
DevDataSeeder: «Розничная» переименована в «Розничная цена», добавлены
IsSystem=true / IsRequired=true; работает только если у организации
ноль PriceType — больше не шлёпает дубль.
API валидация (ProductsController.Create/Update):
- FindMissingRequiredPriceAsync: для каждого PriceType с IsRequired=true
проверяет, что в input.Prices есть запись с Amount > 0. Иначе
возвращает 400 «Цена «<имя>» обязательна и должна быть больше 0.».
API фильтр+сортировка по системной цене:
- ProductsController.List: query parameters systemPriceFrom / systemPriceTo
применяют ≥ / ≤ к Prices.Where(IsSystem).Amount.
- Sort key 'systemPrice' — OrderBy / OrderByDescending по той же
системной цене.
Web ProductsPage:
- Filters.referencePriceFrom/To → systemPriceFrom/To, бэк-параметры
systemPriceFrom/To.
- Подпись фильтра — динамическое имя системного PriceType (имя из
справочника, обновляется при переименовании).
- Колонка системной цены получила sortKey='systemPrice'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Раньше каждая цена в карточке товара рендерилась как dropdown «выбор
PriceType» + поле ввода + кнопка удаления. Это было избыточно:
типы цен и так фиксированы справочником, выбирать нечего.
Теперь:
- Идём по справочнику PriceType (отсортирован по SortOrder→Name).
- На каждый PriceType — одна строка: label = pt.Name, поле MoneyInput.
- IsRequired запись помечается красной звёздочкой * после имени.
- Стерев значение — строка убирается из form.prices (UI пусто).
- Введя значение — создаётся новая запись (currency = KZT
fallback из справочника), либо обновляется существующая.
- Кнопка «+ Добавить» и иконка удаления убраны — управление набором
типов цен теперь только через «Настройки → Типы цен».
addPrice/removePrice вспомогательные функции удалены за ненадобностью.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Field.tsx: новый компонент PercentInput (брат MoneyInput) — только
цифры + точка/запятая, до 2 знаков после, суффикс «%». На onBlur
нормализуется в Math.round(n * 100) / 100. null = пусто.
Использует тот же draft-pattern что MoneyInput, чтобы при наборе
«12.» точка не пропадала.
- ProductGroupsPage:
• поле «Наценка %» в модалке заменено на PercentInput,
• в колонке «Наценка» таблицы теперь inline-PercentInput с
автосохранением через PUT /api/catalog/product-groups/{id} и
инвалидацией листинга после ответа сервера. Click stopped — клик
в инпут не открывает модалку.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ProductEditPage:
- В секции «Основное» добавлено поле «Срок годности (дней)» рядом с
Артикулом — NumberInput, целое ≥ 0, hint «не обязательное поле».
- form.shelfLifeDays хранится строкой и сериализуется в payload как
number | null.
ProductsPage filters:
- Добавлен диапазон «Срок годности (дней) — от / до».
- Удалён остаток фильтра isActive (поле выпилено в Phase3b foundation).
ProductsController.List:
- Принимает shelfLifeDaysFrom / shelfLifeDaysTo (int?) и применяет
≥ / ≤ к p.ShelfLifeDays.
- Также теперь принимает referencePriceFrom / referencePriceTo (новые
имена); старые purchasePriceFrom/To работают как алиасы для
обратной совместимости с уже отрендеренным UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Supply edit page:
- Кнопки «Провести / Отменить проведение» заменены на чекбокс «Проведено»
у заголовка. При попытке отметки — confirm «После проведения товары
будут оприходованы на склад. Продолжить?», при снятии — confirm
«Снять проведение? Остатки откатятся, себестоимость останется
(пересчитать вручную при необходимости).».
- Чекбокс disabled пока хотя бы одна строка пустая (Quantity ≤ 0
или UnitPrice ≤ 0) или вообще нет строк.
- Backend (post/unpost) уже корректно делает StockMovement и
пересчёт цен из Phase3a — UI просто переключает status.
Products list:
- Колонка «Эталонная цена» заменена на колонку системной розничной.
Заголовок = Name той PriceType что IsSystem=true (если пользователь
переименовал «Розничная цена» → «Продажная цена», заголовок
колонки автоматически становится «Продажная цена»).
- Значение = Product.Prices[ системного PriceType ].Amount.
Если у товара нет такой записи — «—» (тире).
- Подпись фильтра «Закупочная цена» → «Эталонная цена» (поведение
фильтра по диапазону цены остаётся прежним).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Domain + миграция Phase3b_PricingCleanup:
- DROP IsActive у products / product_groups / units_of_measure /
counterparties / price_types (включая индекс
IX_products_OrganizationId_IsActive). В этих сущностях концепт
деактивации не оправдан — если товар/группа/единица/контрагент
не нужны, их физически удаляют.
- DROP organizations.MultiplePriceTypesEnabled — раздел «Типы цен»
всегда виден, отдельной настройки больше не нужно.
- ADD price_types.IsRequired bool default false — обязательность
заполнения для каждого товара.
- ADD price_types.IsSystem bool default false — защищённая запись,
не удаляется и IsRequired всегда true; имя редактируется.
В каждой организации гарантируется одна системная запись
«Розничная цена» (создаётся миграцией если её нет).
- ADD products.ShelfLifeDays integer NULL — срок годности.
API:
- ProductsController/UnitsOfMeasureController/ProductGroupsController/
CounterpartiesController/PriceTypesController: убраны параметры
isActive в фильтрах, sort-keys, DTO, Apply, Создании.
- Products проекция: вместо IsActive теперь ShelfLifeDays.
- PriceTypesController: 400 при попытке удалить системную запись;
IsRequired у системной — всегда true, не меняется через PUT.
- recalc-retail / supply posting: дефолтный PriceType ищется по
IsSystem → IsDefault → IsRetail → SortOrder → Name (без IsActive).
- OrgSettingsDto/Input — без MultiplePriceTypesEnabled.
Web:
- types.ts: убраны isActive у Product/ProductGroup/UnitOfMeasure/
Counterparty/PriceType. PriceType пополнен isRequired/isSystem.
Product получил shelfLifeDays.
- useOrgSettings: убрано multiplePriceTypesEnabled.
- AppLayout: меню «Типы цен» всегда видно.
- Pages (Counterparties/Units/ProductGroups/PriceTypes/ProductEdit/
OrganizationSettings): сняты колонки/чекбоксы/поля «Активен»;
удалён GroupMarkupsPage; в PriceTypesPage добавлен Lock-индикатор
системной записи и блок-подсказка, кнопка удаления скрыта.
- DemoCatalogSeeder и OtherSystem-импортёр: больше не пишут IsActive.
UI-перекомпоновка карточки товара (Phase3b пп.6/9), Supply Posted-toggle,
PercentInput, ShelfLifeDays-фильтр и редизайн прайс-секции — отдельными
коммитами далее по плану.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
В таблице строк приёмки добавлена колонка «Розничная (карточка)».
- Значение по умолчанию — текущая дефолтная розничная цена товара
(берётся из ProductDto.prices при подборе или из SupplyLineDto.
currentRetailPrice при загрузке существующего документа).
- Любая ручная правка ставит retailPriceManuallyOverridden=true и
записывает retailPriceOverride. При проведении документа этот
override применяется к Product.Prices[default] вместо автонаценки.
- В payload PUT/POST шлём retailPriceManuallyOverridden и
retailPriceOverride (либо null если override снят).
- types.SupplyLineDto расширен полями currentRetailPrice /
retailPriceManuallyOverridden / retailPriceOverride.
- В addLineFromProduct unitPrice fallbacks теперь учитывают cost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>