From 1f6fa6f1aea194dc238cd01ca89bdf399e812a9e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 25 Jul 2026 19:49:08 +0300 Subject: [PATCH] Add password reset functionality for admin users: implement ResetPassword endpoint, update AdminUserEndpoints to include password reset logic, and enhance user interface for password management. Update translations for new features and ensure proper error handling in the reset process. --- .../Endpoints/AdminUserEndpoints.cs | 20 + .../Endpoints/ChannelEndpoints.cs | 12 +- .../ResetPassword/ResetUserPasswordCommand.cs | 7 + .../ResetUserPasswordCommandHandler.cs | 14 + .../ResetUserPasswordCommandValidator.cs | 13 + .../Broadcast/ChannelDtos.cs | 8 +- .../CreateProgrammingOverrideCommand.cs | 8 +- ...CreateProgrammingOverrideCommandHandler.cs | 28 +- .../GetChannel/GetChannelQueryHandler.cs | 8 +- .../Broadcast/Scheduling/ScheduleGenerator.cs | 8 +- .../Common/Interfaces/IIdentityService.cs | 7 + .../src/TeleWave.Domain/Broadcast/Channel.cs | 15 +- .../Broadcast/OverrideRecurrence.cs | 11 + .../Broadcast/ProgrammingOverride.cs | 58 +- .../Broadcast/Scheduling/SchedulePlanner.cs | 2 +- .../Scheduling/SchedulePlannerModels.cs | 33 +- .../Identity/IdentityService.cs | 23 + ...501_WeeklyProgrammingOverrides.Designer.cs | 1008 +++++++++++++++++ ...260725164501_WeeklyProgrammingOverrides.cs | 96 ++ .../Migrations/AppDbContextModelSnapshot.cs | 16 +- .../Broadcast/SchedulePlannerTests.cs | 63 +- .../features/admin/channels/ChannelDetail.tsx | 118 +- frontend/src/features/admin/channels/api.ts | 9 +- .../src/features/admin/media/MediaPanel.tsx | 20 +- .../src/features/admin/shows/ShowsPanel.tsx | 34 +- .../src/features/admin/users/UsersPanel.tsx | 76 +- frontend/src/features/admin/users/api.ts | 7 + frontend/src/shared/api/types.ts | 11 +- frontend/src/shared/lib/i18n.ts | 38 + frontend/src/shared/ui/pager.tsx | 42 + 30 files changed, 1755 insertions(+), 58 deletions(-) create mode 100644 backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommand.cs create mode 100644 backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommandValidator.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/OverrideRecurrence.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725164501_WeeklyProgrammingOverrides.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725164501_WeeklyProgrammingOverrides.cs create mode 100644 frontend/src/shared/ui/pager.tsx diff --git a/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs index 639b051..edc78b5 100644 --- a/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs @@ -5,6 +5,7 @@ using TeleWave.Application.Admin.Users.CreateUser; using TeleWave.Application.Admin.Users.DeleteUser; using TeleWave.Application.Admin.Users.GetUser; using TeleWave.Application.Admin.Users.ListUsers; +using TeleWave.Application.Admin.Users.ResetPassword; using TeleWave.Application.Admin.Users.UnblockUser; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; @@ -29,6 +30,9 @@ public static class AdminUserEndpoints admin .MapPost("/{id:guid}/unblock", UnblockUser) .Produces(StatusCodes.Status204NoContent); + admin + .MapPost("/{id:guid}/password", ResetPassword) + .Produces(StatusCodes.Status204NoContent); admin.MapDelete("/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent); return app; @@ -96,6 +100,20 @@ public static class AdminUserEndpoints return result.ToHttpResult(); } + private static async Task ResetPassword( + Guid id, + ResetPasswordBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new ResetUserPasswordCommand(id, body.NewPassword), + cancellationToken + ); + return result.ToHttpResult(); + } + private static async Task DeleteUser( Guid id, ISender sender, @@ -108,3 +126,5 @@ public static class AdminUserEndpoints } public sealed record CreateUserBody(string UserName, string Password, Guid RoleId); + +public sealed record ResetPasswordBody(string NewPassword); diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs index 668feaa..5b84a3d 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs @@ -538,8 +538,12 @@ public static class ChannelEndpoints new CreateProgrammingOverrideCommand( id, body.Mode, + body.Recurrence, body.StartsAtUtc, body.EndsAtUtc, + body.DayOfWeek, + body.StartMinute, + body.EndMinute, body.Shows ), cancellationToken @@ -659,7 +663,11 @@ internal static class BumperFiles public sealed record CreateOverrideBody( OverrideMode Mode, - DateTimeOffset StartsAtUtc, - DateTimeOffset EndsAtUtc, + OverrideRecurrence Recurrence, + DateTimeOffset? StartsAtUtc, + DateTimeOffset? EndsAtUtc, + int? DayOfWeek, + int? StartMinute, + int? EndMinute, IReadOnlyList Shows ); diff --git a/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommand.cs b/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommand.cs new file mode 100644 index 0000000..54db876 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommand.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users.ResetPassword; + +/// Сброс пароля пользователя администратором (без текущего пароля). +public sealed record ResetUserPasswordCommand(Guid UserId, string NewPassword) : ICommand; diff --git a/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommandHandler.cs b/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommandHandler.cs new file mode 100644 index 0000000..94a6758 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommandHandler.cs @@ -0,0 +1,14 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users.ResetPassword; + +public sealed class ResetUserPasswordCommandHandler(IIdentityService identityService) + : ICommandHandler +{ + public Task Handle( + ResetUserPasswordCommand command, + CancellationToken cancellationToken + ) => identityService.ResetPasswordAsync(command.UserId, command.NewPassword, cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommandValidator.cs b/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommandValidator.cs new file mode 100644 index 0000000..266fe6b --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace TeleWave.Application.Admin.Users.ResetPassword; + +public sealed class ResetUserPasswordCommandValidator : AbstractValidator +{ + public ResetUserPasswordCommandValidator() + { + // Длина — здесь; сложность (цифра/заглавная) проверяют валидаторы ASP.NET Identity при сбросе. + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs index a3b3b4a..2a7c627 100644 --- a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs +++ b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs @@ -27,8 +27,12 @@ public sealed record OverrideShowDto(Guid ShowId, string ShowName, int Weight); public sealed record ProgrammingOverrideDto( Guid Id, OverrideMode Mode, - DateTimeOffset StartsAtUtc, - DateTimeOffset EndsAtUtc, + OverrideRecurrence Recurrence, + DateTimeOffset? StartsAtUtc, + DateTimeOffset? EndsAtUtc, + int? DayOfWeek, + int? StartMinute, + int? EndMinute, IReadOnlyList Shows ); diff --git a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs index 9155a84..fb0b975 100644 --- a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs +++ b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs @@ -7,7 +7,11 @@ namespace TeleWave.Application.Broadcast.CreateOverride; public sealed record CreateProgrammingOverrideCommand( Guid ChannelId, OverrideMode Mode, - DateTimeOffset StartsAtUtc, - DateTimeOffset EndsAtUtc, + OverrideRecurrence Recurrence, + DateTimeOffset? StartsAtUtc, + DateTimeOffset? EndsAtUtc, + int? DayOfWeek, + int? StartMinute, + int? EndMinute, IReadOnlyList Shows ) : ICommand>; diff --git a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs index fc3a0f2..5490a8a 100644 --- a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs @@ -2,6 +2,7 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; +using TeleWave.Domain.Broadcast; namespace TeleWave.Application.Broadcast.CreateOverride; @@ -13,8 +14,24 @@ public sealed class CreateProgrammingOverrideCommandHandler(IAppDbContext dbCont CancellationToken cancellationToken ) { - if (command.EndsAtUtc <= command.StartsAtUtc) + var weekly = command.Recurrence == OverrideRecurrence.Weekly; + if (weekly) + { + if ( + command.DayOfWeek is not (>= 0 and <= 6) + || command.StartMinute is not { } sm + || command.EndMinute is not { } em + || em <= sm + || sm < 0 + || em > 1440 + ) + return Result.Failure(ChannelErrors.InvalidOverrideWindow); + } + else if (command.StartsAtUtc is not { } start || command.EndsAtUtc is not { } end || end <= start) + { return Result.Failure(ChannelErrors.InvalidOverrideWindow); + } + if (command.Shows.Count == 0) return Result.Failure(ChannelErrors.OverrideNeedsShow); @@ -33,7 +50,14 @@ public sealed class CreateProgrammingOverrideCommandHandler(IAppDbContext dbCont if (existingCount != showIds.Count) return Result.Failure(ChannelErrors.ShowNotFound); - var ovr = channel.AddOverride(command.Mode, command.StartsAtUtc, command.EndsAtUtc); + var ovr = weekly + ? channel.AddWeeklyOverride( + command.Mode, + command.DayOfWeek!.Value, + command.StartMinute!.Value, + command.EndMinute!.Value + ) + : channel.AddOverride(command.Mode, command.StartsAtUtc!.Value, command.EndsAtUtc!.Value); foreach (var show in command.Shows) ovr.AddShow(show.ShowId, show.Weight); diff --git a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs index 9cec779..c4f777c 100644 --- a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs @@ -104,12 +104,18 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) .ToList(); var overrides = channel.Overrides - .OrderBy(o => o.StartsAtUtc) + .OrderBy(o => o.Recurrence) + .ThenBy(o => o.StartsAtUtc) + .ThenBy(o => o.DayOfWeek) .Select(o => new ProgrammingOverrideDto( o.Id, o.Mode, + o.Recurrence, o.StartsAtUtc, o.EndsAtUtc, + o.DayOfWeek, + o.StartMinute, + o.EndMinute, o.Shows .Select(s => new OverrideShowDto(s.ShowId, ShowName(s.ShowId), s.Weight)) .ToList() diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs index 657227f..99d6972 100644 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs @@ -562,10 +562,14 @@ public sealed class ScheduleGenerator( var overrides = channel.Overrides .Select(o => new PlannerOverride( + o.Mode, + o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList(), + o.Recurrence, o.StartsAtUtc, o.EndsAtUtc, - o.Mode, - o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList() + o.DayOfWeek, + o.StartMinute, + o.EndMinute )) .ToList(); diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs b/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs index 1cc9037..323d49c 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs @@ -36,6 +36,13 @@ public interface IIdentityService CancellationToken cancellationToken ); + /// Сброс пароля администратором — без текущего пароля (для чужого аккаунта). + Task ResetPasswordAsync( + Guid userId, + string newPassword, + CancellationToken cancellationToken + ); + Task ChangeUserNameAsync( Guid userId, string newUserName, diff --git a/backend/src/TeleWave.Domain/Broadcast/Channel.cs b/backend/src/TeleWave.Domain/Broadcast/Channel.cs index ca173d0..af6a967 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Channel.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Channel.cs @@ -198,7 +198,20 @@ public class Channel DateTimeOffset endsAtUtc ) { - var ovr = ProgrammingOverride.Create(Id, mode, startsAtUtc, endsAtUtc); + var ovr = ProgrammingOverride.CreateOneTime(Id, mode, startsAtUtc, endsAtUtc); + _overrides.Add(ovr); + return ovr; + } + + /// Еженедельный override: день недели (0=Вс..6=Сб) + окно минут суток (UTC). + public ProgrammingOverride AddWeeklyOverride( + OverrideMode mode, + int dayOfWeek, + int startMinute, + int endMinute + ) + { + var ovr = ProgrammingOverride.CreateWeekly(Id, mode, dayOfWeek, startMinute, endMinute); _overrides.Add(ovr); return ovr; } diff --git a/backend/src/TeleWave.Domain/Broadcast/OverrideRecurrence.cs b/backend/src/TeleWave.Domain/Broadcast/OverrideRecurrence.cs new file mode 100644 index 0000000..5310823 --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/OverrideRecurrence.cs @@ -0,0 +1,11 @@ +namespace TeleWave.Domain.Broadcast; + +/// Как повторяется override программирования канала. +public enum OverrideRecurrence +{ + /// Разовое окно [StartsAtUtc, EndsAtUtc). + OneTime, + + /// Еженедельно в заданный день недели на окне часов суток (UTC). + Weekly, +} diff --git a/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs b/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs index cc6856d..f23ab33 100644 --- a/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs +++ b/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs @@ -1,9 +1,12 @@ namespace TeleWave.Domain.Broadcast; /// -/// Временный override программирования канала на окне [, -/// ). Марафон = с одним шоу и большим -/// временным блоком. Пересекающийся с генерируемым временем override заменяет базовую ротацию. +/// Временный override программирования канала. Разовый () — +/// на окне [, ). Еженедельный +/// () — каждую неделю в на окне минут +/// суток [, ) в UTC. Марафон = обычно +/// с одним шоу; пересекающийся с генерируемым временем override +/// заменяет базовую ротацию. /// public class ProgrammingOverride { @@ -12,14 +15,23 @@ public class ProgrammingOverride public Guid Id { get; private set; } public Guid ChannelId { get; private set; } public OverrideMode Mode { get; private set; } - public DateTimeOffset StartsAtUtc { get; private set; } - public DateTimeOffset EndsAtUtc { get; private set; } + + public OverrideRecurrence Recurrence { get; private set; } + + // ── OneTime ── + public DateTimeOffset? StartsAtUtc { get; private set; } + public DateTimeOffset? EndsAtUtc { get; private set; } + + // ── Weekly ── (день недели 0=Вс..6=Сб как System.DayOfWeek/JS getDay; минуты суток 0..1440, UTC) + public int? DayOfWeek { get; private set; } + public int? StartMinute { get; private set; } + public int? EndMinute { get; private set; } public IReadOnlyList Shows => _shows; private ProgrammingOverride() { } - internal static ProgrammingOverride Create( + internal static ProgrammingOverride CreateOneTime( Guid channelId, OverrideMode mode, DateTimeOffset startsAtUtc, @@ -30,10 +42,29 @@ public class ProgrammingOverride Id = Guid.NewGuid(), ChannelId = channelId, Mode = mode, + Recurrence = OverrideRecurrence.OneTime, StartsAtUtc = startsAtUtc, EndsAtUtc = endsAtUtc, }; + internal static ProgrammingOverride CreateWeekly( + Guid channelId, + OverrideMode mode, + int dayOfWeek, + int startMinute, + int endMinute + ) => + new() + { + Id = Guid.NewGuid(), + ChannelId = channelId, + Mode = mode, + Recurrence = OverrideRecurrence.Weekly, + DayOfWeek = dayOfWeek, + StartMinute = startMinute, + EndMinute = endMinute, + }; + public OverrideShow AddShow(Guid showId, int weight) { var entry = OverrideShow.Create(Id, showId, weight); @@ -41,5 +72,18 @@ public class ProgrammingOverride return entry; } - public bool Covers(DateTimeOffset moment) => moment >= StartsAtUtc && moment < EndsAtUtc; + /// Действует ли override в этот момент (по типу повторения). + public bool Covers(DateTimeOffset moment) + { + if (Recurrence == OverrideRecurrence.Weekly) + { + var utc = moment.UtcDateTime; + var minuteOfDay = utc.Hour * 60 + utc.Minute; + return (int)utc.DayOfWeek == DayOfWeek + && minuteOfDay >= StartMinute + && minuteOfDay < EndMinute; + } + + return moment >= StartsAtUtc && moment < EndsAtUtc; + } } diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs index 47142a7..4146674 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs @@ -205,7 +205,7 @@ public static class SchedulePlanner IReadOnlyDictionary byShowId ) { - var ovr = input.Overrides.FirstOrDefault(o => moment >= o.StartsAtUtc && moment < o.EndsAtUtc); + var ovr = input.Overrides.FirstOrDefault(o => o.Covers(moment)); if (ovr is not null) { var overridden = new List<(PlannerShow, int)>(); diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs index 5dd88b8..fbcf739 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs @@ -19,13 +19,36 @@ public sealed record PlannerHourWindow(int StartHour, int EndHour) public bool Contains(int hour) => hour >= StartHour && hour < EndHour; } -/// Override в терминах планировщика: окно + режим + шоу с весами. +/// +/// Override в терминах планировщика: режим + шоу с весами + правило действия (разовое окно либо +/// еженедельно по дню недели на окне минут суток UTC). +/// public sealed record PlannerOverride( - DateTimeOffset StartsAtUtc, - DateTimeOffset EndsAtUtc, OverrideMode Mode, - IReadOnlyList Shows -); + IReadOnlyList Shows, + OverrideRecurrence Recurrence = OverrideRecurrence.OneTime, + DateTimeOffset? StartsAtUtc = null, + DateTimeOffset? EndsAtUtc = null, + int? DayOfWeek = null, + int? StartMinute = null, + int? EndMinute = null +) +{ + /// Действует ли override в этот момент. + public bool Covers(DateTimeOffset moment) + { + if (Recurrence == OverrideRecurrence.Weekly) + { + var utc = moment.UtcDateTime; + var minuteOfDay = utc.Hour * 60 + utc.Minute; + return (int)utc.DayOfWeek == DayOfWeek + && minuteOfDay >= StartMinute + && minuteOfDay < EndMinute; + } + + return moment >= StartsAtUtc && moment < EndsAtUtc; + } +} public sealed record PlannerOverrideShow(Guid ShowId, int Weight); diff --git a/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs b/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs index 9dedabe..46b9633 100644 --- a/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs +++ b/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs @@ -99,6 +99,29 @@ internal sealed class IdentityService( ); } + public async Task ResetPasswordAsync( + Guid userId, + string newPassword, + CancellationToken cancellationToken + ) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(UserErrors.NotFound); + + // Админ меняет чужой пароль — без текущего: сбрасываем через одноразовый токен сброса. + var token = await userManager.GeneratePasswordResetTokenAsync(user); + var result = await userManager.ResetPasswordAsync(user, token, newPassword); + return result.Succeeded + ? Result.Success() + : Result.Failure( + Error.Validation( + "Auth.ResetPasswordFailed", + string.Join("; ", result.Errors.Select(e => e.Description)) + ) + ); + } + public async Task ChangeUserNameAsync( Guid userId, string newUserName, diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725164501_WeeklyProgrammingOverrides.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725164501_WeeklyProgrammingOverrides.Designer.cs new file mode 100644 index 0000000..da47063 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725164501_WeeklyProgrammingOverrides.Designer.cs @@ -0,0 +1,1008 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260725164501_WeeklyProgrammingOverrides")] + partial class WeeklyProgrammingOverrides + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperEpisodeChangeChance") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumperShowChangeChance") + .HasColumnType("double precision"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("PreferredWeightMultiplier") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelShowId") + .HasColumnType("uuid"); + + b.Property("EndHour") + .HasColumnType("integer"); + + b.Property("StartHour") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelShowId"); + + b.ToTable("ChannelShowHour"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("EndMinute") + .HasColumnType("integer"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Recurrence") + .HasColumnType("integer"); + + b.Property("StartMinute") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "ShowId"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", 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("IsBlocked") + .HasColumnType("boolean"); + + 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("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("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ChannelShow", null) + .WithMany("PreferredHours") + .HasForeignKey("ChannelShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("BumperTemplates"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Navigation("PreferredHours"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725164501_WeeklyProgrammingOverrides.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725164501_WeeklyProgrammingOverrides.cs new file mode 100644 index 0000000..2304584 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725164501_WeeklyProgrammingOverrides.cs @@ -0,0 +1,96 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class WeeklyProgrammingOverrides : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "StartsAtUtc", + table: "ProgrammingOverride", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "EndsAtUtc", + table: "ProgrammingOverride", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + + migrationBuilder.AddColumn( + name: "DayOfWeek", + table: "ProgrammingOverride", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "EndMinute", + table: "ProgrammingOverride", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "Recurrence", + table: "ProgrammingOverride", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "StartMinute", + table: "ProgrammingOverride", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DayOfWeek", + table: "ProgrammingOverride"); + + migrationBuilder.DropColumn( + name: "EndMinute", + table: "ProgrammingOverride"); + + migrationBuilder.DropColumn( + name: "Recurrence", + table: "ProgrammingOverride"); + + migrationBuilder.DropColumn( + name: "StartMinute", + table: "ProgrammingOverride"); + + migrationBuilder.AlterColumn( + name: "StartsAtUtc", + table: "ProgrammingOverride", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "EndsAtUtc", + table: "ProgrammingOverride", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone", + oldNullable: true); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 3afb010..5ceb3e6 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -478,13 +478,25 @@ namespace TeleWave.Infrastructure.Migrations b.Property("ChannelId") .HasColumnType("uuid"); - b.Property("EndsAtUtc") + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("EndMinute") + .HasColumnType("integer"); + + b.Property("EndsAtUtc") .HasColumnType("timestamp with time zone"); b.Property("Mode") .HasColumnType("integer"); - b.Property("StartsAtUtc") + b.Property("Recurrence") + .HasColumnType("integer"); + + b.Property("StartMinute") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") .HasColumnType("timestamp with time zone"); b.HasKey("Id"); diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs index afaede4..4afdb5f 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs @@ -208,10 +208,10 @@ public class SchedulePlannerTests Overrides: [ new PlannerOverride( - Start, - Start.AddHours(1), OverrideMode.Exclusive, - [new PlannerOverrideShow(b.ShowId, 1)] + [new PlannerOverrideShow(b.ShowId, 1)], + StartsAtUtc: Start, + EndsAtUtc: Start.AddHours(1) ), ], StartTime: Start, @@ -479,6 +479,63 @@ public class SchedulePlannerTests Assert.All(bumpers, e => Assert.Equal(t1, e.BumperTemplateId)); } + [Fact] + public void WeeklyOverride_AppliesOnMatchingDayAndTime() + { + // Start = 2026-01-01 (четверг, DayOfWeek=4), полночь. Еженедельный override на четверг 00:00–01:00. + var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); + var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); + var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); + + var input = BaseInput([a, b], durations, Start.AddSeconds(1)) with + { + Overrides = + [ + new PlannerOverride( + OverrideMode.Exclusive, + [new PlannerOverrideShow(b.ShowId, 1)], + OverrideRecurrence.Weekly, + DayOfWeek: (int)Start.UtcDateTime.DayOfWeek, + StartMinute: 0, + EndMinute: 60 + ), + ], + }; + + var result = SchedulePlanner.Plan(input, new FixedRandom(0)); + + Assert.Equal(b.ShowId, result.Entries[0].ShowId); + } + + [Fact] + public void WeeklyOverride_IgnoredOnOtherDay() + { + // Override на другой день недели → базовая ротация (FixedRandom(0) берёт первое шоу — a). + var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); + var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); + var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); + var otherDay = ((int)Start.UtcDateTime.DayOfWeek + 1) % 7; + + var input = BaseInput([a, b], durations, Start.AddSeconds(1)) with + { + Overrides = + [ + new PlannerOverride( + OverrideMode.Exclusive, + [new PlannerOverrideShow(b.ShowId, 1)], + OverrideRecurrence.Weekly, + DayOfWeek: otherDay, + StartMinute: 0, + EndMinute: 1440 + ), + ], + }; + + var result = SchedulePlanner.Plan(input, new FixedRandom(0)); + + Assert.Equal(a.ShowId, result.Entries[0].ShowId); + } + [Fact] public void PreferredHours_BoostsWeight_InsideWindow() { diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index ce3f31a..621f370 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -20,6 +20,7 @@ import type { ChannelShowDto, HourWindow, OverrideMode, + OverrideRecurrence, ScheduleEntryDto, } from '@/shared/api/types' import { Badge } from '@/shared/ui/badge' @@ -57,7 +58,8 @@ import { uploadBumperTemplateAudio, } from './api' -function formatTime(iso: string) { +function formatTime(iso: string | null) { + if (!iso) return '—' return new Date(iso).toLocaleString([], { day: '2-digit', month: '2-digit', @@ -66,6 +68,14 @@ function formatTime(iso: string) { }) } +/** Минуты суток → «HH:MM». */ +function formatMinute(minute: number | null) { + if (minute == null) return '—' + const h = Math.floor(minute / 60) + const m = minute % 60 + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` +} + export function ChannelDetail({ channelId }: { channelId: string }) { const { t } = useTranslation() const queryClient = useQueryClient() @@ -212,8 +222,10 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
  • {t(`admin.channels.modes.${o.mode}`)}{' '} - {formatTime(o.startsAtUtc)} – {formatTime(o.endsAtUtc)} ·{' '} - {o.shows.map((s) => s.showName).join(', ')} + {o.recurrence === 'Weekly' + ? `${t(`admin.channels.weekdays.${o.dayOfWeek}`)} ${formatMinute(o.startMinute)}–${formatMinute(o.endMinute)}` + : `${formatTime(o.startsAtUtc)} – ${formatTime(o.endsAtUtc)}`}{' '} + · {o.shows.map((s) => s.showName).join(', ')} deleteOverride(channelId, o.id).then(invalidate).catch(onError)} @@ -1472,32 +1484,72 @@ function OverrideForm({ }) { const { t } = useTranslation() const [mode, setMode] = useState('Exclusive') + const [recurrence, setRecurrence] = useState('OneTime') const [showId, setShowId] = useState('') const [weight, setWeight] = useState(1) const [start, setStart] = useState('') const [end, setEnd] = useState('') + // Weekly: день недели (0=Вс..6=Сб) + окна времени суток «HH:MM». + const [dayOfWeek, setDayOfWeek] = useState(6) + const [startTime, setStartTime] = useState('') + const [endTime, setEndTime] = useState('') + + const toMinutes = (hhmm: string) => { + const [h, m] = hhmm.split(':').map(Number) + return h * 60 + m + } + const weekly = recurrence === 'Weekly' const create = useMutation({ mutationFn: () => - createOverride(channelId, { - mode, - startsAtUtc: new Date(start).toISOString(), - endsAtUtc: new Date(end).toISOString(), - shows: [{ showId, weight }], - }), + createOverride( + channelId, + weekly + ? { + mode, + recurrence, + dayOfWeek, + startMinute: toMinutes(startTime), + endMinute: toMinutes(endTime), + shows: [{ showId, weight }], + } + : { + mode, + recurrence, + startsAtUtc: new Date(start).toISOString(), + endsAtUtc: new Date(end).toISOString(), + shows: [{ showId, weight }], + }, + ), onSuccess: () => { setShowId('') setStart('') setEnd('') + setStartTime('') + setEndTime('') onCreated() }, onError, }) - const valid = showId && start && end && new Date(end) > new Date(start) + const valid = weekly + ? showId && startTime && endTime && toMinutes(endTime) > toMinutes(startTime) + : showId && start && end && new Date(end) > new Date(start) return (
    +
    + + +
    setStart(e.target.value)} className="w-52" /> -
    -
    - - setEnd(e.target.value)} className="w-52" /> -
    + {weekly ? ( + <> +
    + + +
    +
    + + setStartTime(e.target.value)} className="w-32" /> +
    +
    + + setEndTime(e.target.value)} className="w-32" /> +
    + + ) : ( + <> +
    + + setStart(e.target.value)} className="w-60" /> +
    +
    + + setEnd(e.target.value)} className="w-60" /> +
    + + )} diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts index c47d2c7..2962970 100644 --- a/frontend/src/features/admin/channels/api.ts +++ b/frontend/src/features/admin/channels/api.ts @@ -10,6 +10,7 @@ import type { CreatedIdResponse, HourWindow, OverrideMode, + OverrideRecurrence, ScheduleEntryDto, } from '@/shared/api/types' @@ -217,8 +218,12 @@ export function bumperPreviewPlaylistUrl(id: string, templateId: string, variant export type OverrideBody = { mode: OverrideMode - startsAtUtc: string - endsAtUtc: string + recurrence: OverrideRecurrence + startsAtUtc?: string | null + endsAtUtc?: string | null + dayOfWeek?: number | null + startMinute?: number | null + endMinute?: number | null shows: { showId: string; weight: number }[] } diff --git a/frontend/src/features/admin/media/MediaPanel.tsx b/frontend/src/features/admin/media/MediaPanel.tsx index 3e7c90d..7eb8119 100644 --- a/frontend/src/features/admin/media/MediaPanel.tsx +++ b/frontend/src/features/admin/media/MediaPanel.tsx @@ -6,6 +6,7 @@ import { HttpError } from '@/shared/api/client' import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types' import { Badge, type BadgeProps } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' +import { Pager } from '@/shared/ui/pager' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/ui/toast-store' import { deleteMedia, listMedia } from './api' @@ -46,12 +47,13 @@ export function MediaPanel() { const fileInput = useRef(null) const fileInputShow = useRef(null) const [filter, setFilter] = useState('active') + const [page, setPage] = useState(1) const [filesForShow, setFilesForShow] = useState(null) const enqueue = useUploadStore((s) => s.enqueue) const { data, isLoading } = useQuery({ - queryKey: ['admin', 'media', filter], - queryFn: () => listMedia({ page: 1, pageSize: PAGE_SIZE, statuses: filterStatuses[filter] }), + queryKey: ['admin', 'media', filter, page], + queryFn: () => listMedia({ page, pageSize: PAGE_SIZE, statuses: filterStatuses[filter] }), // Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI. refetchInterval: (query) => query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending') @@ -70,7 +72,13 @@ export function MediaPanel() {

    {t('admin.media.title')}

    - { + setPage(1) + setFilter(v as MediaFilter) + }} + > @@ -158,6 +166,12 @@ export function MediaPanel() {
    + +
    ) } diff --git a/frontend/src/features/admin/shows/ShowsPanel.tsx b/frontend/src/features/admin/shows/ShowsPanel.tsx index 1db58cf..8dbd8f4 100644 --- a/frontend/src/features/admin/shows/ShowsPanel.tsx +++ b/frontend/src/features/admin/shows/ShowsPanel.tsx @@ -1,24 +1,42 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { HttpError } from '@/shared/api/client' import type { ShowKind } from '@/shared/api/types' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' +import { Pager } from '@/shared/ui/pager' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/ui/toast-store' import { createShow, deleteShow, listShows } from './api' +const PAGE_SIZE = 20 + export function ShowsPanel() { const { t } = useTranslation() const queryClient = useQueryClient() const [name, setName] = useState('') const [originalName, setOriginalName] = useState('') const [kind, setKind] = useState('Series') + const [query, setQuery] = useState('') + const [page, setPage] = useState(1) const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows }) + + // Список шоу обычно умещается в одну загрузку — фильтруем и листаем на клиенте (пикеры берут всё). + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + const all = data ?? [] + if (!q) return all + return all.filter( + (s) => + s.name.toLowerCase().includes(q) || (s.originalName ?? '').toLowerCase().includes(q), + ) + }, [data, query]) + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)) + const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] }) const onError = (error: unknown) => toast.error(error instanceof HttpError ? error.detail : t('common.error')) @@ -70,6 +88,16 @@ export function ShowsPanel() { + { + setPage(1) + setQuery(e.target.value) + }} + /> +
    @@ -89,7 +117,7 @@ export function ShowsPanel() { )} - {data?.map((show) => ( + {pageItems.map((show) => (
    + + ) } diff --git a/frontend/src/features/admin/users/UsersPanel.tsx b/frontend/src/features/admin/users/UsersPanel.tsx index 02d91cb..9f3cb64 100644 --- a/frontend/src/features/admin/users/UsersPanel.tsx +++ b/frontend/src/features/admin/users/UsersPanel.tsx @@ -5,13 +5,21 @@ import { HttpError } from '@/shared/api/client' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/ui/toast-store' +import type { UserSummaryDto } from '@/shared/api/types' import { changeUserRole } from '@/features/admin/roles/api' import { listRoles } from '@/features/admin/roles/api' -import { blockUser, createUser, deleteUser, listUsers, unblockUser } from './api' +import { blockUser, createUser, deleteUser, listUsers, resetUserPassword, unblockUser } from './api' const PAGE_SIZE = 20 @@ -24,6 +32,7 @@ export function UsersPanel() { const [newUserName, setNewUserName] = useState('') const [newPassword, setNewPassword] = useState('') const [newRoleId, setNewRoleId] = useState('') + const [resetTarget, setResetTarget] = useState(null) const { data: roles } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles }) const { data, isLoading } = useQuery({ @@ -214,6 +223,9 @@ export function UsersPanel() { {t('admin.users.block')} )} + @@ -238,6 +250,68 @@ export function UsersPanel() { )} + + {resetTarget && ( + setResetTarget(null)} + onError={onError} + /> + )} ) } + +function ResetPasswordDialog({ + user, + onClose, + onError, +}: { + user: UserSummaryDto + onClose: () => void + onError: (e: unknown) => void +}) { + const { t } = useTranslation() + const [password, setPassword] = useState('') + + const reset = useMutation({ + mutationFn: () => resetUserPassword(user.id, password), + onSuccess: () => { + toast.success(t('admin.users.passwordReset')) + onClose() + }, + onError, + }) + + return ( + !open && onClose()}> + + + {t('admin.users.resetPasswordFor', { name: user.userName })} + +
    + + setPassword(e.target.value)} + /> +

    {t('admin.users.passwordHint')}

    +
    + + + + +
    +
    + ) +} diff --git a/frontend/src/features/admin/users/api.ts b/frontend/src/features/admin/users/api.ts index 101c02f..9a4d1fb 100644 --- a/frontend/src/features/admin/users/api.ts +++ b/frontend/src/features/admin/users/api.ts @@ -36,3 +36,10 @@ export function unblockUser(id: string) { export function deleteUser(id: string) { return apiRequest(`/admin/users/${id}`, { method: 'DELETE' }) } + +export function resetUserPassword(id: string, newPassword: string) { + return apiRequest(`/admin/users/${id}/password`, { + method: 'POST', + body: { newPassword }, + }) +} diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 63b809e..29b7307 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -206,11 +206,18 @@ export type ChannelAdDto = { export type OverrideShowDto = { showId: string; showName: string; weight: number } +export type OverrideRecurrence = 'OneTime' | 'Weekly' + export type ProgrammingOverrideDto = { id: string mode: OverrideMode - startsAtUtc: string - endsAtUtc: string + recurrence: OverrideRecurrence + startsAtUtc: string | null + endsAtUtc: string | null + /** Weekly: день недели 0=Вс..6=Сб; окно минут суток (UTC). */ + dayOfWeek: number | null + startMinute: number | null + endMinute: number | null shows: OverrideShowDto[] } diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index a3af50e..412339d 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -27,6 +27,8 @@ const resources = { confirm: 'Подтвердить', search: 'Поиск', actions: 'Действия', + prevPage: 'Предыдущая страница', + nextPage: 'Следующая страница', yes: 'Да', no: 'Нет', }, @@ -105,6 +107,10 @@ const resources = { active: 'Активен', block: 'Заблокировать', unblock: 'Разблокировать', + resetPassword: 'Пароль', + resetPasswordFor: 'Сменить пароль: {{name}}', + newPassword: 'Новый пароль', + passwordReset: 'Пароль изменён', filterAll: 'Все роли', createTitle: 'Создать пользователя', password: 'Пароль', @@ -288,6 +294,19 @@ const resources = { noAds: 'Пул рекламы пуст', overrides: 'Марафоны / override', modes: { Exclusive: 'Эксклюзив', Boost: 'Буст' }, + overrideRecurrence: 'Повтор', + recurrenceOneTime: 'Разово', + recurrenceWeekly: 'Еженедельно', + weekday: 'День недели', + weekdays: { + 0: 'Вс', + 1: 'Пн', + 2: 'Вт', + 3: 'Ср', + 4: 'Чт', + 5: 'Пт', + 6: 'Сб', + }, from: 'С', to: 'По', noOverrides: 'Override не заданы', @@ -368,6 +387,8 @@ const resources = { confirm: 'Confirm', search: 'Search', actions: 'Actions', + prevPage: 'Previous page', + nextPage: 'Next page', yes: 'Yes', no: 'No', }, @@ -446,6 +467,10 @@ const resources = { active: 'Active', block: 'Block', unblock: 'Unblock', + resetPassword: 'Password', + resetPasswordFor: 'Reset password: {{name}}', + newPassword: 'New password', + passwordReset: 'Password changed', filterAll: 'All roles', createTitle: 'Create user', password: 'Password', @@ -629,6 +654,19 @@ const resources = { noAds: 'Ad pool is empty', overrides: 'Marathons / overrides', modes: { Exclusive: 'Exclusive', Boost: 'Boost' }, + overrideRecurrence: 'Repeat', + recurrenceOneTime: 'One-time', + recurrenceWeekly: 'Weekly', + weekday: 'Weekday', + weekdays: { + 0: 'Sun', + 1: 'Mon', + 2: 'Tue', + 3: 'Wed', + 4: 'Thu', + 5: 'Fri', + 6: 'Sat', + }, from: 'From', to: 'To', noOverrides: 'No overrides set', diff --git a/frontend/src/shared/ui/pager.tsx b/frontend/src/shared/ui/pager.tsx new file mode 100644 index 0000000..871ba7d --- /dev/null +++ b/frontend/src/shared/ui/pager.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from 'react-i18next' +import { Button } from './button' + +/** Простой пейджер «‹ N / M ›». Ничего не рисует, если страница одна. */ +export function Pager({ + page, + totalPages, + onChange, +}: { + page: number + totalPages: number + onChange: (page: number) => void +}) { + const { t } = useTranslation() + if (totalPages <= 1) return null + + return ( +
    + + + {page} / {totalPages} + + +
    + ) +}