Compare commits

..

No commits in common. "493ed33fd0dd1be5d8bc7662ecb2c7f743c18082" and "7bb941259a40a6ff48caedaba534f275ab8ead64" have entirely different histories.

21 changed files with 191 additions and 854 deletions

View file

@ -31,6 +31,19 @@ public AuthSignupController(AppDbContext db, UserManager<User> userMgr)
public record SignupInput(string Email, string Password, string OrganizationName, string Phone, string? Plan);
public record SignupResult(Guid OrganizationId, string Email);
/// <summary>Нормализация и проверка телефона Казахстана. Принимаем любое
/// форматирование (пробелы, скобки, +, дефисы), оставляем только цифры,
/// ведущая «8» переписывается в «7». Валидно: ровно 11 цифр, начинается
/// с «77» — мобильный код KZ. «79…» (РФ) и прочие отвергаем.</summary>
private static string? NormalizeKzPhone(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var digits = new string(raw.Where(char.IsDigit).ToArray());
if (digits.Length == 11 && digits[0] == '8') digits = "7" + digits[1..];
if (digits.Length != 11 || !digits.StartsWith("77")) return null;
return "+" + digits;
}
[HttpPost("signup")]
public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput input, CancellationToken ct)
{
@ -39,9 +52,9 @@ public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput inpu
return BadRequest(new { error = "Email, пароль и название обязательны." });
if (input.Password.Length < 8)
return BadRequest(new { error = "Пароль минимум 8 символов." });
var normalizedPhone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone);
var normalizedPhone = NormalizeKzPhone(input.Phone);
if (normalizedPhone is null)
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
return BadRequest(new { error = "Введите корректный номер Казахстана. Пример: +7 700 123 45 67" });
var existing = await _userMgr.FindByEmailAsync(input.Email);
if (existing is not null)

View file

@ -72,7 +72,6 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyInput input, CancellationToken ct)
{
if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err });
var e = Apply(new Counterparty(), input);
_db.Counterparties.Add(e);
await _db.SaveChangesAsync(ct);
@ -82,7 +81,6 @@ public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyI
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput input, CancellationToken ct)
{
if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err });
var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
Apply(e, input);
@ -90,14 +88,6 @@ public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput in
return NoContent();
}
private static string? NormalizePhoneOrError(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
return foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(raw)
? null
: foodmarket.Application.Common.PhoneNormalization.ErrorMessage;
}
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
@ -118,7 +108,7 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i)
e.TaxNumber = i.TaxNumber;
e.CountryId = i.CountryId;
e.Address = i.Address;
e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(i.Phone);
e.Phone = i.Phone;
e.Email = i.Email;
e.BankName = i.BankName;
e.BankAccount = i.BankAccount;

View file

@ -1,6 +1,5 @@
using foodmarket.Application.Catalog;
using foodmarket.Application.Common;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
@ -9,50 +8,19 @@
namespace foodmarket.Api.Controllers.Catalog;
/// <summary>Phase5c: единицы измерения — глобальный справочник (управляется
/// SuperAdmin'ом). Орга подключает нужные через junction org_units_of_measure.
/// Этот контроллер: read-only список + toggle включения/выключения.
/// CRUD — на /api/super-admin/units-of-measure.</summary>
[ApiController]
[Authorize]
[Route("api/catalog/units-of-measure")]
public class UnitsOfMeasureController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public UnitsOfMeasureController(AppDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
public UnitsOfMeasureController(AppDbContext db) => _db = db;
/// <summary>Список единиц для текущей орги: только включённые active
/// globals. Для SuperAdmin без override — все active globals (чтобы UI
/// мог показывать справочник в платформенном контексте).</summary>
[HttpGet]
public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List(
[FromQuery] PagedRequest req, CancellationToken ct)
public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery] PagedRequest req, CancellationToken ct)
{
var orgId = _tenant.OrganizationId;
var isSuperAdminPlatform = _tenant.IsSuperAdmin && !_tenant.IsTenantOverride;
// Globals — read через IgnoreQueryFilters: фильтр иначе пропустит
// нашу же null-OrganizationId-запись только потому, что мы её специально
// ищем; явная фильтрация по OrganizationId IS NULL понятнее.
var q = _db.UnitsOfMeasure
.IgnoreQueryFilters()
.AsNoTracking()
.Where(u => u.OrganizationId == null && u.IsActive);
if (!isSuperAdminPlatform && orgId.HasValue)
{
// Org Admin / Storekeeper: только включённые в его орге.
q = q.Where(u => _db.OrgUnitsOfMeasure
.IgnoreQueryFilters()
.Any(j => j.OrganizationId == orgId.Value && j.UnitOfMeasureId == u.Id));
}
var q = _db.UnitsOfMeasure.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
@ -68,8 +36,7 @@ public UnitsOfMeasureController(AppDbContext db, ITenantContext tenant)
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(u => new UnitOfMeasureDto(
u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, true))
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId))
.ToListAsync(ct);
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
@ -77,74 +44,47 @@ public UnitsOfMeasureController(AppDbContext db, ITenantContext tenant)
[HttpGet("{id:guid}")]
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
{
var u = await _db.UnitsOfMeasure
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == null, ct);
if (u is null) return NotFound();
var orgId = _tenant.OrganizationId;
var enabled = !orgId.HasValue || await _db.OrgUnitsOfMeasure
.IgnoreQueryFilters()
.AnyAsync(j => j.OrganizationId == orgId.Value && j.UnitOfMeasureId == id, ct);
return new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, enabled);
var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId);
}
/// <summary>Включить global для текущей орги. Идемпотентно: повторный
/// вызов отдаёт 204 и не плодит дубликатов junction.</summary>
[HttpPost("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")]
public async Task<IActionResult> Enable(Guid id, CancellationToken ct)
[HttpPost, Authorize(Roles = "Admin")]
public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId;
if (!orgId.HasValue) return BadRequest(new { error = "Tenant context required." });
var unit = await _db.UnitsOfMeasure
.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.Id == id && u.OrganizationId == null && u.IsActive, ct);
if (unit is null) return NotFound();
var existing = await _db.OrgUnitsOfMeasure
.IgnoreQueryFilters()
.AnyAsync(j => j.OrganizationId == orgId.Value && j.UnitOfMeasureId == id, ct);
if (!existing)
var e = new UnitOfMeasure
{
_db.OrgUnitsOfMeasure.Add(new OrgUnitOfMeasure { OrganizationId = orgId.Value, UnitOfMeasureId = id });
Code = input.Code,
Name = input.Name,
Description = input.Description,
};
_db.UnitsOfMeasure.Add(e);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id },
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.OrganizationId));
}
[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);
if (e is null) return NotFound();
if (e.OrganizationId is null && !User.IsInRole("SuperAdmin")) return Forbid();
e.Code = input.Code;
e.Name = input.Name;
e.Description = input.Description;
await _db.SaveChangesAsync(ct);
return NoContent();
}
/// <summary>Отключить global для текущей орги. Если на эту единицу
/// ссылаются продукты орги — 409 со списком названий, чтобы админ
/// перепривязал их сначала.</summary>
[HttpDelete("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")]
public async Task<IActionResult> Disable(Guid id, CancellationToken ct)
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var orgId = _tenant.OrganizationId;
if (!orgId.HasValue) return BadRequest(new { error = "Tenant context required." });
var productNames = await _db.Products
.Where(p => p.UnitOfMeasureId == id)
.OrderBy(p => p.Name)
.Select(p => p.Name)
.Take(10)
.ToListAsync(ct);
if (productNames.Count > 0)
{
return Conflict(new
{
error = "Единица используется в товарах. Перепривяжите товары на другую единицу прежде чем отключать.",
products = productNames,
});
}
var link = await _db.OrgUnitsOfMeasure
.IgnoreQueryFilters()
.FirstOrDefaultAsync(j => j.OrganizationId == orgId.Value && j.UnitOfMeasureId == id, ct);
if (link is not null)
{
_db.OrgUnitsOfMeasure.Remove(link);
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
if (e.OrganizationId is null && !User.IsInRole("SuperAdmin")) return Forbid();
_db.UnitsOfMeasure.Remove(e);
await _db.SaveChangesAsync(ct);
}
return NoContent();
}
}

View file

@ -13,7 +13,7 @@
namespace foodmarket.Api.Controllers.Organizations;
[ApiController]
[Authorize(Roles = "SuperAdmin,Admin")]
[Authorize]
[Route("api/organization/employees")]
public class EmployeesController : ControllerBase
{
@ -111,12 +111,12 @@ public async Task<ActionResult<EmployeeDto>> Get(Guid id, CancellationToken ct)
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
[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.");
var role = await _db.EmployeeRoles.FirstOrDefaultAsync(r => r.Id == input.RoleId, ct);
if (role is null) return BadRequest(new { error = "Роль не найдена." });
var roleExists = await _db.EmployeeRoles.AnyAsync(r => r.Id == input.RoleId, ct);
if (!roleExists) return BadRequest(new { error = "Роль не найдена." });
var employee = new Employee
{
@ -151,15 +151,6 @@ public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] Employee
if (!result.Succeeded)
return BadRequest(new { error = string.Join("; ", result.Errors.Select(e => e.Description)) });
employee.UserId = user.Id;
// Identity-роль маппится из orgRole.Name. Кастомные orgRole не
// получают Identity-роли — они только дают UI-permissions, без
// доступа к role-locked endpoint'ам.
var identityRole = foodmarket.Api.Infrastructure.IdentityRoleMapper.FromOrgRoleName(role.Name);
if (identityRole is not null)
{
await _userMgr.AddToRoleAsync(user, identityRole);
}
}
foreach (var rpId in input.RetailPointIds ?? [])
@ -217,10 +208,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input,
e.MiddleName = input.MiddleName;
e.Position = input.Position;
e.Email = input.Email;
if (!string.IsNullOrWhiteSpace(input.Phone)
&& !foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Phone))
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone);
e.Phone = input.Phone;
e.Salary = input.Salary;
e.TaxNumber = input.TaxNumber;
e.Description = input.Description;

View file

@ -162,21 +162,15 @@ public async Task<ActionResult<EmployeeDetail>> Create(Guid orgId, [FromBody] Cr
var ur = await _userMgr.CreateAsync(u, tempPassword);
if (!ur.Succeeded)
return BadRequest(new { error = string.Join("; ", ur.Errors.Select(x => x.Description)) });
// Identity-роль из orgRole (Администратор/Кладовщик/Кассир) — кастомные не получают.
var identityRole = foodmarket.Api.Infrastructure.IdentityRoleMapper.FromOrgRoleName(role.Name) ?? "Admin";
await _userMgr.AddToRoleAsync(u, identityRole);
await _userMgr.AddToRoleAsync(u, "Admin");
newUserId = u.Id;
}
if (!foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Phone))
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
var emp = new Employee
{
OrganizationId = orgId, UserId = newUserId,
LastName = input.LastName, FirstName = input.FirstName, MiddleName = input.MiddleName,
Position = input.Position, Email = input.Email,
Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone),
Position = input.Position, Email = input.Email, Phone = input.Phone,
RoleId = input.RoleId, IsActive = input.IsActive,
};
foreach (var rp in input.RetailPointIds ?? [])
@ -210,9 +204,7 @@ public async Task<IActionResult> Update(Guid orgId, Guid id, [FromBody] UpdateIn
e.MiddleName = input.Employee.MiddleName;
e.Position = input.Employee.Position;
e.Email = input.Employee.Email;
if (!foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Employee.Phone))
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Employee.Phone);
e.Phone = input.Employee.Phone;
e.Salary = input.Employee.Salary;
e.TaxNumber = input.Employee.TaxNumber;
e.Description = input.Employee.Description;

View file

@ -92,15 +92,11 @@ public async Task<ActionResult<OrgDetail>> Get(Guid id, CancellationToken ct)
[HttpPost]
public async Task<ActionResult<CreateOrgResult>> Create([FromBody] CreateOrgRequest input, CancellationToken ct)
{
var phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Org.Phone);
if (!string.IsNullOrEmpty(input.Org.Phone) && phone is null)
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
var org = new Organization
{
Name = input.Org.Name, CountryCode = input.Org.CountryCode,
Bin = input.Org.Bin, Address = input.Org.Address,
Phone = phone, Email = input.Org.Email,
Phone = input.Org.Phone, Email = input.Org.Email,
DefaultCurrencyId = input.Org.DefaultCurrencyId,
};
_db.Organizations.Add(org);
@ -151,13 +147,10 @@ public async Task<IActionResult> Update(Guid id, [FromBody] OrgInput input, Canc
{
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
if (o is null) return NotFound();
var phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone);
if (!string.IsNullOrEmpty(input.Phone) && phone is null)
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
var before = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
o.Name = input.Name; o.CountryCode = input.CountryCode;
o.Bin = input.Bin; o.Address = input.Address;
o.Phone = phone; o.Email = input.Email;
o.Phone = input.Phone; o.Email = input.Email;
o.DefaultCurrencyId = input.DefaultCurrencyId;
await _db.SaveChangesAsync(ct);
var after = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";

View file

@ -1,158 +0,0 @@
using foodmarket.Application.Catalog;
using foodmarket.Application.Common;
using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.SuperAdmin;
/// <summary>SuperAdmin: CRUD над глобальными единицами измерения (Phase5c).
/// GET отдаёт все globals (active+inactive); POST/PUT всегда устанавливают
/// OrganizationId=NULL; DELETE — soft (IsActive=false), а если на единицу
/// ссылаются продукты или активные org-junction'ы — 409 со списком орг,
/// чтобы они сначала отвязались.</summary>
[ApiController]
[Authorize(Roles = "SuperAdmin")]
[Route("api/super-admin/units-of-measure")]
public class SuperAdminUnitsOfMeasureController : ControllerBase
{
private readonly AppDbContext _db;
public SuperAdminUnitsOfMeasureController(AppDbContext db) => _db = db;
[HttpGet]
public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List(
[FromQuery] PagedRequest req, CancellationToken ct)
{
var q = _db.UnitsOfMeasure
.IgnoreQueryFilters()
.AsNoTracking()
.Where(u => u.OrganizationId == null);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(u => u.Name.ToLower().Contains(s) || u.Code.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("code", false) => q.OrderBy(u => u.Code),
("code", true) => q.OrderByDescending(u => u.Code),
("name", true) => q.OrderByDescending(u => u.Name),
_ => q.OrderBy(u => u.Name),
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(u => new UnitOfMeasureDto(
u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, true))
.ToListAsync(ct);
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
{
var u = await _db.UnitsOfMeasure
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == null, ct);
return u is null
? NotFound()
: new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, true);
}
[HttpPost]
public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.Code) || string.IsNullOrWhiteSpace(input.Name))
return BadRequest(new { error = "Code и Name обязательны." });
// Унiqueness среди active globals (filtered index в БД, но проверяем
// и здесь чтобы вернуть осмысленный 409).
var dup = await _db.UnitsOfMeasure.IgnoreQueryFilters()
.AnyAsync(u => u.OrganizationId == null && u.IsActive && u.Code == input.Code, ct);
if (dup) return Conflict(new { error = $"Код «{input.Code}» уже используется." });
var e = new UnitOfMeasure
{
OrganizationId = null,
Code = input.Code.Trim(),
Name = input.Name.Trim(),
Description = input.Description,
IsActive = true,
};
_db.UnitsOfMeasure.Add(e);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id },
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.OrganizationId, e.IsActive, true));
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput input, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.Code) || string.IsNullOrWhiteSpace(input.Name))
return BadRequest(new { error = "Code и Name обязательны." });
var e = await _db.UnitsOfMeasure
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == null, ct);
if (e is null) return NotFound();
if (e.Code != input.Code.Trim())
{
var dup = await _db.UnitsOfMeasure.IgnoreQueryFilters()
.AnyAsync(u => u.OrganizationId == null && u.IsActive && u.Code == input.Code && u.Id != id, ct);
if (dup) return Conflict(new { error = $"Код «{input.Code}» уже используется." });
}
e.Code = input.Code.Trim();
e.Name = input.Name.Trim();
e.Description = input.Description;
await _db.SaveChangesAsync(ct);
return NoContent();
}
/// <summary>Soft-delete: IsActive=false. Если на единицу ссылаются
/// продукты или активные org-junction'ы — 409 со списком орг (до 10),
/// чтобы SuperAdmin понимал кого нужно сначала перевести.</summary>
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var e = await _db.UnitsOfMeasure
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == null, ct);
if (e is null) return NotFound();
var productCount = await _db.Products
.IgnoreQueryFilters()
.CountAsync(p => p.UnitOfMeasureId == id, ct);
var orgsUsing = await _db.OrgUnitsOfMeasure
.IgnoreQueryFilters()
.Where(j => j.UnitOfMeasureId == id)
.Join(_db.Organizations.IgnoreQueryFilters(),
j => j.OrganizationId,
o => o.Id,
(j, o) => o.Name)
.OrderBy(n => n)
.Take(10)
.ToListAsync(ct);
if (productCount > 0 || orgsUsing.Count > 0)
{
return Conflict(new
{
error = $"Единица используется: {productCount} товар(ов), {orgsUsing.Count} организаций. Сначала отключите её у этих орг.",
productCount,
organizations = orgsUsing,
});
}
e.IsActive = false;
await _db.SaveChangesAsync(ct);
return NoContent();
}
}

View file

@ -1,25 +0,0 @@
namespace foodmarket.Api.Infrastructure;
/// <summary>Маппинг доменной orgRole.Name (русский, видимый юзеру в UI)
/// на Identity-роль (английский, используется в [Authorize(Roles=...)]).
/// Identity-роль определяет ЧТО юзер может вызывать через API; orgRole —
/// видимое имя в UI и набор прав для конкретной орги.
///
/// Системные orgRole сидятся при создании org (см. DevDataSeeder.SeedEmployeeRolesAsync).
/// Кастомные orgRole создаются админом орги — для них Identity-роль НЕ
/// присваивается (юзер не сможет дёрнуть Admin/Cashier/Storekeeper-only
/// endpoints, что и нужно: кастомные роли — только UI-permissions внутри org).</summary>
public static class IdentityRoleMapper
{
public static string? FromOrgRoleName(string? orgRoleName)
{
if (string.IsNullOrWhiteSpace(orgRoleName)) return null;
return orgRoleName.Trim() switch
{
"Администратор" => "Admin",
"Кладовщик" => "Storekeeper",
"Кассир" => "Cashier",
_ => null,
};
}
}

View file

@ -36,13 +36,12 @@ public async Task StartAsync(CancellationToken ct)
const decimal vatDefault = 16m;
const decimal vat0 = 0m;
// Phase5c: единицы измерения — глобальные (OrganizationId IS NULL).
var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.OrganizationId == null && u.Code == "796", ct);
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "796", ct);
var unitKg = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.OrganizationId == null && u.Code == "166", ct);
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "166", ct);
var unitL = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.OrganizationId == null && u.Code == "112", ct);
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "112", ct);
if (unitSht is null) return;

View file

@ -38,11 +38,6 @@ public async Task StartAsync(CancellationToken ct)
}
}
// Глобальные единицы измерения (Phase5c). Идемпотентно: если миграция
// уже их создала, ничего не происходит. Нужно для случая, когда
// развернули с нуля без миграции данных.
await SeedGlobalUnitsAsync(db, ct);
var kzt = await db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct);
var demoOrg = await db.Organizations.FirstOrDefaultAsync(o => o.Name == "Demo Market", ct);
if (demoOrg is null)
@ -170,9 +165,17 @@ private static async Task SeedAdminEmployeeAsync(AppDbContext db, Guid orgId, Gu
/// SuperAdmin UI.</summary>
public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct)
{
// Phase5c: единицы измерения теперь global. Подключаем к новой
// организации все active globals через junction org_units_of_measure.
await EnableAllActiveUnitsForOrgAsync(db, orgId, ct);
var anyUnit = await db.UnitsOfMeasure.IgnoreQueryFilters().AnyAsync(u => u.OrganizationId == orgId, ct);
if (!anyUnit)
{
db.UnitsOfMeasure.AddRange(
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Name = "штука" },
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Name = "килограмм" },
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Name = "литр" },
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Name = "метр" },
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Name = "упаковка" }
);
}
// Сидируем PriceType ТОЛЬКО если у организации не было ни одной записи.
// Если есть — никогда не создаём «системную копию», корректность IsSystem
@ -269,60 +272,5 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca
await db.SaveChangesAsync(ct);
}
/// <summary>Канонический набор 5 глобальных единиц измерения (Phase5c).
/// Идемпотентно: вставляет только отсутствующие по Code среди globals.
/// На свежем prod-environment где миграция данных не нашла исходных
/// tenant-rows — этот сидер обеспечит наличие минимума.</summary>
private static async Task SeedGlobalUnitsAsync(AppDbContext db, CancellationToken ct)
{
var canonical = new (string Code, string Name)[]
{
("796", "штука"),
("166", "килограмм"),
("112", "литр"),
("006", "метр"),
("625", "упаковка"),
};
var existingCodes = await db.UnitsOfMeasure
.IgnoreQueryFilters()
.Where(u => u.OrganizationId == null)
.Select(u => u.Code)
.ToListAsync(ct);
foreach (var (code, name) in canonical)
{
if (!existingCodes.Contains(code))
{
db.UnitsOfMeasure.Add(new UnitOfMeasure { OrganizationId = null, Code = code, Name = name, IsActive = true });
}
}
await db.SaveChangesAsync(ct);
}
/// <summary>Включить все active globals для новой организации через
/// junction org_units_of_measure. Идемпотентно: на повторный вызов не
/// добавляет дубликатов.</summary>
private static async Task EnableAllActiveUnitsForOrgAsync(AppDbContext db, Guid orgId, CancellationToken ct)
{
var globals = await db.UnitsOfMeasure
.IgnoreQueryFilters()
.Where(u => u.OrganizationId == null && u.IsActive)
.Select(u => u.Id)
.ToListAsync(ct);
var alreadyEnabled = await db.OrgUnitsOfMeasure
.IgnoreQueryFilters()
.Where(j => j.OrganizationId == orgId)
.Select(j => j.UnitOfMeasureId)
.ToListAsync(ct);
foreach (var unitId in globals.Except(alreadyEnabled))
{
db.OrgUnitsOfMeasure.Add(new OrgUnitOfMeasure { OrganizationId = orgId, UnitOfMeasureId = unitId });
}
await db.SaveChangesAsync(ct);
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

View file

@ -12,8 +12,7 @@ public record CountryDto(
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol);
public record UnitOfMeasureDto(
Guid Id, string Code, string Name, string? Description, Guid? OrganizationId,
bool IsActive = true, bool IsEnabledForOrg = true);
Guid Id, string Code, string Name, string? Description, Guid? OrganizationId);
public record PriceTypeDto(
Guid Id, string Name, bool IsRequired, bool IsSystem,

View file

@ -1,35 +0,0 @@
namespace foodmarket.Application.Common;
/// <summary>Серверная валидация и нормализация телефонов Казахстана.
/// Принимает любое форматирование (+7 700 123-45-67, 8 (700) 1234567, ...),
/// оставляет только цифры, ведущая «8» переписывается в «7».
/// Валидно: ровно 11 цифр, начинается с «77» — мобильный код KZ.
/// «79…» (РФ) и прочие отвергаются.
///
/// Используется на всех endpoint'ах, принимающих phone — defense-in-depth
/// к фронт-валидации (PhoneInput / validatePhone).</summary>
public static class PhoneNormalization
{
public const string ErrorMessage = "Введите корректный номер Казахстана. Пример: +7 700 123 45 67";
/// <summary>Возвращает нормализованный номер вида "+77001234567" если phone валиден,
/// null если не валиден. Пустой ввод возвращает null без ошибки —
/// проверку «обязательно ли» делает вызывающая сторона.</summary>
public static string? TryNormalizeKz(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var digits = new string(raw.Where(char.IsDigit).ToArray());
if (digits.Length == 11 && digits[0] == '8') digits = "7" + digits[1..];
if (digits.Length != 11 || !digits.StartsWith("77")) return null;
return "+" + digits;
}
/// <summary>True если phone валиден или null/пусто (опциональное поле).
/// False — только если непустой ввод не парсится. Удобно для контроллеров,
/// которые принимают phone как опциональный.</summary>
public static bool IsValidOrEmpty(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return true;
return TryNormalizeKz(raw) is not null;
}
}

View file

@ -1,14 +0,0 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Catalog;
/// <summary>Junction «организация ↔ глобальная единица измерения».
/// Орга включает только нужные единицы из глобального справочника, чтобы
/// в формах товара не маячили все 50+ ОКЕИ-кодов. Composite PK
/// (OrganizationId, UnitOfMeasureId) — naturally unique.</summary>
public class OrgUnitOfMeasure : ITenantEntity
{
public Guid OrganizationId { get; set; }
public Guid UnitOfMeasureId { get; set; }
public UnitOfMeasure? UnitOfMeasure { get; set; }
}

View file

@ -2,17 +2,13 @@
namespace foodmarket.Domain.Catalog;
// Единица измерения — глобальный справочник (Phase5c): только SuperAdmin
// CRUD'ит. Каждая орга включает нужные ей единицы через junction
// OrgUnitOfMeasure (см. ниже). OrganizationId оставлен nullable для
// обратной совместимости со снимком EF (после миграции всегда NULL).
// IsActive — soft-delete: глобал, на который ссылаются продукты, нельзя
// удалить, но можно деактивировать.
// Единица измерения как в MoySklad entity/uom: code + name + description.
// Двухуровневый справочник: системные эталонные (OrganizationId=null,
// управляются SuperAdmin'ом — ОКЕИ-эталоны) и tenant'овские расширения.
public class UnitOfMeasure : Entity, IOptionalTenantEntity
{
public Guid? OrganizationId { get; set; }
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
public string? Description { get; set; }
public bool IsActive { get; set; } = true;
}

View file

@ -27,7 +27,6 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<Country> Countries => Set<Country>();
public DbSet<Currency> Currencies => Set<Currency>();
public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>();
public DbSet<OrgUnitOfMeasure> OrgUnitsOfMeasure => Set<OrgUnitOfMeasure>();
public DbSet<Counterparty> Counterparties => Set<Counterparty>();
public DbSet<Store> Stores => Set<Store>();
public DbSet<RetailPoint> RetailPoints => Set<RetailPoint>();

View file

@ -11,7 +11,6 @@ public static void ConfigureCatalog(this ModelBuilder b)
b.Entity<Country>(ConfigureCountry);
b.Entity<Currency>(ConfigureCurrency);
b.Entity<UnitOfMeasure>(ConfigureUnit);
b.Entity<OrgUnitOfMeasure>(ConfigureOrgUnit);
b.Entity<Counterparty>(ConfigureCounterparty);
b.Entity<Store>(ConfigureStore);
b.Entity<RetailPoint>(ConfigureRetailPoint);
@ -48,19 +47,7 @@ private static void ConfigureUnit(EntityTypeBuilder<UnitOfMeasure> b)
b.Property(x => x.Code).HasMaxLength(10).IsRequired();
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
b.Property(x => x.Description).HasMaxLength(500);
b.Property(x => x.IsActive).HasDefaultValue(true);
// Phase5c: после миграции OrganizationId всегда NULL — глобальный справочник.
// Уникальность по Code только среди активных, чтобы можно было
// soft-delete старую запись и создать новую с тем же кодом.
b.HasIndex(x => x.Code).IsUnique().HasFilter("\"IsActive\" = true");
}
private static void ConfigureOrgUnit(EntityTypeBuilder<OrgUnitOfMeasure> b)
{
b.ToTable("org_units_of_measure");
b.HasKey(x => new { x.OrganizationId, x.UnitOfMeasureId });
b.HasOne(x => x.UnitOfMeasure).WithMany().HasForeignKey(x => x.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => x.UnitOfMeasureId);
b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique();
}
private static void ConfigureCounterparty(EntityTypeBuilder<Counterparty> b)

View file

@ -1,163 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase5c — рефакторинг справочника единиц измерения в глобальный.
/// До: каждая орга держала свои 5 копий («штука», «кг», ...). 95 строк
/// в БД на 19 орг — duplication, и редактирует их кто угодно.
/// После: 5 globals (OrganizationId=NULL), CRUD только у SuperAdmin.
/// Орга включает нужные единицы через junction org_units_of_measure.
///
/// Миграция данных:
/// 1. По одной строке на каждую (Code, Name) пару поднимается в global
/// (OrganizationId→NULL).
/// 2. Junction наполняется: каждая орга получает запись о включённости
/// того global'а, чью (Code, Name) она раньше держала локально.
/// 3. products.UnitOfMeasureId remap'ится с tenant-row на global.
/// 4. Оставшиеся tenant-rows (дубликаты) удаляются.
/// 5. Если в БД не было какого-то канонического (Code, Name), он
/// добавляется явно.
///
/// Безопасно для prod: products FK (OnDelete=Restrict) не падает
/// благодаря шагу 3 перед DELETE на шаге 4.</summary>
public partial class Phase5c_UnitsOfMeasureGlobal : Migration
{
protected override void Up(MigrationBuilder b)
{
// 0. IsActive (default true) — для будущего soft-delete.
b.AddColumn<bool>(
name: "IsActive",
schema: "public",
table: "units_of_measure",
type: "boolean",
nullable: false,
defaultValue: true);
// 1. Старый unique index (OrganizationId, Code) — больше не нужен.
b.DropIndex(
name: "IX_units_of_measure_OrganizationId_Code",
schema: "public",
table: "units_of_measure");
// 2. Поднять одну строку на (Code, Name) пару в global.
b.Sql(@"
WITH first_per_pair AS (
SELECT DISTINCT ON (""Code"", ""Name"") ""Id""
FROM public.units_of_measure
WHERE ""OrganizationId"" IS NOT NULL
ORDER BY ""Code"", ""Name"", ""Id""
)
UPDATE public.units_of_measure
SET ""OrganizationId"" = NULL
WHERE ""Id"" IN (SELECT ""Id"" FROM first_per_pair);");
// 3. Создать junction org_units_of_measure.
b.CreateTable(
name: "org_units_of_measure",
schema: "public",
columns: table => new
{
OrganizationId = table.Column<System.Guid>(type: "uuid", nullable: false),
UnitOfMeasureId = table.Column<System.Guid>(type: "uuid", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_org_units_of_measure", x => new { x.OrganizationId, x.UnitOfMeasureId });
table.ForeignKey(
name: "FK_org_units_of_measure_units_of_measure_UnitOfMeasureId",
column: x => x.UnitOfMeasureId,
principalSchema: "public",
principalTable: "units_of_measure",
principalColumn: "Id",
onDelete: Microsoft.EntityFrameworkCore.Migrations.ReferentialAction.Restrict);
});
b.CreateIndex(
name: "IX_org_units_of_measure_UnitOfMeasureId",
schema: "public",
table: "org_units_of_measure",
column: "UnitOfMeasureId");
// 4. Заполнить junction: для каждой орги — её активные globals
// (через сравнение Code+Name).
b.Sql(@"
INSERT INTO public.org_units_of_measure (""OrganizationId"", ""UnitOfMeasureId"")
SELECT DISTINCT t.""OrganizationId"", g.""Id""
FROM public.units_of_measure t
JOIN public.units_of_measure g
ON g.""OrganizationId"" IS NULL
AND g.""Code"" = t.""Code""
AND g.""Name"" = t.""Name""
WHERE t.""OrganizationId"" IS NOT NULL
ON CONFLICT DO NOTHING;");
// 5. Remap products.UnitOfMeasureId с tenant-row на global.
b.Sql(@"
UPDATE public.products p
SET ""UnitOfMeasureId"" = g.""Id""
FROM public.units_of_measure t, public.units_of_measure g
WHERE p.""UnitOfMeasureId"" = t.""Id""
AND t.""OrganizationId"" IS NOT NULL
AND g.""OrganizationId"" IS NULL
AND g.""Code"" = t.""Code""
AND g.""Name"" = t.""Name"";");
// 6. Удалить tenant-row дубликаты (на globals никто уже не ссылается
// напрямую кроме junction и products — те remap'нуты выше).
b.Sql(@"DELETE FROM public.units_of_measure WHERE ""OrganizationId"" IS NOT NULL;");
// 7. Доинсёртить канонические globals, если каких-то не было в БД.
b.Sql(@"
INSERT INTO public.units_of_measure (""Id"", ""Code"", ""Name"", ""IsActive"")
SELECT gen_random_uuid(), v.code, v.name, true
FROM (VALUES
('796','штука'),
('166','килограмм'),
('112','литр'),
('006','метр'),
('625','упаковка')
) AS v(code, name)
WHERE NOT EXISTS (
SELECT 1 FROM public.units_of_measure
WHERE ""OrganizationId"" IS NULL AND ""Code"" = v.code
);");
// 8. Новый unique index на Code среди active globals.
b.CreateIndex(
name: "IX_units_of_measure_Code",
schema: "public",
table: "units_of_measure",
column: "Code",
unique: true,
filter: "\"IsActive\" = true");
}
protected override void Down(MigrationBuilder b)
{
b.DropIndex(
name: "IX_units_of_measure_Code",
schema: "public",
table: "units_of_measure");
b.DropTable(
name: "org_units_of_measure",
schema: "public");
b.DropColumn(
name: "IsActive",
schema: "public",
table: "units_of_measure");
// Восстановить старый unique index. Данные не возвращаем — это
// одностороння миграция (rollback вернёт лишь схему).
b.CreateIndex(
name: "IX_units_of_measure_OrganizationId_Code",
schema: "public",
table: "units_of_measure",
columns: new[] { "OrganizationId", "Code" },
unique: true);
}
}
}

View file

@ -12,7 +12,6 @@ import { SuperAdminSetupPage } from '@/pages/SuperAdminSetupPage'
import { SuperAdminSettingsPage } from '@/pages/SuperAdminSettingsPage'
import { CountriesPage } from '@/pages/CountriesPage'
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
import { SuperAdminUnitsOfMeasurePage } from '@/pages/SuperAdminUnitsOfMeasurePage'
import { PriceTypesPage } from '@/pages/PriceTypesPage'
import { StoresPage } from '@/pages/StoresPage'
import { RetailPointsPage } from '@/pages/RetailPointsPage'
@ -76,7 +75,7 @@ export default function App() {
<Route path="audit-log" element={<SuperAdminAuditLogPage />} />
<Route path="countries" element={<CountriesPage />} />
<Route path="groups" element={<ProductGroupsPage />} />
<Route path="units" element={<SuperAdminUnitsOfMeasurePage />} />
<Route path="units" element={<UnitsOfMeasurePage />} />
<Route path="settings" element={<SuperAdminSettingsPage />} />
<Route path="platform-settings" element={<SuperAdminPlatformSettingsPage />} />
</Route>

View file

@ -28,10 +28,7 @@ export interface Country {
vatRate: number
}
export interface Currency { id: string; code: string; name: string; symbol: string }
export interface UnitOfMeasure {
id: string; code: string; name: string; description: string | null; organizationId: string | null;
isActive: boolean; isEnabledForOrg: boolean
}
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; organizationId: string | null }
export interface PriceType { id: string; name: string; isRequired: boolean; isSystem: boolean; isRetail: boolean; sortOrder: number }
export interface Store {
id: string; name: string; code: string | null; address: string | null; phone: string | null;

View file

@ -1,135 +0,0 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { UnitOfMeasure } from '@/lib/types'
const URL = '/api/super-admin/units-of-measure'
interface Form {
id?: string
code: string
name: string
description: string
}
const blank: Form = { code: '', name: '', description: '' }
export function SuperAdminUnitsOfMeasurePage() {
const list = useCatalogList<UnitOfMeasure>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const save = async () => {
if (!form) return
setSubmitError(null)
const { id, ...payload } = form
try {
if (id) await update.mutateAsync({ id, input: payload })
else await create.mutateAsync(payload)
setForm(null)
} catch (e: unknown) {
type ErrShape = { response?: { data?: { error?: string } } }
const err = (e as ErrShape).response?.data?.error
setSubmitError(err ?? 'Не удалось сохранить.')
}
}
const onDelete = async () => {
if (!form?.id) return
if (!confirm('Деактивировать единицу? Если на неё ссылаются товары или орги — операция не пройдёт.')) return
try {
await remove.mutateAsync(form.id)
setForm(null)
} catch (e: unknown) {
type ErrShape = { response?: { data?: { error?: string; organizations?: string[]; productCount?: number } } }
const r = (e as ErrShape).response?.data
const orgs = r?.organizations?.length ? `\n\nОрганизации: ${r.organizations.join(', ')}` : ''
alert((r?.error ?? 'Не удалось удалить.') + orgs)
}
}
return (
<>
<ListPageShell
title="Единицы измерения (платформа)"
description="Глобальный справочник. Орги включают нужные единицы у себя в каталоге."
actions={
<>
<SearchBar value={list.search} onChange={list.setSearch} />
<Button onClick={() => { setSubmitError(null); setForm(blank) }}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
footer={list.data && list.data.total > 0 && (
<Pagination page={list.page} pageSize={list.data.pageSize} total={list.data.total} onPageChange={list.setPage} />
)}
>
<DataTable
rows={list.data?.items ?? []}
isLoading={list.isLoading}
rowKey={(r) => r.id}
sortKey={list.sortKey}
sortOrder={list.sortOrder}
onSortChange={list.setSort}
onRowClick={(r) => {
setSubmitError(null)
setForm({ id: r.id, code: r.code, name: r.name, description: r.description ?? '' })
}}
columns={[
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> },
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
{ header: 'Описание', cell: (r) => r.description ?? '—' },
{
header: 'Статус',
width: '110px',
cell: (r) => r.isActive
? <span className="text-xs text-green-700 dark:text-green-300">Активна</span>
: <span className="text-xs text-gray-500">Деактивирована</span>,
},
]}
/>
</ListPageShell>
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? 'Редактировать единицу' : 'Новая единица измерения'}
footer={
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={onDelete}>
<Trash2 className="w-4 h-4" /> Деактивировать
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.code || !form?.name}>Сохранить</Button>
</>
}
>
{form && (
<div className="space-y-3">
<Field label="Код ОКЕИ">
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
</Field>
<Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Описание">
<TextInput value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
{submitError && (
<p className="text-sm text-red-600 dark:text-red-400">{submitError}</p>
)}
</div>
)}
</Modal>
</>
)
}

View file

@ -1,94 +1,121 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { useCatalogList } from '@/lib/useCatalog'
import { api } from '@/lib/api'
import { useIsSuperAdmin } from '@/lib/useMe'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { UnitOfMeasure } from '@/lib/types'
import { Link } from 'react-router-dom'
const URL = '/api/catalog/units-of-measure'
export function UnitsOfMeasurePage() {
const isSuperAdmin = useIsSuperAdmin()
const list = useCatalogList<UnitOfMeasure>(URL)
const qc = useQueryClient()
interface Form {
id?: string
code: string
name: string
description: string
}
const enable = useMutation({
mutationFn: async (id: string) => (await api.post(`${URL}/${id}/enable`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: [URL] }),
})
const disable = useMutation({
mutationFn: async (id: string) => (await api.delete(`${URL}/${id}/enable`)).data,
onError: (err: unknown) => {
type ErrShape = { response?: { data?: { error?: string; products?: string[] } } }
const r = (err as ErrShape).response?.data
const products = r?.products?.length ? `\n\nТовары: ${r.products.join(', ')}` : ''
alert((r?.error ?? 'Не удалось отключить.') + products)
},
onSuccess: () => qc.invalidateQueries({ queryKey: [URL] }),
})
const blankForm: Form = { code: '', name: '', description: '' }
export function UnitsOfMeasurePage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<UnitOfMeasure>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const save = async () => {
if (!form) return
const { id, ...payload } = form
if (id) await update.mutateAsync({ id, input: payload })
else await create.mutateAsync(payload)
setForm(null)
}
return (
<>
<ListPageShell
title="Единицы измерения"
description={
isSuperAdmin
? 'Глобальный справочник ОКЕИ. Управление полным справочником — на странице платформы.'
: 'Включите единицы, нужные вашей организации. Добавление новых единиц — у платформы.'
}
description="Код по ОКЕИ (штука=796, килограмм=166, литр=112, метр=006)."
actions={
<>
<SearchBar value={list.search} onChange={list.setSearch} />
{isSuperAdmin && (
<Link
to="/super-admin/units"
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700"
>Управление справочником</Link>
)}
<SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
footer={list.data && list.data.total > 0 && (
<Pagination page={list.page} pageSize={list.data.pageSize} total={list.data.total} onPageChange={list.setPage} />
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={list.data?.items ?? []}
isLoading={list.isLoading}
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
sortKey={list.sortKey}
sortOrder={list.sortOrder}
onSortChange={list.setSort}
sortKey={sortKey}
sortOrder={sortOrder}
onSortChange={setSort}
onRowClick={(r) => {
if (r.organizationId === null) {
alert('Эталонная единица измерения. Изменения недоступны — управляются на уровне платформы.')
return
}
setForm({
id: r.id, code: r.code, name: r.name,
description: r.description ?? ''
})
}}
columns={[
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> },
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
{ header: 'Название', sortKey: 'name', cell: (r) => (
<span>
{r.name}
{r.organizationId === null && (
<span className="ml-2 text-[10px] uppercase px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">Эталон</span>
)}
</span>
)},
{ header: 'Описание', cell: (r) => r.description ?? '—' },
{
header: 'Для орги',
width: '120px',
cell: (r) => (
<button
type="button"
disabled={enable.isPending || disable.isPending}
onClick={(e) => {
e.stopPropagation()
if (r.isEnabledForOrg) disable.mutate(r.id)
else enable.mutate(r.id)
}}
className={
r.isEnabledForOrg
? 'px-2 py-1 text-xs rounded bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-300'
: 'px-2 py-1 text-xs rounded bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400'
}
>
{r.isEnabledForOrg ? 'Включено' : 'Выключено'}
</button>
),
},
]}
/>
</ListPageShell>
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? 'Редактировать единицу' : 'Новая единица измерения'}
footer={
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить единицу измерения?')) {
await remove.mutateAsync(form.id!)
setForm(null)
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
</>
}
>
{form && (
<div className="space-y-3">
<Field label="Код ОКЕИ">
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
</Field>
<Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Описание">
<TextInput value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
</div>
)}
</Modal>
</>
)
}