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.
This commit is contained in:
@@ -5,6 +5,7 @@ using TeleWave.Application.Admin.Users.CreateUser;
|
|||||||
using TeleWave.Application.Admin.Users.DeleteUser;
|
using TeleWave.Application.Admin.Users.DeleteUser;
|
||||||
using TeleWave.Application.Admin.Users.GetUser;
|
using TeleWave.Application.Admin.Users.GetUser;
|
||||||
using TeleWave.Application.Admin.Users.ListUsers;
|
using TeleWave.Application.Admin.Users.ListUsers;
|
||||||
|
using TeleWave.Application.Admin.Users.ResetPassword;
|
||||||
using TeleWave.Application.Admin.Users.UnblockUser;
|
using TeleWave.Application.Admin.Users.UnblockUser;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
@@ -29,6 +30,9 @@ public static class AdminUserEndpoints
|
|||||||
admin
|
admin
|
||||||
.MapPost("/{id:guid}/unblock", UnblockUser)
|
.MapPost("/{id:guid}/unblock", UnblockUser)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapPost("/{id:guid}/password", ResetPassword)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapDelete("/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent);
|
admin.MapDelete("/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
@@ -96,6 +100,20 @@ public static class AdminUserEndpoints
|
|||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> 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<IResult> DeleteUser(
|
private static async Task<IResult> DeleteUser(
|
||||||
Guid id,
|
Guid id,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
@@ -108,3 +126,5 @@ public static class AdminUserEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
public sealed record CreateUserBody(string UserName, string Password, Guid RoleId);
|
public sealed record CreateUserBody(string UserName, string Password, Guid RoleId);
|
||||||
|
|
||||||
|
public sealed record ResetPasswordBody(string NewPassword);
|
||||||
|
|||||||
@@ -538,8 +538,12 @@ public static class ChannelEndpoints
|
|||||||
new CreateProgrammingOverrideCommand(
|
new CreateProgrammingOverrideCommand(
|
||||||
id,
|
id,
|
||||||
body.Mode,
|
body.Mode,
|
||||||
|
body.Recurrence,
|
||||||
body.StartsAtUtc,
|
body.StartsAtUtc,
|
||||||
body.EndsAtUtc,
|
body.EndsAtUtc,
|
||||||
|
body.DayOfWeek,
|
||||||
|
body.StartMinute,
|
||||||
|
body.EndMinute,
|
||||||
body.Shows
|
body.Shows
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
@@ -659,7 +663,11 @@ internal static class BumperFiles
|
|||||||
|
|
||||||
public sealed record CreateOverrideBody(
|
public sealed record CreateOverrideBody(
|
||||||
OverrideMode Mode,
|
OverrideMode Mode,
|
||||||
DateTimeOffset StartsAtUtc,
|
OverrideRecurrence Recurrence,
|
||||||
DateTimeOffset EndsAtUtc,
|
DateTimeOffset? StartsAtUtc,
|
||||||
|
DateTimeOffset? EndsAtUtc,
|
||||||
|
int? DayOfWeek,
|
||||||
|
int? StartMinute,
|
||||||
|
int? EndMinute,
|
||||||
IReadOnlyList<OverrideShowInput> Shows
|
IReadOnlyList<OverrideShowInput> Shows
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Admin.Users.ResetPassword;
|
||||||
|
|
||||||
|
/// <summary>Сброс пароля пользователя администратором (без текущего пароля).</summary>
|
||||||
|
public sealed record ResetUserPasswordCommand(Guid UserId, string NewPassword) : ICommand<Result>;
|
||||||
+14
@@ -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<ResetUserPasswordCommand, Result>
|
||||||
|
{
|
||||||
|
public Task<Result> Handle(
|
||||||
|
ResetUserPasswordCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
) => identityService.ResetPasswordAsync(command.UserId, command.NewPassword, cancellationToken);
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Admin.Users.ResetPassword;
|
||||||
|
|
||||||
|
public sealed class ResetUserPasswordCommandValidator : AbstractValidator<ResetUserPasswordCommand>
|
||||||
|
{
|
||||||
|
public ResetUserPasswordCommandValidator()
|
||||||
|
{
|
||||||
|
// Длина — здесь; сложность (цифра/заглавная) проверяют валидаторы ASP.NET Identity при сбросе.
|
||||||
|
RuleFor(x => x.UserId).NotEmpty();
|
||||||
|
RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,8 +27,12 @@ public sealed record OverrideShowDto(Guid ShowId, string ShowName, int Weight);
|
|||||||
public sealed record ProgrammingOverrideDto(
|
public sealed record ProgrammingOverrideDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
OverrideMode Mode,
|
OverrideMode Mode,
|
||||||
DateTimeOffset StartsAtUtc,
|
OverrideRecurrence Recurrence,
|
||||||
DateTimeOffset EndsAtUtc,
|
DateTimeOffset? StartsAtUtc,
|
||||||
|
DateTimeOffset? EndsAtUtc,
|
||||||
|
int? DayOfWeek,
|
||||||
|
int? StartMinute,
|
||||||
|
int? EndMinute,
|
||||||
IReadOnlyList<OverrideShowDto> Shows
|
IReadOnlyList<OverrideShowDto> Shows
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -7,7 +7,11 @@ namespace TeleWave.Application.Broadcast.CreateOverride;
|
|||||||
public sealed record CreateProgrammingOverrideCommand(
|
public sealed record CreateProgrammingOverrideCommand(
|
||||||
Guid ChannelId,
|
Guid ChannelId,
|
||||||
OverrideMode Mode,
|
OverrideMode Mode,
|
||||||
DateTimeOffset StartsAtUtc,
|
OverrideRecurrence Recurrence,
|
||||||
DateTimeOffset EndsAtUtc,
|
DateTimeOffset? StartsAtUtc,
|
||||||
|
DateTimeOffset? EndsAtUtc,
|
||||||
|
int? DayOfWeek,
|
||||||
|
int? StartMinute,
|
||||||
|
int? EndMinute,
|
||||||
IReadOnlyList<OverrideShowInput> Shows
|
IReadOnlyList<OverrideShowInput> Shows
|
||||||
) : ICommand<Result<Guid>>;
|
) : ICommand<Result<Guid>>;
|
||||||
|
|||||||
+26
-2
@@ -2,6 +2,7 @@ using LiteCqrs;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.CreateOverride;
|
namespace TeleWave.Application.Broadcast.CreateOverride;
|
||||||
|
|
||||||
@@ -13,8 +14,24 @@ public sealed class CreateProgrammingOverrideCommandHandler(IAppDbContext dbCont
|
|||||||
CancellationToken cancellationToken
|
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<Guid>(ChannelErrors.InvalidOverrideWindow);
|
||||||
|
}
|
||||||
|
else if (command.StartsAtUtc is not { } start || command.EndsAtUtc is not { } end || end <= start)
|
||||||
|
{
|
||||||
return Result.Failure<Guid>(ChannelErrors.InvalidOverrideWindow);
|
return Result.Failure<Guid>(ChannelErrors.InvalidOverrideWindow);
|
||||||
|
}
|
||||||
|
|
||||||
if (command.Shows.Count == 0)
|
if (command.Shows.Count == 0)
|
||||||
return Result.Failure<Guid>(ChannelErrors.OverrideNeedsShow);
|
return Result.Failure<Guid>(ChannelErrors.OverrideNeedsShow);
|
||||||
|
|
||||||
@@ -33,7 +50,14 @@ public sealed class CreateProgrammingOverrideCommandHandler(IAppDbContext dbCont
|
|||||||
if (existingCount != showIds.Count)
|
if (existingCount != showIds.Count)
|
||||||
return Result.Failure<Guid>(ChannelErrors.ShowNotFound);
|
return Result.Failure<Guid>(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)
|
foreach (var show in command.Shows)
|
||||||
ovr.AddShow(show.ShowId, show.Weight);
|
ovr.AddShow(show.ShowId, show.Weight);
|
||||||
|
|
||||||
|
|||||||
@@ -104,12 +104,18 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var overrides = channel.Overrides
|
var overrides = channel.Overrides
|
||||||
.OrderBy(o => o.StartsAtUtc)
|
.OrderBy(o => o.Recurrence)
|
||||||
|
.ThenBy(o => o.StartsAtUtc)
|
||||||
|
.ThenBy(o => o.DayOfWeek)
|
||||||
.Select(o => new ProgrammingOverrideDto(
|
.Select(o => new ProgrammingOverrideDto(
|
||||||
o.Id,
|
o.Id,
|
||||||
o.Mode,
|
o.Mode,
|
||||||
|
o.Recurrence,
|
||||||
o.StartsAtUtc,
|
o.StartsAtUtc,
|
||||||
o.EndsAtUtc,
|
o.EndsAtUtc,
|
||||||
|
o.DayOfWeek,
|
||||||
|
o.StartMinute,
|
||||||
|
o.EndMinute,
|
||||||
o.Shows
|
o.Shows
|
||||||
.Select(s => new OverrideShowDto(s.ShowId, ShowName(s.ShowId), s.Weight))
|
.Select(s => new OverrideShowDto(s.ShowId, ShowName(s.ShowId), s.Weight))
|
||||||
.ToList()
|
.ToList()
|
||||||
|
|||||||
@@ -562,10 +562,14 @@ public sealed class ScheduleGenerator(
|
|||||||
|
|
||||||
var overrides = channel.Overrides
|
var overrides = channel.Overrides
|
||||||
.Select(o => new PlannerOverride(
|
.Select(o => new PlannerOverride(
|
||||||
|
o.Mode,
|
||||||
|
o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList(),
|
||||||
|
o.Recurrence,
|
||||||
o.StartsAtUtc,
|
o.StartsAtUtc,
|
||||||
o.EndsAtUtc,
|
o.EndsAtUtc,
|
||||||
o.Mode,
|
o.DayOfWeek,
|
||||||
o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList()
|
o.StartMinute,
|
||||||
|
o.EndMinute
|
||||||
))
|
))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ public interface IIdentityService
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>Сброс пароля администратором — без текущего пароля (для чужого аккаунта).</summary>
|
||||||
|
Task<Result> ResetPasswordAsync(
|
||||||
|
Guid userId,
|
||||||
|
string newPassword,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
Task<Result> ChangeUserNameAsync(
|
Task<Result> ChangeUserNameAsync(
|
||||||
Guid userId,
|
Guid userId,
|
||||||
string newUserName,
|
string newUserName,
|
||||||
|
|||||||
@@ -198,7 +198,20 @@ public class Channel
|
|||||||
DateTimeOffset endsAtUtc
|
DateTimeOffset endsAtUtc
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var ovr = ProgrammingOverride.Create(Id, mode, startsAtUtc, endsAtUtc);
|
var ovr = ProgrammingOverride.CreateOneTime(Id, mode, startsAtUtc, endsAtUtc);
|
||||||
|
_overrides.Add(ovr);
|
||||||
|
return ovr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Еженедельный override: день недели (0=Вс..6=Сб) + окно минут суток (UTC).</summary>
|
||||||
|
public ProgrammingOverride AddWeeklyOverride(
|
||||||
|
OverrideMode mode,
|
||||||
|
int dayOfWeek,
|
||||||
|
int startMinute,
|
||||||
|
int endMinute
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var ovr = ProgrammingOverride.CreateWeekly(Id, mode, dayOfWeek, startMinute, endMinute);
|
||||||
_overrides.Add(ovr);
|
_overrides.Add(ovr);
|
||||||
return ovr;
|
return ovr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
|
/// <summary>Как повторяется override программирования канала.</summary>
|
||||||
|
public enum OverrideRecurrence
|
||||||
|
{
|
||||||
|
/// <summary>Разовое окно [StartsAtUtc, EndsAtUtc).</summary>
|
||||||
|
OneTime,
|
||||||
|
|
||||||
|
/// <summary>Еженедельно в заданный день недели на окне часов суток (UTC).</summary>
|
||||||
|
Weekly,
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
namespace TeleWave.Domain.Broadcast;
|
namespace TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Временный override программирования канала на окне [<see cref="StartsAtUtc"/>,
|
/// Временный override программирования канала. Разовый (<see cref="OverrideRecurrence.OneTime"/>) —
|
||||||
/// <see cref="EndsAtUtc"/>). Марафон = <see cref="OverrideMode.Exclusive"/> с одним шоу и большим
|
/// на окне [<see cref="StartsAtUtc"/>, <see cref="EndsAtUtc"/>). Еженедельный
|
||||||
/// временным блоком. Пересекающийся с генерируемым временем override заменяет базовую ротацию.
|
/// (<see cref="OverrideRecurrence.Weekly"/>) — каждую неделю в <see cref="DayOfWeek"/> на окне минут
|
||||||
|
/// суток [<see cref="StartMinute"/>, <see cref="EndMinute"/>) в UTC. Марафон = обычно
|
||||||
|
/// <see cref="OverrideMode.Exclusive"/> с одним шоу; пересекающийся с генерируемым временем override
|
||||||
|
/// заменяет базовую ротацию.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ProgrammingOverride
|
public class ProgrammingOverride
|
||||||
{
|
{
|
||||||
@@ -12,14 +15,23 @@ public class ProgrammingOverride
|
|||||||
public Guid Id { get; private set; }
|
public Guid Id { get; private set; }
|
||||||
public Guid ChannelId { get; private set; }
|
public Guid ChannelId { get; private set; }
|
||||||
public OverrideMode Mode { 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<OverrideShow> Shows => _shows;
|
public IReadOnlyList<OverrideShow> Shows => _shows;
|
||||||
|
|
||||||
private ProgrammingOverride() { }
|
private ProgrammingOverride() { }
|
||||||
|
|
||||||
internal static ProgrammingOverride Create(
|
internal static ProgrammingOverride CreateOneTime(
|
||||||
Guid channelId,
|
Guid channelId,
|
||||||
OverrideMode mode,
|
OverrideMode mode,
|
||||||
DateTimeOffset startsAtUtc,
|
DateTimeOffset startsAtUtc,
|
||||||
@@ -30,10 +42,29 @@ public class ProgrammingOverride
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChannelId = channelId,
|
ChannelId = channelId,
|
||||||
Mode = mode,
|
Mode = mode,
|
||||||
|
Recurrence = OverrideRecurrence.OneTime,
|
||||||
StartsAtUtc = startsAtUtc,
|
StartsAtUtc = startsAtUtc,
|
||||||
EndsAtUtc = endsAtUtc,
|
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)
|
public OverrideShow AddShow(Guid showId, int weight)
|
||||||
{
|
{
|
||||||
var entry = OverrideShow.Create(Id, showId, weight);
|
var entry = OverrideShow.Create(Id, showId, weight);
|
||||||
@@ -41,5 +72,18 @@ public class ProgrammingOverride
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Covers(DateTimeOffset moment) => moment >= StartsAtUtc && moment < EndsAtUtc;
|
/// <summary>Действует ли override в этот момент (по типу повторения).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ public static class SchedulePlanner
|
|||||||
IReadOnlyDictionary<Guid, PlannerShow> byShowId
|
IReadOnlyDictionary<Guid, PlannerShow> 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)
|
if (ovr is not null)
|
||||||
{
|
{
|
||||||
var overridden = new List<(PlannerShow, int)>();
|
var overridden = new List<(PlannerShow, int)>();
|
||||||
|
|||||||
@@ -19,13 +19,36 @@ public sealed record PlannerHourWindow(int StartHour, int EndHour)
|
|||||||
public bool Contains(int hour) => hour >= StartHour && hour < EndHour;
|
public bool Contains(int hour) => hour >= StartHour && hour < EndHour;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Override в терминах планировщика: окно + режим + шоу с весами.</summary>
|
/// <summary>
|
||||||
|
/// Override в терминах планировщика: режим + шоу с весами + правило действия (разовое окно либо
|
||||||
|
/// еженедельно по дню недели на окне минут суток UTC).
|
||||||
|
/// </summary>
|
||||||
public sealed record PlannerOverride(
|
public sealed record PlannerOverride(
|
||||||
DateTimeOffset StartsAtUtc,
|
|
||||||
DateTimeOffset EndsAtUtc,
|
|
||||||
OverrideMode Mode,
|
OverrideMode Mode,
|
||||||
IReadOnlyList<PlannerOverrideShow> Shows
|
IReadOnlyList<PlannerOverrideShow> Shows,
|
||||||
);
|
OverrideRecurrence Recurrence = OverrideRecurrence.OneTime,
|
||||||
|
DateTimeOffset? StartsAtUtc = null,
|
||||||
|
DateTimeOffset? EndsAtUtc = null,
|
||||||
|
int? DayOfWeek = null,
|
||||||
|
int? StartMinute = null,
|
||||||
|
int? EndMinute = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
/// <summary>Действует ли override в этот момент.</summary>
|
||||||
|
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);
|
public sealed record PlannerOverrideShow(Guid ShowId, int Weight);
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,29 @@ internal sealed class IdentityService(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Result> 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<Result> ChangeUserNameAsync(
|
public async Task<Result> ChangeUserNameAsync(
|
||||||
Guid userId,
|
Guid userId,
|
||||||
string newUserName,
|
string newUserName,
|
||||||
|
|||||||
backend/src/TeleWave.Infrastructure/Migrations/20260725164501_WeeklyProgrammingOverrides.Designer.cs
Generated
+1008
File diff suppressed because it is too large
Load Diff
+96
@@ -0,0 +1,96 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class WeeklyProgrammingOverrides : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<DateTimeOffset>(
|
||||||
|
name: "StartsAtUtc",
|
||||||
|
table: "ProgrammingOverride",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(DateTimeOffset),
|
||||||
|
oldType: "timestamp with time zone");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTimeOffset>(
|
||||||
|
name: "EndsAtUtc",
|
||||||
|
table: "ProgrammingOverride",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(DateTimeOffset),
|
||||||
|
oldType: "timestamp with time zone");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DayOfWeek",
|
||||||
|
table: "ProgrammingOverride",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "EndMinute",
|
||||||
|
table: "ProgrammingOverride",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Recurrence",
|
||||||
|
table: "ProgrammingOverride",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "StartMinute",
|
||||||
|
table: "ProgrammingOverride",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<DateTimeOffset>(
|
||||||
|
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<DateTimeOffset>(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -478,13 +478,25 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Property<Guid>("ChannelId")
|
b.Property<Guid>("ChannelId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("EndsAtUtc")
|
b.Property<int?>("DayOfWeek")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("EndMinute")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("EndsAtUtc")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<int>("Mode")
|
b.Property<int>("Mode")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("StartsAtUtc")
|
b.Property<int>("Recurrence")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("StartMinute")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("StartsAtUtc")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|||||||
@@ -208,10 +208,10 @@ public class SchedulePlannerTests
|
|||||||
Overrides:
|
Overrides:
|
||||||
[
|
[
|
||||||
new PlannerOverride(
|
new PlannerOverride(
|
||||||
Start,
|
|
||||||
Start.AddHours(1),
|
|
||||||
OverrideMode.Exclusive,
|
OverrideMode.Exclusive,
|
||||||
[new PlannerOverrideShow(b.ShowId, 1)]
|
[new PlannerOverrideShow(b.ShowId, 1)],
|
||||||
|
StartsAtUtc: Start,
|
||||||
|
EndsAtUtc: Start.AddHours(1)
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
StartTime: Start,
|
StartTime: Start,
|
||||||
@@ -479,6 +479,63 @@ public class SchedulePlannerTests
|
|||||||
Assert.All(bumpers, e => Assert.Equal(t1, e.BumperTemplateId));
|
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]
|
[Fact]
|
||||||
public void PreferredHours_BoostsWeight_InsideWindow()
|
public void PreferredHours_BoostsWeight_InsideWindow()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
ChannelShowDto,
|
ChannelShowDto,
|
||||||
HourWindow,
|
HourWindow,
|
||||||
OverrideMode,
|
OverrideMode,
|
||||||
|
OverrideRecurrence,
|
||||||
ScheduleEntryDto,
|
ScheduleEntryDto,
|
||||||
} from '@/shared/api/types'
|
} from '@/shared/api/types'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
@@ -57,7 +58,8 @@ import {
|
|||||||
uploadBumperTemplateAudio,
|
uploadBumperTemplateAudio,
|
||||||
} from './api'
|
} from './api'
|
||||||
|
|
||||||
function formatTime(iso: string) {
|
function formatTime(iso: string | null) {
|
||||||
|
if (!iso) return '—'
|
||||||
return new Date(iso).toLocaleString([], {
|
return new Date(iso).toLocaleString([], {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '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 }) {
|
export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -212,8 +222,10 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
<li key={o.id} className="flex items-center justify-between py-2 text-sm">
|
<li key={o.id} className="flex items-center justify-between py-2 text-sm">
|
||||||
<span>
|
<span>
|
||||||
<Badge variant="muted">{t(`admin.channels.modes.${o.mode}`)}</Badge>{' '}
|
<Badge variant="muted">{t(`admin.channels.modes.${o.mode}`)}</Badge>{' '}
|
||||||
{formatTime(o.startsAtUtc)} – {formatTime(o.endsAtUtc)} ·{' '}
|
{o.recurrence === 'Weekly'
|
||||||
{o.shows.map((s) => s.showName).join(', ')}
|
? `${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(', ')}
|
||||||
</span>
|
</span>
|
||||||
<RemoveButton
|
<RemoveButton
|
||||||
onClick={() => deleteOverride(channelId, o.id).then(invalidate).catch(onError)}
|
onClick={() => deleteOverride(channelId, o.id).then(invalidate).catch(onError)}
|
||||||
@@ -1472,32 +1484,72 @@ function OverrideForm({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [mode, setMode] = useState<OverrideMode>('Exclusive')
|
const [mode, setMode] = useState<OverrideMode>('Exclusive')
|
||||||
|
const [recurrence, setRecurrence] = useState<OverrideRecurrence>('OneTime')
|
||||||
const [showId, setShowId] = useState('')
|
const [showId, setShowId] = useState('')
|
||||||
const [weight, setWeight] = useState(1)
|
const [weight, setWeight] = useState(1)
|
||||||
const [start, setStart] = useState('')
|
const [start, setStart] = useState('')
|
||||||
const [end, setEnd] = 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({
|
const create = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
createOverride(channelId, {
|
createOverride(
|
||||||
mode,
|
channelId,
|
||||||
startsAtUtc: new Date(start).toISOString(),
|
weekly
|
||||||
endsAtUtc: new Date(end).toISOString(),
|
? {
|
||||||
shows: [{ showId, weight }],
|
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: () => {
|
onSuccess: () => {
|
||||||
setShowId('')
|
setShowId('')
|
||||||
setStart('')
|
setStart('')
|
||||||
setEnd('')
|
setEnd('')
|
||||||
|
setStartTime('')
|
||||||
|
setEndTime('')
|
||||||
onCreated()
|
onCreated()
|
||||||
},
|
},
|
||||||
onError,
|
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 (
|
return (
|
||||||
<div className="flex flex-wrap items-end gap-2">
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.overrideRecurrence')}</Label>
|
||||||
|
<Select value={recurrence} onValueChange={(v) => setRecurrence(v as OverrideRecurrence)}>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="OneTime">{t('admin.channels.recurrenceOneTime')}</SelectItem>
|
||||||
|
<SelectItem value="Weekly">{t('admin.channels.recurrenceWeekly')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<Select value={mode} onValueChange={(v) => setMode(v as OverrideMode)}>
|
<Select value={mode} onValueChange={(v) => setMode(v as OverrideMode)}>
|
||||||
<SelectTrigger className="w-36">
|
<SelectTrigger className="w-36">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -1522,14 +1574,44 @@ function OverrideForm({
|
|||||||
{mode === 'Boost' && (
|
{mode === 'Boost' && (
|
||||||
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
|
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-1.5">
|
{weekly ? (
|
||||||
<Label>{t('admin.channels.from')}</Label>
|
<>
|
||||||
<Input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} className="w-52" />
|
<div className="flex flex-col gap-1.5">
|
||||||
</div>
|
<Label>{t('admin.channels.weekday')}</Label>
|
||||||
<div className="flex flex-col gap-1.5">
|
<Select value={String(dayOfWeek)} onValueChange={(v) => setDayOfWeek(Number(v))}>
|
||||||
<Label>{t('admin.channels.to')}</Label>
|
<SelectTrigger className="w-36">
|
||||||
<Input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} className="w-52" />
|
<SelectValue />
|
||||||
</div>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||||
|
<SelectItem key={d} value={String(d)}>
|
||||||
|
{t(`admin.channels.weekdays.${d}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.from')}</Label>
|
||||||
|
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} className="w-32" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.to')}</Label>
|
||||||
|
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} className="w-32" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.from')}</Label>
|
||||||
|
<Input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} className="w-60" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.to')}</Label>
|
||||||
|
<Input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} className="w-60" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Button size="sm" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
|
<Button size="sm" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
|
||||||
{t('common.create')}
|
{t('common.create')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
CreatedIdResponse,
|
CreatedIdResponse,
|
||||||
HourWindow,
|
HourWindow,
|
||||||
OverrideMode,
|
OverrideMode,
|
||||||
|
OverrideRecurrence,
|
||||||
ScheduleEntryDto,
|
ScheduleEntryDto,
|
||||||
} from '@/shared/api/types'
|
} from '@/shared/api/types'
|
||||||
|
|
||||||
@@ -217,8 +218,12 @@ export function bumperPreviewPlaylistUrl(id: string, templateId: string, variant
|
|||||||
|
|
||||||
export type OverrideBody = {
|
export type OverrideBody = {
|
||||||
mode: OverrideMode
|
mode: OverrideMode
|
||||||
startsAtUtc: string
|
recurrence: OverrideRecurrence
|
||||||
endsAtUtc: string
|
startsAtUtc?: string | null
|
||||||
|
endsAtUtc?: string | null
|
||||||
|
dayOfWeek?: number | null
|
||||||
|
startMinute?: number | null
|
||||||
|
endMinute?: number | null
|
||||||
shows: { showId: string; weight: number }[]
|
shows: { showId: string; weight: number }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { HttpError } from '@/shared/api/client'
|
|||||||
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
|
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
|
||||||
import { Badge, type BadgeProps } from '@/shared/ui/badge'
|
import { Badge, type BadgeProps } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Pager } from '@/shared/ui/pager'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { deleteMedia, listMedia } from './api'
|
import { deleteMedia, listMedia } from './api'
|
||||||
@@ -46,12 +47,13 @@ export function MediaPanel() {
|
|||||||
const fileInput = useRef<HTMLInputElement>(null)
|
const fileInput = useRef<HTMLInputElement>(null)
|
||||||
const fileInputShow = useRef<HTMLInputElement>(null)
|
const fileInputShow = useRef<HTMLInputElement>(null)
|
||||||
const [filter, setFilter] = useState<MediaFilter>('active')
|
const [filter, setFilter] = useState<MediaFilter>('active')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
||||||
const enqueue = useUploadStore((s) => s.enqueue)
|
const enqueue = useUploadStore((s) => s.enqueue)
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'media', filter],
|
queryKey: ['admin', 'media', filter, page],
|
||||||
queryFn: () => listMedia({ page: 1, pageSize: PAGE_SIZE, statuses: filterStatuses[filter] }),
|
queryFn: () => listMedia({ page, pageSize: PAGE_SIZE, statuses: filterStatuses[filter] }),
|
||||||
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
|
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
|
||||||
refetchInterval: (query) =>
|
refetchInterval: (query) =>
|
||||||
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
|
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
|
||||||
@@ -70,7 +72,13 @@ export function MediaPanel() {
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.media.title')}</h2>
|
<h2 className="crt-glow text-xl font-semibold">{t('admin.media.title')}</h2>
|
||||||
<Select value={filter} onValueChange={(v) => setFilter(v as MediaFilter)}>
|
<Select
|
||||||
|
value={filter}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setPage(1)
|
||||||
|
setFilter(v as MediaFilter)
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SelectTrigger className="w-40">
|
<SelectTrigger className="w-40">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -158,6 +166,12 @@ export function MediaPanel() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Pager
|
||||||
|
page={page}
|
||||||
|
totalPages={data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1}
|
||||||
|
onChange={setPage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,42 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { HttpError } from '@/shared/api/client'
|
import { HttpError } from '@/shared/api/client'
|
||||||
import type { ShowKind } from '@/shared/api/types'
|
import type { ShowKind } from '@/shared/api/types'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Pager } from '@/shared/ui/pager'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { createShow, deleteShow, listShows } from './api'
|
import { createShow, deleteShow, listShows } from './api'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
export function ShowsPanel() {
|
export function ShowsPanel() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [originalName, setOriginalName] = useState('')
|
const [originalName, setOriginalName] = useState('')
|
||||||
const [kind, setKind] = useState<ShowKind>('Series')
|
const [kind, setKind] = useState<ShowKind>('Series')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
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 invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
|
||||||
const onError = (error: unknown) =>
|
const onError = (error: unknown) =>
|
||||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||||
@@ -70,6 +88,16 @@ export function ShowsPanel() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
className="max-w-xs"
|
||||||
|
placeholder={t('common.search')}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPage(1)
|
||||||
|
setQuery(e.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="crt-panel overflow-x-auto rounded-md">
|
<div className="crt-panel overflow-x-auto rounded-md">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-border text-left text-muted-foreground">
|
<thead className="border-b border-border text-left text-muted-foreground">
|
||||||
@@ -89,7 +117,7 @@ export function ShowsPanel() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{data?.map((show) => (
|
{pageItems.map((show) => (
|
||||||
<tr key={show.id} className="border-b border-border last:border-0">
|
<tr key={show.id} className="border-b border-border last:border-0">
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<Link
|
<Link
|
||||||
@@ -119,6 +147,8 @@ export function ShowsPanel() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,21 @@ import { HttpError } from '@/shared/api/client'
|
|||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
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 { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import type { UserSummaryDto } from '@/shared/api/types'
|
||||||
import { changeUserRole } from '@/features/admin/roles/api'
|
import { changeUserRole } from '@/features/admin/roles/api'
|
||||||
import { listRoles } 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
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -24,6 +32,7 @@ export function UsersPanel() {
|
|||||||
const [newUserName, setNewUserName] = useState('')
|
const [newUserName, setNewUserName] = useState('')
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
const [newRoleId, setNewRoleId] = useState('')
|
const [newRoleId, setNewRoleId] = useState('')
|
||||||
|
const [resetTarget, setResetTarget] = useState<UserSummaryDto | null>(null)
|
||||||
|
|
||||||
const { data: roles } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
|
const { data: roles } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
@@ -214,6 +223,9 @@ export function UsersPanel() {
|
|||||||
{t('admin.users.block')}
|
{t('admin.users.block')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setResetTarget(user)}>
|
||||||
|
{t('admin.users.resetPassword')}
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="destructive" onClick={() => deleteMutation.mutate(user.id)}>
|
<Button size="sm" variant="destructive" onClick={() => deleteMutation.mutate(user.id)}>
|
||||||
{t('common.delete')}
|
{t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -238,6 +250,68 @@ export function UsersPanel() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{resetTarget && (
|
||||||
|
<ResetPasswordDialog
|
||||||
|
user={resetTarget}
|
||||||
|
onClose={() => setResetTarget(null)}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('admin.users.resetPasswordFor', { name: user.userName })}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>{t('admin.users.newPassword')}</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
autoFocus
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.users.passwordHint')}</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={password.length < 8 || reset.isPending}
|
||||||
|
onClick={() => reset.mutate()}
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,3 +36,10 @@ export function unblockUser(id: string) {
|
|||||||
export function deleteUser(id: string) {
|
export function deleteUser(id: string) {
|
||||||
return apiRequest<void>(`/admin/users/${id}`, { method: 'DELETE' })
|
return apiRequest<void>(`/admin/users/${id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resetUserPassword(id: string, newPassword: string) {
|
||||||
|
return apiRequest<void>(`/admin/users/${id}/password`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { newPassword },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -206,11 +206,18 @@ export type ChannelAdDto = {
|
|||||||
|
|
||||||
export type OverrideShowDto = { showId: string; showName: string; weight: number }
|
export type OverrideShowDto = { showId: string; showName: string; weight: number }
|
||||||
|
|
||||||
|
export type OverrideRecurrence = 'OneTime' | 'Weekly'
|
||||||
|
|
||||||
export type ProgrammingOverrideDto = {
|
export type ProgrammingOverrideDto = {
|
||||||
id: string
|
id: string
|
||||||
mode: OverrideMode
|
mode: OverrideMode
|
||||||
startsAtUtc: string
|
recurrence: OverrideRecurrence
|
||||||
endsAtUtc: string
|
startsAtUtc: string | null
|
||||||
|
endsAtUtc: string | null
|
||||||
|
/** Weekly: день недели 0=Вс..6=Сб; окно минут суток (UTC). */
|
||||||
|
dayOfWeek: number | null
|
||||||
|
startMinute: number | null
|
||||||
|
endMinute: number | null
|
||||||
shows: OverrideShowDto[]
|
shows: OverrideShowDto[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const resources = {
|
|||||||
confirm: 'Подтвердить',
|
confirm: 'Подтвердить',
|
||||||
search: 'Поиск',
|
search: 'Поиск',
|
||||||
actions: 'Действия',
|
actions: 'Действия',
|
||||||
|
prevPage: 'Предыдущая страница',
|
||||||
|
nextPage: 'Следующая страница',
|
||||||
yes: 'Да',
|
yes: 'Да',
|
||||||
no: 'Нет',
|
no: 'Нет',
|
||||||
},
|
},
|
||||||
@@ -105,6 +107,10 @@ const resources = {
|
|||||||
active: 'Активен',
|
active: 'Активен',
|
||||||
block: 'Заблокировать',
|
block: 'Заблокировать',
|
||||||
unblock: 'Разблокировать',
|
unblock: 'Разблокировать',
|
||||||
|
resetPassword: 'Пароль',
|
||||||
|
resetPasswordFor: 'Сменить пароль: {{name}}',
|
||||||
|
newPassword: 'Новый пароль',
|
||||||
|
passwordReset: 'Пароль изменён',
|
||||||
filterAll: 'Все роли',
|
filterAll: 'Все роли',
|
||||||
createTitle: 'Создать пользователя',
|
createTitle: 'Создать пользователя',
|
||||||
password: 'Пароль',
|
password: 'Пароль',
|
||||||
@@ -288,6 +294,19 @@ const resources = {
|
|||||||
noAds: 'Пул рекламы пуст',
|
noAds: 'Пул рекламы пуст',
|
||||||
overrides: 'Марафоны / override',
|
overrides: 'Марафоны / override',
|
||||||
modes: { Exclusive: 'Эксклюзив', Boost: 'Буст' },
|
modes: { Exclusive: 'Эксклюзив', Boost: 'Буст' },
|
||||||
|
overrideRecurrence: 'Повтор',
|
||||||
|
recurrenceOneTime: 'Разово',
|
||||||
|
recurrenceWeekly: 'Еженедельно',
|
||||||
|
weekday: 'День недели',
|
||||||
|
weekdays: {
|
||||||
|
0: 'Вс',
|
||||||
|
1: 'Пн',
|
||||||
|
2: 'Вт',
|
||||||
|
3: 'Ср',
|
||||||
|
4: 'Чт',
|
||||||
|
5: 'Пт',
|
||||||
|
6: 'Сб',
|
||||||
|
},
|
||||||
from: 'С',
|
from: 'С',
|
||||||
to: 'По',
|
to: 'По',
|
||||||
noOverrides: 'Override не заданы',
|
noOverrides: 'Override не заданы',
|
||||||
@@ -368,6 +387,8 @@ const resources = {
|
|||||||
confirm: 'Confirm',
|
confirm: 'Confirm',
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
actions: 'Actions',
|
actions: 'Actions',
|
||||||
|
prevPage: 'Previous page',
|
||||||
|
nextPage: 'Next page',
|
||||||
yes: 'Yes',
|
yes: 'Yes',
|
||||||
no: 'No',
|
no: 'No',
|
||||||
},
|
},
|
||||||
@@ -446,6 +467,10 @@ const resources = {
|
|||||||
active: 'Active',
|
active: 'Active',
|
||||||
block: 'Block',
|
block: 'Block',
|
||||||
unblock: 'Unblock',
|
unblock: 'Unblock',
|
||||||
|
resetPassword: 'Password',
|
||||||
|
resetPasswordFor: 'Reset password: {{name}}',
|
||||||
|
newPassword: 'New password',
|
||||||
|
passwordReset: 'Password changed',
|
||||||
filterAll: 'All roles',
|
filterAll: 'All roles',
|
||||||
createTitle: 'Create user',
|
createTitle: 'Create user',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
@@ -629,6 +654,19 @@ const resources = {
|
|||||||
noAds: 'Ad pool is empty',
|
noAds: 'Ad pool is empty',
|
||||||
overrides: 'Marathons / overrides',
|
overrides: 'Marathons / overrides',
|
||||||
modes: { Exclusive: 'Exclusive', Boost: 'Boost' },
|
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',
|
from: 'From',
|
||||||
to: 'To',
|
to: 'To',
|
||||||
noOverrides: 'No overrides set',
|
noOverrides: 'No overrides set',
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex items-center justify-center gap-2 text-sm">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => onChange(page - 1)}
|
||||||
|
aria-label={t('common.prevPage')}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</Button>
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => onChange(page + 1)}
|
||||||
|
aria-label={t('common.nextPage')}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user