Compare commits

..

No commits in common. "fc9f7c9ee45dbcbf8cc124a35fa084431c62f16e" and "8eceff0bb5eab4c4712ed1cb8db0af2624855120" have entirely different histories.

28 changed files with 204 additions and 488 deletions

View file

@ -1,107 +0,0 @@
# Системный аудит авторизации — 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,Storekeeper")] [HttpPost, Authorize(Roles = "Admin,Manager,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,Storekeeper")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,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")] [HttpPost, Authorize(Roles = "Admin,Manager")]
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")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
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")] [HttpPost, Authorize(Roles = "Admin,Manager")]
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,SuperAdmin")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,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,Storekeeper")] [HttpPost, Authorize(Roles = "Admin,Manager,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,Storekeeper")] [HttpDelete("{imageId:guid}"), Authorize(Roles = "Admin,Manager,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,Storekeeper")] [HttpPost("{imageId:guid}/main"), Authorize(Roles = "Admin,Manager,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,Storekeeper")] [HttpPost, Authorize(Roles = "Admin,Manager,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,Storekeeper")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,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,Storekeeper")] [HttpPost("{id:guid}/recalc-retail"), Authorize(Roles = "Admin,Manager,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")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")]
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")] [HttpGet("barcode-duplicates"), Authorize(Roles = "Admin,Manager")]
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")] [HttpPost, Authorize(Roles = "Admin,Manager")]
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")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
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")] [HttpPost, Authorize(Roles = "Admin,Manager")]
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")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
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")] [HttpPost, Authorize(Roles = "Admin,Manager")]
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,SuperAdmin")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,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")] [HttpPost, Authorize(Roles = "SuperAdmin,Admin,Manager")]
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")] [HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin,Manager")]
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,9 +32,6 @@ 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);
@ -52,9 +49,7 @@ 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, [FromQuery] PagedRequest req, CancellationToken ct)
[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()
@ -65,16 +60,6 @@ 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();
@ -94,8 +79,6 @@ 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))
@ -111,7 +94,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")] [HttpPost, Authorize(Roles = "SuperAdmin,Admin,Manager")]
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.");
@ -165,7 +148,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")] [HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin,Manager")]
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.");
@ -236,18 +219,17 @@ 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). // Главный администратор удаляется только Супер-администратором платформы.
// IsActive=false && IsDeleted=false → этот endpoint выполняет soft-delete. // Если кому-то критично hard-delete (cleanup при удалении org) — это идёт
// IsDeleted=true → 409, уже удалён. // через SuperAdmin консоль, а не через этот эндпоинт.
// Гарды (главный админ + 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 = "Нельзя удалить себя. Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.",
}); });
} }
@ -267,21 +249,8 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
} }
} }
if (e.IsDeleted) e.IsActive = false;
return Conflict(new { error = "Сотрудник уже удалён." }); e.FiredAt ??= DateTime.UtcNow;
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();
} }
@ -307,8 +276,6 @@ 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")] [HttpPut("settings"), Authorize(Roles = "Admin,Manager")]
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,Storekeeper")] [HttpPost, Authorize(Roles = "Admin,Manager,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,Storekeeper")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,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,Storekeeper")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager,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,Storekeeper")] [HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Manager,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,Storekeeper")] [HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager,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,Cashier")] [HttpPost, Authorize(Roles = "Admin,Manager,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,Cashier")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,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")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")]
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,Cashier")] [HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Manager,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")] [HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager")]
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.Cashier, SystemRoles.Storekeeper }) foreach (var role in new[] { SystemRoles.SuperAdmin, SystemRoles.Admin, SystemRoles.Manager, SystemRoles.Cashier, SystemRoles.Storekeeper })
{ {
if (!await roleMgr.RoleExistsAsync(role)) if (!await roleMgr.RoleExistsAsync(role))
{ {
@ -222,11 +222,10 @@ 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): Администратор / Менеджер /
/// Кассир. Менеджеры/Закупщики/Бухгалтеры и пр. — это кастомные роли, /// Кладовщик / Кассир / Закупщик / Бухгалтер. Сидируется один раз
/// которые создаёт администратор орги сам через UI «Настроить права ролей». /// per организацию; обновлять не пытаемся, чтобы не сбросить кастомные
/// Системные нельзя редактировать или удалить из UI; только просмотр. /// правки галок которые админ мог сделать.</summary>
/// Сидируется один раз 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);
@ -240,12 +239,28 @@ 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 = true, SortOrder = 10, IsSystem = false, SortOrder = 20,
Permissions = new RolePermissions Permissions = new RolePermissions
{ {
ProductsView = true, ProductsView = true,
@ -258,7 +273,7 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca
OrganizationId = orgId, OrganizationId = orgId,
Name = "Кассир", Name = "Кассир",
Description = "Только работа на кассе. Без доступа к веб-админке.", Description = "Только работа на кассе. Без доступа к веб-админке.",
IsSystem = true, SortOrder = 20, IsSystem = true, SortOrder = 30,
Permissions = new RolePermissions Permissions = new RolePermissions
{ {
ProductsView = true, ProductsView = true,
@ -267,8 +282,35 @@ 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, keeper, cashier); db.EmployeeRoles.AddRange(admin, manager, keeper, cashier, buyer, accountant);
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 цифр для физлица в РК), опционально.</summary> /// <summary>ИИН/ИНН (12-14 символов), опц.</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,18 +32,10 @@ public class Employee : TenantEntity
public EmployeeRole Role { get; set; } = null!; public EmployeeRole Role { get; set; } = null!;
/// <summary>Активен ли сотрудник. False — заблокирован, не может логиниться. /// <summary>Активен ли сотрудник. False — заблокирован, не может логиниться.
/// Двухступенчатое удаление: сначала «Уволить» (IsActive=false + FiredAt), /// Удаление физически не делаем (FK из документов), просто IsActive=false.</summary>
/// затем «Удалить» (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,12 +10,9 @@ 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

@ -1,46 +0,0 @@
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,7 +35,6 @@ 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: {
@ -85,9 +84,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={<RoleGuard roles={['Admin']}><CounterpartiesPage /></RoleGuard>} /> <Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
<Route path="/catalog/stores" element={<RoleGuard roles={['Admin']}><StoresPage /></RoleGuard>} /> <Route path="/catalog/stores" element={<StoresPage />} />
<Route path="/catalog/retail-points" element={<RoleGuard roles={['Admin']}><RetailPointsPage /></RoleGuard>} /> <Route path="/catalog/retail-points" element={<RetailPointsPage />} />
<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 />} />
@ -96,10 +95,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={<RoleGuard roles={['Admin']}><MoySkladImportPage /></RoleGuard>} /> <Route path="/admin/import/moysklad" element={<MoySkladImportPage />} />
<Route path="/settings/organization" element={<RoleGuard roles={['Admin']}><OrganizationSettingsPage /></RoleGuard>} /> <Route path="/settings/organization" element={<OrganizationSettingsPage />} />
<Route path="/settings/employees" element={<RoleGuard roles={['Admin']}><EmployeesPage /></RoleGuard>} /> <Route path="/settings/employees" element={<EmployeesPage />} />
<Route path="/settings/employee-roles" element={<RoleGuard roles={['Admin']}><EmployeeRolesPage /></RoleGuard>} /> <Route path="/settings/employee-roles" element={<EmployeeRolesPage />} />
</Route> </Route>
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View file

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

View file

@ -36,15 +36,7 @@ export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
useEffect(() => { useEffect(() => {
if (ref.current) localizeNativeValidation(ref.current) if (ref.current) localizeNativeValidation(ref.current)
}, []) }, [])
// Email type без явного pattern — добавляем строгий pattern (требует TLD ≥2), return <input ref={ref} {...props} className={cn(inputClass, props.className)} />
// чтобы 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

@ -1,35 +0,0 @@
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,10 +143,13 @@ export function CounterpartiesPage() {
</Select> </Select>
</Field> </Field>
<Field label="БИН (юрлицо РК)"> <Field label="БИН (юрлицо РК)">
<TextInput value={form.bin} onChange={(e) => setForm({ ...form, bin: e.target.value })} placeholder="12 цифр" maxLength={12} inputMode="numeric" /> <TextInput value={form.bin} onChange={(e) => setForm({ ...form, bin: e.target.value })} />
</Field> </Field>
<Field label="ИИН (физлицо РК)"> <Field label="ИИН (физлицо РК)">
<TextInput value={form.iin} onChange={(e) => setForm({ ...form, iin: e.target.value })} placeholder="12 цифр" maxLength={12} inputMode="numeric" /> <TextInput value={form.iin} onChange={(e) => setForm({ ...form, iin: e.target.value })} />
</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,8 +158,10 @@ export function EmployeeRolesPage() {
sortOrder={sortOrder} sortOrder={sortOrder}
onSortChange={setSort} onSortChange={setSort}
onRowClick={(r) => { onRowClick={(r) => {
// Системные роли — показываем форму с правами в read-only. if (r.isSystem) {
// Все чекбоксы disabled, кнопка «Сохранить» скрыта (см. footer). alert('Системная роль, изменения недоступны.')
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 },
@ -233,9 +235,7 @@ export function EmployeeRolesPage() {
<Modal <Modal
open={!!form} open={!!form}
onClose={() => setForm(null)} onClose={() => setForm(null)}
title={form?.id title={form?.id ? `Редактировать роль${form.isSystem ? ' (системная)' : ''}` : 'Новая роль'}
? (form.isSystem ? `Системная роль «${form.name}» (только просмотр)` : `Редактировать роль «${form.name}»`)
: 'Новая роль'}
width="max-w-2xl" width="max-w-2xl"
footer={ footer={
<> <>
@ -249,30 +249,19 @@ export function EmployeeRolesPage() {
<Trash2 className="w-4 h-4" /> Удалить <Trash2 className="w-4 h-4" /> Удалить
</Button> </Button>
)} )}
<Button variant="secondary" onClick={() => setForm(null)}> <Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
{form?.isSystem ? 'Закрыть' : 'Отмена'} <Button onClick={save} disabled={!form?.name}>Сохранить</Button>
</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} disabled={form.isSystem} <TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field> </Field>
<Field label="Описание"> <Field label="Описание">
<TextArea rows={2} value={form.description} disabled={form.isSystem} <TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
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, MoneyInput } from '@/components/Field' import { Field, TextInput, TextArea, Checkbox } 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,8 +16,6 @@ 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
@ -35,9 +33,6 @@ 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). Любые изменения этой записи (роль, активность,
@ -56,10 +51,7 @@ interface Form {
position: string position: string
email: string email: string
phone: string phone: string
/** Денежное значение в валюте организации (берётся из useOrgSettings). salary: string
* Используется MoneyInput, который сам формирует с копейками или без
* в зависимости от org-setting allowFractionalPrices. */
salary: number | null
taxNumber: string taxNumber: string
description: string description: string
imageUrl: string imageUrl: string
@ -72,13 +64,14 @@ interface Form {
const blankForm = (): Form => ({ const blankForm = (): Form => ({
lastName: '', firstName: '', middleName: '', position: '', lastName: '', firstName: '', middleName: '', position: '',
email: '', phone: '', email: '', phone: '',
salary: null, taxNumber: '', description: '', imageUrl: '', salary: '', 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)
// Сгенерированный пароль возвращается с сервера один раз — показываем // Сгенерированный пароль возвращается с сервера один раз — показываем
@ -120,7 +113,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, salary: form.salary ? Number(form.salary) : null,
taxNumber: form.taxNumber || null, taxNumber: form.taxNumber || null,
description: form.description || null, description: form.description || null,
imageUrl: form.imageUrl || null, imageUrl: form.imageUrl || null,
@ -156,11 +149,6 @@ 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
@ -168,40 +156,29 @@ export function EmployeesPage() {
description="Учётные записи и сотрудники без логина. Привязка к ролям, для кассиров — к конкретным кассам." description="Учётные записи и сотрудники без логина. Привязка к ролям, для кассиров — к конкретным кассам."
actions={ actions={
<> <>
<SearchBar value={list.search} onChange={list.setSearch} /> <SearchBar value={search} onChange={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={list.data && list.data.total > 0 && ( footer={data && data.total > 0 && (
<Pagination page={list.page} pageSize={list.data.pageSize} total={list.data.total} onPageChange={list.setPage} /> <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)} )}
> >
<DataTable <DataTable
rows={list.data?.items ?? []} rows={data?.items ?? []}
isLoading={list.isLoading} isLoading={isLoading}
rowKey={(r) => r.id} rowKey={(r) => r.id}
sortKey={list.sortKey} sortKey={sortKey}
sortOrder={list.sortOrder} sortOrder={sortOrder}
onSortChange={list.setSort} onSortChange={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, salary: r.salary != null ? String(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,
@ -211,11 +188,7 @@ 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 className={r.status === 'deleted' ? 'line-through text-slate-400' : ''}> <span>{r.lastName} {r.firstName} {r.middleName ?? ''}</span>
{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">
Главный администратор Главный администратор
@ -231,11 +204,9 @@ 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) => { { header: 'Статус', width: '110px', cell: (r) => r.isActive
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-emerald-600">Активен</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> : <span className="text-xs text-slate-400">Уволен</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>
@ -247,20 +218,20 @@ export function EmployeesPage() {
width="max-w-xl" width="max-w-xl"
footer={ footer={
<> <>
{form?.id && activeEmployee && activeEmployee.status !== 'deleted' && ( {form?.id && (
<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:
@ -270,37 +241,28 @@ export function EmployeesPage() {
}) })
return return
} }
if (activeEmployee.isSelf) { if (activeEmployee?.isSelf) {
setBlockedDelete({ setBlockedDelete({
title: 'Нельзя уволить или удалить себя', title: 'Нельзя удалить себя',
body: body:
'Свою учётную запись нельзя изменить из этой страницы. ' + 'Свою учётную запись нельзя удалить из этой страницы. ' +
'Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.', 'Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.',
}) })
return return
} }
const fullName = `${activeEmployee.lastName ?? ''} ${activeEmployee.firstName ?? ''}`.trim() if (!confirm(`Удалить сотрудника «${activeEmployee?.lastName ?? ''} ${activeEmployee?.firstName ?? ''}»?\n\nСотрудник будет деактивирован, его учётная запись потеряет доступ к организации. Историю операций сохраняем.`)) return
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>
</> </>
@ -334,10 +296,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="Оклад">
<MoneyInput value={form.salary} onChange={(v) => setForm({ ...form, salary: v })} /> <TextInput type="number" value={form.salary} onChange={(e) => setForm({ ...form, salary: e.target.value })} placeholder="—" />
</Field> </Field>
<Field label="ИИН"> <Field label="ИИН/ИНН">
<TextInput value={form.taxNumber} onChange={(e) => setForm({ ...form, taxNumber: e.target.value })} placeholder="12 цифр" maxLength={12} inputMode="numeric" /> <TextInput value={form.taxNumber} onChange={(e) => setForm({ ...form, taxNumber: e.target.value })} placeholder="12-14 цифр" maxLength={20} />
</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)} placeholder="12 цифр" maxLength={12} inputMode="numeric" /></Field> <Field label="БИН/ИНН"><TextInput value={bin} onChange={(e) => setBin(e.target.value)} /></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)} placeholder="12 цифр" maxLength={12} inputMode="numeric" /></Field> <Field label="БИН/ИНН"><TextInput value={bin} onChange={(e) => setBin(e.target.value)} /></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">