Add group suggestions feature with API endpoints and frontend integration
ci / build-backend (push) Successful in 1m55s
ci / build-frontend (push) Successful in 44s
ci / tests (push) Successful in 2m8s
ci / sonar (push) Successful in 4m37s

Implemented new API endpoints for suggesting groups based on the current library, allowing admins to retrieve and create groups from suggestions. Updated the backend to include error handling for suggestions and added necessary DTOs. Enhanced the frontend with new API functions and UI components to display suggestions, improving the user experience for group management. Localization updates were made to support new features in both English and Russian.
This commit is contained in:
Leonid Pershin
2026-07-27 04:18:30 +03:00
parent 4774083dc9
commit 24fdbdf680
18 changed files with 810 additions and 1 deletions
@@ -10,6 +10,7 @@ using TeleWave.Application.Programming.Groups.ListGroups;
using TeleWave.Application.Programming.Groups.RemoveGroupItem;
using TeleWave.Application.Programming.Groups.ReorderGroup;
using TeleWave.Application.Programming.Groups.SetGroupItemWeight;
using TeleWave.Application.Programming.Groups.Suggest;
using TeleWave.Application.Programming.Groups.UpdateGroup;
using TeleWave.Infrastructure.Identity;
@@ -34,6 +35,12 @@ public static class GroupEndpoints
.MapPost("/{id:guid}/candidates", FindCandidates)
.Produces<IReadOnlyList<GroupCandidateDto>>();
// Предложения: что имеет смысл завести группой при нынешней библиотеке.
admin.MapGet("/suggestions", Suggestions).Produces<IReadOnlyList<GroupSuggestionDto>>();
admin
.MapPost("/suggestions", CreateFromSuggestion)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPost("/{id:guid}/items", AddElements).Produces<AddedCountResponse>();
admin
.MapDelete("/{id:guid}/items/{itemId:guid}", RemoveItem)
@@ -80,6 +87,33 @@ public static class GroupEndpoints
: result.ToHttpResult();
}
private static async Task<IResult> Suggestions(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new SuggestGroupsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> CreateFromSuggestion(
CreateGroupFromSuggestionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateGroupFromSuggestionCommand(body.Key),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/groups/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateGroup(
Guid id,
UpdateGroupBody body,
@@ -179,6 +213,8 @@ public sealed record UpdateGroupBody(string Name, string? Description, GroupFilt
public sealed record FindCandidatesBody(GroupFilter? Filter);
public sealed record CreateGroupFromSuggestionBody(string Key);
public sealed record AddGroupElementsBody(IReadOnlyList<GroupElementRef> Elements);
public sealed record GroupItemWeightBody(int Weight);
@@ -7,6 +7,7 @@ using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Behaviors;
using TeleWave.Application.Library.Genres;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Groups.Suggest;
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
@@ -37,6 +38,7 @@ public static class DependencyInjection
services.AddScoped<GenreMatcher>();
services.AddScoped<GroupElementResolver>();
services.AddScoped<GroupStatsService>();
services.AddScoped<GroupSuggestionBuilder>();
services.AddScoped<GroupMembershipCleaner>();
services.AddScoped<SlotWriter>();
services.AddScoped<GroupExpander>();
@@ -25,4 +25,14 @@ public static class GroupErrors
"Groups.FilterNotSet",
"У группы не задано правило набора."
);
public static readonly Error SuggestionNotFound = Error.NotFound(
"Groups.SuggestionNotFound",
"Предложение устарело — библиотека изменилась. Обновите список предложений."
);
public static readonly Error SuggestionAlreadyCreated = Error.Conflict(
"Groups.SuggestionAlreadyCreated",
"Группа с таким названием уже есть."
);
}
@@ -0,0 +1,11 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.Suggest;
/// <summary>
/// Создаёт группу по предложению: имя, правило набора и состав берутся с сервера по ключу, а не
/// приезжают с клиента. Так между показом предложения и нажатием кнопки библиотека может измениться —
/// в группу попадёт актуальное, а не то, что успело устареть на экране.
/// </summary>
public sealed record CreateGroupFromSuggestionCommand(string Key) : ICommand<Result<Guid>>;
@@ -0,0 +1,42 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.Suggest;
public sealed class CreateGroupFromSuggestionCommandHandler(
IAppDbContext dbContext,
GroupSuggestionBuilder builder,
GroupStatsService stats
) : ICommandHandler<CreateGroupFromSuggestionCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateGroupFromSuggestionCommand command,
CancellationToken cancellationToken
)
{
var suggestions = await builder.BuildAsync(cancellationToken);
var suggestion = suggestions.FirstOrDefault(s => s.Key == command.Key);
if (suggestion is null)
return Result.Failure<Guid>(GroupErrors.SuggestionNotFound);
// Имя — ключ узнаваемости группы, и две «Мультфильм» в списке слота неразличимы.
var taken = await dbContext.Groups.AnyAsync(
g => g.Name.ToLower() == suggestion.Name.ToLower(),
cancellationToken
);
if (taken)
return Result.Failure<Guid>(GroupErrors.SuggestionAlreadyCreated);
var group = Group.Create(suggestion.Name);
group.SetFilter(suggestion.Filter?.ToJson());
foreach (var showId in suggestion.ShowIds)
group.AddElement(GroupElementKind.Show, showId);
dbContext.Groups.Add(group);
await stats.RecomputeAsync(group, cancellationToken);
return Result.Success(group.Id);
}
}
@@ -0,0 +1,230 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
using ElementStats = System.Collections.Generic.IReadOnlyDictionary<
(TeleWave.Domain.Programming.GroupElementKind Kind, System.Guid Id),
TeleWave.Application.Programming.Groups.GroupElementInfo
>;
namespace TeleWave.Application.Programming.Groups.Suggest;
/// <summary>
/// Предложение: готовая к созданию группа — имя, правило набора и состав. Ключ самоописателен
/// (<c>genre:{id}</c>, <c>kind:Series</c>, …), поэтому создание не тащит состав с клиента: команда
/// пересобирает предложение по ключу на свежих данных.
/// </summary>
public sealed record GroupSuggestion(
string Key,
GroupSuggestionKind Kind,
string Name,
GroupFilter? Filter,
IReadOnlyList<Guid> ShowIds,
int UnitCount,
TimeSpan TotalDuration
);
/// <summary>
/// Разбирает библиотеку и предлагает группы, которые имеет смысл завести. Правила намеренно простые
/// и объяснимые: админ должен понимать, почему ему это показали, — «12 шоу с жанром Мультфильм»
/// проверяемо, а рекомендательная магия нет.
///
/// Состав предложения — только шоу. Коллекции сюда не берутся: франшиза почти всегда состоит из тех
/// же шоу, и добавление обеих форм задваивало бы эфир. Правило набора у предложения сохраняется,
/// поэтому коллекции при желании добираются кнопкой «Подобрать» уже в самой группе.
/// </summary>
public sealed class GroupSuggestionBuilder(IAppDbContext dbContext, GroupElementResolver resolver)
{
/// <summary>Сколько шоу должно набраться, чтобы предлагать сборную группу (жанр/тип/рейтинг).</summary>
private const int MinShows = 2;
/// <summary>С какого числа серий сериал заслуживает собственной группы.</summary>
private const int BigSeriesEpisodes = 20;
/// <summary>Пороги «мягких» рейтингов: до них группа читается как «детское»/«семейное».</summary>
private static readonly (ShowAudience Max, string Name)[] AudienceTiers =
[
(ShowAudience.Pg, "Детское (не строже PG)"),
(ShowAudience.Pg13, "Семейное (не строже PG-13)"),
];
private static readonly (ShowKind Kind, string Name)[] ShowKindNames =
[
(ShowKind.Series, "Сериалы"),
(ShowKind.Single, "Полнометражки"),
];
public async Task<IReadOnlyList<GroupSuggestion>> BuildAsync(
CancellationToken cancellationToken
)
{
// Ролики-врезки в группы не идут: у рекламы и заставок своя подсистема.
var shows = await dbContext
.Shows.AsNoTracking()
.Where(s => s.Kind != ShowKind.Interstitial)
.Select(s => new ShowRow(
s.Id,
s.Name,
s.Kind,
s.Audience,
s.Genres.Select(g => g.GenreId).ToList(),
s.Episodes.Count
))
.ToListAsync(cancellationToken);
if (shows.Count == 0)
return [];
var stats = await resolver.ResolveAsync(
shows.Select(s => (GroupElementKind.Show, s.Id)),
cancellationToken
);
var genres = await dbContext
.Genres.AsNoTracking()
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var suggestions = new List<GroupSuggestion>();
suggestions.AddRange(ByGenre(shows, genres, stats));
suggestions.AddRange(ByShowKind(shows, stats));
suggestions.AddRange(ByAudience(shows, stats));
suggestions.AddRange(ByBigSeries(shows, stats));
// Внутри вида — сначала самые ёмкие: с них и начинают собирать сетку.
return
[
.. suggestions
.OrderBy(s => s.Kind)
.ThenByDescending(s => s.UnitCount)
.ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase),
];
}
private static IEnumerable<GroupSuggestion> ByGenre(
List<ShowRow> shows,
IReadOnlyDictionary<Guid, string> genres,
ElementStats stats
)
{
foreach (var (genreId, name) in genres)
{
var matched = shows.Where(s => s.GenreIds.Contains(genreId)).ToList();
if (matched.Count < MinShows)
continue;
yield return Build(
$"genre:{genreId:N}",
GroupSuggestionKind.Genre,
name,
new GroupFilter(GenreIds: [genreId]),
matched,
stats
);
}
}
private static IEnumerable<GroupSuggestion> ByShowKind(List<ShowRow> shows, ElementStats stats)
{
foreach (var (kind, name) in ShowKindNames)
{
var matched = shows.Where(s => s.Kind == kind).ToList();
if (matched.Count < MinShows)
continue;
yield return Build(
$"kind:{kind}",
GroupSuggestionKind.ShowKind,
name,
new GroupFilter(ShowKinds: [kind]),
matched,
stats
);
}
}
/// <summary>
/// «Не строже X» — ровно та форма, которую понимает правило набора. Шоу без рейтинга в выборке
/// остаются (как в фильтре и в планировщике): источники проставляют рейтинг далеко не всему.
/// </summary>
private static IEnumerable<GroupSuggestion> ByAudience(List<ShowRow> shows, ElementStats stats)
{
foreach (var (max, name) in AudienceTiers)
{
var matched = shows.Where(s => s.Audience == null || s.Audience <= max).ToList();
if (matched.Count < MinShows)
continue;
// Если под порог попадает вообще всё, группа повторяет библиотеку — предлагать нечего.
if (matched.Count == shows.Count)
continue;
yield return Build(
$"audience:{max}",
GroupSuggestionKind.Audience,
name,
new GroupFilter(MaxAudience: max),
matched,
stats
);
}
}
/// <summary>
/// Крупный сериал ставят в слот целиком, поэтому ему нужна собственная группа. Правила набора
/// у неё нет: «именно это шоу» фильтром не выражается, да и не нужно — состав из одной позиции.
/// </summary>
private static IEnumerable<GroupSuggestion> ByBigSeries(List<ShowRow> shows, ElementStats stats)
{
foreach (
var show in shows.Where(s =>
s.Kind == ShowKind.Series && s.EpisodeCount >= BigSeriesEpisodes
)
)
yield return Build(
$"show:{show.Id:N}",
GroupSuggestionKind.BigSeries,
show.Name,
null,
[show],
stats
);
}
private static GroupSuggestion Build(
string key,
GroupSuggestionKind kind,
string name,
GroupFilter? filter,
IReadOnlyList<ShowRow> matched,
ElementStats stats
)
{
var units = 0;
var duration = TimeSpan.Zero;
foreach (var show in matched)
{
if (!stats.TryGetValue((GroupElementKind.Show, show.Id), out var info))
continue;
units += info.UnitCount;
duration += info.TotalDuration;
}
return new GroupSuggestion(
key,
kind,
name,
filter,
[.. matched.Select(s => s.Id)],
units,
duration
);
}
private sealed record ShowRow(
Guid Id,
string Name,
ShowKind Kind,
ShowAudience? Audience,
List<Guid> GenreIds,
int EpisodeCount
);
}
@@ -0,0 +1,17 @@
namespace TeleWave.Application.Programming.Groups.Suggest;
/// <summary>Откуда взялось предложение — UI по нему группирует список и подписывает происхождение.</summary>
public enum GroupSuggestionKind
{
/// <summary>Всё, что помечено одним жанром.</summary>
Genre = 0,
/// <summary>Всё одного типа: сериалы либо полнометражки.</summary>
ShowKind = 1,
/// <summary>Всё не строже указанного возрастного рейтинга.</summary>
Audience = 2,
/// <summary>Один крупный сериал, которого хватает на слот целиком.</summary>
BigSeries = 3,
}
@@ -0,0 +1,23 @@
using LiteCqrs;
namespace TeleWave.Application.Programming.Groups.Suggest;
/// <summary>Что имеет смысл завести группой при текущем составе библиотеки.</summary>
public sealed record SuggestGroupsQuery : IQuery<IReadOnlyList<GroupSuggestionDto>>;
/// <param name="Key">Ключ предложения — с ним же приходит команда создания.</param>
/// <param name="ShowCount">Сколько шоу попадёт в состав.</param>
/// <param name="UnitCount">Сколько единиц воспроизведения (серий/фильмов) они дают.</param>
/// <param name="TotalDurationSeconds">Объём готового эфира — по обработанным ассетам.</param>
/// <param name="AlreadyExists">
/// Такая группа уже есть (по имени либо по совпадающему правилу набора) — создавать повторно нечего.
/// </param>
public sealed record GroupSuggestionDto(
string Key,
GroupSuggestionKind Kind,
string Name,
int ShowCount,
int UnitCount,
double TotalDurationSeconds,
bool AlreadyExists
);
@@ -0,0 +1,50 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Programming.Groups.Suggest;
public sealed class SuggestGroupsQueryHandler(
IAppDbContext dbContext,
GroupSuggestionBuilder builder
) : IQueryHandler<SuggestGroupsQuery, IReadOnlyList<GroupSuggestionDto>>
{
public async Task<IReadOnlyList<GroupSuggestionDto>> Handle(
SuggestGroupsQuery query,
CancellationToken cancellationToken
)
{
var suggestions = await builder.BuildAsync(cancellationToken);
if (suggestions.Count == 0)
return [];
// Уже созданное отмечаем, но из списка не убираем: пропавшее без следа предложение читается
// как сбой, а «уже есть» объясняет, почему кнопки нет.
var existing = await dbContext
.Groups.AsNoTracking()
.Select(g => new { g.Name, g.FilterJson })
.ToListAsync(cancellationToken);
var names = existing
.Select(g => g.Name.Trim())
.ToHashSet(StringComparer.CurrentCultureIgnoreCase);
var filters = existing
.Select(g => g.FilterJson)
.Where(json => !string.IsNullOrWhiteSpace(json))
.ToHashSet(StringComparer.Ordinal);
return
[
.. suggestions.Select(s => new GroupSuggestionDto(
s.Key,
s.Kind,
s.Name,
s.ShowIds.Count,
s.UnitCount,
s.TotalDuration.TotalSeconds,
names.Contains(s.Name)
|| (s.Filter is not null && filters.Contains(s.Filter.ToJson()))
)),
];
}
}
@@ -0,0 +1,191 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Groups.Suggest;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>
/// Предложения групп: разбор библиотеки должен быть объяснимым — что показали, то и создастся.
/// </summary>
public class GroupSuggestionsTests
{
[Fact]
public async Task SuggestsGenre_WhenAtLeastTwoShowsShareIt()
{
var fixture = new TestDb();
var cartoons = Genre.Create("Мультфильм", "animation");
var drama = Genre.Create("Драма", "drama");
var first = WithGenre(Show.Create("A", ShowKind.Series), cartoons);
var second = WithGenre(Show.Create("B", ShowKind.Series), cartoons);
// Один драматический — на группу не тянет.
var lonely = WithGenre(Show.Create("C", ShowKind.Single), drama);
await using (var seed = fixture.New())
{
seed.Genres.AddRange(cartoons, drama);
seed.Shows.AddRange(first, second, lonely);
await seed.SaveChangesAsync(CancellationToken.None);
}
var suggestions = await BuildAsync(fixture);
var genre = Assert.Single(suggestions, s => s.Kind == GroupSuggestionKind.Genre);
Assert.Equal("Мультфильм", genre.Name);
Assert.Equal(2, genre.ShowIds.Count);
Assert.Equal([cartoons.Id], genre.Filter!.GenreIds);
}
[Fact]
public async Task SuggestsBigSeries_Separately()
{
var fixture = new TestDb();
var big = Show.Create("Гравити Фолз", ShowKind.Series);
for (var i = 0; i < 20; i++)
big.AddEpisode(Guid.NewGuid());
var small = Show.Create("Короткое", ShowKind.Series);
small.AddEpisode(Guid.NewGuid());
await using (var seed = fixture.New())
{
seed.Shows.AddRange(big, small);
await seed.SaveChangesAsync(CancellationToken.None);
}
var suggestions = await BuildAsync(fixture);
var series = Assert.Single(suggestions, s => s.Kind == GroupSuggestionKind.BigSeries);
Assert.Equal("Гравити Фолз", series.Name);
Assert.Equal([big.Id], series.ShowIds);
// Правила у одиночного шоу нет: «именно это шоу» фильтром не выражается.
Assert.Null(series.Filter);
}
[Fact]
public async Task SkipsAudienceTier_WhenItCoversEverything()
{
// Если под «не строже PG» попадает вся библиотека, группа повторяет её — предлагать нечего.
var fixture = new TestDb();
var first = Show.Create("A", ShowKind.Series);
first.SetAudience(ShowAudience.G);
var second = Show.Create("B", ShowKind.Series);
second.SetAudience(ShowAudience.Pg);
await using (var seed = fixture.New())
{
seed.Shows.AddRange(first, second);
await seed.SaveChangesAsync(CancellationToken.None);
}
var suggestions = await BuildAsync(fixture);
Assert.DoesNotContain(suggestions, s => s.Kind == GroupSuggestionKind.Audience);
}
[Fact]
public async Task SuggestsAudienceTier_WhenStricterContentExists()
{
var fixture = new TestDb();
var kids = Show.Create("A", ShowKind.Series);
kids.SetAudience(ShowAudience.G);
var adult = Show.Create("B", ShowKind.Series);
adult.SetAudience(ShowAudience.R);
var unrated = Show.Create("C", ShowKind.Series);
await using (var seed = fixture.New())
{
seed.Shows.AddRange(kids, adult, unrated);
await seed.SaveChangesAsync(CancellationToken.None);
}
var suggestions = await BuildAsync(fixture);
var tier = suggestions.First(s => s.Kind == GroupSuggestionKind.Audience);
// Непроставленный рейтинг остаётся в выборке — как в правиле набора и в планировщике.
Assert.Equal(2, tier.ShowIds.Count);
Assert.Contains(kids.Id, tier.ShowIds);
Assert.Contains(unrated.Id, tier.ShowIds);
}
[Fact]
public async Task CreateFromSuggestion_SavesFilterAndComposition()
{
var fixture = new TestDb();
var genre = Genre.Create("Мультфильм", "animation");
var first = WithGenre(Show.Create("A", ShowKind.Series), genre);
var second = WithGenre(Show.Create("B", ShowKind.Series), genre);
await using (var seed = fixture.New())
{
seed.Genres.Add(genre);
seed.Shows.AddRange(first, second);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var handler = new CreateGroupFromSuggestionCommandHandler(
db,
Builder(db),
new GroupStatsService(db, new GroupElementResolver(db))
);
var created = await handler.Handle(
new CreateGroupFromSuggestionCommand($"genre:{genre.Id:N}"),
CancellationToken.None
);
Assert.True(created.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
await using var check = fixture.New();
var group = check.Groups.Include(g => g.Items).Single();
Assert.Equal("Мультфильм", group.Name);
Assert.Equal(2, group.Items.Count);
Assert.All(group.Items, item => Assert.Equal(GroupElementKind.Show, item.ElementKind));
Assert.Equal([genre.Id], GroupFilter.FromJson(group.FilterJson)!.GenreIds);
// Повторное создание того же — конфликт: две одноимённые группы в слоте неразличимы.
var again = await handler.Handle(
new CreateGroupFromSuggestionCommand($"genre:{genre.Id:N}"),
CancellationToken.None
);
Assert.False(again.IsSuccess);
}
[Fact]
public async Task CreateFromSuggestion_FailsForUnknownKey()
{
var fixture = new TestDb();
await using (var seed = fixture.New())
{
seed.Shows.Add(Show.Create("A", ShowKind.Series));
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new CreateGroupFromSuggestionCommandHandler(
db,
Builder(db),
new GroupStatsService(db, new GroupElementResolver(db))
).Handle(new CreateGroupFromSuggestionCommand("genre:нет-такого"), CancellationToken.None);
Assert.False(result.IsSuccess);
}
private static Show WithGenre(Show show, Genre genre)
{
show.SetGenres([genre.Id]);
return show;
}
private static GroupSuggestionBuilder Builder(Infrastructure.Persistence.AppDbContext db) =>
new(db, new GroupElementResolver(db));
private static async Task<IReadOnlyList<GroupSuggestion>> BuildAsync(TestDb fixture)
{
await using var db = fixture.New();
return await Builder(db).BuildAsync(CancellationToken.None);
}
}
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { ChevronLeft, GripVertical, Search, Trash2 } from 'lucide-react'
import { ChevronLeft, GripVertical, Search, Sparkles, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
@@ -51,6 +51,19 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
}
const onError = useApiError()
/**
* Что подходит под сохранённое правило, но в группу ещё не попало: библиотека пополняется после
* того, как группа собрана, и без этой проверки новое шоу лежало бы мимо эфира, пока кто-нибудь
* не вспомнит нажать «Подобрать». Спрашиваем именно сохранённое правило (фильтр не передаём —
* сервер берёт его сам), а не черновик формы: подсказка не должна прыгать, пока крутят поля.
*/
const { data: pending } = useQuery({
queryKey: qk.groups.pending(groupId),
queryFn: () => findGroupCandidates(groupId, null),
enabled: !!group?.filter,
})
const pendingFresh = (pending ?? []).filter((c) => !c.alreadyInGroup)
const saveMutation = useMutation({
mutationFn: () =>
updateGroup(groupId, {
@@ -157,6 +170,29 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
</Badge>
</div>
{pendingFresh.length > 0 && (
<div className="crt-panel flex flex-wrap items-center gap-3 rounded-md border border-primary/40 px-4 py-3 text-sm">
<Sparkles className="h-4 w-4 shrink-0 text-primary" />
<span className="min-w-0 flex-1">
{t('admin.groups.pendingFound', { count: pendingFresh.length })}{' '}
<span className="text-muted-foreground">
{pendingFresh
.slice(0, 3)
.map((c) => c.elementName)
.join(', ')}
{pendingFresh.length > 3 && '…'}
</span>
</span>
<Button
size="sm"
disabled={addMutation.isPending}
onClick={() => addMutation.mutate(pendingFresh)}
>
{t('admin.groups.pendingAdd', { count: pendingFresh.length })}
</Button>
</div>
)}
<div className="grid gap-6 lg:grid-cols-2">
{/* Левая панель — конструктор правила набора */}
<div className="crt-panel flex flex-col gap-4 rounded-md p-4">
@@ -0,0 +1,97 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ChevronDown, ChevronRight, Plus, Wand2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import type { GroupSuggestionDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { toast } from '@/shared/ui/toast-store'
import { createGroupFromSuggestion, suggestGroups } from './api'
import { DurationLabel } from './DurationLabel'
/**
* Что имеет смысл завести группой при нынешней библиотеке. Разбор делает сервер (жанры, типы,
* рейтинги, крупные сериалы), здесь — только показ и кнопка. Уже созданное остаётся в списке
* помеченным: исчезнувшее без следа предложение читается как сбой, а «уже есть» объясняет,
* почему кнопки нет.
*/
export function GroupSuggestions() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const onError = useApiError()
const [collapsed, setCollapsed] = useState(false)
const { data, isLoading } = useQuery({
queryKey: qk.groups.suggestions,
queryFn: suggestGroups,
})
const create = useMutation({
mutationFn: (suggestion: GroupSuggestionDto) => createGroupFromSuggestion(suggestion.key),
onSuccess: () => {
toast.success(t('admin.groups.suggestions.created'))
void queryClient.invalidateQueries({ queryKey: qk.groups.all })
},
onError,
})
// Пока предлагать нечего (пустая библиотека или всё уже создано) — секции нет вовсе.
if (isLoading || !data || data.length === 0) return null
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
onClick={() => setCollapsed((v) => !v)}
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
<Wand2 className="h-4 w-4" />
{t('admin.groups.suggestions.title')}
<Badge variant="muted">{data.filter((s) => !s.alreadyExists).length}</Badge>
</button>
</div>
{!collapsed && (
<>
<p className="text-xs text-muted-foreground">{t('admin.groups.suggestions.hint')}</p>
<ul className="crt-panel divide-y divide-border rounded-md text-sm">
{data.map((suggestion) => (
<li key={suggestion.key} className="flex flex-wrap items-center gap-2 px-4 py-2">
<span className="min-w-0 flex-1 truncate font-medium">{suggestion.name}</span>
<Badge variant="muted">
{t(`admin.groups.suggestions.kinds.${suggestion.kind}`)}
</Badge>
<span className="text-muted-foreground">
{t('admin.groups.suggestions.stats', {
shows: suggestion.showCount,
units: suggestion.unitCount,
})}
</span>
<span className="text-muted-foreground">
<DurationLabel seconds={suggestion.totalDurationSeconds} />
</span>
{suggestion.alreadyExists ? (
<Badge variant="muted">{t('admin.groups.suggestions.exists')}</Badge>
) : (
<Button
size="sm"
variant="outline"
disabled={create.isPending}
onClick={() => create.mutate(suggestion)}
>
<Plus className="h-4 w-4" />
{t('common.create')}
</Button>
)}
</li>
))}
</ul>
</>
)}
</div>
)
}
@@ -11,6 +11,7 @@ import { sortRows, useTableSort } from '@/shared/lib/table-sort'
import { SortHeader } from '@/shared/ui/sortable'
import { createGroup, deleteGroup, listGroups } from './api'
import { DurationLabel } from './DurationLabel'
import { GroupSuggestions } from './GroupSuggestions'
export function GroupsPanel() {
const { t } = useTranslation()
@@ -63,6 +64,8 @@ export function GroupsPanel() {
</Button>
</div>
<GroupSuggestions />
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
+14
View File
@@ -5,6 +5,7 @@ import type {
GroupDto,
GroupElementKind,
GroupFilter,
GroupSuggestionDto,
GroupSummaryDto,
} from '@/shared/api/types'
@@ -39,6 +40,19 @@ export function findGroupCandidates(id: string, filter: GroupFilter | null) {
})
}
/** Что имеет смысл завести группой при нынешней библиотеке. */
export function suggestGroups() {
return apiRequest<GroupSuggestionDto[]>('/admin/groups/suggestions')
}
/** Создаёт группу по предложению: имя, правило и состав сервер берёт по ключу сам. */
export function createGroupFromSuggestion(key: string) {
return apiRequest<CreatedIdResponse>('/admin/groups/suggestions', {
method: 'POST',
body: { key },
})
}
export function addGroupElements(
id: string,
elements: { elementKind: GroupElementKind; elementId: string }[],
+3
View File
@@ -39,6 +39,9 @@ export const qk = {
groups: {
all: ['admin', 'groups'] as const,
detail: (id: string) => ['admin', 'groups', id] as const,
suggestions: ['admin', 'groups', 'suggestions'] as const,
/** Подходящее по сохранённому правилу, но ещё не добавленное — проверяется при открытии группы. */
pending: (id: string) => ['admin', 'groups', id, 'pending'] as const,
},
collections: {
+14
View File
@@ -291,6 +291,20 @@ export type GroupCandidateDto = {
alreadyInGroup: boolean
}
/** Откуда взялось предложение группы — по нему подписывается происхождение. */
export type GroupSuggestionKind = 'Genre' | 'ShowKind' | 'Audience' | 'BigSeries'
/** Предложение завести группу: имя, объём и признак «уже создана». */
export type GroupSuggestionDto = {
key: string
kind: GroupSuggestionKind
name: string
showCount: number
unitCount: number
totalDurationSeconds: number
alreadyExists: boolean
}
export type MetadataCandidate = {
externalId: string
title: string
+15
View File
@@ -101,7 +101,22 @@ export const en = {
found: 'Found: {{total}}, new: {{fresh}}',
added: 'Items added: {{count}}',
alreadyIn: 'already in group',
pendingFound: 'New matches for this rule: {{count}} —',
pendingAdd: 'Add all ({{count}})',
elementKinds: { Show: 'Show', Collection: 'Collection' },
suggestions: {
title: 'Suggestions',
hint: 'Built from the current library. The button creates a group with these items and saves the rule — later additions are then one click away.',
stats: '{{shows}} shows · {{units}} units',
exists: 'already exists',
created: 'Group created',
kinds: {
Genre: 'genre',
ShowKind: 'kind',
Audience: 'rating',
BigSeries: 'big series',
},
},
filter: {
title: 'Selection rule',
hint: 'The rule only finds candidates — the group composition stays an explicit list.',
+15
View File
@@ -101,7 +101,22 @@ export const ru = {
found: 'Найдено: {{total}}, новых: {{fresh}}',
added: 'Добавлено позиций: {{count}}',
alreadyIn: 'уже в группе',
pendingFound: 'Под правило группы подходит нового: {{count}} —',
pendingAdd: 'Добавить все ({{count}})',
elementKinds: { Show: 'Шоу', Collection: 'Коллекция' },
suggestions: {
title: 'Предложения',
hint: 'Собрано по текущей библиотеке. Кнопка создаёт группу с этим составом и сохраняет правило набора — новое из библиотеки потом добавится в один клик.',
stats: '{{shows}} шоу · {{units}} ед.',
exists: 'уже есть',
created: 'Группа создана',
kinds: {
Genre: 'жанр',
ShowKind: 'тип',
Audience: 'рейтинг',
BigSeries: 'крупный сериал',
},
},
filter: {
title: 'Правило набора',
hint: 'Правило только ищет кандидатов — состав группы остаётся явным списком.',