Implement dynamic group management features with exclusions and mode handling
ci / build-backend (push) Successful in 2m30s
ci / build-frontend (push) Successful in 43s
ci / tests (push) Successful in 2m36s
ci / sonar (push) Failing after 33s

Enhanced the group management system by introducing dynamic group capabilities, allowing for the exclusion of elements from dynamic groups. Updated the Group and GroupItem models to support a new GroupMode, enabling the distinction between static and dynamic groups. Implemented API endpoints for excluding elements and updated related services to handle group composition based on the selected mode. Improved the frontend to accommodate these changes, including new API functions and UI components for managing exclusions and group modes, thereby enhancing the overall user experience in group management.
This commit is contained in:
Leonid Pershin
2026-07-27 05:03:09 +03:00
parent 24fdbdf680
commit 91af589547
35 changed files with 2571 additions and 219 deletions
@@ -1,18 +1,25 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { ChevronLeft, GripVertical, Search, Sparkles, Trash2 } from 'lucide-react'
import { ChevronLeft, GripVertical, Search, Sparkles, Trash2, Undo2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import type { GroupCandidateDto, GroupFilter } from '@/shared/api/types'
import type {
GroupCandidateDto,
GroupElementKind,
GroupFilter,
GroupMode,
} 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 { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { toast } from '@/shared/ui/toast-store'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import {
addGroupElements,
excludeGroupElement,
findGroupCandidates,
getGroup,
removeGroupItem,
@@ -60,21 +67,35 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
const { data: pending } = useQuery({
queryKey: qk.groups.pending(groupId),
queryFn: () => findGroupCandidates(groupId, null),
enabled: !!group?.filter,
// Динамической группе подсказка не нужна: правило применяется само при каждом обращении.
enabled: !!group?.filter && group?.mode === 'Static',
})
const pendingFresh = (pending ?? []).filter((c) => !c.alreadyInGroup)
const saveMutation = useMutation({
mutationFn: () =>
mutationFn: (mode?: GroupMode) =>
updateGroup(groupId, {
name: (name ?? group?.name ?? '').trim(),
description: description ?? group?.description ?? null,
filter: filter ?? null,
mode,
}),
onSuccess: invalidate,
onError,
})
const excludeMutation = useMutation({
mutationFn: ({
element,
excluded,
}: Readonly<{
element: { elementKind: GroupElementKind; elementId: string }
excluded: boolean
}>) => excludeGroupElement(groupId, element, excluded),
onSuccess: invalidate,
onError,
})
const findMutation = useMutation({
mutationFn: () => findGroupCandidates(groupId, filter ?? EMPTY_FILTER),
onSuccess: setCandidates,
@@ -116,13 +137,18 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
const dropOn = (targetItemId: string) => {
if (!dragged || dragged === targetItemId) return
const order = group.items.map((i) => i.id).filter((id) => id !== dragged)
// Порядок задаётся идентификаторами позиций, поэтому найденное правилом (без своей строки)
// в перестановку не входит — его место определяет алфавит.
const order = group.items
.map((i) => i.id)
.filter((id): id is string => id !== null && id !== dragged)
order.splice(order.indexOf(targetItemId), 0, dragged)
setDragged(null)
reorderMutation.mutate(order)
}
const fresh = (candidates ?? []).filter((c) => !c.alreadyInGroup)
const isDynamic = group.mode === 'Dynamic'
return (
<div className="flex flex-col gap-6">
@@ -153,11 +179,35 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<Button size="sm" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
<Button
size="sm"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate(undefined)}
>
{t('common.save')}
</Button>
</div>
{/* Режим — первое, что нужно понять про группу: от него зависит, чем является список ниже. */}
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="font-medium">{t('admin.groups.mode.label')}:</span>
<Select
value={group.mode}
onValueChange={(value) => saveMutation.mutate(value as GroupMode)}
>
<SelectTrigger className="h-8 w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Static">{t('admin.groups.mode.Static')}</SelectItem>
<SelectItem value="Dynamic">{t('admin.groups.mode.Dynamic')}</SelectItem>
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">
{t(`admin.groups.mode.${group.mode}Hint`)}
</span>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<Badge variant="muted">
{t('admin.groups.items')}: {group.itemCount}
@@ -263,50 +313,118 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
{showWeights ? t('admin.groups.hideAdvanced') : t('admin.groups.showAdvanced')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('admin.groups.orderHint')}</p>
<p className="text-xs text-muted-foreground">
{isDynamic ? t('admin.groups.dynamicOrderHint') : t('admin.groups.orderHint')}
</p>
{group.items.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.groups.empty')}</p>
) : (
<ul className="divide-y divide-border text-sm">
{group.items.map((item, index) => (
<li
key={item.id}
draggable
onDragStart={() => setDragged(item.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => dropOn(item.id)}
className="flex items-center gap-2 py-2"
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
<span className="w-6 shrink-0 text-muted-foreground">{index + 1}</span>
<span className="min-w-0 flex-1 truncate">{item.elementName}</span>
<Badge variant="muted">
{t(`admin.groups.elementKinds.${item.elementKind}`)}
</Badge>
<span className="text-muted-foreground">
{t('admin.groups.units')}: {item.unitCount}
</span>
{showWeights && (
<Input
type="number"
min={0}
className="h-8 w-20"
defaultValue={item.weight}
onBlur={(e) => {
const weight = Number(e.target.value)
if (Number.isFinite(weight) && weight !== item.weight)
weightMutation.mutate({ itemId: item.id, weight })
}}
{group.items.map((item, index) => {
// Перетаскивать и взвешивать можно только то, у чего есть своя строка: найденному
// правилом порядок задаёт алфавит, а вес всегда единица.
const editable = item.id !== null
return (
<li
key={`${item.elementKind}:${item.elementId}`}
draggable={editable && !isDynamic}
onDragStart={() => item.id && setDragged(item.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => item.id && dropOn(item.id)}
className="flex items-center gap-2 py-2"
>
<GripVertical
className={`h-4 w-4 shrink-0 text-muted-foreground ${
editable && !isDynamic ? 'cursor-grab' : 'opacity-30'
}`}
/>
)}
<Button size="sm" variant="ghost" onClick={() => removeMutation.mutate(item.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
<span className="w-6 shrink-0 text-muted-foreground">{index + 1}</span>
<span className="min-w-0 flex-1 truncate">{item.elementName}</span>
{isDynamic && (
<Badge variant={item.pinned ? 'default' : 'muted'}>
{item.pinned ? t('admin.groups.pinned') : t('admin.groups.byRule')}
</Badge>
)}
<Badge variant="muted">
{t(`admin.groups.elementKinds.${item.elementKind}`)}
</Badge>
<span className="text-muted-foreground">
{t('admin.groups.units')}: {item.unitCount}
</span>
{showWeights && editable && (
<Input
type="number"
min={0}
className="h-8 w-20"
defaultValue={item.weight}
onBlur={(e) => {
const weight = Number(e.target.value)
if (item.id && Number.isFinite(weight) && weight !== item.weight)
weightMutation.mutate({ itemId: item.id, weight })
}}
/>
)}
<Button
size="sm"
variant="ghost"
title={isDynamic ? t('admin.groups.excludeHint') : undefined}
onClick={() =>
isDynamic
? excludeMutation.mutate({
element: {
elementKind: item.elementKind,
elementId: item.elementId,
},
excluded: true,
})
: item.id && removeMutation.mutate(item.id)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
)
})}
</ul>
)}
{/* Исключённое видно списком: иначе непонятно, почему шоу нет в составе. */}
{isDynamic && group.excluded.length > 0 && (
<div className="flex flex-col gap-1 border-t border-border pt-2">
<span className="text-xs uppercase tracking-wide text-muted-foreground">
{t('admin.groups.excluded')}
</span>
<ul className="flex flex-col gap-1 text-sm">
{group.excluded.map((element) => (
<li
key={`${element.elementKind}:${element.elementId}`}
className="flex items-center gap-2"
>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{element.elementName}
</span>
<Button
size="sm"
variant="ghost"
onClick={() =>
excludeMutation.mutate({
element: {
elementKind: element.elementKind,
elementId: element.elementId,
},
excluded: false,
})
}
>
<Undo2 className="h-4 w-4" />
{t('admin.groups.restore')}
</Button>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
@@ -116,8 +116,12 @@ export function GroupsPanel() {
>
{group.name}
</Link>
{group.hasFilter && (
<Badge variant="muted">{t('admin.groups.hasFilter')}</Badge>
{group.mode === 'Dynamic' ? (
<Badge>{t('admin.groups.mode.Dynamic')}</Badge>
) : (
group.hasFilter && (
<Badge variant="muted">{t('admin.groups.hasFilter')}</Badge>
)
)}
</div>
</td>
+20 -1
View File
@@ -5,6 +5,7 @@ import type {
GroupDto,
GroupElementKind,
GroupFilter,
GroupMode,
GroupSuggestionDto,
GroupSummaryDto,
} from '@/shared/api/types'
@@ -23,7 +24,13 @@ export function createGroup(body: { name: string; description?: string }) {
export function updateGroup(
id: string,
body: { name: string; description: string | null; filter: GroupFilter | null },
body: {
name: string
description: string | null
filter: GroupFilter | null
/** Не передан — режим остаётся прежним. */
mode?: GroupMode
},
) {
return apiRequest<void>(`/admin/groups/${id}`, { method: 'PUT', body })
}
@@ -63,6 +70,18 @@ export function addGroupElements(
})
}
/** Исключить элемент из вычисленного состава динамической группы либо вернуть его правилу. */
export function excludeGroupElement(
id: string,
element: { elementKind: GroupElementKind; elementId: string },
excluded: boolean,
) {
return apiRequest<void>(`/admin/groups/${id}/exclusions`, {
method: 'POST',
body: { ...element, excluded },
})
}
export function removeGroupItem(id: string, itemId: string) {
return apiRequest<void>(`/admin/groups/${id}/items/${itemId}`, { method: 'DELETE' })
}
+17 -1
View File
@@ -248,12 +248,17 @@ export type GroupSummaryDto = {
unitCount: number
totalDurationSeconds: number
hasFilter: boolean
mode: GroupMode
statsComputedAt: string | null
createdAt: string
}
/** Откуда группа берёт состав: явный список либо вычисление правилом. */
export type GroupMode = 'Static' | 'Dynamic'
type GroupItemDto = {
id: string
/** null — элемент найден правилом динамической группы: своей строки, веса и порядка у него нет. */
id: string | null
elementKind: GroupElementKind
elementId: string
elementName: string
@@ -264,18 +269,29 @@ type GroupItemDto = {
audience: ShowAudience | null
year: number | null
posterImageId: string | null
/** Закреплён руками в динамической группе — правило его не выбрасывает. */
pinned: boolean
}
/** Элемент, исключённый из динамической группы вручную. */
export type GroupExcludedDto = {
elementKind: GroupElementKind
elementId: string
elementName: string
}
export type GroupDto = {
id: string
name: string
description: string | null
mode: GroupMode
filter: GroupFilter | null
itemCount: number
unitCount: number
totalDurationSeconds: number
statsComputedAt: string | null
items: GroupItemDto[]
excluded: GroupExcludedDto[]
}
/** Кандидат, найденный правилом набора. */
+15
View File
@@ -101,6 +101,21 @@ export const en = {
found: 'Found: {{total}}, new: {{fresh}}',
added: 'Items added: {{count}}',
alreadyIn: 'already in group',
mode: {
label: 'Composition',
Static: 'Explicit list',
Dynamic: 'By rule',
StaticHint: 'Items are added by hand; the rule only helps to find them.',
DynamicHint:
'Composition is computed by the rule on every read — new library items go on air by themselves.',
},
pinned: 'pinned',
byRule: 'by rule',
excluded: 'Excluded',
excludeHint: 'Exclude from the composition — the rule will stop adding it back',
restore: 'Restore',
dynamicOrderHint:
'Order: pinned items first (drag to arrange), then rule matches alphabetically.',
pendingFound: 'New matches for this rule: {{count}} —',
pendingAdd: 'Add all ({{count}})',
elementKinds: { Show: 'Show', Collection: 'Collection' },
+15
View File
@@ -101,6 +101,21 @@ export const ru = {
found: 'Найдено: {{total}}, новых: {{fresh}}',
added: 'Добавлено позиций: {{count}}',
alreadyIn: 'уже в группе',
mode: {
label: 'Состав',
Static: 'Явный список',
Dynamic: 'По правилу',
StaticHint: 'Позиции добавляются вручную; правило только помогает их найти.',
DynamicHint:
'Состав считается правилом при каждом обращении — новое из библиотеки попадает в эфир само.',
},
pinned: 'закреплено',
byRule: 'по правилу',
excluded: 'Исключено',
excludeHint: 'Исключить из состава — правило больше не будет его подставлять',
restore: 'Вернуть',
dynamicOrderHint:
'Порядок: сначала закреплённое (перетаскиванием), затем найденное правилом по алфавиту.',
pendingFound: 'Под правило группы подходит нового: {{count}} —',
pendingAdd: 'Добавить все ({{count}})',
elementKinds: { Show: 'Шоу', Collection: 'Коллекция' },