From 3e25498c3b324bcaee49acefb42389bbdd0b7247 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:59:24 +0500 Subject: [PATCH] =?UTF-8?q?feat(super-admin):=20=D0=BD=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=B8=D0=B2=D0=B0=D0=B5=D0=BC=D1=8B=D0=B9=20retent?= =?UTF-8?q?ion=20period=20=D0=B4=D0=BB=D1=8F=20=D0=B0=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D0=B2=D0=BD=D1=8B=D1=85=20=D0=BE=D1=80=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше «удалить орг навсегда» было захардкожено на 30 дней архива. Теперь — глобальная системная настройка SuperAdmin'а. Domain/DB: - SystemSettings : Entity (single-row table system_settings). Поле ArchiveRetentionDays (int, default 30). Структура расширяется именованными полями по мере необходимости — без key-value generic'а. - Migration Phase4e_SystemSettings создаёт таблицу с default 30. - DevDataSeeder: при первом старте создаёт single-row дефолт. API: - GET /api/super-admin/settings — текущие настройки. - PUT /api/super-admin/settings — обновить с валидацией [0..3650]. Audit-log запись ActionType=EditSystemSettings с before/after. - SuperAdminOrganizationsController.Delete: хардкод 30 заменён чтением SystemSettings.ArchiveRetentionDays. При retention=0 — удаление доступно сразу после архивации. UI: - /super-admin/settings — страница «Системные настройки». Select из 6 опций (0/1/3/7/14/30), warning-баннер при выборе «Немедленно». Кнопка «Сохранить» disabled пока нет изменений. - В SuperAdminLayout убрана пометка «скоро» с пункта «Системные настройки» — раздел активен. - SuperAdminOrganizationsPage: кнопка «Удалить навсегда» теперь читает retentionDays из API; tooltip показывает оставшиеся дни «Доступно через X дн. (retention N)»; при retention=0 — всегда active для архивных орг. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SuperAdmin/SuperAdminController.cs | 44 + .../SuperAdminOrganizationsController.cs | 7 +- src/food-market.api/Seed/DevDataSeeder.cs | 9 + .../Organizations/SystemSettings.cs | 15 + .../Persistence/AppDbContext.cs | 6 + ...7060000_Phase4e_SystemSettings.Designer.cs | 2058 +++++++++++++++++ .../20260427060000_Phase4e_SystemSettings.cs | 33 + .../Migrations/AppDbContextModelSnapshot.cs | 10 + src/food-market.web/src/App.tsx | 2 + .../src/components/SuperAdminLayout.tsx | 2 +- .../src/pages/SuperAdminOrganizationsPage.tsx | 27 +- .../src/pages/SuperAdminSettingsPage.tsx | 90 + 12 files changed, 2293 insertions(+), 10 deletions(-) create mode 100644 src/food-market.domain/Organizations/SystemSettings.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260427060000_Phase4e_SystemSettings.Designer.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260427060000_Phase4e_SystemSettings.cs create mode 100644 src/food-market.web/src/pages/SuperAdminSettingsPage.tsx diff --git a/src/food-market.api/Controllers/SuperAdmin/SuperAdminController.cs b/src/food-market.api/Controllers/SuperAdmin/SuperAdminController.cs index c04f68a..0194498 100644 --- a/src/food-market.api/Controllers/SuperAdmin/SuperAdminController.cs +++ b/src/food-market.api/Controllers/SuperAdmin/SuperAdminController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Security.Claims; namespace foodmarket.Api.Controllers.SuperAdmin; @@ -47,6 +48,49 @@ public async Task> Dashboard(CancellationToken ct) RegistrationsLast30Days: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => o.CreatedAt >= monthAgo, ct)); } + public record SystemSettingsDto(int ArchiveRetentionDays); + public record SystemSettingsInput(int ArchiveRetentionDays); + + [HttpGet("settings")] + public async Task> GetSettings(CancellationToken ct) + { + var s = await _db.SystemSettings.FirstOrDefaultAsync(ct); + return new SystemSettingsDto(s?.ArchiveRetentionDays ?? 30); + } + + [HttpPut("settings")] + public async Task> UpdateSettings([FromBody] SystemSettingsInput input, CancellationToken ct) + { + if (input.ArchiveRetentionDays < 0 || input.ArchiveRetentionDays > 3650) + return BadRequest(new { error = "ArchiveRetentionDays должен быть в диапазоне 0–3650." }); + var s = await _db.SystemSettings.FirstOrDefaultAsync(ct); + var prev = s?.ArchiveRetentionDays; + if (s is null) + { + s = new SystemSettings { ArchiveRetentionDays = input.ArchiveRetentionDays }; + _db.SystemSettings.Add(s); + } + else + { + s.ArchiveRetentionDays = input.ArchiveRetentionDays; + } + await _db.SaveChangesAsync(ct); + + // Audit-log смены настройки + var userIdRaw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); + Guid.TryParse(userIdRaw, out var uid); + _db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog + { + SuperAdminUserId = uid, + ActionType = "EditSystemSettings", + Description = $"ArchiveRetentionDays: {prev?.ToString() ?? "(default 30)"} → {input.ArchiveRetentionDays}", + ChangesJson = $"{{\"archiveRetentionDays\":{{\"from\":{prev?.ToString() ?? "30"},\"to\":{input.ArchiveRetentionDays}}}}}", + IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "", + }); + await _db.SaveChangesAsync(ct); + return new SystemSettingsDto(s.ArchiveRetentionDays); + } + public record AuditRow( Guid Id, DateTime CreatedAt, Guid SuperAdminUserId, string ActionType, Guid? OrganizationId, string? OrganizationName, diff --git a/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs b/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs index 1e34bc0..089fdd2 100644 --- a/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs +++ b/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs @@ -189,8 +189,11 @@ public async Task Delete(Guid id, [FromBody] DeleteRequest req, C { var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct); if (o is null) return NotFound(); - if (!o.IsArchived || o.ArchivedAt is null || o.ArchivedAt > DateTime.UtcNow.AddDays(-30)) - return Conflict(new { error = "Удалить можно только организации в архиве >30 дней." }); + if (!o.IsArchived || o.ArchivedAt is null) + return Conflict(new { error = "Удалить можно только архивированную организацию." }); + var retentionDays = await _db.SystemSettings.Select(s => (int?)s.ArchiveRetentionDays).FirstOrDefaultAsync(ct) ?? 30; + if (o.ArchivedAt > DateTime.UtcNow.AddDays(-retentionDays)) + return Conflict(new { error = $"Доступно через {retentionDays} дней архива." }); if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно." }); await LogAsync("DeleteOrg", o.Id, $"Удалена навсегда «{o.Name}»", null, $"{{\"name\":\"{o.Name}\"}}", ct); diff --git a/src/food-market.api/Seed/DevDataSeeder.cs b/src/food-market.api/Seed/DevDataSeeder.cs index e792d66..0bdfe89 100644 --- a/src/food-market.api/Seed/DevDataSeeder.cs +++ b/src/food-market.api/Seed/DevDataSeeder.cs @@ -93,6 +93,15 @@ public async Task StartAsync(CancellationToken ct) } await SeedAdminEmployeeAsync(db, demoOrg.Id, admin?.Id, ct); + + // Глобальные SystemSettings — single-row. Сидируем дефолт 30 дней + // retention если ещё нет записи. + var anySettings = await db.SystemSettings.AnyAsync(ct); + if (!anySettings) + { + db.SystemSettings.Add(new SystemSettings { ArchiveRetentionDays = 30 }); + await db.SaveChangesAsync(ct); + } } /// Привязывает существующего admin@food-market.local к diff --git a/src/food-market.domain/Organizations/SystemSettings.cs b/src/food-market.domain/Organizations/SystemSettings.cs new file mode 100644 index 0000000..c8c2e99 --- /dev/null +++ b/src/food-market.domain/Organizations/SystemSettings.cs @@ -0,0 +1,15 @@ +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Organizations; + +/// Глобальные настройки платформы (single-row table). Управляются +/// SuperAdmin'ом в /super-admin/settings. Не tenant-scoped — действует на всю +/// систему. Расширяется добавлением новых типизированных полей по мере +/// необходимости (KISS — без key-value generic'а). +public class SystemSettings : Entity +{ + /// Сколько дней должен пройти после архивации, прежде чем + /// SuperAdmin может удалить организацию навсегда. 0 = удалять сразу + /// после архивации (для dev/staging). По умолчанию 30 (prod-safe). + public int ArchiveRetentionDays { get; set; } = 30; +} diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index 48d8ed9..1397a10 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -50,6 +50,7 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet Employees => Set(); public DbSet EmployeeRetailPointAssignments => Set(); public DbSet SuperAdminAuditLogs => Set(); + public DbSet SystemSettings => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -80,6 +81,11 @@ protected override void OnModelCreating(ModelBuilder builder) b.HasIndex(o => o.IsArchived); }); + builder.Entity(b => + { + b.ToTable("system_settings"); + }); + builder.Entity(b => { b.ToTable("super_admin_audit_log"); diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260427060000_Phase4e_SystemSettings.Designer.cs b/src/food-market.infrastructure/Persistence/Migrations/20260427060000_Phase4e_SystemSettings.Designer.cs new file mode 100644 index 0000000..a623686 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260427060000_Phase4e_SystemSettings.Designer.cs @@ -0,0 +1,2058 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260427060000_Phase4e_SystemSettings")] + partial class Phase4e_SystemSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("public") + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Counterparty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("BankAccount") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BankName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Bik") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Bin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ContactPerson") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CountryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Iin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("LegalName") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TaxNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CountryId"); + + b.HasIndex("OrganizationId", "Bin"); + + b.HasIndex("OrganizationId", "Name"); + + b.ToTable("counterparties", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Country", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultCurrencyId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VatRate") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("DefaultCurrencyId"); + + b.ToTable("countries", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Currency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MinorUnit") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("currencies", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.PriceType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("IsRetail") + .HasColumnType("boolean"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("price_types", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Article") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CountryOfOriginId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultSupplierId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsMarked") + .HasColumnType("boolean"); + + b.Property("IsService") + .HasColumnType("boolean"); + + b.Property("Packaging") + .HasColumnType("boolean"); + + b.Property("MaxStock") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("MinStock") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductGroupId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("LastSupplyAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PurchaseCurrencyId") + .HasColumnType("uuid"); + + b.Property("ReferencePrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReferencePriceUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UnitOfMeasureId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Vat") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("VatEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("CountryOfOriginId"); + + b.HasIndex("DefaultSupplierId"); + + b.HasIndex("ProductGroupId"); + + b.HasIndex("PurchaseCurrencyId"); + + b.HasIndex("UnitOfMeasureId"); + + b.HasIndex("OrganizationId", "Article"); + + b.HasIndex("OrganizationId", "Name"); + + b.HasIndex("OrganizationId", "ProductGroupId"); + + b.ToTable("products", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductBarcode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("OrganizationId", "Code") + .IsUnique(); + + b.ToTable("product_barcodes", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MarkupPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("OrganizationId", "ParentId"); + + b.HasIndex("OrganizationId", "Path"); + + b.ToTable("product_groups", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsMain") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("product_images", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PriceTypeId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyId"); + + b.HasIndex("PriceTypeId"); + + b.HasIndex("ProductId", "PriceTypeId") + .IsUnique(); + + b.ToTable("product_prices", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.RetailPoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FiscalRegNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("FiscalSerial") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("StoreId"); + + b.HasIndex("OrganizationId", "Name"); + + b.ToTable("retail_points", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMain") + .HasColumnType("boolean"); + + b.Property("ManagerName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name"); + + b.ToTable("stores", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.UnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Code") + .IsUnique(); + + b.ToTable("units_of_measure", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReservedQuantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("StoreId"); + + b.HasIndex("OrganizationId", "StoreId"); + + b.HasIndex("OrganizationId", "ProductId", "StoreId") + .IsUnique(); + + b.ToTable("stocks", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.StockMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("DocumentId") + .HasColumnType("uuid"); + + b.Property("DocumentNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DocumentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("StoreId"); + + b.HasIndex("DocumentType", "DocumentId"); + + b.HasIndex("OrganizationId", "ProductId", "OccurredAt"); + + b.HasIndex("OrganizationId", "StoreId", "OccurredAt"); + + b.ToTable("stock_movements", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("AllowFractionalPrices") + .HasColumnType("boolean"); + + b.Property("Bin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CountryCode") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("DefaultCurrencyId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AccountOwnerUserId") + .HasColumnType("uuid"); + + b.Property("MoySkladToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MultiCurrencyEnabled") + .HasColumnType("boolean"); + + b.Property("ShowCountryOfOriginOnProduct") + .HasColumnType("boolean"); + + b.Property("ShowDescriptionOnProduct") + .HasColumnType("boolean"); + + b.Property("ShowMarkedOnProduct") + .HasColumnType("boolean"); + + b.Property("ShowReferencePriceOnProduct") + .HasColumnType("boolean"); + + b.Property("ShowMinMaxStock") + .HasColumnType("boolean"); + + b.Property("ShowServiceOnProduct") + .HasColumnType("boolean"); + + b.Property("ShowVatEnabledOnProduct") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DefaultCurrencyId"); + + b.HasIndex("Name"); + + b.HasIndex("IsArchived"); + + b.ToTable("organizations", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PostedByUserId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyId"); + + b.HasIndex("StoreId"); + + b.HasIndex("SupplierId"); + + b.HasIndex("OrganizationId", "Date"); + + b.HasIndex("OrganizationId", "Number") + .IsUnique(); + + b.HasIndex("OrganizationId", "Status"); + + b.HasIndex("OrganizationId", "SupplierId"); + + b.ToTable("supplies", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.SupplyLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RetailPriceManuallyOverridden") + .HasColumnType("boolean"); + + b.Property("RetailPriceOverride") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("SupplyId") + .HasColumnType("uuid"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("SupplyId"); + + b.HasIndex("OrganizationId", "ProductId"); + + b.ToTable("supply_lines", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CashierUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyId") + .HasColumnType("uuid"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaidCard") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("PaidCash") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Payment") + .HasColumnType("integer"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PostedByUserId") + .HasColumnType("uuid"); + + b.Property("RetailPointId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Total") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("RetailPointId"); + + b.HasIndex("StoreId"); + + b.HasIndex("OrganizationId", "CashierUserId"); + + b.HasIndex("OrganizationId", "Date"); + + b.HasIndex("OrganizationId", "Number") + .IsUnique(); + + b.HasIndex("OrganizationId", "Status"); + + b.ToTable("retail_sales", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Discount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RetailSaleId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VatPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("RetailSaleId"); + + b.HasIndex("OrganizationId", "ProductId"); + + b.ToTable("retail_sale_lines", "public"); + }); + + modelBuilder.Entity("foodmarket.Infrastructure.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", "public"); + }); + + modelBuilder.Entity("foodmarket.Infrastructure.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Counterparty", b => + { + b.HasOne("foodmarket.Domain.Catalog.Country", "Country") + .WithMany() + .HasForeignKey("CountryId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Country"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Product", b => + { + b.HasOne("foodmarket.Domain.Catalog.Country", "CountryOfOrigin") + .WithMany() + .HasForeignKey("CountryOfOriginId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.Counterparty", "DefaultSupplier") + .WithMany() + .HasForeignKey("DefaultSupplierId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.ProductGroup", "ProductGroup") + .WithMany() + .HasForeignKey("ProductGroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.Currency", "PurchaseCurrency") + .WithMany() + .HasForeignKey("PurchaseCurrencyId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.UnitOfMeasure", "UnitOfMeasure") + .WithMany() + .HasForeignKey("UnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CountryOfOrigin"); + + b.Navigation("DefaultSupplier"); + + b.Navigation("ProductGroup"); + + b.Navigation("PurchaseCurrency"); + + b.Navigation("UnitOfMeasure"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductBarcode", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany("Barcodes") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductGroup", b => + { + b.HasOne("foodmarket.Domain.Catalog.ProductGroup", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductImage", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany("Images") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductPrice", b => + { + b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency") + .WithMany() + .HasForeignKey("CurrencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.PriceType", "PriceType") + .WithMany() + .HasForeignKey("PriceTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany("Prices") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Currency"); + + b.Navigation("PriceType"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.RetailPoint", b => + { + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.StockMovement", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b => + { + b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency") + .WithMany() + .HasForeignKey("CurrencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Supplier") + .WithMany() + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Currency"); + + b.Navigation("Store"); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.SupplyLine", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Purchases.Supply", "Supply") + .WithMany("Lines") + .HasForeignKey("SupplyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Supply"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency") + .WithMany() + .HasForeignKey("CurrencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.RetailPoint", "RetailPoint") + .WithMany() + .HasForeignKey("RetailPointId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Currency"); + + b.Navigation("Customer"); + + b.Navigation("RetailPoint"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Sales.RetailSale", "RetailSale") + .WithMany("Lines") + .HasForeignKey("RetailSaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("RetailSale"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Product", b => + { + b.Navigation("Barcodes"); + + b.Navigation("Images"); + + b.Navigation("Prices"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductGroup", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.SystemSettings", b => + { + b.Property("Id").ValueGeneratedOnAdd().HasColumnType("uuid"); + b.Property("ArchiveRetentionDays").HasColumnType("integer"); + b.Property("CreatedAt").HasColumnType("timestamp with time zone"); + b.Property("UpdatedAt").HasColumnType("timestamp with time zone"); + b.HasKey("Id"); + b.ToTable("system_settings", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.SuperAdminAuditLog", b => + { + b.Property("Id").ValueGeneratedOnAdd().HasColumnType("uuid"); + b.Property("SuperAdminUserId").HasColumnType("uuid"); + b.Property("ActionType").IsRequired().HasMaxLength(50).HasColumnType("character varying(50)"); + b.Property("OrganizationId").HasColumnType("uuid"); + b.Property("EntityType").HasMaxLength(100).HasColumnType("character varying(100)"); + b.Property("EntityId").HasColumnType("uuid"); + b.Property("Description").HasMaxLength(500).HasColumnType("character varying(500)"); + b.Property("Reason").HasMaxLength(1000).HasColumnType("character varying(1000)"); + b.Property("ChangesJson").HasColumnType("jsonb"); + b.Property("IpAddress").HasMaxLength(45).HasColumnType("character varying(45)"); + b.Property("CreatedAt").HasColumnType("timestamp with time zone"); + b.Property("UpdatedAt").HasColumnType("timestamp with time zone"); + b.HasKey("Id"); + b.HasIndex("CreatedAt"); + b.HasIndex("SuperAdminUserId", "CreatedAt"); + b.HasIndex("OrganizationId", "CreatedAt"); + b.ToTable("super_admin_audit_log", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.EmployeeRole", b => + { + b.Property("Id").ValueGeneratedOnAdd().HasColumnType("uuid"); + b.Property("OrganizationId").HasColumnType("uuid"); + b.Property("Name").IsRequired().HasMaxLength(100).HasColumnType("character varying(100)"); + b.Property("Description").HasMaxLength(500).HasColumnType("character varying(500)"); + b.Property("IsSystem").HasColumnType("boolean"); + b.Property("SortOrder").HasColumnType("integer"); + b.Property("CreatedAt").HasColumnType("timestamp with time zone"); + b.Property("UpdatedAt").HasColumnType("timestamp with time zone"); + b.HasKey("Id"); + b.HasIndex("OrganizationId", "Name").IsUnique(); + b.ToTable("employee_roles", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.EmployeeRole", b => + { + b.OwnsOne("foodmarket.Domain.Organizations.RolePermissions", "Permissions", o => + { + o.Property("EmployeeRoleId").HasColumnType("uuid"); + o.Property("ProductsView").HasColumnType("boolean"); + o.Property("ProductsEdit").HasColumnType("boolean"); + o.Property("ProductsDelete").HasColumnType("boolean"); + o.Property("ProductGroupsManage").HasColumnType("boolean"); + o.Property("PriceTypesManage").HasColumnType("boolean"); + o.Property("UnitsManage").HasColumnType("boolean"); + o.Property("SuppliesView").HasColumnType("boolean"); + o.Property("SuppliesEdit").HasColumnType("boolean"); + o.Property("SuppliesPost").HasColumnType("boolean"); + o.Property("SuppliesDelete").HasColumnType("boolean"); + o.Property("DemandsView").HasColumnType("boolean"); + o.Property("DemandsEdit").HasColumnType("boolean"); + o.Property("DemandsPost").HasColumnType("boolean"); + o.Property("RetailSalesOperate").HasColumnType("boolean"); + o.Property("RetailSalesRefund").HasColumnType("boolean"); + o.Property("CounterpartiesView").HasColumnType("boolean"); + o.Property("CounterpartiesEdit").HasColumnType("boolean"); + o.Property("CounterpartiesDelete").HasColumnType("boolean"); + o.Property("StocksView").HasColumnType("boolean"); + o.Property("InventoryEdit").HasColumnType("boolean"); + o.Property("LossEdit").HasColumnType("boolean"); + o.Property("EnterEdit").HasColumnType("boolean"); + o.Property("ReportsView").HasColumnType("boolean"); + o.Property("ReportsFinanceView").HasColumnType("boolean"); + o.Property("ReportsStockView").HasColumnType("boolean"); + o.Property("OrgSettingsManage").HasColumnType("boolean"); + o.Property("EmployeesManage").HasColumnType("boolean"); + o.Property("RolesManage").HasColumnType("boolean"); + o.Property("StoresManage").HasColumnType("boolean"); + o.Property("RetailPointsManage").HasColumnType("boolean"); + o.Property("CashRegistersManage").HasColumnType("boolean"); + o.Property("IntegrationsManage").HasColumnType("boolean"); + o.HasKey("EmployeeRoleId"); + o.ToJson("permissions"); + o.WithOwner().HasForeignKey("EmployeeRoleId"); + }); + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.Employee", b => + { + b.Property("Id").ValueGeneratedOnAdd().HasColumnType("uuid"); + b.Property("OrganizationId").HasColumnType("uuid"); + b.Property("UserId").HasColumnType("uuid"); + b.Property("LastName").IsRequired().HasMaxLength(100).HasColumnType("character varying(100)"); + b.Property("FirstName").IsRequired().HasMaxLength(100).HasColumnType("character varying(100)"); + b.Property("MiddleName").HasMaxLength(100).HasColumnType("character varying(100)"); + b.Property("Position").HasMaxLength(150).HasColumnType("character varying(150)"); + b.Property("Email").HasMaxLength(200).HasColumnType("character varying(200)"); + b.Property("Phone").HasMaxLength(50).HasColumnType("character varying(50)"); + b.Property("Salary").HasPrecision(18, 2).HasColumnType("numeric(18,2)"); + b.Property("TaxNumber").HasMaxLength(20).HasColumnType("character varying(20)"); + b.Property("Description").HasMaxLength(2000).HasColumnType("character varying(2000)"); + b.Property("ImageUrl").HasMaxLength(500).HasColumnType("character varying(500)"); + b.Property("RoleId").HasColumnType("uuid"); + b.Property("IsActive").HasColumnType("boolean"); + b.Property("FiredAt").HasColumnType("timestamp with time zone"); + b.Property("CreatedAt").HasColumnType("timestamp with time zone"); + b.Property("UpdatedAt").HasColumnType("timestamp with time zone"); + b.HasKey("Id"); + b.HasIndex("RoleId"); + b.HasIndex("OrganizationId", "LastName"); + b.HasIndex("OrganizationId", "UserId").IsUnique().HasFilter("\"UserId\" IS NOT NULL"); + b.ToTable("employees", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.Employee", b => + { + b.HasOne("foodmarket.Domain.Organizations.EmployeeRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + b.Navigation("Role"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.EmployeeRetailPointAssignment", b => + { + b.Property("Id").ValueGeneratedOnAdd().HasColumnType("uuid"); + b.Property("OrganizationId").HasColumnType("uuid"); + b.Property("EmployeeId").HasColumnType("uuid"); + b.Property("RetailPointId").HasColumnType("uuid"); + b.Property("CreatedAt").HasColumnType("timestamp with time zone"); + b.Property("UpdatedAt").HasColumnType("timestamp with time zone"); + b.HasKey("Id"); + b.HasIndex("EmployeeId", "RetailPointId").IsUnique(); + b.ToTable("employee_retail_point_assignments", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.EmployeeRetailPointAssignment", b => + { + b.HasOne("foodmarket.Domain.Organizations.Employee", "Employee") + .WithMany("RetailPointAssignments") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + b.Navigation("Employee"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260427060000_Phase4e_SystemSettings.cs b/src/food-market.infrastructure/Persistence/Migrations/20260427060000_Phase4e_SystemSettings.cs new file mode 100644 index 0000000..12f3cf1 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260427060000_Phase4e_SystemSettings.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Глобальные настройки платформы (single-row): ArchiveRetentionDays + /// (по умолчанию 30) — сколько дней архивная орга должна пробыть в архиве + /// прежде чем её можно удалить навсегда. Юзер настраивает через UI + /// /super-admin/settings; раньше был хардкод 30 в SuperAdminOrganizationsController. + public partial class Phase4e_SystemSettings : Migration + { + protected override void Up(MigrationBuilder b) + { + b.CreateTable( + name: "system_settings", + schema: "public", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ArchiveRetentionDays = table.Column(type: "integer", nullable: false, defaultValue: 30), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + }, + constraints: table => table.PrimaryKey("PK_system_settings", x => x.Id)); + } + + protected override void Down(MigrationBuilder b) + { + b.DropTable(name: "system_settings", schema: "public"); + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index fddd6f6..2c30eb9 100644 --- a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -1900,6 +1900,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Lines"); }); + modelBuilder.Entity("foodmarket.Domain.Organizations.SystemSettings", b => + { + b.Property("Id").ValueGeneratedOnAdd().HasColumnType("uuid"); + b.Property("ArchiveRetentionDays").HasColumnType("integer"); + b.Property("CreatedAt").HasColumnType("timestamp with time zone"); + b.Property("UpdatedAt").HasColumnType("timestamp with time zone"); + b.HasKey("Id"); + b.ToTable("system_settings", "public"); + }); + modelBuilder.Entity("foodmarket.Domain.Organizations.SuperAdminAuditLog", b => { b.Property("Id").ValueGeneratedOnAdd().HasColumnType("uuid"); diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index d6ba755..0cfc9f8 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -8,6 +8,7 @@ import { SuperAdminOrganizationsPage } from '@/pages/SuperAdminOrganizationsPage import { SuperAdminOrgCreatePage } from '@/pages/SuperAdminOrgCreatePage' import { SuperAdminAuditLogPage } from '@/pages/SuperAdminAuditLogPage' import { SuperAdminSetupPage } from '@/pages/SuperAdminSetupPage' +import { SuperAdminSettingsPage } from '@/pages/SuperAdminSettingsPage' import { CountriesPage } from '@/pages/CountriesPage' import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage' import { PriceTypesPage } from '@/pages/PriceTypesPage' @@ -60,6 +61,7 @@ export default function App() { } /> } /> } /> + } /> {/* Tenant-роуты — обычный AppLayout, но с TenantRouteGuard: diff --git a/src/food-market.web/src/components/SuperAdminLayout.tsx b/src/food-market.web/src/components/SuperAdminLayout.tsx index d011381..f0b4b7a 100644 --- a/src/food-market.web/src/components/SuperAdminLayout.tsx +++ b/src/food-market.web/src/components/SuperAdminLayout.tsx @@ -32,7 +32,7 @@ const NAV: NavSection[] = [ { group: 'Тех. обслуживание', items: [ { to: '/super-admin/health', icon: HeartPulse, label: 'Здоровье', soon: true }, { to: '/super-admin/backups', icon: HardDriveDownload, label: 'Бэкапы', soon: true }, - { to: '/super-admin/settings', icon: Settings, label: 'Системные настройки', soon: true }, + { to: '/super-admin/settings', icon: Settings, label: 'Системные настройки' }, ]}, ] diff --git a/src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx b/src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx index d060b5b..92df598 100644 --- a/src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx +++ b/src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx @@ -1,7 +1,8 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' import { Plus, Archive, RotateCcw, Trash2, LogIn } from 'lucide-react' -import { setOrgOverride } from '@/lib/api' +import { api, setOrgOverride } from '@/lib/api' import { ListPageShell } from '@/components/ListPageShell' import { DataTable } from '@/components/DataTable' import { Pagination } from '@/components/Pagination' @@ -10,7 +11,6 @@ import { Button } from '@/components/Button' import { Modal } from '@/components/Modal' import { Field, TextInput } from '@/components/Field' import { useCatalogList } from '@/lib/useCatalog' -import { api } from '@/lib/api' const URL = '/api/super-admin/organizations' @@ -45,9 +45,20 @@ export function SuperAdminOrganizationsPage() { list.refetch() } - const isOver30Days = (iso: string | null) => { + // Период до возможности удалить навсегда — читаем из системных настроек. + const settings = useQuery({ + queryKey: ['/api/super-admin/settings'], + queryFn: async () => (await api.get<{ archiveRetentionDays: number }>('/api/super-admin/settings')).data, + }) + const retentionDays = settings.data?.archiveRetentionDays ?? 30 + const canDelete = (iso: string | null) => { if (!iso) return false - return new Date(iso).getTime() < Date.now() - 30 * 24 * 60 * 60 * 1000 + if (retentionDays === 0) return true + return new Date(iso).getTime() < Date.now() - retentionDays * 24 * 60 * 60 * 1000 + } + const daysSinceArchive = (iso: string | null) => { + if (!iso) return 0 + return Math.floor((Date.now() - new Date(iso).getTime()) / (24 * 60 * 60 * 1000)) } return ( @@ -116,9 +127,11 @@ export function SuperAdminOrganizationsPage() { className="p-1.5 text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded"> - diff --git a/src/food-market.web/src/pages/SuperAdminSettingsPage.tsx b/src/food-market.web/src/pages/SuperAdminSettingsPage.tsx new file mode 100644 index 0000000..441d476 --- /dev/null +++ b/src/food-market.web/src/pages/SuperAdminSettingsPage.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Save, AlertTriangle, CheckCircle2 } from 'lucide-react' +import { api } from '@/lib/api' +import { PageHeader } from '@/components/PageHeader' +import { Field, Select } from '@/components/Field' +import { Button } from '@/components/Button' + +interface SystemSettingsDto { archiveRetentionDays: number } + +const RETENTION_OPTIONS = [ + { value: 0, label: 'Немедленно (0 дней)' }, + { value: 1, label: '1 день' }, + { value: 3, label: '3 дня' }, + { value: 7, label: '7 дней' }, + { value: 14, label: '14 дней' }, + { value: 30, label: '30 дней (рекомендуется)' }, +] + +export function SuperAdminSettingsPage() { + const qc = useQueryClient() + const { data } = useQuery({ + queryKey: ['/api/super-admin/settings'], + queryFn: async () => (await api.get('/api/super-admin/settings')).data, + }) + const [retention, setRetention] = useState(null) + const [savedHint, setSavedHint] = useState(false) + useEffect(() => { if (data && retention === null) setRetention(data.archiveRetentionDays) }, [data, retention]) + + const save = useMutation({ + mutationFn: async () => { + if (retention === null) return + await api.put('/api/super-admin/settings', { archiveRetentionDays: retention }) + }, + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: ['/api/super-admin/settings'] }) + setSavedHint(true) + setTimeout(() => setSavedHint(false), 2500) + }, + }) + + const dirty = retention !== null && data && retention !== data.archiveRetentionDays + const isImmediate = retention === 0 + + return ( +
+
+ + +
+

Архивирование организаций

+ + + +

+ Через указанное время архивная организация может быть удалена навсегда. + При значении «Немедленно» — кнопка удаления доступна сразу после архивации. + Рекомендуется 7–30 дней в продакшене. +

+ + {isImmediate && ( +
+ +

+ ⚠️ Удалённые организации нельзя восстановить. Это поведение подходит для разработки/тестирования, + в продакшене не рекомендуется. +

+
+ )} + +
+ + {savedHint && ( + + Сохранено + + )} +
+
+
+
+ ) +}