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
@@ -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: 'Правило только ищет кандидатов — состав группы остаётся явным списком.',