Compare commits
8 commits
8eceff0bb5
...
fc9f7c9ee4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc9f7c9ee4 | ||
|
|
f824e38959 | ||
|
|
e8a28ba1f6 | ||
|
|
0e4b7868c9 | ||
|
|
a6ecc65b97 | ||
|
|
dcb28a9811 | ||
|
|
c6ece2adea | ||
|
|
b073e99ca7 |
107
docs/audit-2026-05-06.md
Normal file
107
docs/audit-2026-05-06.md
Normal 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-секцией.
|
||||
|
|
@ -69,7 +69,7 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
|
|||
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)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||
[HttpPost, Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||
[HttpPost, Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupInput input, CancellationToken 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));
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ public async Task<ActionResult<IReadOnlyList<ImageDto>>> List(Guid productId, Ca
|
|||
return images;
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
||||
[RequestSizeLimit(MaxBytes)]
|
||||
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);
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
|
|||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
if (input.Barcodes is null || input.Barcodes.Count == 0)
|
||||
|
|
@ -312,7 +312,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
|
|||
/// <summary>«Привести розничную к себестоимости»: ставит дефолтную
|
||||
/// розничную цену = ceil(Cost * (1 + Group.MarkupPercent/100)). Если у
|
||||
/// группы товара не задан 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)
|
||||
{
|
||||
var p = await _db.Products
|
||||
|
|
@ -361,7 +361,7 @@ public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
|
|||
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)
|
||||
{
|
||||
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>
|
||||
[HttpGet("barcode-duplicates"), Authorize(Roles = "Admin,Manager")]
|
||||
[HttpGet("barcode-duplicates"), Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicates(CancellationToken ct)
|
||||
{
|
||||
var rows = await _db.ProductBarcodes
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ public async Task<ActionResult<RetailPointDto>> Get(Guid id, CancellationToken c
|
|||
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)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
var e = await _db.RetailPoints.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||
[HttpPost, Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, CancellationToken ct)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
var e = await _db.Stores.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||
[HttpPost, Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "SuperAdmin,Admin,Manager")]
|
||||
[HttpPost, Authorize(Roles = "SuperAdmin,Admin")]
|
||||
public async Task<ActionResult<EmployeeRoleDto>> Create([FromBody] EmployeeRoleInput input, CancellationToken ct)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
var r = await _db.EmployeeRoles.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ public record EmployeeDto(
|
|||
decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl,
|
||||
Guid RoleId, string RoleName,
|
||||
bool IsActive, DateTime? FiredAt,
|
||||
bool IsDeleted, DateTime? DeletedAt,
|
||||
// active | fired | deleted — производное от IsActive/IsDeleted, удобно для UI-бейджа
|
||||
string Status,
|
||||
IReadOnlyList<Guid> RetailPointIds,
|
||||
bool IsOwner, bool IsSelf);
|
||||
|
||||
|
|
@ -49,7 +52,9 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
|
|||
|
||||
[HttpGet]
|
||||
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 ownerUserId = await _db.Organizations.IgnoreQueryFilters()
|
||||
|
|
@ -60,6 +65,16 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
|
|||
?? User.FindFirst("sub")?.Value);
|
||||
|
||||
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))
|
||||
{
|
||||
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.RoleId, e.Role.Name,
|
||||
e.IsActive, e.FiredAt,
|
||||
e.IsDeleted, e.DeletedAt,
|
||||
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
|
||||
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
|
||||
e.UserId != null && ownerUserId == e.UserId,
|
||||
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);
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "SuperAdmin,Admin,Manager")]
|
||||
[HttpPost, Authorize(Roles = "SuperAdmin,Admin")]
|
||||
public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] EmployeeInput input, CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
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);
|
||||
if (e is null) return NotFound();
|
||||
|
||||
// Soft-delete c гардами: главного администратора — нельзя, себя — нельзя.
|
||||
// Главный администратор удаляется только Супер-администратором платформы.
|
||||
// Если кому-то критично hard-delete (cleanup при удалении org) — это идёт
|
||||
// через SuperAdmin консоль, а не через этот эндпоинт.
|
||||
// Двухступенчатое удаление:
|
||||
// IsActive=true → этот endpoint выполняет «увольнение» (Fired).
|
||||
// IsActive=false && IsDeleted=false → этот endpoint выполняет soft-delete.
|
||||
// IsDeleted=true → 409, уже удалён.
|
||||
// Гарды (главный админ + self) применяются на ОБОИХ шагах.
|
||||
var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? User.FindFirst("sub")?.Value);
|
||||
if (currentUserId is not null && e.UserId == currentUserId)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
error = "Нельзя удалить себя. Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.",
|
||||
error = "Нельзя уволить или удалить себя. Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -249,8 +267,21 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
|||
}
|
||||
}
|
||||
|
||||
e.IsActive = false;
|
||||
e.FiredAt ??= DateTime.UtcNow;
|
||||
if (e.IsDeleted)
|
||||
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);
|
||||
return NoContent();
|
||||
}
|
||||
|
|
@ -276,6 +307,8 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
|||
e.Salary, e.TaxNumber, e.Description, e.ImageUrl,
|
||||
e.RoleId, e.Role.Name,
|
||||
e.IsActive, e.FiredAt,
|
||||
e.IsDeleted, e.DeletedAt,
|
||||
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
|
||||
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
|
||||
e.UserId != null && ownerUserId == e.UserId,
|
||||
e.UserId != null && currentUserId != null && e.UserId == currentUserId))
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
|
|||
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)
|
||||
{
|
||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
|
|||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken 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();
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ public async Task<ActionResult<RetailSaleDto>> Get(Guid id, CancellationToken ct
|
|||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken 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();
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager")]
|
||||
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||||
{
|
||||
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ public async Task StartAsync(CancellationToken ct)
|
|||
var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
|
||||
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))
|
||||
{
|
||||
|
|
@ -222,10 +222,11 @@ public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
|
|||
await SeedEmployeeRolesAsync(db, orgId, ct);
|
||||
}
|
||||
|
||||
/// <summary>Системные роли (IsSystem=true): Администратор / Менеджер /
|
||||
/// Кладовщик / Кассир / Закупщик / Бухгалтер. Сидируется один раз
|
||||
/// per организацию; обновлять не пытаемся, чтобы не сбросить кастомные
|
||||
/// правки галок которые админ мог сделать.</summary>
|
||||
/// <summary>Системные роли (IsSystem=true): только Администратор / Кладовщик /
|
||||
/// Кассир. Менеджеры/Закупщики/Бухгалтеры и пр. — это кастомные роли,
|
||||
/// которые создаёт администратор орги сам через UI «Настроить права ролей».
|
||||
/// Системные нельзя редактировать или удалить из UI; только просмотр.
|
||||
/// Сидируется один раз per организацию.</summary>
|
||||
private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, CancellationToken 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,
|
||||
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
|
||||
{
|
||||
OrganizationId = orgId,
|
||||
Name = "Кладовщик",
|
||||
Description = "Приёмки, инвентаризация, остатки",
|
||||
IsSystem = false, SortOrder = 20,
|
||||
IsSystem = true, SortOrder = 10,
|
||||
Permissions = new RolePermissions
|
||||
{
|
||||
ProductsView = true,
|
||||
|
|
@ -273,7 +258,7 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca
|
|||
OrganizationId = orgId,
|
||||
Name = "Кассир",
|
||||
Description = "Только работа на кассе. Без доступа к веб-админке.",
|
||||
IsSystem = true, SortOrder = 30,
|
||||
IsSystem = true, SortOrder = 20,
|
||||
Permissions = new RolePermissions
|
||||
{
|
||||
ProductsView = true,
|
||||
|
|
@ -282,35 +267,8 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca
|
|||
// 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ public class Employee : TenantEntity
|
|||
|
||||
/// <summary>Оклад в валюте организации, опц.</summary>
|
||||
public decimal? Salary { get; set; }
|
||||
/// <summary>ИИН/ИНН (12-14 символов), опц.</summary>
|
||||
/// <summary>ИИН (12 цифр для физлица в РК), опционально.</summary>
|
||||
public string? TaxNumber { get; set; }
|
||||
/// <summary>Произвольное описание (комментарий HR'а).</summary>
|
||||
public string? Description { get; set; }
|
||||
|
|
@ -32,10 +32,18 @@ public class Employee : TenantEntity
|
|||
public EmployeeRole Role { get; set; } = null!;
|
||||
|
||||
/// <summary>Активен ли сотрудник. False — заблокирован, не может логиниться.
|
||||
/// Удаление физически не делаем (FK из документов), просто IsActive=false.</summary>
|
||||
/// Двухступенчатое удаление: сначала «Уволить» (IsActive=false + FiredAt),
|
||||
/// затем «Удалить» (IsDeleted=true + DeletedAt). Физически не удаляем
|
||||
/// никогда — у сотрудника есть FK из документов (продаж, поставок).</summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
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; }
|
||||
= new List<EmployeeRetailPointAssignment>();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@ public class Role : IdentityRole<Guid>
|
|||
|
||||
public static class SystemRoles
|
||||
{
|
||||
// Tenant-scoped: только три системные роли. Менеджер/Закупщик/Бухгалтер
|
||||
// и любые другие — это кастомные роли, создаются администратором орги
|
||||
// вручную (требование юзера, см. коммит).
|
||||
public const string Admin = "Admin";
|
||||
public const string Manager = "Manager";
|
||||
public const string Cashier = "Cashier";
|
||||
public const string Storekeeper = "Storekeeper";
|
||||
// Platform-level: только SuperAdmin платформы.
|
||||
public const string SuperAdmin = "SuperAdmin";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ import { TenantRouteGuard } from '@/components/TenantRouteGuard'
|
|||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
|
||||
import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage'
|
||||
import { RoleGuard } from '@/components/RoleGuard'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -84,9 +85,9 @@ export default function App() {
|
|||
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
|
||||
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
|
||||
<Route path="/catalog/price-types" element={<PriceTypesPage />} />
|
||||
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
|
||||
<Route path="/catalog/stores" element={<StoresPage />} />
|
||||
<Route path="/catalog/retail-points" element={<RetailPointsPage />} />
|
||||
<Route path="/catalog/counterparties" element={<RoleGuard roles={['Admin']}><CounterpartiesPage /></RoleGuard>} />
|
||||
<Route path="/catalog/stores" element={<RoleGuard roles={['Admin']}><StoresPage /></RoleGuard>} />
|
||||
<Route path="/catalog/retail-points" element={<RoleGuard roles={['Admin']}><RetailPointsPage /></RoleGuard>} />
|
||||
<Route path="/inventory/stock" element={<StockPage />} />
|
||||
<Route path="/inventory/movements" element={<StockMovementsPage />} />
|
||||
<Route path="/purchases/supplies" element={<SuppliesPage />} />
|
||||
|
|
@ -95,10 +96,10 @@ export default function App() {
|
|||
<Route path="/sales/retail" element={<RetailSalesPage />} />
|
||||
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
|
||||
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />
|
||||
<Route path="/admin/import/moysklad" element={<MoySkladImportPage />} />
|
||||
<Route path="/settings/organization" element={<OrganizationSettingsPage />} />
|
||||
<Route path="/settings/employees" element={<EmployeesPage />} />
|
||||
<Route path="/settings/employee-roles" element={<EmployeeRolesPage />} />
|
||||
<Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}><MoySkladImportPage /></RoleGuard>} />
|
||||
<Route path="/settings/organization" element={<RoleGuard roles={['Admin']}><OrganizationSettingsPage /></RoleGuard>} />
|
||||
<Route path="/settings/employees" element={<RoleGuard roles={['Admin']}><EmployeesPage /></RoleGuard>} />
|
||||
<Route path="/settings/employee-roles" element={<RoleGuard roles={['Admin']}><EmployeeRolesPage /></RoleGuard>} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
|
|
|||
|
|
@ -39,53 +39,92 @@ function translateRoles(roles: string[]): string {
|
|||
.join(', ')
|
||||
}
|
||||
|
||||
function buildNav(isSuperAdmin: boolean): NavSection[] {
|
||||
const catalog: NavItem[] = [
|
||||
{ to: '/catalog/products', icon: Package, label: 'Товары' },
|
||||
{ to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' },
|
||||
{ to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' },
|
||||
{ to: '/catalog/price-types', icon: Tag, label: 'Типы цен' },
|
||||
]
|
||||
return [
|
||||
{ group: 'Главное', items: [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Главная', end: true },
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Аналитика' },
|
||||
]},
|
||||
{ group: 'Каталог', items: catalog },
|
||||
{ group: 'Контрагенты', items: [
|
||||
{ to: '/catalog/counterparties', icon: Users, label: 'Контрагенты' },
|
||||
]},
|
||||
{ group: 'Остатки', items: [
|
||||
{ to: '/inventory/stock', icon: Boxes, 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-only). Остальные разделы фильтруются по системной роли:
|
||||
* Кассир работает на кассе и видит товары/остатки + продажи; Кладовщик
|
||||
* — приёмки + остатки. Администратор и SuperAdmin (override) видят всё.
|
||||
*
|
||||
* Источник правды для прав — серверные `[Authorize(Roles = ...)]` на
|
||||
* каждом endpoint-е; sidebar-фильтр это UX-слой чтобы не показывать
|
||||
* неработающие пункты. */
|
||||
function buildNav(roles: string[]): NavSection[] {
|
||||
const isSuperAdmin = roles.includes('SuperAdmin')
|
||||
const isAdmin = roles.includes('Admin') || isSuperAdmin
|
||||
const isCashier = roles.includes('Cashier')
|
||||
const isStorekeeper = roles.includes('Storekeeper')
|
||||
|
||||
const sections: NavSection[] = [
|
||||
{ group: 'Главное', items: [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Главная', end: true },
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Аналитика' },
|
||||
]},
|
||||
]
|
||||
|
||||
// Каталог — Кассиру и Кладовщику только просмотр товаров; группы/типы цен/единицы — 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() {
|
||||
|
|
@ -96,7 +135,7 @@ export function AppLayout() {
|
|||
})
|
||||
|
||||
const isSuperAdmin = !!me?.roles?.includes('SuperAdmin')
|
||||
const nav = buildNav(isSuperAdmin)
|
||||
const nav = buildNav(me?.roles ?? [])
|
||||
// При активном «открыто как…» — слабая жёлтая тонировка фона tenant-области
|
||||
// даёт периферийный сигнал «я не в своей админке».
|
||||
const inOverride = typeof window !== 'undefined' && !!localStorage.getItem('superAdminAsOrg')
|
||||
|
|
|
|||
|
|
@ -36,7 +36,15 @@ export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
|||
useEffect(() => {
|
||||
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>) {
|
||||
|
|
|
|||
35
src/food-market.web/src/components/RoleGuard.tsx
Normal file
35
src/food-market.web/src/components/RoleGuard.tsx
Normal 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}</>
|
||||
}
|
||||
|
|
@ -143,13 +143,10 @@ export function CounterpartiesPage() {
|
|||
</Select>
|
||||
</Field>
|
||||
<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 label="ИИН (физлицо РК)">
|
||||
<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 })} />
|
||||
<TextInput value={form.iin} onChange={(e) => setForm({ ...form, iin: e.target.value })} placeholder="12 цифр" maxLength={12} inputMode="numeric" />
|
||||
</Field>
|
||||
<Field label="Страна">
|
||||
<Select value={form.countryId} onChange={(e) => setForm({ ...form, countryId: e.target.value })}>
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export function EmployeeRolesPage() {
|
|||
<>
|
||||
<ListPageShell
|
||||
title="Роли сотрудников"
|
||||
description="Системные роли можно редактировать (галки прав), но не удалять. Кастомные — полный CRUD."
|
||||
description="Системные роли — только просмотр прав. Кастомные роли — полный CRUD; можно создать на основе системной как шаблона."
|
||||
actions={
|
||||
<>
|
||||
<SearchBar value={search} onChange={setSearch} />
|
||||
|
|
@ -158,10 +158,8 @@ export function EmployeeRolesPage() {
|
|||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
onRowClick={(r) => {
|
||||
if (r.isSystem) {
|
||||
alert('Системная роль, изменения недоступны.')
|
||||
return
|
||||
}
|
||||
// Системные роли — показываем форму с правами в read-only.
|
||||
// Все чекбоксы disabled, кнопка «Сохранить» скрыта (см. footer).
|
||||
setForm({
|
||||
id: r.id, name: r.name, description: r.description ?? '',
|
||||
isSystem: r.isSystem, permissions: { ...blankPerms(), ...r.permissions },
|
||||
|
|
@ -235,7 +233,9 @@ export function EmployeeRolesPage() {
|
|||
<Modal
|
||||
open={!!form}
|
||||
onClose={() => setForm(null)}
|
||||
title={form?.id ? `Редактировать роль${form.isSystem ? ' (системная)' : ''}` : 'Новая роль'}
|
||||
title={form?.id
|
||||
? (form.isSystem ? `Системная роль «${form.name}» (только просмотр)` : `Редактировать роль «${form.name}»`)
|
||||
: 'Новая роль'}
|
||||
width="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
|
|
@ -249,19 +249,30 @@ export function EmployeeRolesPage() {
|
|||
<Trash2 className="w-4 h-4" /> Удалить
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
|
||||
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
|
||||
<Button variant="secondary" onClick={() => setForm(null)}>
|
||||
{form?.isSystem ? 'Закрыть' : 'Отмена'}
|
||||
</Button>
|
||||
{!form?.isSystem && (
|
||||
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{form && (
|
||||
<div className="space-y-4">
|
||||
<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 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>
|
||||
{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 && (
|
||||
<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">
|
||||
⚠️ Изменение прав применится ко ВСЕМ сотрудникам с этой ролью.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Pagination } from '@/components/Pagination'
|
|||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
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 { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
||||
import type { PagedResult, RetailPoint } from '@/lib/types'
|
||||
|
|
@ -16,6 +16,8 @@ import type { EmployeeRoleDto } from '@/pages/EmployeeRolesPage'
|
|||
|
||||
const URL = '/api/organization/employees'
|
||||
|
||||
type EmployeeStatus = 'active' | 'fired' | 'deleted'
|
||||
|
||||
interface EmployeeDto {
|
||||
id: string
|
||||
userId: string | null
|
||||
|
|
@ -33,6 +35,9 @@ interface EmployeeDto {
|
|||
roleName: string
|
||||
isActive: boolean
|
||||
firedAt: string | null
|
||||
isDeleted: boolean
|
||||
deletedAt: string | null
|
||||
status: EmployeeStatus
|
||||
retailPointIds: string[]
|
||||
/** Главный администратор организации (Organization.AccountOwnerUserId ==
|
||||
* Employee.UserId). Любые изменения этой записи (роль, активность,
|
||||
|
|
@ -51,7 +56,10 @@ interface Form {
|
|||
position: string
|
||||
email: string
|
||||
phone: string
|
||||
salary: string
|
||||
/** Денежное значение в валюте организации (берётся из useOrgSettings).
|
||||
* Используется MoneyInput, который сам формирует с копейками или без
|
||||
* в зависимости от org-setting allowFractionalPrices. */
|
||||
salary: number | null
|
||||
taxNumber: string
|
||||
description: string
|
||||
imageUrl: string
|
||||
|
|
@ -64,14 +72,13 @@ interface Form {
|
|||
const blankForm = (): Form => ({
|
||||
lastName: '', firstName: '', middleName: '', position: '',
|
||||
email: '', phone: '',
|
||||
salary: '', taxNumber: '', description: '', imageUrl: '',
|
||||
salary: null, taxNumber: '', description: '', imageUrl: '',
|
||||
roleId: '', isActive: true,
|
||||
retailPointIds: [],
|
||||
createAccount: true,
|
||||
})
|
||||
|
||||
export function EmployeesPage() {
|
||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<EmployeeDto>(URL)
|
||||
const { update, remove } = useCatalogMutations(URL, URL)
|
||||
const [form, setForm] = useState<Form | null>(null)
|
||||
// Сгенерированный пароль возвращается с сервера один раз — показываем
|
||||
|
|
@ -113,7 +120,7 @@ export function EmployeesPage() {
|
|||
const payload = {
|
||||
lastName: form.lastName, firstName: form.firstName, middleName: form.middleName || 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,
|
||||
description: form.description || 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 (
|
||||
<>
|
||||
<ListPageShell
|
||||
|
|
@ -156,29 +168,40 @@ export function EmployeesPage() {
|
|||
description="Учётные записи и сотрудники без логина. Привязка к ролям, для кассиров — к конкретным кассам."
|
||||
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())}>
|
||||
<Plus className="w-4 h-4" /> Добавить сотрудника
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
footer={data && data.total > 0 && (
|
||||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
footer={list.data && list.data.total > 0 && (
|
||||
<Pagination page={list.page} pageSize={list.data.pageSize} total={list.data.total} onPageChange={list.setPage} />
|
||||
)}
|
||||
>
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rows={list.data?.items ?? []}
|
||||
isLoading={list.isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
sortKey={list.sortKey}
|
||||
sortOrder={list.sortOrder}
|
||||
onSortChange={list.setSort}
|
||||
onRowClick={(r) => {
|
||||
setActiveEmployee(r)
|
||||
setForm({
|
||||
id: r.id, lastName: r.lastName, firstName: r.firstName, middleName: r.middleName ?? '',
|
||||
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 ?? '',
|
||||
roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds,
|
||||
createAccount: false,
|
||||
|
|
@ -188,7 +211,11 @@ export function EmployeesPage() {
|
|||
{ header: 'ФИО', cell: (r) => (
|
||||
<div>
|
||||
<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 && (
|
||||
<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
|
||||
? <span className="text-xs text-emerald-600">есть</span>
|
||||
: <span className="text-xs text-slate-400">нет</span> },
|
||||
{ header: 'Статус', width: '110px', cell: (r) => r.isActive
|
||||
? <span className="text-xs text-emerald-600">Активен</span>
|
||||
: <span className="text-xs text-slate-400">Уволен</span> },
|
||||
{ header: 'Статус', width: '110px', cell: (r) => {
|
||||
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>
|
||||
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>
|
||||
|
|
@ -218,20 +247,20 @@ export function EmployeesPage() {
|
|||
width="max-w-xl"
|
||||
footer={
|
||||
<>
|
||||
{form?.id && (
|
||||
{form?.id && activeEmployee && activeEmployee.status !== 'deleted' && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={activeEmployee?.isOwner || activeEmployee?.isSelf}
|
||||
disabled={activeEmployee.isOwner || activeEmployee.isSelf}
|
||||
title={
|
||||
activeEmployee?.isOwner
|
||||
activeEmployee.isOwner
|
||||
? 'Главного администратора может удалить только Супер-администратор платформы'
|
||||
: activeEmployee?.isSelf
|
||||
? 'Нельзя удалить себя'
|
||||
: activeEmployee.isSelf
|
||||
? 'Нельзя уволить или удалить себя'
|
||||
: undefined
|
||||
}
|
||||
onClick={async () => {
|
||||
if (activeEmployee?.isOwner) {
|
||||
if (activeEmployee.isOwner) {
|
||||
setBlockedDelete({
|
||||
title: 'Действие заблокировано',
|
||||
body:
|
||||
|
|
@ -241,28 +270,37 @@ export function EmployeesPage() {
|
|||
})
|
||||
return
|
||||
}
|
||||
if (activeEmployee?.isSelf) {
|
||||
if (activeEmployee.isSelf) {
|
||||
setBlockedDelete({
|
||||
title: 'Нельзя удалить себя',
|
||||
title: 'Нельзя уволить или удалить себя',
|
||||
body:
|
||||
'Свою учётную запись нельзя удалить из этой страницы. ' +
|
||||
'Свою учётную запись нельзя изменить из этой страницы. ' +
|
||||
'Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.',
|
||||
})
|
||||
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 {
|
||||
await remove.mutateAsync(form.id!)
|
||||
list.refetch?.()
|
||||
setForm(null); setActiveEmployee(null)
|
||||
} catch (e) {
|
||||
const err = e as { response?: { data?: { error?: string } }, message?: string }
|
||||
const msg = err.response?.data?.error ?? err.message ?? 'Не удалось удалить сотрудника'
|
||||
setBlockedDelete({ title: 'Не удалось удалить', body: msg })
|
||||
const msg = err.response?.data?.error ?? err.message ?? 'Не удалось выполнить операцию'
|
||||
setBlockedDelete({ title: 'Не удалось выполнить', body: msg })
|
||||
}
|
||||
}}>
|
||||
<Trash2 className="w-4 h-4" /> Удалить
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{activeEmployee.status === 'active' ? 'Уволить' : 'Удалить'}
|
||||
</Button>
|
||||
)}
|
||||
{form?.id && activeEmployee?.status === 'deleted' && (
|
||||
<span className="text-xs text-slate-500 italic">Сотрудник удалён — изменения недоступны.</span>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
|
||||
<Button onClick={save} disabled={!form?.lastName || !form.firstName || !form.roleId}>Сохранить</Button>
|
||||
</>
|
||||
|
|
@ -296,10 +334,10 @@ export function EmployeesPage() {
|
|||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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 label="ИИН/ИНН">
|
||||
<TextInput value={form.taxNumber} onChange={(e) => setForm({ ...form, taxNumber: e.target.value })} placeholder="12-14 цифр" maxLength={20} />
|
||||
<Field label="ИИН">
|
||||
<TextInput value={form.taxNumber} onChange={(e) => setForm({ ...form, taxNumber: e.target.value })} placeholder="12 цифр" maxLength={12} inputMode="numeric" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Описание / комментарий">
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ export function MoySkladImportPage() {
|
|||
{test.data && (
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
{test.error && <div className="text-sm text-red-600">{formatError(test.error)}</div>}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export function SuperAdminOrgCreatePage() {
|
|||
</Field>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<Field label="Адрес"><TextInput value={address} onChange={(e) => setAddress(e.target.value)} /></Field>
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ export function SuperAdminSetupPage() {
|
|||
</Field>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
|
|
|||
Loading…
Reference in a new issue