feat(super-admin): настраиваемый retention period для архивных орг
Раньше «удалить орг навсегда» было захардкожено на 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) <noreply@anthropic.com>
This commit is contained in:
parent
2cadb6e5d9
commit
fc3f63c49a
|
|
@ -4,6 +4,7 @@
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace foodmarket.Api.Controllers.SuperAdmin;
|
namespace foodmarket.Api.Controllers.SuperAdmin;
|
||||||
|
|
||||||
|
|
@ -47,6 +48,49 @@ public async Task<ActionResult<DashboardStats>> Dashboard(CancellationToken ct)
|
||||||
RegistrationsLast30Days: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => o.CreatedAt >= monthAgo, 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<ActionResult<SystemSettingsDto>> GetSettings(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var s = await _db.SystemSettings.FirstOrDefaultAsync(ct);
|
||||||
|
return new SystemSettingsDto(s?.ArchiveRetentionDays ?? 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("settings")]
|
||||||
|
public async Task<ActionResult<SystemSettingsDto>> 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(
|
public record AuditRow(
|
||||||
Guid Id, DateTime CreatedAt, Guid SuperAdminUserId,
|
Guid Id, DateTime CreatedAt, Guid SuperAdminUserId,
|
||||||
string ActionType, Guid? OrganizationId, string? OrganizationName,
|
string ActionType, Guid? OrganizationId, string? OrganizationName,
|
||||||
|
|
|
||||||
|
|
@ -189,8 +189,11 @@ public async Task<IActionResult> Delete(Guid id, [FromBody] DeleteRequest req, C
|
||||||
{
|
{
|
||||||
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
|
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (o is null) return NotFound();
|
if (o is null) return NotFound();
|
||||||
if (!o.IsArchived || o.ArchivedAt is null || o.ArchivedAt > DateTime.UtcNow.AddDays(-30))
|
if (!o.IsArchived || o.ArchivedAt is null)
|
||||||
return Conflict(new { error = "Удалить можно только организации в архиве >30 дней." });
|
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 = "Введи название организации точно." });
|
if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно." });
|
||||||
await LogAsync("DeleteOrg", o.Id, $"Удалена навсегда «{o.Name}»", null,
|
await LogAsync("DeleteOrg", o.Id, $"Удалена навсегда «{o.Name}»", null,
|
||||||
$"{{\"name\":\"{o.Name}\"}}", ct);
|
$"{{\"name\":\"{o.Name}\"}}", ct);
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,15 @@ public async Task StartAsync(CancellationToken ct)
|
||||||
}
|
}
|
||||||
|
|
||||||
await SeedAdminEmployeeAsync(db, demoOrg.Id, admin?.Id, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Привязывает существующего admin@food-market.local к
|
/// <summary>Привязывает существующего admin@food-market.local к
|
||||||
|
|
|
||||||
15
src/food-market.domain/Organizations/SystemSettings.cs
Normal file
15
src/food-market.domain/Organizations/SystemSettings.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
using foodmarket.Domain.Common;
|
||||||
|
|
||||||
|
namespace foodmarket.Domain.Organizations;
|
||||||
|
|
||||||
|
/// <summary>Глобальные настройки платформы (single-row table). Управляются
|
||||||
|
/// SuperAdmin'ом в /super-admin/settings. Не tenant-scoped — действует на всю
|
||||||
|
/// систему. Расширяется добавлением новых типизированных полей по мере
|
||||||
|
/// необходимости (KISS — без key-value generic'а).</summary>
|
||||||
|
public class SystemSettings : Entity
|
||||||
|
{
|
||||||
|
/// <summary>Сколько дней должен пройти после архивации, прежде чем
|
||||||
|
/// SuperAdmin может удалить организацию навсегда. 0 = удалять сразу
|
||||||
|
/// после архивации (для dev/staging). По умолчанию 30 (prod-safe).</summary>
|
||||||
|
public int ArchiveRetentionDays { get; set; } = 30;
|
||||||
|
}
|
||||||
|
|
@ -50,6 +50,7 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
||||||
public DbSet<Employee> Employees => Set<Employee>();
|
public DbSet<Employee> Employees => Set<Employee>();
|
||||||
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
|
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
|
||||||
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
|
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
|
||||||
|
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
|
|
@ -80,6 +81,11 @@ protected override void OnModelCreating(ModelBuilder builder)
|
||||||
b.HasIndex(o => o.IsArchived);
|
b.HasIndex(o => o.IsArchived);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<SystemSettings>(b =>
|
||||||
|
{
|
||||||
|
b.ToTable("system_settings");
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<SuperAdminAuditLog>(b =>
|
builder.Entity<SuperAdminAuditLog>(b =>
|
||||||
{
|
{
|
||||||
b.ToTable("super_admin_audit_log");
|
b.ToTable("super_admin_audit_log");
|
||||||
|
|
|
||||||
2058
src/food-market.infrastructure/Persistence/Migrations/20260427060000_Phase4e_SystemSettings.Designer.cs
generated
Normal file
2058
src/food-market.infrastructure/Persistence/Migrations/20260427060000_Phase4e_SystemSettings.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,33 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>Глобальные настройки платформы (single-row): ArchiveRetentionDays
|
||||||
|
/// (по умолчанию 30) — сколько дней архивная орга должна пробыть в архиве
|
||||||
|
/// прежде чем её можно удалить навсегда. Юзер настраивает через UI
|
||||||
|
/// /super-admin/settings; раньше был хардкод 30 в SuperAdminOrganizationsController.</summary>
|
||||||
|
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<System.Guid>(type: "uuid", nullable: false),
|
||||||
|
ArchiveRetentionDays = table.Column<int>(type: "integer", nullable: false, defaultValue: 30),
|
||||||
|
CreatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<System.DateTime>(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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1900,6 +1900,16 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Navigation("Lines");
|
b.Navigation("Lines");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("foodmarket.Domain.Organizations.SystemSettings", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id").ValueGeneratedOnAdd().HasColumnType("uuid");
|
||||||
|
b.Property<int>("ArchiveRetentionDays").HasColumnType("integer");
|
||||||
|
b.Property<DateTime>("CreatedAt").HasColumnType("timestamp with time zone");
|
||||||
|
b.Property<DateTime?>("UpdatedAt").HasColumnType("timestamp with time zone");
|
||||||
|
b.HasKey("Id");
|
||||||
|
b.ToTable("system_settings", "public");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("foodmarket.Domain.Organizations.SuperAdminAuditLog", b =>
|
modelBuilder.Entity("foodmarket.Domain.Organizations.SuperAdminAuditLog", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id").ValueGeneratedOnAdd().HasColumnType("uuid");
|
b.Property<Guid>("Id").ValueGeneratedOnAdd().HasColumnType("uuid");
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { SuperAdminOrganizationsPage } from '@/pages/SuperAdminOrganizationsPage
|
||||||
import { SuperAdminOrgCreatePage } from '@/pages/SuperAdminOrgCreatePage'
|
import { SuperAdminOrgCreatePage } from '@/pages/SuperAdminOrgCreatePage'
|
||||||
import { SuperAdminAuditLogPage } from '@/pages/SuperAdminAuditLogPage'
|
import { SuperAdminAuditLogPage } from '@/pages/SuperAdminAuditLogPage'
|
||||||
import { SuperAdminSetupPage } from '@/pages/SuperAdminSetupPage'
|
import { SuperAdminSetupPage } from '@/pages/SuperAdminSetupPage'
|
||||||
|
import { SuperAdminSettingsPage } from '@/pages/SuperAdminSettingsPage'
|
||||||
import { CountriesPage } from '@/pages/CountriesPage'
|
import { CountriesPage } from '@/pages/CountriesPage'
|
||||||
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
|
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
|
||||||
import { PriceTypesPage } from '@/pages/PriceTypesPage'
|
import { PriceTypesPage } from '@/pages/PriceTypesPage'
|
||||||
|
|
@ -60,6 +61,7 @@ export default function App() {
|
||||||
<Route path="countries" element={<CountriesPage />} />
|
<Route path="countries" element={<CountriesPage />} />
|
||||||
<Route path="groups" element={<ProductGroupsPage />} />
|
<Route path="groups" element={<ProductGroupsPage />} />
|
||||||
<Route path="units" element={<UnitsOfMeasurePage />} />
|
<Route path="units" element={<UnitsOfMeasurePage />} />
|
||||||
|
<Route path="settings" element={<SuperAdminSettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Tenant-роуты — обычный AppLayout, но с TenantRouteGuard:
|
{/* Tenant-роуты — обычный AppLayout, но с TenantRouteGuard:
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ const NAV: NavSection[] = [
|
||||||
{ group: 'Тех. обслуживание', items: [
|
{ group: 'Тех. обслуживание', items: [
|
||||||
{ to: '/super-admin/health', icon: HeartPulse, label: 'Здоровье', soon: true },
|
{ to: '/super-admin/health', icon: HeartPulse, label: 'Здоровье', soon: true },
|
||||||
{ to: '/super-admin/backups', icon: HardDriveDownload, 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: 'Системные настройки' },
|
||||||
]},
|
]},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Plus, Archive, RotateCcw, Trash2, LogIn } from 'lucide-react'
|
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 { ListPageShell } from '@/components/ListPageShell'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
|
|
@ -10,7 +11,6 @@ import { Button } from '@/components/Button'
|
||||||
import { Modal } from '@/components/Modal'
|
import { Modal } from '@/components/Modal'
|
||||||
import { Field, TextInput } from '@/components/Field'
|
import { Field, TextInput } from '@/components/Field'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
import { api } from '@/lib/api'
|
|
||||||
|
|
||||||
const URL = '/api/super-admin/organizations'
|
const URL = '/api/super-admin/organizations'
|
||||||
|
|
||||||
|
|
@ -45,9 +45,20 @@ export function SuperAdminOrganizationsPage() {
|
||||||
list.refetch()
|
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
|
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 (
|
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">
|
className="p-1.5 text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded">
|
||||||
<RotateCcw className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button title={isOver30Days(r.archivedAt) ? 'Удалить навсегда' : 'Удаление доступно через 30 дней архива'}
|
<button title={canDelete(r.archivedAt)
|
||||||
disabled={!isOver30Days(r.archivedAt)}
|
? 'Удалить навсегда'
|
||||||
onClick={() => isOver30Days(r.archivedAt) && (setDeleteOf(r), setConfirmName(''))}
|
: `Доступно через ${Math.max(0, retentionDays - daysSinceArchive(r.archivedAt))} дн. (retention ${retentionDays})`}
|
||||||
|
disabled={!canDelete(r.archivedAt)}
|
||||||
|
onClick={() => canDelete(r.archivedAt) && (setDeleteOf(r), setConfirmName(''))}
|
||||||
className="p-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-30 disabled:cursor-not-allowed">
|
className="p-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-30 disabled:cursor-not-allowed">
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
90
src/food-market.web/src/pages/SuperAdminSettingsPage.tsx
Normal file
90
src/food-market.web/src/pages/SuperAdminSettingsPage.tsx
Normal file
|
|
@ -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<SystemSettingsDto>('/api/super-admin/settings')).data,
|
||||||
|
})
|
||||||
|
const [retention, setRetention] = useState<number | null>(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<SystemSettingsDto>('/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 (
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
<div className="max-w-2xl mx-auto p-4 sm:p-6 space-y-5">
|
||||||
|
<PageHeader
|
||||||
|
title="Системные настройки"
|
||||||
|
description="Глобальные параметры платформы. Применяются ко всем организациям."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
|
||||||
|
<h3 className="font-semibold">Архивирование организаций</h3>
|
||||||
|
<Field label="Период до полного удаления из архива">
|
||||||
|
<Select value={String(retention ?? 30)} onChange={(e) => setRetention(Number(e.target.value))}>
|
||||||
|
{RETENTION_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Через указанное время архивная организация может быть удалена навсегда.
|
||||||
|
При значении «Немедленно» — кнопка удаления доступна сразу после архивации.
|
||||||
|
Рекомендуется 7–30 дней в продакшене.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isImmediate && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 px-3 py-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-amber-800 dark:text-amber-200">
|
||||||
|
⚠️ Удалённые организации нельзя восстановить. Это поведение подходит для разработки/тестирования,
|
||||||
|
в продакшене не рекомендуется.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<Button onClick={() => save.mutate()} disabled={!dirty || save.isPending}>
|
||||||
|
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
{savedHint && (
|
||||||
|
<span className="text-sm text-emerald-600 inline-flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="w-4 h-4" /> Сохранено
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue