Compare commits

..

8 commits

Author SHA1 Message Date
nns fc9f7c9ee4 docs(audit): полный аудит цепочки авторизации — 2026-05-06
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 53s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m19s
Docker Web / Build + push Web (push) Successful in 35s
Docker API / Deploy API on stage (push) Failing after 38s
Docker Web / Deploy Web on stage (push) Successful in 13s
Завершающий пункт пакета фиксов по ролям/валидации/удалению. Обход:
1. /connect/token — IsActive + BelongsToLiveOrg + SuperAdmin bypass.
2. JWT cookie vs Bearer — все три AuthN-схемы переопределены в
   OpenIddictValidationAspNetCoreDefaults; cookie не активна для API.
3. X-Org-Override — фильтрует по IsInRole(SuperAdmin), подделать нельзя.
4. Tenant query filters — ITenantEntity и IOptionalTenantEntity
   подключаются через reflection, фильтр консистентен с tenant.context.
5. Smoke per-role — sidebar+RoleGuard за один проход покрывает все
   tenant-роуты; tenant-admin на /super-admin URL → описан risk + future fix.
6. Reset password / deactivate account — токены revoke в БД одним SQL.
7. Catch-22 для SuperAdmin платформы — он не Employee и не имеет
   OrganizationId, через текущие endpoint-ы deactivate невозможен.

Findings разбиты на critical (закрыто этим пакетом), high/medium (не
закрыто — будущая серия) и low (косметика).
2026-05-06 11:32:07 +05:00
nns f824e38959 feat(roles): фильтр sidebar и route-guard по ролям пользователя
Раньше 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-слой.
2026-05-06 11:32:07 +05:00
nns e8a28ba1f6 feat(employees): двухступенчатое удаление — «уволить» → «удалить»
Полное физическое удаление сотрудника невозможно — у него 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 — отображение «Иванов И.И. (удалён)» работает автоматически.
2026-05-06 11:32:07 +05:00
nns 0e4b7868c9 feat(forms): TextInput с type=email — авто-pattern для TLD-проверки
Раньше явный 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) — клиент и сервер консистентны.
2026-05-06 11:32:07 +05:00
nns a6ecc65b97 feat(forms): MoneyInput для поля «Оклад» в карточке сотрудника
Раньше 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.
2026-05-06 11:32:07 +05:00
nns dcb28a9811 feat(localization): убрать «ИНН» из UI — РК использует ИИН/БИН
ИНН — российское поле. В Казахстане физлица используют ИИН (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
при импорте контрагента (как и было).
2026-05-06 11:31:42 +05:00
nns c6ece2adea feat(roles): системная роль — read-only форма прав вместо alert
Раньше клик по системной роли в списке выкидывал alert «Системная
роль, изменения недоступны». Теперь открывается обычная модалка с
правами, но: имя/описание disabled, все чекбоксы disabled, кнопка
«Сохранить» скрыта (вместо неё «Закрыть»). Юзер видит ровно какие
галки стоят у Администратора/Кладовщика/Кассира — это нужно как
шаблон при создании кастомной роли.

Также description страницы и заголовок модалки обновлены под новый
смысл: системные = только просмотр; кастомные = полный CRUD.
2026-05-06 11:31:15 +05:00
nns b073e99ca7 feat(roles): три системные роли — Admin/Cashier/Storekeeper
Менеджер/Закупщик/Бухгалтер сидились как кастомные шаблоны вместе с
организацией, но при этом числились системными в 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.
2026-05-06 11:31:15 +05:00
28 changed files with 488 additions and 204 deletions

107
docs/audit-2026-05-06.md Normal file
View file

@ -0,0 +1,107 @@
# Системный аудит авторизации — 2026-05-06
Финальный пункт пакета фиксов по системе ролей. Прохожу цепочку
авторизации от логина до серверной защиты конкретных endpoint-ов
и фиксирую все findings; критичные — сразу починены, в коммитах
этого же дня.
## 1. Логин: OpenIddict /connect/token
`AuthorizationController.cs`:
- Password grant: `_userManager.FindByNameAsync` + `CheckPasswordSignInAsync` + проверка `User.IsActive`.
- После успешного password-grant — дополнительная проверка `CheckUserStillBelongsToLiveOrgAsync` (исключение для SuperAdmin): отказывает в токене, если `User.OrganizationId` указывает на удалённую/архивированную org. Это закрывает orphan-AppUser сценарий из аудита 2026-04-27.
- Refresh grant: повторно проверяет `IsActive` и `BelongsToLiveOrg`.
- Поле `org_id` пишется в JWT-claims как `HttpContextTenantContext.OrganizationClaim`.
**Status: OK.** Проверки покрывают: deactivated user, orphan org, SuperAdmin override.
## 2. JWT cookie vs Bearer
API использует только Bearer-токены через OpenIddict. Cookie-схему AspNetCore Identity подавляет `AddAuthentication` (см. `Program.cs:108-113` — все три схемы переопределены в `OpenIddictValidationAspNetCoreDefaults`). Это критично — иначе `[Authorize]` бы редиректил API-запросы на `/Account/Login`.
**Status: OK.**
## 3. X-Org-Override (impersonation)
`HttpContextTenantContext.cs`:
- `OrgOverrideHeader = "X-Org-Override"`.
- `TryGetHttpOverrideOrg`: возвращает `true` ТОЛЬКО если `User.IsInRole("SuperAdmin")` И header присутствует. Обычный юзер не может задать override (даже если подсунет header — `IsInRole` фильтрует).
- В режиме override `IsTenantOverride=true`. Tenant-фильтр в `AppDbContext.ApplyTenantFilter` строится так:
```
(IsSuperAdmin && !IsTenantOverride) || OrganizationId == _tenant.OrganizationId
```
То есть SuperAdmin без override видит всё; SuperAdmin в override — фильтр обязан применяться к выбранному `OrganizationId`. Ровно так, как нужно.
**Status: OK.** Проверка роли защищает от подделки header'а.
## 4. Tenant query filters
`AppDbContext.cs:109-153`:
- Для каждого `ITenantEntity` через reflection ставится `HasQueryFilter`.
- Для `IOptionalTenantEntity` (системные справочники с nullable `OrganizationId`) — отдельный фильтр: NULL-записи видны всем, остальные — обычная изоляция.
- Все Identity-таблицы (Users/Roles/UserRoles) — НЕ tenant-scoped (они не реализуют ITenantEntity), запросы к ним идут без фильтра. Это by design — Identity управляется через UserManager/RoleManager.
**Status: OK.**
## 5. Smoke (UI) ожидаемое поведение по ролям
Согласно `AppLayout.buildNav` (после step 7) и `RoleGuard` (новый в этом пакете):
| Юзер пытается зайти | Поведение |
|---|---|
| Cashier на `/super-admin/orgs` | TenantRouteGuard / RoleGuard **не пускает** на /super-admin (он под отдельным layout с `[Authorize(Roles = "SuperAdmin")]` на эндпойнтах). Юзер увидит «Нет доступа» из RoleGuard и/или 403 от API. |
| Storekeeper на `/settings/employees` | RoleGuard `roles=['Admin']` → «Нет доступа». |
| Cashier на `/catalog/counterparties` | RoleGuard `roles=['Admin']` → «Нет доступа». |
| Tenant-Admin на `/super-admin/...` | TenantRouteGuard для не-SuperAdmin не редиректит туда (он только tenant-роуты охраняет); сам `/super-admin` под `<SuperAdminLayout>` без guard'а, но все `[Authorize(Roles = "SuperAdmin")]` на endpoint-ах вернут 403. UI покажет 403-страницы пустые таблицы / ошибки. **Findings:** добавить RoleGuard на сам `<Route path="/super-admin">` чтобы Tenant-Admin не видел индиго-sidebar админа платформы. → Не сделано в этом пакете, описано как future. |
| 401 на любом запросе | `api.ts` interceptor: попытка refresh; если refresh упал — `clearTokens()` + редирект на `/login`. |
## 6. Reset пароля и инвалидация токенов
`SuperAdminEmployeesController.ResetPassword`:
- `_userMgr.RemovePasswordAsync` + `AddPasswordAsync(temp)`.
- `UPDATE OpenIddictTokens SET Status='revoked' WHERE Subject = userId AND Status='valid'` — обрывает все активные сессии.
`SuperAdminEmployeesController.ToggleAccountActive` при `IsActive=false`:
- Те же `revoked` для всех valid токенов.
**Status: OK.**
## 7. Catch-22: SuperAdmin блочит свою же учётку
`SuperAdminEmployeesController` оперирует на сущности `Employee` конкретной org (`/api/super-admin/organizations/{orgId}/employees/...`). SuperAdmin платформы — это `User` БЕЗ `OrganizationId` и БЕЗ `Employee`. Через этот контроллер до его учётки не дойти.
Других endpoint-ов, через которые можно `User.IsActive=false` для произвольного user-id — НЕТ. `SuperAdminOrganizationsController.Delete` деактивирует только тех, чей `OrganizationId` совпадает с удаляемой org — SuperAdmin платформы туда не попадает (`u.OrganizationId == null`).
**Status: OK сейчас.** Future risk: если добавится `/api/super-admin/users/...` с возможностью deactivate любого user-id, нужен гард `if (currentUserId == targetUserId) → 403 «нельзя себя»`. Запишу как TODO.
## 8. Findings (зафиксированы / не зафиксированы)
### Critical (зафиксировано в этом пакете)
| # | Описание | Где | Коммит |
|---|---|---|---|
| 1 | `Manager` — лишняя системная роль, путала UI и Authorize-гарды | SystemRoles, 13 контроллеров, DevDataSeeder | `fce9be9` |
| 2 | Системная роль выкидывала alert вместо show-permissions | EmployeeRolesPage | `77de34f` |
| 3 | ИИН-формы маркированы как «ИНН/ИИН» (РФ-термин) | EmployeesPage, Counterparties, SuperAdmin* | `9a31650` |
| 4 | Salary через `<input type=number>` (не учитывал org-настройку копеек) | EmployeesPage | `9f9d273` |
| 5 | type=email не требовал TLD на patternMismatch | TextInput общий | `ed7740e` |
| 6 | Удаление сотрудника одноступенчатое, нельзя «уволить → удалить» отдельно | Employee domain + EmployeesController + UI | `049e847` |
| 7 | Sidebar показывал Cashier/Storekeeper лишние пункты | AppLayout + RoleGuard + App.tsx | `542eff2` |
### High / Medium (не зафиксировано — отдельная серия)
- **Tenant-Admin может открыть `/super-admin` URL** и увидеть пустой индиго-sidebar (API вернёт 403 на каждый запрос). Нет RoleGuard на сам Route `/super-admin/*`. Фикс: обернуть `<Route element={<RoleGuard roles={['SuperAdmin']}><SuperAdminLayout /></RoleGuard>}>` или добавить ранний return в SuperAdminLayout.
- **`Authorize(Policy = "AdminAccess")`** в `MoySkladImportController`/`AdminJobsController`/`AdminCleanupController` — policy в `Program.cs:118-119` пропускает Admin **или** SuperAdmin. SuperAdmin без override проходит — нужен ли он там? Если нет (cleanup задачи tenant-scoped), тогда либо `IsTenantOverride` обязателен, либо policy сузить до Admin. Это не critical, но архитектурно хочется единообразия.
- **Catch-22 защита для будущего `/api/super-admin/users/...`** — если такой endpoint появится, нужно `if (currentUserId == targetUserId) → 403`. Сейчас такого endpoint-а нет, риска тоже нет.
- **Identity-Manager-роль** (`AddToRoleAsync(user, "Manager")`) использовалась только в DevDataSeeder и signup; обе ветки убраны в `fce9be9`. У существующих юзеров Identity-роль `Manager` может остаться в БД (раньше signup её ставил → нет, signup ставил `Admin`, Manager только для dev-сидов). Нужно ли `RoleManager.DeleteAsync(Manager)`? Решение: оставил; роль есть в БД, но нигде не назначается и не используется в коде. Безопасно.
- **DevDataSeeder продолжает создавать Demo Market и admin@food-market.local** на каждом старте API. Для production это лишнее — Demo Market и dev-admin засоряют prod-БД. Не критично сейчас (dev-данные предсказуемы), но стоит вынести seed в `IsDevelopment()`.
- **MoneyInput не используется** в SuperAdminOrgEmployeesPage (там нет поля Salary). При добавлении Salary в SuperAdmin-form'у нужно сразу применять MoneyInput.
### Low (косметика / документация)
- `EmployeesPage.useEffect` имеет логику дефолтной роли «Менеджер ?? roles[0]» — после удаления Manager-сидера всегда упадёт на roles[0]. Не баг, но стоит переписать на `Кассир` или `Кладовщик` как дефолт.
- `EmployeeRole.cs` summary упоминает «Менеджер/Кладовщик/Закупщик/Бухгалтер» — устарело, обновить.
## Итог
Все 8 пунктов задачи закрыты или зафиксированы в этом отчёте. 7 атомарных коммитов между `fce9be9..542eff2`. Билд и API, и web проходят чисто (0 errors). Финальный отчёт по аудиту оставляю в отдельном коммите вместе с обновлённой docs-секцией.

View file

@ -69,7 +69,7 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes); c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes);
} }
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPost, Authorize(Roles = "Admin,Storekeeper")]
public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyInput input, CancellationToken ct) public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyInput input, CancellationToken ct)
{ {
var e = Apply(new Counterparty(), input); var e = Apply(new Counterparty(), input);
@ -78,7 +78,7 @@ public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyI
return CreatedAtAction(nameof(Get), new { id = e.Id }, await ProjectAsync(e.Id, ct)); return CreatedAtAction(nameof(Get), new { id = e.Id }, await ProjectAsync(e.Id, ct));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput input, CancellationToken ct)
{ {
var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct);

View file

@ -49,7 +49,7 @@ public async Task<ActionResult<PriceTypeDto>> Get(Guid id, CancellationToken ct)
return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsRetail, p.SortOrder); return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsRetail, p.SortOrder);
} }
[HttpPost, Authorize(Roles = "Admin,Manager")] [HttpPost, Authorize(Roles = "Admin")]
public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct) public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct)
{ {
if (input.IsRetail) if (input.IsRetail)
@ -72,7 +72,7 @@ await _db.PriceTypes.Where(p => p.IsRetail)
new PriceTypeDto(e.Id, e.Name, e.IsRequired, e.IsSystem, e.IsRetail, e.SortOrder)); new PriceTypeDto(e.Id, e.Name, e.IsRequired, e.IsSystem, e.IsRetail, e.SortOrder));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input, CancellationToken ct)
{ {
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);

View file

@ -56,7 +56,7 @@ public async Task<ActionResult<ProductGroupDto>> Get(Guid id, CancellationToken
return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId); return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId);
} }
[HttpPost, Authorize(Roles = "Admin,Manager")] [HttpPost, Authorize(Roles = "Admin")]
public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupInput input, CancellationToken ct) public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupInput input, CancellationToken ct)
{ {
var path = await BuildPathAsync(input.ParentId, input.Name, ct); var path = await BuildPathAsync(input.ParentId, input.Name, ct);
@ -72,7 +72,7 @@ public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupI
new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.MarkupPercent, e.OrganizationId)); new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.MarkupPercent, e.OrganizationId));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,SuperAdmin")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken ct)
{ {
var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct);

View file

@ -49,7 +49,7 @@ public async Task<ActionResult<IReadOnlyList<ImageDto>>> List(Guid productId, Ca
return images; return images;
} }
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPost, Authorize(Roles = "Admin,Storekeeper")]
[RequestSizeLimit(MaxBytes)] [RequestSizeLimit(MaxBytes)]
public async Task<ActionResult<ImageDto>> Upload(Guid productId, IFormFile file, CancellationToken ct) public async Task<ActionResult<ImageDto>> Upload(Guid productId, IFormFile file, CancellationToken ct)
{ {
@ -90,7 +90,7 @@ public async Task<ActionResult<ImageDto>> Upload(Guid productId, IFormFile file,
return new ImageDto(entity.Id, entity.Url, entity.IsMain, entity.SortOrder); return new ImageDto(entity.Id, entity.Url, entity.IsMain, entity.SortOrder);
} }
[HttpDelete("{imageId:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpDelete("{imageId:guid}"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Delete(Guid productId, Guid imageId, CancellationToken ct) public async Task<IActionResult> Delete(Guid productId, Guid imageId, CancellationToken ct)
{ {
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct); var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
@ -128,7 +128,7 @@ public async Task<IActionResult> Delete(Guid productId, Guid imageId, Cancellati
return NoContent(); return NoContent();
} }
[HttpPost("{imageId:guid}/main"), Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPost("{imageId:guid}/main"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> SetMain(Guid productId, Guid imageId, CancellationToken ct) public async Task<IActionResult> SetMain(Guid productId, Guid imageId, CancellationToken ct)
{ {
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct); var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);

View file

@ -195,7 +195,7 @@ public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
return p is null ? NotFound() : p; return p is null ? NotFound() : p;
} }
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPost, Authorize(Roles = "Admin,Storekeeper")]
public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct) public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
{ {
if (input.Barcodes is null || input.Barcodes.Count == 0) if (input.Barcodes is null || input.Barcodes.Count == 0)
@ -232,7 +232,7 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
return CreatedAtAction(nameof(Get), new { id = e.Id }, dto); return CreatedAtAction(nameof(Get), new { id = e.Id }, dto);
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct)
{ {
if (input.Barcodes is null || input.Barcodes.Count == 0) if (input.Barcodes is null || input.Barcodes.Count == 0)
@ -312,7 +312,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
/// <summary>«Привести розничную к себестоимости»: ставит дефолтную /// <summary>«Привести розничную к себестоимости»: ставит дефолтную
/// розничную цену = ceil(Cost * (1 + Group.MarkupPercent/100)). Если у /// розничную цену = ceil(Cost * (1 + Group.MarkupPercent/100)). Если у
/// группы товара не задан MarkupPercent — 400 с подсказкой.</summary> /// группы товара не задан MarkupPercent — 400 с подсказкой.</summary>
[HttpPost("{id:guid}/recalc-retail"), Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPost("{id:guid}/recalc-retail"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct) public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
{ {
var p = await _db.Products var p = await _db.Products
@ -361,7 +361,7 @@ public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
return Ok(new { retail = newRetail }); return Ok(new { retail = newRetail });
} }
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var e = await _db.Products.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.Products.FirstOrDefaultAsync(x => x.Id == id, ct);
@ -378,7 +378,7 @@ public record DuplicateProductRef(Guid ProductId, string ProductName, string? Ar
/// организации. Уникальный индекс это запрещает в новых записях, но реальная /// организации. Уникальный индекс это запрещает в новых записях, но реальная
/// БД может содержать исторические дубли (например, после ручной правки). /// БД может содержать исторические дубли (например, после ручной правки).
/// Используется UI чистки (/admin/cleanup) и отчётом MoySklad-импорта.</summary> /// Используется UI чистки (/admin/cleanup) и отчётом MoySklad-импорта.</summary>
[HttpGet("barcode-duplicates"), Authorize(Roles = "Admin,Manager")] [HttpGet("barcode-duplicates"), Authorize(Roles = "Admin")]
public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicates(CancellationToken ct) public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicates(CancellationToken ct)
{ {
var rows = await _db.ProductBarcodes var rows = await _db.ProductBarcodes

View file

@ -56,7 +56,7 @@ public async Task<ActionResult<RetailPointDto>> Get(Guid id, CancellationToken c
r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive); r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive);
} }
[HttpPost, Authorize(Roles = "Admin,Manager")] [HttpPost, Authorize(Roles = "Admin")]
public async Task<ActionResult<RetailPointDto>> Create([FromBody] RetailPointInput input, CancellationToken ct) public async Task<ActionResult<RetailPointDto>> Create([FromBody] RetailPointInput input, CancellationToken ct)
{ {
var store = await _db.Stores.FirstOrDefaultAsync(s => s.Id == input.StoreId, ct); var store = await _db.Stores.FirstOrDefaultAsync(s => s.Id == input.StoreId, ct);
@ -76,7 +76,7 @@ public async Task<ActionResult<RetailPointDto>> Create([FromBody] RetailPointInp
e.Address, e.Phone, e.FiscalSerial, e.FiscalRegNumber, e.IsActive)); e.Address, e.Phone, e.FiscalSerial, e.FiscalRegNumber, e.IsActive));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Update(Guid id, [FromBody] RetailPointInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] RetailPointInput input, CancellationToken ct)
{ {
var e = await _db.RetailPoints.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.RetailPoints.FirstOrDefaultAsync(x => x.Id == id, ct);

View file

@ -55,7 +55,7 @@ public async Task<ActionResult<StoreDto>> Get(Guid id, CancellationToken ct)
return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive); return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
} }
[HttpPost, Authorize(Roles = "Admin,Manager")] [HttpPost, Authorize(Roles = "Admin")]
public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, CancellationToken ct) public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, CancellationToken ct)
{ {
if (input.IsMain) if (input.IsMain)
@ -73,7 +73,7 @@ public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, Ca
new StoreDto(e.Id, e.Name, e.Code, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive)); new StoreDto(e.Id, e.Name, e.Code, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Update(Guid id, [FromBody] StoreInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] StoreInput input, CancellationToken ct)
{ {
var e = await _db.Stores.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.Stores.FirstOrDefaultAsync(x => x.Id == id, ct);

View file

@ -48,7 +48,7 @@ public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId); return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId);
} }
[HttpPost, Authorize(Roles = "Admin,Manager")] [HttpPost, Authorize(Roles = "Admin")]
public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct) public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct)
{ {
var e = new UnitOfMeasure var e = new UnitOfMeasure
@ -63,7 +63,7 @@ public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasur
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.OrganizationId)); new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.OrganizationId));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,SuperAdmin")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput input, CancellationToken ct)
{ {
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);

View file

@ -55,7 +55,7 @@ public async Task<ActionResult<EmployeeRoleDto>> Get(Guid id, CancellationToken
return r is null ? NotFound() : new EmployeeRoleDto(r.Id, r.Name, r.Description, r.IsSystem, r.SortOrder, r.Permissions); return r is null ? NotFound() : new EmployeeRoleDto(r.Id, r.Name, r.Description, r.IsSystem, r.SortOrder, r.Permissions);
} }
[HttpPost, Authorize(Roles = "SuperAdmin,Admin,Manager")] [HttpPost, Authorize(Roles = "SuperAdmin,Admin")]
public async Task<ActionResult<EmployeeRoleDto>> Create([FromBody] EmployeeRoleInput input, CancellationToken ct) public async Task<ActionResult<EmployeeRoleDto>> Create([FromBody] EmployeeRoleInput input, CancellationToken ct)
{ {
var role = new EmployeeRole var role = new EmployeeRole
@ -72,7 +72,7 @@ public async Task<ActionResult<EmployeeRoleDto>> Create([FromBody] EmployeeRoleI
new EmployeeRoleDto(role.Id, role.Name, role.Description, role.IsSystem, role.SortOrder, role.Permissions)); new EmployeeRoleDto(role.Id, role.Name, role.Description, role.IsSystem, role.SortOrder, role.Permissions));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin,Manager")] [HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")]
public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeRoleInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeRoleInput input, CancellationToken ct)
{ {
var r = await _db.EmployeeRoles.FirstOrDefaultAsync(x => x.Id == id, ct); var r = await _db.EmployeeRoles.FirstOrDefaultAsync(x => x.Id == id, ct);

View file

@ -32,6 +32,9 @@ public record EmployeeDto(
decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl, decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl,
Guid RoleId, string RoleName, Guid RoleId, string RoleName,
bool IsActive, DateTime? FiredAt, bool IsActive, DateTime? FiredAt,
bool IsDeleted, DateTime? DeletedAt,
// active | fired | deleted — производное от IsActive/IsDeleted, удобно для UI-бейджа
string Status,
IReadOnlyList<Guid> RetailPointIds, IReadOnlyList<Guid> RetailPointIds,
bool IsOwner, bool IsSelf); bool IsOwner, bool IsSelf);
@ -49,7 +52,9 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
[HttpGet] [HttpGet]
public async Task<ActionResult<PagedResult<EmployeeDto>>> List( public async Task<ActionResult<PagedResult<EmployeeDto>>> List(
[FromQuery] PagedRequest req, CancellationToken ct) [FromQuery] PagedRequest req,
[FromQuery] string? status, // active | fired | deleted | all (default: active+fired)
CancellationToken ct = default)
{ {
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var ownerUserId = await _db.Organizations.IgnoreQueryFilters() var ownerUserId = await _db.Organizations.IgnoreQueryFilters()
@ -60,6 +65,16 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
?? User.FindFirst("sub")?.Value); ?? User.FindFirst("sub")?.Value);
var q = _db.Employees.AsNoTracking().Include(e => e.Role).Include(e => e.RetailPointAssignments).AsQueryable(); var q = _db.Employees.AsNoTracking().Include(e => e.Role).Include(e => e.RetailPointAssignments).AsQueryable();
// Фильтр по статусу. По умолчанию (status=null) — показываем активных
// и уволенных, удалённых скрываем; «all» включает удалённых.
switch (status)
{
case "active": q = q.Where(e => e.IsActive && !e.IsDeleted); break;
case "fired": q = q.Where(e => !e.IsActive && !e.IsDeleted); break;
case "deleted": q = q.Where(e => e.IsDeleted); break;
case "all": /* без фильтра */ break;
default: q = q.Where(e => !e.IsDeleted); break;
}
if (!string.IsNullOrWhiteSpace(req.Search)) if (!string.IsNullOrWhiteSpace(req.Search))
{ {
var s = req.Search.Trim().ToLower(); var s = req.Search.Trim().ToLower();
@ -79,6 +94,8 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
e.Salary, e.TaxNumber, e.Description, e.ImageUrl, e.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name, e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt, e.IsActive, e.FiredAt,
e.IsDeleted, e.DeletedAt,
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(), e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
e.UserId != null && ownerUserId == e.UserId, e.UserId != null && ownerUserId == e.UserId,
e.UserId != null && currentUserId != null && e.UserId == currentUserId)) e.UserId != null && currentUserId != null && e.UserId == currentUserId))
@ -94,7 +111,7 @@ public async Task<ActionResult<EmployeeDto>> Get(Guid id, CancellationToken ct)
return dto is null ? NotFound() : Ok(dto); return dto is null ? NotFound() : Ok(dto);
} }
[HttpPost, Authorize(Roles = "SuperAdmin,Admin,Manager")] [HttpPost, Authorize(Roles = "SuperAdmin,Admin")]
public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] EmployeeInput input, CancellationToken ct) public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] EmployeeInput input, CancellationToken ct)
{ {
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
@ -148,7 +165,7 @@ public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] Employee
return new EmployeeCreateResult(dto!, tempPassword); return new EmployeeCreateResult(dto!, tempPassword);
} }
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin,Manager")] [HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")]
public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input, CancellationToken ct)
{ {
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
@ -219,17 +236,18 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); if (e is null) return NotFound();
// Soft-delete c гардами: главного администратора — нельзя, себя — нельзя. // Двухступенчатое удаление:
// Главный администратор удаляется только Супер-администратором платформы. // IsActive=true → этот endpoint выполняет «увольнение» (Fired).
// Если кому-то критично hard-delete (cleanup при удалении org) — это идёт // IsActive=false && IsDeleted=false → этот endpoint выполняет soft-delete.
// через SuperAdmin консоль, а не через этот эндпоинт. // IsDeleted=true → 409, уже удалён.
// Гарды (главный админ + self) применяются на ОБОИХ шагах.
var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value); ?? User.FindFirst("sub")?.Value);
if (currentUserId is not null && e.UserId == currentUserId) if (currentUserId is not null && e.UserId == currentUserId)
{ {
return StatusCode(StatusCodes.Status403Forbidden, new return StatusCode(StatusCodes.Status403Forbidden, new
{ {
error = "Нельзя удалить себя. Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.", error = "Нельзя уволить или удалить себя. Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.",
}); });
} }
@ -249,8 +267,21 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
} }
} }
e.IsActive = false; if (e.IsDeleted)
e.FiredAt ??= DateTime.UtcNow; return Conflict(new { error = "Сотрудник уже удалён." });
if (e.IsActive)
{
// Шаг 1: увольнение.
e.IsActive = false;
e.FiredAt ??= DateTime.UtcNow;
}
else
{
// Шаг 2: soft-delete (физически не удаляем — есть FK из retail_sales/supplies).
e.IsDeleted = true;
e.DeletedAt = DateTime.UtcNow;
}
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
} }
@ -276,6 +307,8 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
e.Salary, e.TaxNumber, e.Description, e.ImageUrl, e.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name, e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt, e.IsActive, e.FiredAt,
e.IsDeleted, e.DeletedAt,
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(), e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
e.UserId != null && ownerUserId == e.UserId, e.UserId != null && ownerUserId == e.UserId,
e.UserId != null && currentUserId != null && e.UserId == currentUserId)) e.UserId != null && currentUserId != null && e.UserId == currentUserId))

View file

@ -65,7 +65,7 @@ public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
return Project(o, vat); return Project(o, vat);
} }
[HttpPut("settings"), Authorize(Roles = "Admin,Manager")] [HttpPut("settings"), Authorize(Roles = "Admin")]
public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInput input, CancellationToken ct) public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInput input, CancellationToken ct)
{ {
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");

View file

@ -121,7 +121,7 @@ public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
return dto is null ? NotFound() : Ok(dto); return dto is null ? NotFound() : Ok(dto);
} }
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPost, Authorize(Roles = "Admin,Storekeeper")]
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct) public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
{ {
if (input.Lines is null || input.Lines.Count == 0) if (input.Lines is null || input.Lines.Count == 0)
@ -164,7 +164,7 @@ public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input,
return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto); return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto);
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct)
{ {
if (input.Lines is null || input.Lines.Count == 0) if (input.Lines is null || input.Lines.Count == 0)
@ -208,7 +208,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, C
return NoContent(); return NoContent();
} }
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var supply = await _db.Supplies.FirstOrDefaultAsync(s => s.Id == id, ct); var supply = await _db.Supplies.FirstOrDefaultAsync(s => s.Id == id, ct);
@ -220,7 +220,7 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
return NoContent(); return NoContent();
} }
[HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct) public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{ {
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
@ -322,7 +322,7 @@ private void SetDefaultRetail(foodmarket.Domain.Catalog.Product p, decimal value
} }
} }
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct) public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{ {
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);

View file

@ -194,7 +194,7 @@ public async Task<ActionResult<RetailSaleDto>> Get(Guid id, CancellationToken ct
return dto is null ? NotFound() : Ok(dto); return dto is null ? NotFound() : Ok(dto);
} }
[HttpPost, Authorize(Roles = "Admin,Manager,Cashier")] [HttpPost, Authorize(Roles = "Admin,Cashier")]
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct) public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
{ {
var number = await GenerateNumberAsync(input.Date, ct); var number = await GenerateNumberAsync(input.Date, ct);
@ -221,7 +221,7 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
return CreatedAtAction(nameof(Get), new { id = sale.Id }, dto); return CreatedAtAction(nameof(Get), new { id = sale.Id }, dto);
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Cashier")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Cashier")]
public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct)
{ {
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
@ -249,7 +249,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput inpu
return NoContent(); return NoContent();
} }
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var sale = await _db.RetailSales.FirstOrDefaultAsync(s => s.Id == id, ct); var sale = await _db.RetailSales.FirstOrDefaultAsync(s => s.Id == id, ct);
@ -261,7 +261,7 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
return NoContent(); return NoContent();
} }
[HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Manager,Cashier")] [HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Cashier")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct) public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{ {
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
@ -289,7 +289,7 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
return NoContent(); return NoContent();
} }
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager")] [HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct) public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{ {
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);

View file

@ -30,7 +30,7 @@ public async Task StartAsync(CancellationToken ct)
var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<User>>(); var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
var roleMgr = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>(); var roleMgr = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>();
foreach (var role in new[] { SystemRoles.SuperAdmin, SystemRoles.Admin, SystemRoles.Manager, SystemRoles.Cashier, SystemRoles.Storekeeper }) foreach (var role in new[] { SystemRoles.SuperAdmin, SystemRoles.Admin, SystemRoles.Cashier, SystemRoles.Storekeeper })
{ {
if (!await roleMgr.RoleExistsAsync(role)) if (!await roleMgr.RoleExistsAsync(role))
{ {
@ -222,10 +222,11 @@ public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
await SeedEmployeeRolesAsync(db, orgId, ct); await SeedEmployeeRolesAsync(db, orgId, ct);
} }
/// <summary>Системные роли (IsSystem=true): Администратор / Менеджер / /// <summary>Системные роли (IsSystem=true): только Администратор / Кладовщик /
/// Кладовщик / Кассир / Закупщик / Бухгалтер. Сидируется один раз /// Кассир. Менеджеры/Закупщики/Бухгалтеры и пр. — это кастомные роли,
/// per организацию; обновлять не пытаемся, чтобы не сбросить кастомные /// которые создаёт администратор орги сам через UI «Настроить права ролей».
/// правки галок которые админ мог сделать.</summary> /// Системные нельзя редактировать или удалить из UI; только просмотр.
/// Сидируется один раз per организацию.</summary>
private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, CancellationToken ct) private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, CancellationToken ct)
{ {
var anyRole = await db.EmployeeRoles.IgnoreQueryFilters().AnyAsync(r => r.OrganizationId == orgId, ct); var anyRole = await db.EmployeeRoles.IgnoreQueryFilters().AnyAsync(r => r.OrganizationId == orgId, ct);
@ -239,28 +240,12 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca
IsSystem = true, SortOrder = 0, IsSystem = true, SortOrder = 0,
Permissions = RolePermissions.All(), Permissions = RolePermissions.All(),
}; };
// Менеджер/Кладовщик/Закупщик/Бухгалтер — кастомные шаблоны (IsSystem=false),
// юзер может удалить или подкрутить под себя. Системные только Администратор + Кассир.
var manager = new EmployeeRole
{
OrganizationId = orgId,
Name = "Менеджер",
Description = "Управление каталогом, документами и контрагентами",
IsSystem = false, SortOrder = 10,
Permissions = new RolePermissions
{
ProductsView = true, ProductsEdit = true, ProductGroupsManage = true, PriceTypesManage = true,
SuppliesView = true, SuppliesEdit = true, SuppliesPost = true,
CounterpartiesView = true, CounterpartiesEdit = true,
ReportsView = true, StocksView = true,
},
};
var keeper = new EmployeeRole var keeper = new EmployeeRole
{ {
OrganizationId = orgId, OrganizationId = orgId,
Name = "Кладовщик", Name = "Кладовщик",
Description = "Приёмки, инвентаризация, остатки", Description = "Приёмки, инвентаризация, остатки",
IsSystem = false, SortOrder = 20, IsSystem = true, SortOrder = 10,
Permissions = new RolePermissions Permissions = new RolePermissions
{ {
ProductsView = true, ProductsView = true,
@ -273,7 +258,7 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca
OrganizationId = orgId, OrganizationId = orgId,
Name = "Кассир", Name = "Кассир",
Description = "Только работа на кассе. Без доступа к веб-админке.", Description = "Только работа на кассе. Без доступа к веб-админке.",
IsSystem = true, SortOrder = 30, IsSystem = true, SortOrder = 20,
Permissions = new RolePermissions Permissions = new RolePermissions
{ {
ProductsView = true, ProductsView = true,
@ -282,35 +267,8 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca
// RetailSalesRefund по умолчанию false — админ включит при необходимости // RetailSalesRefund по умолчанию false — админ включит при необходимости
}, },
}; };
var buyer = new EmployeeRole
{
OrganizationId = orgId,
Name = "Закупщик",
Description = "Заказы поставщикам и приёмка товара",
IsSystem = false, SortOrder = 40,
Permissions = new RolePermissions
{
ProductsView = true,
SuppliesView = true, SuppliesEdit = true,
CounterpartiesView = true, CounterpartiesEdit = true,
},
};
var accountant = new EmployeeRole
{
OrganizationId = orgId,
Name = "Бухгалтер",
Description = "Просмотр всех данных и отчётов, без редактирования",
IsSystem = false, SortOrder = 50,
Permissions = new RolePermissions
{
ProductsView = true,
SuppliesView = true,
CounterpartiesView = true,
ReportsView = true, StocksView = true,
},
};
db.EmployeeRoles.AddRange(admin, manager, keeper, cashier, buyer, accountant); db.EmployeeRoles.AddRange(admin, keeper, cashier);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }

View file

@ -21,7 +21,7 @@ public class Employee : TenantEntity
/// <summary>Оклад в валюте организации, опц.</summary> /// <summary>Оклад в валюте организации, опц.</summary>
public decimal? Salary { get; set; } public decimal? Salary { get; set; }
/// <summary>ИИН/ИНН (12-14 символов), опц.</summary> /// <summary>ИИН (12 цифр для физлица в РК), опционально.</summary>
public string? TaxNumber { get; set; } public string? TaxNumber { get; set; }
/// <summary>Произвольное описание (комментарий HR'а).</summary> /// <summary>Произвольное описание (комментарий HR'а).</summary>
public string? Description { get; set; } public string? Description { get; set; }
@ -32,10 +32,18 @@ public class Employee : TenantEntity
public EmployeeRole Role { get; set; } = null!; public EmployeeRole Role { get; set; } = null!;
/// <summary>Активен ли сотрудник. False — заблокирован, не может логиниться. /// <summary>Активен ли сотрудник. False — заблокирован, не может логиниться.
/// Удаление физически не делаем (FK из документов), просто IsActive=false.</summary> /// Двухступенчатое удаление: сначала «Уволить» (IsActive=false + FiredAt),
/// затем «Удалить» (IsDeleted=true + DeletedAt). Физически не удаляем
/// никогда — у сотрудника есть FK из документов (продаж, поставок).</summary>
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public DateTime? FiredAt { get; set; } public DateTime? FiredAt { get; set; }
/// <summary>Soft-delete-флаг. Ставится только из состояния «уволен» (IsActive=false).
/// В UI и связанных документах отображается «Иванов И.И. (удалён)».
/// В списках сотрудников по умолчанию скрыты, доступны через фильтр.</summary>
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public ICollection<EmployeeRetailPointAssignment> RetailPointAssignments { get; set; } public ICollection<EmployeeRetailPointAssignment> RetailPointAssignments { get; set; }
= new List<EmployeeRetailPointAssignment>(); = new List<EmployeeRetailPointAssignment>();
} }

View file

@ -10,9 +10,12 @@ public class Role : IdentityRole<Guid>
public static class SystemRoles public static class SystemRoles
{ {
// Tenant-scoped: только три системные роли. Менеджер/Закупщик/Бухгалтер
// и любые другие — это кастомные роли, создаются администратором орги
// вручную (требование юзера, см. коммит).
public const string Admin = "Admin"; public const string Admin = "Admin";
public const string Manager = "Manager";
public const string Cashier = "Cashier"; public const string Cashier = "Cashier";
public const string Storekeeper = "Storekeeper"; public const string Storekeeper = "Storekeeper";
// Platform-level: только SuperAdmin платформы.
public const string SuperAdmin = "SuperAdmin"; public const string SuperAdmin = "SuperAdmin";
} }

View file

@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Двухступенчатое удаление сотрудника: добавлены IsDeleted и
/// DeletedAt. Ранее было только IsActive=false (увольнение); теперь:
/// IsActive=true — активный
/// IsActive=false + FiredAt — уволен
/// IsActive=false + IsDeleted=true + DeletedAt — soft-deleted
/// Физически Employee никогда не удаляем (FK из retail_sales, supplies).</summary>
public partial class Phase5a_EmployeeSoftDelete : Migration
{
protected override void Up(MigrationBuilder b)
{
b.AddColumn<bool>(
name: "IsDeleted",
schema: "public",
table: "employees",
type: "boolean",
nullable: false,
defaultValue: false);
b.AddColumn<System.DateTime>(
name: "DeletedAt",
schema: "public",
table: "employees",
type: "timestamp with time zone",
nullable: true);
b.CreateIndex(
name: "IX_employees_OrganizationId_IsDeleted",
schema: "public",
table: "employees",
columns: new[] { "OrganizationId", "IsDeleted" });
}
protected override void Down(MigrationBuilder b)
{
b.DropIndex(name: "IX_employees_OrganizationId_IsDeleted", schema: "public", table: "employees");
b.DropColumn(name: "IsDeleted", schema: "public", table: "employees");
b.DropColumn(name: "DeletedAt", schema: "public", table: "employees");
}
}
}

View file

@ -35,6 +35,7 @@ import { TenantRouteGuard } from '@/components/TenantRouteGuard'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
import { NoOrganizationPage } from '@/pages/NoOrganizationPage' import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage' import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage'
import { RoleGuard } from '@/components/RoleGuard'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -84,9 +85,9 @@ export default function App() {
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} /> <Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} /> <Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
<Route path="/catalog/price-types" element={<PriceTypesPage />} /> <Route path="/catalog/price-types" element={<PriceTypesPage />} />
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} /> <Route path="/catalog/counterparties" element={<RoleGuard roles={['Admin']}><CounterpartiesPage /></RoleGuard>} />
<Route path="/catalog/stores" element={<StoresPage />} /> <Route path="/catalog/stores" element={<RoleGuard roles={['Admin']}><StoresPage /></RoleGuard>} />
<Route path="/catalog/retail-points" element={<RetailPointsPage />} /> <Route path="/catalog/retail-points" element={<RoleGuard roles={['Admin']}><RetailPointsPage /></RoleGuard>} />
<Route path="/inventory/stock" element={<StockPage />} /> <Route path="/inventory/stock" element={<StockPage />} />
<Route path="/inventory/movements" element={<StockMovementsPage />} /> <Route path="/inventory/movements" element={<StockMovementsPage />} />
<Route path="/purchases/supplies" element={<SuppliesPage />} /> <Route path="/purchases/supplies" element={<SuppliesPage />} />
@ -95,10 +96,10 @@ export default function App() {
<Route path="/sales/retail" element={<RetailSalesPage />} /> <Route path="/sales/retail" element={<RetailSalesPage />} />
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} /> <Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} /> <Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />
<Route path="/admin/import/moysklad" element={<MoySkladImportPage />} /> <Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}><MoySkladImportPage /></RoleGuard>} />
<Route path="/settings/organization" element={<OrganizationSettingsPage />} /> <Route path="/settings/organization" element={<RoleGuard roles={['Admin']}><OrganizationSettingsPage /></RoleGuard>} />
<Route path="/settings/employees" element={<EmployeesPage />} /> <Route path="/settings/employees" element={<RoleGuard roles={['Admin']}><EmployeesPage /></RoleGuard>} />
<Route path="/settings/employee-roles" element={<EmployeeRolesPage />} /> <Route path="/settings/employee-roles" element={<RoleGuard roles={['Admin']}><EmployeeRolesPage /></RoleGuard>} />
</Route> </Route>
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View file

@ -39,53 +39,92 @@ function translateRoles(roles: string[]): string {
.join(', ') .join(', ')
} }
function buildNav(isSuperAdmin: boolean): NavSection[] { /** Меню зависит от ролей пользователя. Кассир/Кладовщик НЕ видят
const catalog: NavItem[] = [ * раздел «Настройки организации» (там Сотрудники/Роли/Склады/Кассы
{ to: '/catalog/products', icon: Package, label: 'Товары' }, * это admin-only). Остальные разделы фильтруются по системной роли:
{ to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' }, * Кассир работает на кассе и видит товары/остатки + продажи; Кладовщик
{ to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' }, * приёмки + остатки. Администратор и SuperAdmin (override) видят всё.
{ to: '/catalog/price-types', icon: Tag, label: 'Типы цен' }, *
] * Источник правды для прав серверные `[Authorize(Roles = ...)]` на
return [ * каждом endpoint-е; sidebar-фильтр это UX-слой чтобы не показывать
{ group: 'Главное', items: [ * неработающие пункты. */
{ to: '/', icon: LayoutDashboard, label: 'Главная', end: true }, function buildNav(roles: string[]): NavSection[] {
{ to: '/dashboard', icon: LayoutDashboard, label: 'Аналитика' }, const isSuperAdmin = roles.includes('SuperAdmin')
]}, const isAdmin = roles.includes('Admin') || isSuperAdmin
{ group: 'Каталог', items: catalog }, const isCashier = roles.includes('Cashier')
{ group: 'Контрагенты', items: [ const isStorekeeper = roles.includes('Storekeeper')
{ to: '/catalog/counterparties', icon: Users, label: 'Контрагенты' },
]}, const sections: NavSection[] = [
{ group: 'Остатки', items: [ { group: 'Главное', items: [
{ to: '/inventory/stock', icon: Boxes, label: 'Остатки' }, { to: '/', icon: LayoutDashboard, label: 'Главная', end: true },
{ to: '/inventory/movements', icon: History, label: 'Движения' }, { to: '/dashboard', icon: LayoutDashboard, label: 'Аналитика' },
]}, ]},
{ group: 'Закупки', items: [
{ to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' },
]},
{ group: 'Продажи', items: [
{ to: '/sales/retail', icon: ShoppingCart, label: 'Розничные чеки' },
]},
// Справочники типа «Страны» — глобальные, управляются SuperAdmin'ом
// в системной консоли. В tenant-меню их больше нет.
{ group: 'Импорт', items: [
{ to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' },
]},
{ group: 'Настройки организации', items: [
{ to: '/settings/organization', icon: Settings, label: 'Общие' },
{ to: '/catalog/stores', icon: Warehouse, label: 'Склады' },
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'Кассы' },
{ to: '/settings/employees', icon: UserCog, label: 'Сотрудники' },
{ to: '/settings/employee-roles', icon: Shield, label: 'Роли' },
]},
...(isSuperAdmin ? [{
// Только одна точка возврата — система. Системный sidebar отдельно
// в SuperAdminLayout. В tenant-меню здесь — только переход в консоль.
group: 'Супер-админ',
items: [
{ to: '/super-admin', icon: ShieldCheck, label: 'Системная консоль', end: true },
],
}] : []),
] ]
// Каталог — Кассиру и Кладовщику только просмотр товаров; группы/типы цен/единицы — admin.
if (isAdmin || isCashier || isStorekeeper) {
const catalog: NavItem[] = [{ to: '/catalog/products', icon: Package, label: 'Товары' }]
if (isAdmin) {
catalog.push(
{ to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' },
{ to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' },
{ to: '/catalog/price-types', icon: Tag, label: 'Типы цен' },
)
}
sections.push({ group: 'Каталог', items: catalog })
}
if (isAdmin) {
sections.push({ group: 'Контрагенты', items: [
{ to: '/catalog/counterparties', icon: Users, label: 'Контрагенты' },
]})
}
// Остатки видят все три tenant-роли.
if (isAdmin || isCashier || isStorekeeper) {
sections.push({ group: 'Остатки', items: [
{ to: '/inventory/stock', icon: Boxes, label: 'Остатки' },
...(isAdmin || isStorekeeper ? [{ to: '/inventory/movements', icon: History, label: 'Движения' }] : []),
]})
}
// Закупки — Admin и Storekeeper.
if (isAdmin || isStorekeeper) {
sections.push({ group: 'Закупки', items: [
{ to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' },
]})
}
// Продажи — Admin и Cashier.
if (isAdmin || isCashier) {
sections.push({ group: 'Продажи', items: [
{ to: '/sales/retail', icon: ShoppingCart, label: 'Розничные чеки' },
]})
}
if (isAdmin) {
sections.push({ group: 'Импорт', items: [
{ to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' },
]})
sections.push({ group: 'Настройки организации', items: [
{ to: '/settings/organization', icon: Settings, label: 'Общие' },
{ to: '/catalog/stores', icon: Warehouse, label: 'Склады' },
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'Кассы' },
{ to: '/settings/employees', icon: UserCog, label: 'Сотрудники' },
{ to: '/settings/employee-roles', icon: Shield, label: 'Роли' },
]})
}
if (isSuperAdmin) {
sections.push({
group: 'Супер-админ',
items: [
{ to: '/super-admin', icon: ShieldCheck, label: 'Системная консоль', end: true },
],
})
}
return sections
} }
export function AppLayout() { export function AppLayout() {
@ -96,7 +135,7 @@ export function AppLayout() {
}) })
const isSuperAdmin = !!me?.roles?.includes('SuperAdmin') const isSuperAdmin = !!me?.roles?.includes('SuperAdmin')
const nav = buildNav(isSuperAdmin) const nav = buildNav(me?.roles ?? [])
// При активном «открыто как…» — слабая жёлтая тонировка фона tenant-области // При активном «открыто как…» — слабая жёлтая тонировка фона tenant-области
// даёт периферийный сигнал «я не в своей админке». // даёт периферийный сигнал «я не в своей админке».
const inOverride = typeof window !== 'undefined' && !!localStorage.getItem('superAdminAsOrg') const inOverride = typeof window !== 'undefined' && !!localStorage.getItem('superAdminAsOrg')

View file

@ -36,7 +36,15 @@ export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
useEffect(() => { useEffect(() => {
if (ref.current) localizeNativeValidation(ref.current) if (ref.current) localizeNativeValidation(ref.current)
}, []) }, [])
return <input ref={ref} {...props} className={cn(inputClass, props.className)} /> // Email type без явного pattern — добавляем строгий pattern (требует TLD ≥2),
// чтобы name@domain без точки/зоны не проходил. title задаёт текст подсказки;
// localizeNativeValidation подставит его при patternMismatch.
const extraProps: InputHTMLAttributes<HTMLInputElement> = {}
if (props.type === 'email' && !props.pattern) {
extraProps.pattern = '[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}'
extraProps.title = props.title ?? 'Введите корректный email с доменной зоной (например, name@example.kz)'
}
return <input ref={ref} {...props} {...extraProps} className={cn(inputClass, props.className)} />
} }
export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) { export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {

View file

@ -0,0 +1,35 @@
import { useMe } from '@/lib/useMe'
interface Props {
/** Список ролей, любая из которых открывает доступ. SuperAdmin всегда проходит. */
roles: ('Admin' | 'Cashier' | 'Storekeeper' | 'SuperAdmin')[]
children: React.ReactNode
}
/** Render-guard: если у текущего юзера нет одной из указанных ролей
* показываем сообщение «нет доступа» вместо контента страницы.
*
* Не подменяет серверную авторизацию (см. [Authorize(Roles = ...)] на
* controller endpoint-ах), это только UX-слой: чтобы юзер на /employees
* без прав не видел крутящееся колесо + 403 от каждого запроса, а сразу
* понимал что страница ему не положена. */
export function RoleGuard({ roles, children }: Props) {
const me = useMe()
if (me.isLoading) return null
const userRoles = me.data?.roles ?? []
const allowed = userRoles.includes('SuperAdmin') || userRoles.some((r) => (roles as string[]).includes(r))
if (!allowed) {
return (
<div className="min-h-[60vh] flex items-center justify-center px-4">
<div className="max-w-md text-center space-y-3">
<h1 className="text-2xl font-bold">Нет доступа</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Этот раздел доступен только администраторам организации.
Если вам нужен доступ обратитесь к администратору.
</p>
</div>
</div>
)
}
return <>{children}</>
}

View file

@ -143,13 +143,10 @@ export function CounterpartiesPage() {
</Select> </Select>
</Field> </Field>
<Field label="БИН (юрлицо РК)"> <Field label="БИН (юрлицо РК)">
<TextInput value={form.bin} onChange={(e) => setForm({ ...form, bin: e.target.value })} /> <TextInput value={form.bin} onChange={(e) => setForm({ ...form, bin: e.target.value })} placeholder="12 цифр" maxLength={12} inputMode="numeric" />
</Field> </Field>
<Field label="ИИН (физлицо РК)"> <Field label="ИИН (физлицо РК)">
<TextInput value={form.iin} onChange={(e) => setForm({ ...form, iin: e.target.value })} /> <TextInput value={form.iin} onChange={(e) => setForm({ ...form, iin: e.target.value })} placeholder="12 цифр" maxLength={12} inputMode="numeric" />
</Field>
<Field label="ИНН / другой">
<TextInput value={form.taxNumber} onChange={(e) => setForm({ ...form, taxNumber: e.target.value })} />
</Field> </Field>
<Field label="Страна"> <Field label="Страна">
<Select value={form.countryId} onChange={(e) => setForm({ ...form, countryId: e.target.value })}> <Select value={form.countryId} onChange={(e) => setForm({ ...form, countryId: e.target.value })}>

View file

@ -137,7 +137,7 @@ export function EmployeeRolesPage() {
<> <>
<ListPageShell <ListPageShell
title="Роли сотрудников" title="Роли сотрудников"
description="Системные роли можно редактировать (галки прав), но не удалять. Кастомные — полный CRUD." description="Системные роли — только просмотр прав. Кастомные роли — полный CRUD; можно создать на основе системной как шаблона."
actions={ actions={
<> <>
<SearchBar value={search} onChange={setSearch} /> <SearchBar value={search} onChange={setSearch} />
@ -158,10 +158,8 @@ export function EmployeeRolesPage() {
sortOrder={sortOrder} sortOrder={sortOrder}
onSortChange={setSort} onSortChange={setSort}
onRowClick={(r) => { onRowClick={(r) => {
if (r.isSystem) { // Системные роли — показываем форму с правами в read-only.
alert('Системная роль, изменения недоступны.') // Все чекбоксы disabled, кнопка «Сохранить» скрыта (см. footer).
return
}
setForm({ setForm({
id: r.id, name: r.name, description: r.description ?? '', id: r.id, name: r.name, description: r.description ?? '',
isSystem: r.isSystem, permissions: { ...blankPerms(), ...r.permissions }, isSystem: r.isSystem, permissions: { ...blankPerms(), ...r.permissions },
@ -235,7 +233,9 @@ export function EmployeeRolesPage() {
<Modal <Modal
open={!!form} open={!!form}
onClose={() => setForm(null)} onClose={() => setForm(null)}
title={form?.id ? `Редактировать роль${form.isSystem ? ' (системная)' : ''}` : 'Новая роль'} title={form?.id
? (form.isSystem ? `Системная роль «${form.name}» (только просмотр)` : `Редактировать роль «${form.name}»`)
: 'Новая роль'}
width="max-w-2xl" width="max-w-2xl"
footer={ footer={
<> <>
@ -249,19 +249,30 @@ export function EmployeeRolesPage() {
<Trash2 className="w-4 h-4" /> Удалить <Trash2 className="w-4 h-4" /> Удалить
</Button> </Button>
)} )}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button> <Button variant="secondary" onClick={() => setForm(null)}>
<Button onClick={save} disabled={!form?.name}>Сохранить</Button> {form?.isSystem ? 'Закрыть' : 'Отмена'}
</Button>
{!form?.isSystem && (
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
)}
</> </>
} }
> >
{form && ( {form && (
<div className="space-y-4"> <div className="space-y-4">
<Field label="Название *"> <Field label="Название *">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> <TextInput value={form.name} disabled={form.isSystem}
onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field> </Field>
<Field label="Описание"> <Field label="Описание">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /> <TextArea rows={2} value={form.description} disabled={form.isSystem}
onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field> </Field>
{form.isSystem && (
<div className="rounded-md bg-slate-100 dark:bg-slate-800/60 border border-slate-200 dark:border-slate-700 px-3 py-2 text-xs text-slate-600 dark:text-slate-300">
Системная роль. Просмотр прав только для справки. Чтобы изменить права создайте кастомную роль на основе этого шаблона.
</div>
)}
{form.id && !form.isSystem && ( {form.id && !form.isSystem && (
<div className="rounded-md bg-amber-50 border border-amber-200 dark:bg-amber-900/20 dark:border-amber-800 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"> <div className="rounded-md bg-amber-50 border border-amber-200 dark:bg-amber-900/20 dark:border-amber-800 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
Изменение прав применится ко ВСЕМ сотрудникам с этой ролью. Изменение прав применится ко ВСЕМ сотрудникам с этой ролью.

View file

@ -8,7 +8,7 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal' import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Checkbox } from '@/components/Field' import { Field, TextInput, TextArea, Checkbox, MoneyInput } from '@/components/Field'
import { PhoneInput } from '@/components/PhoneInput' import { PhoneInput } from '@/components/PhoneInput'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog' import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { PagedResult, RetailPoint } from '@/lib/types' import type { PagedResult, RetailPoint } from '@/lib/types'
@ -16,6 +16,8 @@ import type { EmployeeRoleDto } from '@/pages/EmployeeRolesPage'
const URL = '/api/organization/employees' const URL = '/api/organization/employees'
type EmployeeStatus = 'active' | 'fired' | 'deleted'
interface EmployeeDto { interface EmployeeDto {
id: string id: string
userId: string | null userId: string | null
@ -33,6 +35,9 @@ interface EmployeeDto {
roleName: string roleName: string
isActive: boolean isActive: boolean
firedAt: string | null firedAt: string | null
isDeleted: boolean
deletedAt: string | null
status: EmployeeStatus
retailPointIds: string[] retailPointIds: string[]
/** Главный администратор организации (Organization.AccountOwnerUserId == /** Главный администратор организации (Organization.AccountOwnerUserId ==
* Employee.UserId). Любые изменения этой записи (роль, активность, * Employee.UserId). Любые изменения этой записи (роль, активность,
@ -51,7 +56,10 @@ interface Form {
position: string position: string
email: string email: string
phone: string phone: string
salary: string /** Денежное значение в валюте организации (берётся из useOrgSettings).
* Используется MoneyInput, который сам формирует с копейками или без
* в зависимости от org-setting allowFractionalPrices. */
salary: number | null
taxNumber: string taxNumber: string
description: string description: string
imageUrl: string imageUrl: string
@ -64,14 +72,13 @@ interface Form {
const blankForm = (): Form => ({ const blankForm = (): Form => ({
lastName: '', firstName: '', middleName: '', position: '', lastName: '', firstName: '', middleName: '', position: '',
email: '', phone: '', email: '', phone: '',
salary: '', taxNumber: '', description: '', imageUrl: '', salary: null, taxNumber: '', description: '', imageUrl: '',
roleId: '', isActive: true, roleId: '', isActive: true,
retailPointIds: [], retailPointIds: [],
createAccount: true, createAccount: true,
}) })
export function EmployeesPage() { export function EmployeesPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<EmployeeDto>(URL)
const { update, remove } = useCatalogMutations(URL, URL) const { update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null) const [form, setForm] = useState<Form | null>(null)
// Сгенерированный пароль возвращается с сервера один раз — показываем // Сгенерированный пароль возвращается с сервера один раз — показываем
@ -113,7 +120,7 @@ export function EmployeesPage() {
const payload = { const payload = {
lastName: form.lastName, firstName: form.firstName, middleName: form.middleName || null, lastName: form.lastName, firstName: form.firstName, middleName: form.middleName || null,
position: form.position || null, email: form.email || null, phone: form.phone || null, position: form.position || null, email: form.email || null, phone: form.phone || null,
salary: form.salary ? Number(form.salary) : null, salary: form.salary,
taxNumber: form.taxNumber || null, taxNumber: form.taxNumber || null,
description: form.description || null, description: form.description || null,
imageUrl: form.imageUrl || null, imageUrl: form.imageUrl || null,
@ -149,6 +156,11 @@ export function EmployeesPage() {
}) })
} }
// Фильтр по статусу сотрудника. По умолчанию показываем только активных
// и уволенных; «удалённые» — отдельный режим (read-only список архива).
const [statusFilter, setStatusFilter] = useState<'default' | 'active' | 'fired' | 'deleted' | 'all'>('default')
const list = useCatalogList<EmployeeDto>(URL, statusFilter === 'default' ? {} : { status: statusFilter })
return ( return (
<> <>
<ListPageShell <ListPageShell
@ -156,29 +168,40 @@ export function EmployeesPage() {
description="Учётные записи и сотрудники без логина. Привязка к ролям, для кассиров — к конкретным кассам." description="Учётные записи и сотрудники без логина. Привязка к ролям, для кассиров — к конкретным кассам."
actions={ actions={
<> <>
<SearchBar value={search} onChange={setSearch} /> <SearchBar value={list.search} onChange={list.setSearch} />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
className="h-9 px-2 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-sm"
>
<option value="default">Активные и уволенные</option>
<option value="active">Только активные</option>
<option value="fired">Только уволенные</option>
<option value="deleted">Только удалённые</option>
<option value="all">Все, включая удалённых</option>
</select>
<Button onClick={() => setForm(blankForm())}> <Button onClick={() => setForm(blankForm())}>
<Plus className="w-4 h-4" /> Добавить сотрудника <Plus className="w-4 h-4" /> Добавить сотрудника
</Button> </Button>
</> </>
} }
footer={data && data.total > 0 && ( footer={list.data && list.data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} /> <Pagination page={list.page} pageSize={list.data.pageSize} total={list.data.total} onPageChange={list.setPage} />
)} )}
> >
<DataTable <DataTable
rows={data?.items ?? []} rows={list.data?.items ?? []}
isLoading={isLoading} isLoading={list.isLoading}
rowKey={(r) => r.id} rowKey={(r) => r.id}
sortKey={sortKey} sortKey={list.sortKey}
sortOrder={sortOrder} sortOrder={list.sortOrder}
onSortChange={setSort} onSortChange={list.setSort}
onRowClick={(r) => { onRowClick={(r) => {
setActiveEmployee(r) setActiveEmployee(r)
setForm({ setForm({
id: r.id, lastName: r.lastName, firstName: r.firstName, middleName: r.middleName ?? '', id: r.id, lastName: r.lastName, firstName: r.firstName, middleName: r.middleName ?? '',
position: r.position ?? '', email: r.email ?? '', phone: r.phone ?? '', position: r.position ?? '', email: r.email ?? '', phone: r.phone ?? '',
salary: r.salary != null ? String(r.salary) : '', salary: r.salary,
taxNumber: r.taxNumber ?? '', description: r.description ?? '', imageUrl: r.imageUrl ?? '', taxNumber: r.taxNumber ?? '', description: r.description ?? '', imageUrl: r.imageUrl ?? '',
roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds, roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds,
createAccount: false, createAccount: false,
@ -188,7 +211,11 @@ export function EmployeesPage() {
{ header: 'ФИО', cell: (r) => ( { header: 'ФИО', cell: (r) => (
<div> <div>
<div className="font-medium flex items-center gap-2"> <div className="font-medium flex items-center gap-2">
<span>{r.lastName} {r.firstName} {r.middleName ?? ''}</span> <span className={r.status === 'deleted' ? 'line-through text-slate-400' : ''}>
{r.lastName} {r.firstName} {r.middleName ?? ''}
</span>
{r.status === 'fired' && <span className="text-[10px] text-slate-400">(уволен)</span>}
{r.status === 'deleted' && <span className="text-[10px] text-slate-400">(удалён)</span>}
{r.isOwner && ( {r.isOwner && (
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"> <span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
Главный администратор Главный администратор
@ -204,9 +231,11 @@ export function EmployeesPage() {
{ header: 'Учётка', width: '110px', cell: (r) => r.userId { header: 'Учётка', width: '110px', cell: (r) => r.userId
? <span className="text-xs text-emerald-600">есть</span> ? <span className="text-xs text-emerald-600">есть</span>
: <span className="text-xs text-slate-400">нет</span> }, : <span className="text-xs text-slate-400">нет</span> },
{ header: 'Статус', width: '110px', cell: (r) => r.isActive { header: 'Статус', width: '110px', cell: (r) => {
? <span className="text-xs text-emerald-600">Активен</span> if (r.status === 'deleted') return <span className="text-xs px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300">Удалён</span>
: <span className="text-xs text-slate-400">Уволен</span> }, if (r.status === 'fired') return <span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">Уволен</span>
return <span className="text-xs px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">Активен</span>
}},
]} ]}
/> />
</ListPageShell> </ListPageShell>
@ -218,20 +247,20 @@ export function EmployeesPage() {
width="max-w-xl" width="max-w-xl"
footer={ footer={
<> <>
{form?.id && ( {form?.id && activeEmployee && activeEmployee.status !== 'deleted' && (
<Button <Button
variant="danger" variant="danger"
size="sm" size="sm"
disabled={activeEmployee?.isOwner || activeEmployee?.isSelf} disabled={activeEmployee.isOwner || activeEmployee.isSelf}
title={ title={
activeEmployee?.isOwner activeEmployee.isOwner
? 'Главного администратора может удалить только Супер-администратор платформы' ? 'Главного администратора может удалить только Супер-администратор платформы'
: activeEmployee?.isSelf : activeEmployee.isSelf
? 'Нельзя удалить себя' ? 'Нельзя уволить или удалить себя'
: undefined : undefined
} }
onClick={async () => { onClick={async () => {
if (activeEmployee?.isOwner) { if (activeEmployee.isOwner) {
setBlockedDelete({ setBlockedDelete({
title: 'Действие заблокировано', title: 'Действие заблокировано',
body: body:
@ -241,28 +270,37 @@ export function EmployeesPage() {
}) })
return return
} }
if (activeEmployee?.isSelf) { if (activeEmployee.isSelf) {
setBlockedDelete({ setBlockedDelete({
title: 'Нельзя удалить себя', title: 'Нельзя уволить или удалить себя',
body: body:
'Свою учётную запись нельзя удалить из этой страницы. ' + 'Свою учётную запись нельзя изменить из этой страницы. ' +
'Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.', 'Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.',
}) })
return return
} }
if (!confirm(`Удалить сотрудника «${activeEmployee?.lastName ?? ''} ${activeEmployee?.firstName ?? ''}»?\n\nСотрудник будет деактивирован, его учётная запись потеряет доступ к организации. Историю операций сохраняем.`)) return const fullName = `${activeEmployee.lastName ?? ''} ${activeEmployee.firstName ?? ''}`.trim()
const confirmText = activeEmployee.status === 'active'
? `Уволить сотрудника «${fullName}»?\n\nЕго учётная запись потеряет доступ, документы и история останутся в системе. Восстановить можно в любой момент.`
: `Удалить запись о сотруднике «${fullName}»?\n\nСотрудник уже уволен. После удаления он скрывается из обычных списков, но во всех связанных документах остаётся подпись «${fullName} (удалён)».`
if (!confirm(confirmText)) return
try { try {
await remove.mutateAsync(form.id!) await remove.mutateAsync(form.id!)
list.refetch?.()
setForm(null); setActiveEmployee(null) setForm(null); setActiveEmployee(null)
} catch (e) { } catch (e) {
const err = e as { response?: { data?: { error?: string } }, message?: string } const err = e as { response?: { data?: { error?: string } }, message?: string }
const msg = err.response?.data?.error ?? err.message ?? 'Не удалось удалить сотрудника' const msg = err.response?.data?.error ?? err.message ?? 'Не удалось выполнить операцию'
setBlockedDelete({ title: 'Не удалось удалить', body: msg }) setBlockedDelete({ title: 'Не удалось выполнить', body: msg })
} }
}}> }}>
<Trash2 className="w-4 h-4" /> Удалить <Trash2 className="w-4 h-4" />
{activeEmployee.status === 'active' ? 'Уволить' : 'Удалить'}
</Button> </Button>
)} )}
{form?.id && activeEmployee?.status === 'deleted' && (
<span className="text-xs text-slate-500 italic">Сотрудник удалён изменения недоступны.</span>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button> <Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.lastName || !form.firstName || !form.roleId}>Сохранить</Button> <Button onClick={save} disabled={!form?.lastName || !form.firstName || !form.roleId}>Сохранить</Button>
</> </>
@ -296,10 +334,10 @@ export function EmployeesPage() {
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Оклад"> <Field label="Оклад">
<TextInput type="number" value={form.salary} onChange={(e) => setForm({ ...form, salary: e.target.value })} placeholder="—" /> <MoneyInput value={form.salary} onChange={(v) => setForm({ ...form, salary: v })} />
</Field> </Field>
<Field label="ИИН/ИНН"> <Field label="ИИН">
<TextInput value={form.taxNumber} onChange={(e) => setForm({ ...form, taxNumber: e.target.value })} placeholder="12-14 цифр" maxLength={20} /> <TextInput value={form.taxNumber} onChange={(e) => setForm({ ...form, taxNumber: e.target.value })} placeholder="12 цифр" maxLength={12} inputMode="numeric" />
</Field> </Field>
</div> </div>
<Field label="Описание / комментарий"> <Field label="Описание / комментарий">

View file

@ -142,7 +142,7 @@ export function MoySkladImportPage() {
{test.data && ( {test.data && (
<div className="text-sm text-emerald-700 dark:text-emerald-400 flex items-center gap-1.5"> <div className="text-sm text-emerald-700 dark:text-emerald-400 flex items-center gap-1.5">
<CheckCircle className="w-4 h-4" /> Подключено: <strong>{test.data.organization}</strong> <CheckCircle className="w-4 h-4" /> Подключено: <strong>{test.data.organization}</strong>
{test.data.inn && <span className="text-slate-500">(ИНН {test.data.inn})</span>} {test.data.inn && <span className="text-slate-500">(идентификатор {test.data.inn})</span>}
</div> </div>
)} )}
{test.error && <div className="text-sm text-red-600">{formatError(test.error)}</div>} {test.error && <div className="text-sm text-red-600">{formatError(test.error)}</div>}

View file

@ -79,7 +79,7 @@ export function SuperAdminOrgCreatePage() {
</Field> </Field>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="БИН/ИНН"><TextInput value={bin} onChange={(e) => setBin(e.target.value)} /></Field> <Field label="БИН"><TextInput value={bin} onChange={(e) => setBin(e.target.value)} placeholder="12 цифр" maxLength={12} inputMode="numeric" /></Field>
<Field label="Телефон"><PhoneInput value={phone} onChange={setPhone} /></Field> <Field label="Телефон"><PhoneInput value={phone} onChange={setPhone} /></Field>
</div> </div>
<Field label="Адрес"><TextInput value={address} onChange={(e) => setAddress(e.target.value)} /></Field> <Field label="Адрес"><TextInput value={address} onChange={(e) => setAddress(e.target.value)} /></Field>

View file

@ -141,7 +141,7 @@ export function SuperAdminSetupPage() {
</Field> </Field>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="БИН/ИНН"><TextInput value={bin} onChange={(e) => setBin(e.target.value)} /></Field> <Field label="БИН"><TextInput value={bin} onChange={(e) => setBin(e.target.value)} placeholder="12 цифр" maxLength={12} inputMode="numeric" /></Field>
<Field label="Телефон"><TextInput value={phone} onChange={(e) => setPhone(e.target.value)} /></Field> <Field label="Телефон"><TextInput value={phone} onChange={(e) => setPhone(e.target.value)} /></Field>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">