Enhance role management by adding pricing fields and updating related logic
- Updated `CreateRoleCommand` and `UpdateRoleCommand` to include optional pricing fields: `PricePerConfigPerQuarter` and `PricePerConfigPerYear`. - Modified `RoleEndpoints` to handle the new pricing parameters during role creation and updates. - Enhanced validation logic in `CreateRoleCommandValidator` and `UpdateRoleCommandValidator` to ensure pricing fields are non-negative when provided. - Updated `RoleDto` and `SelectableRoleDto` to include pricing information, ensuring proper data handling in API responses. - Adjusted frontend components to support new pricing fields in role forms and display total costs based on configurations. - Updated API documentation to reflect changes in role management endpoints and pricing structure.
This commit is contained in:
@@ -53,7 +53,13 @@ public static class RoleEndpoints
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit),
|
||||
new UpdateRoleCommand(
|
||||
id,
|
||||
body.MaxConfigs,
|
||||
body.MaxIpLimit,
|
||||
body.PricePerConfigPerQuarter,
|
||||
body.PricePerConfigPerYear
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
@@ -84,6 +90,11 @@ public static class RoleEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdateRoleBody(int MaxConfigs, int MaxIpLimit);
|
||||
public sealed record UpdateRoleBody(
|
||||
int MaxConfigs,
|
||||
int MaxIpLimit,
|
||||
int? PricePerConfigPerQuarter,
|
||||
int? PricePerConfigPerYear
|
||||
);
|
||||
|
||||
public sealed record ChangeUserRoleBody(Guid RoleId);
|
||||
|
||||
@@ -22,7 +22,7 @@ public static class SupportEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization();
|
||||
|
||||
group.MapGet("/roles", ListSelectableRoles).Produces<IReadOnlyList<RoleDto>>();
|
||||
group.MapGet("/roles", ListSelectableRoles).Produces<IReadOnlyList<SelectableRoleDto>>();
|
||||
group
|
||||
.MapPost("/tickets/bug-reports", CreateBugReport)
|
||||
.DisableAntiforgery()
|
||||
|
||||
@@ -4,5 +4,10 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Roles;
|
||||
|
||||
public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit)
|
||||
: ICommand<Result<RoleDto>>;
|
||||
public sealed record CreateRoleCommand(
|
||||
string Name,
|
||||
int MaxConfigs,
|
||||
int MaxIpLimit,
|
||||
int? PricePerConfigPerQuarter,
|
||||
int? PricePerConfigPerYear
|
||||
) : ICommand<Result<RoleDto>>;
|
||||
|
||||
@@ -15,6 +15,8 @@ public sealed class CreateRoleCommandHandler(IRoleService roleService)
|
||||
command.Name,
|
||||
command.MaxConfigs,
|
||||
command.MaxIpLimit,
|
||||
command.PricePerConfigPerQuarter,
|
||||
command.PricePerConfigPerYear,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,5 +10,12 @@ public sealed class CreateRoleCommandValidator : AbstractValidator<CreateRoleCom
|
||||
|
||||
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
|
||||
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
|
||||
|
||||
RuleFor(x => x.PricePerConfigPerQuarter!.Value)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.When(x => x.PricePerConfigPerQuarter.HasValue);
|
||||
RuleFor(x => x.PricePerConfigPerYear!.Value)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.When(x => x.PricePerConfigPerYear.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,10 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Roles;
|
||||
|
||||
public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit)
|
||||
: ICommand<Result<RoleDto>>;
|
||||
public sealed record UpdateRoleCommand(
|
||||
Guid RoleId,
|
||||
int MaxConfigs,
|
||||
int MaxIpLimit,
|
||||
int? PricePerConfigPerQuarter,
|
||||
int? PricePerConfigPerYear
|
||||
) : ICommand<Result<RoleDto>>;
|
||||
|
||||
@@ -15,6 +15,8 @@ public sealed class UpdateRoleCommandHandler(IRoleService roleService)
|
||||
command.RoleId,
|
||||
command.MaxConfigs,
|
||||
command.MaxIpLimit,
|
||||
command.PricePerConfigPerQuarter,
|
||||
command.PricePerConfigPerYear,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,5 +8,12 @@ public sealed class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCom
|
||||
{
|
||||
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
|
||||
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
|
||||
|
||||
RuleFor(x => x.PricePerConfigPerQuarter!.Value)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.When(x => x.PricePerConfigPerQuarter.HasValue);
|
||||
RuleFor(x => x.PricePerConfigPerYear!.Value)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.When(x => x.PricePerConfigPerYear.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ public sealed class ApproveRoleRequestCommandHandler(
|
||||
ticket.ProposedRoleName!,
|
||||
ticket.ProposedMaxConfigs!.Value,
|
||||
ticket.ProposedMaxIpLimit!.Value,
|
||||
pricePerConfigPerQuarter: null,
|
||||
pricePerConfigPerYear: null,
|
||||
cancellationToken
|
||||
);
|
||||
if (!createResult.IsSuccess)
|
||||
|
||||
@@ -2,7 +2,15 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Common.Interfaces;
|
||||
|
||||
public sealed record RoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimit, bool IsSystem);
|
||||
public sealed record RoleDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
int MaxConfigs,
|
||||
int MaxIpLimit,
|
||||
bool IsSystem,
|
||||
int? PricePerConfigPerQuarter = null,
|
||||
int? PricePerConfigPerYear = null
|
||||
);
|
||||
|
||||
public interface IRoleService
|
||||
{
|
||||
@@ -10,6 +18,8 @@ public interface IRoleService
|
||||
string name,
|
||||
int maxConfigs,
|
||||
int maxIpLimit,
|
||||
int? pricePerConfigPerQuarter,
|
||||
int? pricePerConfigPerYear,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
@@ -17,6 +27,8 @@ public interface IRoleService
|
||||
Guid roleId,
|
||||
int maxConfigs,
|
||||
int maxIpLimit,
|
||||
int? pricePerConfigPerQuarter,
|
||||
int? pricePerConfigPerYear,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
|
||||
+1
-2
@@ -1,4 +1,3 @@
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
@@ -7,5 +6,5 @@ namespace PnvPanel.Application.Support.ListSelectableRoles;
|
||||
/// <summary>Список ролей для выбора в заявке на роль (без admin) — в отличие от ListRolesQuery
|
||||
/// (Admin/Roles), доступен любому активированному пользователю.</summary>
|
||||
public sealed record ListSelectableRolesQuery
|
||||
: IQuery<Result<IReadOnlyList<RoleDto>>>,
|
||||
: IQuery<Result<IReadOnlyList<SelectableRoleDto>>>,
|
||||
IRequiresActivation;
|
||||
|
||||
+6
-4
@@ -9,29 +9,31 @@ public sealed class ListSelectableRolesQueryHandler(
|
||||
IRoleService roleService,
|
||||
IIdentityService identityService,
|
||||
ICurrentUser currentUser
|
||||
) : IQueryHandler<ListSelectableRolesQuery, Result<IReadOnlyList<RoleDto>>>
|
||||
) : IQueryHandler<ListSelectableRolesQuery, Result<IReadOnlyList<SelectableRoleDto>>>
|
||||
{
|
||||
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в
|
||||
// CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure).
|
||||
private const string AdminRoleName = "admin";
|
||||
|
||||
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(
|
||||
public async Task<Result<IReadOnlyList<SelectableRoleDto>>> Handle(
|
||||
ListSelectableRolesQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure<IReadOnlyList<RoleDto>>(AuthErrors.Unauthorized);
|
||||
return Result.Failure<IReadOnlyList<SelectableRoleDto>>(AuthErrors.Unauthorized);
|
||||
|
||||
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
||||
var roles = await roleService.ListRolesAsync(cancellationToken);
|
||||
|
||||
// Без admin (нельзя запросить) и без текущей роли пользователя (уже есть — нечего запрашивать).
|
||||
// Цена (RoleDto.PricePerConfigPer*) намеренно не пробрасывается — видна только админу.
|
||||
var selectable = roles
|
||||
.Where(r => !r.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(r => profile is null || r.Id != profile.RoleId)
|
||||
.Select(r => new SelectableRoleDto(r.Id, r.Name, r.MaxConfigs, r.MaxIpLimit))
|
||||
.ToList();
|
||||
|
||||
return Result.Success<IReadOnlyList<RoleDto>>(selectable);
|
||||
return Result.Success<IReadOnlyList<SelectableRoleDto>>(selectable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace PnvPanel.Application.Support.ListSelectableRoles;
|
||||
|
||||
/// <summary>Роль для выбора в заявке на роль. Без справочной цены (RoleDto.PricePerConfigPer*) —
|
||||
/// она видна только админу, этот эндпоинт доступен любому активированному пользователю.</summary>
|
||||
public sealed record SelectableRoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimit);
|
||||
@@ -18,6 +18,14 @@ public class AppRole : IdentityRole<Guid>
|
||||
|
||||
public bool IsSystem { get; set; }
|
||||
|
||||
/// <summary>Справочная цена за конфиг за минимальный период оплаты (3 месяца), в рублях. Только для
|
||||
/// отображения админу — без биллинга/статусов оплаты. У роли `admin` всегда null.</summary>
|
||||
public int? PricePerConfigPerQuarter { get; set; }
|
||||
|
||||
/// <summary>Справочная цена за конфиг за год, в рублях. Задаётся независимо от квартальной цены
|
||||
/// (не производная — позволяет админу задать скидку за годовую оплату). У роли `admin` всегда null.</summary>
|
||||
public int? PricePerConfigPerYear { get; set; }
|
||||
|
||||
public AppRole() { }
|
||||
|
||||
public AppRole(string name)
|
||||
|
||||
@@ -16,6 +16,8 @@ internal sealed class RoleService(
|
||||
string name,
|
||||
int maxConfigs,
|
||||
int maxIpLimit,
|
||||
int? pricePerConfigPerQuarter,
|
||||
int? pricePerConfigPerYear,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
@@ -27,6 +29,8 @@ internal sealed class RoleService(
|
||||
MaxConfigs = maxConfigs,
|
||||
MaxIpLimit = maxIpLimit,
|
||||
IsSystem = false,
|
||||
PricePerConfigPerQuarter = pricePerConfigPerQuarter,
|
||||
PricePerConfigPerYear = pricePerConfigPerYear,
|
||||
};
|
||||
var result = await roleManager.CreateAsync(role);
|
||||
if (!result.Succeeded)
|
||||
@@ -46,6 +50,8 @@ internal sealed class RoleService(
|
||||
Guid roleId,
|
||||
int maxConfigs,
|
||||
int maxIpLimit,
|
||||
int? pricePerConfigPerQuarter,
|
||||
int? pricePerConfigPerYear,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
@@ -53,8 +59,13 @@ internal sealed class RoleService(
|
||||
if (role is null)
|
||||
return Result.Failure<RoleDto>(RoleErrors.NotFound);
|
||||
|
||||
var isAdmin = role.Name!.Equals(RoleNames.Admin, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
role.MaxConfigs = maxConfigs;
|
||||
role.MaxIpLimit = maxIpLimit;
|
||||
// Роль admin оплаты не имеет — цена не задаётся, даже если пришла в запросе.
|
||||
role.PricePerConfigPerQuarter = isAdmin ? null : pricePerConfigPerQuarter;
|
||||
role.PricePerConfigPerYear = isAdmin ? null : pricePerConfigPerYear;
|
||||
await roleManager.UpdateAsync(role);
|
||||
|
||||
return Result.Success(ToDto(role));
|
||||
@@ -81,7 +92,15 @@ internal sealed class RoleService(
|
||||
{
|
||||
return await roleManager
|
||||
.Roles.OrderBy(r => r.Name)
|
||||
.Select(r => new RoleDto(r.Id, r.Name!, r.MaxConfigs, r.MaxIpLimit, r.IsSystem))
|
||||
.Select(r => new RoleDto(
|
||||
r.Id,
|
||||
r.Name!,
|
||||
r.MaxConfigs,
|
||||
r.MaxIpLimit,
|
||||
r.IsSystem,
|
||||
r.PricePerConfigPerQuarter,
|
||||
r.PricePerConfigPerYear
|
||||
))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -118,5 +137,13 @@ internal sealed class RoleService(
|
||||
}
|
||||
|
||||
private static RoleDto ToDto(AppRole role) =>
|
||||
new(role.Id, role.Name!, role.MaxConfigs, role.MaxIpLimit, role.IsSystem);
|
||||
new(
|
||||
role.Id,
|
||||
role.Name!,
|
||||
role.MaxConfigs,
|
||||
role.MaxIpLimit,
|
||||
role.IsSystem,
|
||||
role.PricePerConfigPerQuarter,
|
||||
role.PricePerConfigPerYear
|
||||
);
|
||||
}
|
||||
|
||||
backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718154538_AddRolePricing.Designer.cs
Generated
+944
@@ -0,0 +1,944 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using PnvPanel.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260718154538_AddRolePricing")]
|
||||
partial class AddRolePricing
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Comment")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DecidedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DecidedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("RejectionReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "Status");
|
||||
|
||||
b.ToTable("ActivationRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("DownloadUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("IconUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsRecommended")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("OperatingSystem")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ClientApps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("ActorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Metadata")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("TargetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("AuditLogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Guid>("ConfigId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("DownBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("UpBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ConfigId", "Timestamp");
|
||||
|
||||
b.ToTable("TrafficSamples", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ClientEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("ClientExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("InboundId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("SubscriptionToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<long>("UsedDownBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("UsedUpBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InboundId");
|
||||
|
||||
b.HasIndex("SubscriptionToken")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "Status");
|
||||
|
||||
b.ToTable("VpnConfigs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.PrimitiveCollection<Guid[]>("AllowedRoleIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("uuid[]");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("NodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Port")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Remark")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("RemoteInboundId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NodeId", "RemoteInboundId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Inbounds", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20000)
|
||||
.HasColumnType("character varying(20000)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("InstructionIntros", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20000)
|
||||
.HasColumnType("character varying(20000)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SortOrder");
|
||||
|
||||
b.ToTable("InstructionTabs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20000)
|
||||
.HasColumnType("character varying(20000)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("NewsPosts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BaseAddress")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Nodes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("ProposedMaxConfigs")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("ProposedMaxIpLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ProposedRoleName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("RequestedRoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Type", "Status");
|
||||
|
||||
b.HasIndex("UserId", "Status");
|
||||
|
||||
b.ToTable("SupportTickets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CommentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StoredFileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CommentId");
|
||||
|
||||
b.HasIndex("StoredFileName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TicketAttachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AuthorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("TicketId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TicketId", "CreatedAt");
|
||||
|
||||
b.ToTable("TicketComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TelegramLinkTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Context")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TelegramLoginRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsSystem")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("MaxConfigs")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("MaxIpLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<int?>("PricePerConfigPerQuarter")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("PricePerConfigPerYear")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("ActivatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("ActivatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsActivated")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBlocked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SubscriptionToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("TelegramLinkedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("TelegramUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("TelegramUsername")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.HasIndex("SubscriptionToken")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TelegramUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReplacedByTokenHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b =>
|
||||
{
|
||||
b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("NodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("ProtectedPassword")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("CredentialsProtectedPassword");
|
||||
|
||||
b1.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("CredentialsUsername");
|
||||
|
||||
b1.HasKey("NodeId");
|
||||
|
||||
b1.ToTable("Nodes");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("NodeId");
|
||||
});
|
||||
|
||||
b.Navigation("Credentials")
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRolePricing : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PricePerConfigPerQuarter",
|
||||
table: "AspNetRoles",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PricePerConfigPerYear",
|
||||
table: "AspNetRoles",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PricePerConfigPerQuarter",
|
||||
table: "AspNetRoles");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PricePerConfigPerYear",
|
||||
table: "AspNetRoles");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -708,6 +708,12 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<int?>("PricePerConfigPerQuarter")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("PricePerConfigPerYear")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
|
||||
+4
-2
@@ -27,7 +27,7 @@ public class ApproveRoleRequestCommandHandlerTests
|
||||
|
||||
var newRoleId = Guid.NewGuid();
|
||||
_roleService
|
||||
.CreateRoleAsync("premium", 10, 5, Arg.Any<CancellationToken>())
|
||||
.CreateRoleAsync("premium", 10, 5, null, null, Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Success(new RoleDto(newRoleId, "premium", 10, 5, false)));
|
||||
_roleService
|
||||
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>())
|
||||
@@ -51,7 +51,7 @@ public class ApproveRoleRequestCommandHandlerTests
|
||||
Assert.Equal(TicketStatus.Resolved, ticket.Status);
|
||||
await _roleService
|
||||
.Received(1)
|
||||
.CreateRoleAsync("premium", 10, 5, Arg.Any<CancellationToken>());
|
||||
.CreateRoleAsync("premium", 10, 5, null, null, Arg.Any<CancellationToken>());
|
||||
await _roleService
|
||||
.Received(1)
|
||||
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>());
|
||||
@@ -100,6 +100,8 @@ public class ApproveRoleRequestCommandHandlerTests
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int?>(),
|
||||
Arg.Any<int?>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -244,8 +244,8 @@ reject/approve владением тикета не ограничены. Еди
|
||||
| POST | `/api/admin/activation-requests/{id}/approve` | admin | — | `204 No Content` |
|
||||
| POST | `/api/admin/activation-requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
|
||||
| GET | `/api/admin/roles` | admin | — | `RoleDto[]` |
|
||||
| POST | `/api/admin/roles` | admin | `{ name, maxConfigs, maxIpLimit }` | `RoleDto` |
|
||||
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit }` | `RoleDto` |
|
||||
| POST | `/api/admin/roles` | admin | `{ name, maxConfigs, maxIpLimit, pricePerConfigPerQuarter?, pricePerConfigPerYear? }` | `RoleDto` |
|
||||
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit, pricePerConfigPerQuarter?, pricePerConfigPerYear? }` | `RoleDto` (для `admin` цена игнорируется, остаётся `null`) |
|
||||
| DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) |
|
||||
| PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` (`409 Roles.CannotRemoveLastAdmin`, если у цели сейчас `admin`, новая роль другая, и это единственный админ) |
|
||||
|
||||
|
||||
+13
-1
@@ -5,7 +5,9 @@
|
||||
`IdentityRole<Guid>`); чистый `PnvPanel.Domain` ссылается на пользователя/роль только по `Guid`.
|
||||
|
||||
Тарифы `Plan` и лимиты трафика на конфиг (`TrafficLimit`) не реализованы — единственная квота:
|
||||
число активных конфигов на роль (`AppRole.MaxConfigs`).
|
||||
число активных конфигов на роль (`AppRole.MaxConfigs`). У роли есть справочная цена за конфиг
|
||||
(`AppRole.PricePerConfigPer*`, видна только админу) — это не биллинг: без статусов оплаты, дат
|
||||
окончания и интеграций с платёжными системами, см. ниже.
|
||||
|
||||
## Диаграмма связей
|
||||
|
||||
@@ -278,6 +280,16 @@ UI **настойчиво напоминает** привязать его (ед
|
||||
| `MaxConfigs` | `int` | Квота активных конфигов (-1 = без лимита; для `admin` — без лимита) |
|
||||
| `MaxIpLimit` | `int` | Лимит одновременных IP на клиента (`limitIp` в 3x-ui; -1 = без лимита; для `admin` — без лимита) |
|
||||
| `IsSystem` | `bool` | Системная (`admin`, `user`) — нельзя удалить/переименовать |
|
||||
| `PricePerConfigPerQuarter` | `int?` | Справочная цена за конфиг за минимальный период оплаты (3 месяца), руб. Видна только админу (Admin-only эндпоинты); у `admin` всегда `null` |
|
||||
| `PricePerConfigPerYear` | `int?` | Справочная цена за конфиг за год, руб. Задаётся независимо от квартальной (не производная — позволяет скидку); у `admin` всегда `null` |
|
||||
|
||||
Итоговая цена роли для админа — `Price × MaxConfigs` (напр. `user` с `MaxConfigs=3`, 200₽/конфиг/3мес →
|
||||
600₽/3мес; 400₽/конфиг/год → 1200₽/год); считается на фронте, не хранится отдельно. Чисто
|
||||
информационное поле: без биллинга, статусов оплаты, дат окончания подписки, интеграций с платёжными
|
||||
системами. `IRoleService.UpdateRoleAsync` игнорирует цену при обновлении роли `admin` (остаётся
|
||||
`null`), даже если она передана в запросе. `GET /api/support/roles` (список ролей для заявки на роль,
|
||||
доступен любому активированному пользователю) отдаёт отдельный `SelectableRoleDto` без цены — она не
|
||||
протекает за пределы `/api/admin/*`.
|
||||
|
||||
Сидируются: `admin` (оба лимита без ограничения) и `user` (`MaxConfigs` = `Roles__DefaultUserMaxConfigs`,
|
||||
по умолчанию 3; `MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`, по умолчанию 2).
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@
|
||||
| -------------------------- | --------------------------------------------------------------------------------- |
|
||||
| Ролей у пользователя | Ровно одна роль (квота = `MaxConfigs` роли) |
|
||||
| Секреты нод | ASP.NET Core Data Protection (шифрование at-rest, key-ring на томе) |
|
||||
| Тарифы/лимиты трафика | Не реализованы — конфиги без лимитов трафика/срока |
|
||||
| Тарифы/лимиты трафика | Не реализованы — конфиги без лимитов трафика/срока. У роли есть справочная цена за конфиг (`AppRole.PricePerConfigPer*`, видна только админу) — без биллинг-логики |
|
||||
| i18n | RU + EN (react-i18next) |
|
||||
| Telegram-транспорт | Long polling |
|
||||
| Регистрация через Telegram | Поддержана (логин — Telegram `@username`/id, пароль генерируется и присылается в чат) |
|
||||
|
||||
@@ -24,17 +24,38 @@ export function RoleFormDialog({
|
||||
const [name, setName] = useState(role?.name ?? '')
|
||||
const [maxConfigs, setMaxConfigs] = useState(String(role?.maxConfigs ?? 3))
|
||||
const [maxIpLimit, setMaxIpLimit] = useState(String(role?.maxIpLimit ?? 2))
|
||||
const [pricePerConfigPerQuarter, setPricePerConfigPerQuarter] = useState(
|
||||
role?.pricePerConfigPerQuarter != null ? String(role.pricePerConfigPerQuarter) : '',
|
||||
)
|
||||
const [pricePerConfigPerYear, setPricePerConfigPerYear] = useState(
|
||||
role?.pricePerConfigPerYear != null ? String(role.pricePerConfigPerYear) : '',
|
||||
)
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
|
||||
const isControlled = open !== undefined
|
||||
const dialogOpen = isControlled ? open : internalOpen
|
||||
const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen
|
||||
|
||||
// Роль admin оплаты не имеет — поля цены для неё не показываются и не отправляются.
|
||||
const isAdmin = role?.name === 'admin'
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
role
|
||||
? updateRole(role.id, Number(maxConfigs), Number(maxIpLimit))
|
||||
: createRole(name.trim(), Number(maxConfigs), Number(maxIpLimit)),
|
||||
? updateRole(
|
||||
role.id,
|
||||
Number(maxConfigs),
|
||||
Number(maxIpLimit),
|
||||
pricePerConfigPerQuarter === '' ? null : Number(pricePerConfigPerQuarter),
|
||||
pricePerConfigPerYear === '' ? null : Number(pricePerConfigPerYear),
|
||||
)
|
||||
: createRole(
|
||||
name.trim(),
|
||||
Number(maxConfigs),
|
||||
Number(maxIpLimit),
|
||||
pricePerConfigPerQuarter === '' ? null : Number(pricePerConfigPerQuarter),
|
||||
pricePerConfigPerYear === '' ? null : Number(pricePerConfigPerYear),
|
||||
),
|
||||
onSuccess: async () => {
|
||||
toast.success(role ? t('admin.roles.updated') : t('admin.roles.created'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-roles'] })
|
||||
@@ -42,10 +63,17 @@ export function RoleFormDialog({
|
||||
setName('')
|
||||
setMaxConfigs('3')
|
||||
setMaxIpLimit('2')
|
||||
setPricePerConfigPerQuarter('')
|
||||
setPricePerConfigPerYear('')
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
const totalFor = (price: string) => {
|
||||
if (price === '' || Number(maxConfigs) < 0) return null
|
||||
return Number(price) * Number(maxConfigs)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{!isControlled && (
|
||||
@@ -80,6 +108,40 @@ export function RoleFormDialog({
|
||||
<Input id="maxIpLimit" type="number" value={maxIpLimit} onChange={(e) => setMaxIpLimit(e.target.value)} />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.roles.maxIpLimitHint')}</p>
|
||||
</div>
|
||||
{!isAdmin && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="pricePerConfigPerQuarter">{t('admin.roles.pricePerConfigPerQuarter')}</Label>
|
||||
<Input
|
||||
id="pricePerConfigPerQuarter"
|
||||
type="number"
|
||||
min={0}
|
||||
value={pricePerConfigPerQuarter}
|
||||
onChange={(e) => setPricePerConfigPerQuarter(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.roles.pricePerConfigPerQuarterHint')}
|
||||
{totalFor(pricePerConfigPerQuarter) !== null &&
|
||||
` ${t('admin.roles.totalValue', { total: totalFor(pricePerConfigPerQuarter) })}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="pricePerConfigPerYear">{t('admin.roles.pricePerConfigPerYear')}</Label>
|
||||
<Input
|
||||
id="pricePerConfigPerYear"
|
||||
type="number"
|
||||
min={0}
|
||||
value={pricePerConfigPerYear}
|
||||
onChange={(e) => setPricePerConfigPerYear(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.roles.pricePerConfigPerYearHint')}
|
||||
{totalFor(pricePerConfigPerYear) !== null &&
|
||||
` ${t('admin.roles.totalValue', { total: totalFor(pricePerConfigPerYear) })}`}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button type="submit" disabled={mutation.isPending || (!role && !name.trim())}>
|
||||
{role ? t('admin.roles.save') : t('admin.roles.create')}
|
||||
</Button>
|
||||
|
||||
@@ -5,12 +5,30 @@ export function listRoles() {
|
||||
return apiRequest<RoleDto[]>('/admin/roles')
|
||||
}
|
||||
|
||||
export function createRole(name: string, maxConfigs: number, maxIpLimit: number) {
|
||||
return apiRequest<RoleDto>('/admin/roles', { method: 'POST', body: { name, maxConfigs, maxIpLimit } })
|
||||
export function createRole(
|
||||
name: string,
|
||||
maxConfigs: number,
|
||||
maxIpLimit: number,
|
||||
pricePerConfigPerQuarter: number | null,
|
||||
pricePerConfigPerYear: number | null,
|
||||
) {
|
||||
return apiRequest<RoleDto>('/admin/roles', {
|
||||
method: 'POST',
|
||||
body: { name, maxConfigs, maxIpLimit, pricePerConfigPerQuarter, pricePerConfigPerYear },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateRole(id: string, maxConfigs: number, maxIpLimit: number) {
|
||||
return apiRequest<RoleDto>(`/admin/roles/${id}`, { method: 'PUT', body: { maxConfigs, maxIpLimit } })
|
||||
export function updateRole(
|
||||
id: string,
|
||||
maxConfigs: number,
|
||||
maxIpLimit: number,
|
||||
pricePerConfigPerQuarter: number | null,
|
||||
pricePerConfigPerYear: number | null,
|
||||
) {
|
||||
return apiRequest<RoleDto>(`/admin/roles/${id}`, {
|
||||
method: 'PUT',
|
||||
body: { maxConfigs, maxIpLimit, pricePerConfigPerQuarter, pricePerConfigPerYear },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteRole(id: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { apiRequest, apiUpload, getAccessToken } from '@/shared/api/client'
|
||||
import type {
|
||||
PagedList,
|
||||
RoleDto,
|
||||
SelectableRoleDto,
|
||||
TicketCommentDto,
|
||||
TicketDetailDto,
|
||||
TicketStatus,
|
||||
@@ -25,7 +25,7 @@ export function getTicket(id: string) {
|
||||
}
|
||||
|
||||
export function listSelectableRoles() {
|
||||
return apiRequest<RoleDto[]>('/support/roles')
|
||||
return apiRequest<SelectableRoleDto[]>('/support/roles')
|
||||
}
|
||||
|
||||
export function createBugReportTicket(message: string, files: File[]) {
|
||||
|
||||
@@ -18,6 +18,9 @@ function AdminRolesPage() {
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles })
|
||||
|
||||
const totalPrice = (price: number | null, maxConfigs: number) =>
|
||||
price == null || maxConfigs < 0 ? t('admin.roles.noPrice') : `${price * maxConfigs} ₽`
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteRole,
|
||||
onSuccess: async () => {
|
||||
@@ -52,6 +55,8 @@ function AdminRolesPage() {
|
||||
<th className="py-2 font-medium">{t('admin.roles.name')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.roles.maxConfigs')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.roles.maxIpLimit')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.roles.totalPerQuarter')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.roles.totalPerYear')}</th>
|
||||
<th className="py-2" />
|
||||
<th className="py-2" />
|
||||
</tr>
|
||||
@@ -64,6 +69,8 @@ function AdminRolesPage() {
|
||||
</td>
|
||||
<td className="py-2">{role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}</td>
|
||||
<td className="py-2">{role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit}</td>
|
||||
<td className="py-2">{totalPrice(role.pricePerConfigPerQuarter, role.maxConfigs)}</td>
|
||||
<td className="py-2">{totalPrice(role.pricePerConfigPerYear, role.maxConfigs)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
|
||||
{t('admin.roles.edit')}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -173,6 +173,16 @@ export type RoleDto = {
|
||||
maxConfigs: number
|
||||
maxIpLimit: number
|
||||
isSystem: boolean
|
||||
pricePerConfigPerQuarter: number | null
|
||||
pricePerConfigPerYear: number | null
|
||||
}
|
||||
|
||||
/** Роль для выбора в заявке на роль (GET /support/roles) — без цены, она видна только админу. */
|
||||
export type SelectableRoleDto = {
|
||||
id: string
|
||||
name: string
|
||||
maxConfigs: number
|
||||
maxIpLimit: number
|
||||
}
|
||||
|
||||
export type ActivationRequestAdminDto = {
|
||||
|
||||
@@ -253,6 +253,14 @@ const resources = {
|
||||
maxConfigsHint: '−1 = без лимита.',
|
||||
maxIpLimit: 'Лимит IP на конфиг',
|
||||
maxIpLimitHint: '−1 = без лимита. Применяется только к новым конфигам.',
|
||||
pricePerConfigPerQuarter: 'Цена за конфиг / 3 мес',
|
||||
pricePerConfigPerQuarterHint: 'Справочно, видно только админу. Минимальный период оплаты.',
|
||||
pricePerConfigPerYear: 'Цена за конфиг / год',
|
||||
pricePerConfigPerYearHint: 'Справочно, видно только админу. Задаётся отдельно от квартальной цены.',
|
||||
totalPerQuarter: 'Итого / 3 мес',
|
||||
totalPerYear: 'Итого / год',
|
||||
totalValue: 'Итого для роли: {{total}} ₽.',
|
||||
noPrice: '—',
|
||||
system: 'системная',
|
||||
edit: 'Изменить',
|
||||
save: 'Сохранить',
|
||||
@@ -671,6 +679,14 @@ const resources = {
|
||||
maxConfigsHint: '−1 = unlimited.',
|
||||
maxIpLimit: 'IP limit per config',
|
||||
maxIpLimitHint: '−1 = unlimited. Applies to new configs only.',
|
||||
pricePerConfigPerQuarter: 'Price per config / 3 months',
|
||||
pricePerConfigPerQuarterHint: 'Reference only, visible to admin only. Minimum billing period.',
|
||||
pricePerConfigPerYear: 'Price per config / year',
|
||||
pricePerConfigPerYearHint: 'Reference only, visible to admin only. Set independently from the quarterly price.',
|
||||
totalPerQuarter: 'Total / 3 months',
|
||||
totalPerYear: 'Total / year',
|
||||
totalValue: 'Total for role: {{total}} ₽.',
|
||||
noPrice: '—',
|
||||
system: 'system',
|
||||
edit: 'Edit',
|
||||
save: 'Save',
|
||||
|
||||
Reference in New Issue
Block a user