Files
TeleWave/frontend/src/features/admin/groups/GroupDetail.tsx
T
Leonid Pershin 5ad28b2966
ci / build-backend (push) Successful in 2m41s
ci / build-frontend (push) Successful in 44s
ci / tests (push) Successful in 2m33s
ci / sonar (push) Successful in 5m12s
Implement collection suggestions and bulk show addition features
Added new API endpoints for suggesting collections based on existing shows and for bulk adding shows to collections. Enhanced the backend with necessary logic and DTOs to support these features. Updated the frontend to include new components for displaying collection suggestions and managing bulk additions, improving the user experience for collection management. Localization updates were made to support these new features in both English and Russian.
2026-07-27 05:39:23 +03:00

446 lines
18 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
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,
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,
reorderGroup,
setGroupItemWeight,
updateGroup,
} from './api'
import { DurationLabel } from './DurationLabel'
import { GroupFilterPanel } from './GroupFilterPanel'
const EMPTY_FILTER: GroupFilter = {}
export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [name, setName] = useState<string | null>(null)
const [description, setDescription] = useState<string | null>(null)
const [filter, setFilter] = useState<GroupFilter | null>(null)
const [candidates, setCandidates] = useState<GroupCandidateDto[] | null>(null)
const [showWeights, setShowWeights] = useState(false)
const [dragged, setDragged] = useState<string | null>(null)
const { data: group, isLoading } = useQuery({
queryKey: qk.groups.detail(groupId),
queryFn: () => getGroup(groupId),
})
// Черновик правила поднимаем из сохранённого один раз — дальше им владеет форма.
useEffect(() => {
if (group && filter === null) setFilter(group.filter ?? EMPTY_FILTER)
}, [group, filter])
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: qk.groups.all })
}
const onError = useApiError()
/**
* Что подходит под сохранённое правило, но в группу ещё не попало: библиотека пополняется после
* того, как группа собрана, и без этой проверки новое шоу лежало бы мимо эфира, пока кто-нибудь
* не вспомнит нажать «Подобрать». Спрашиваем именно сохранённое правило (фильтр не передаём —
* сервер берёт его сам), а не черновик формы: подсказка не должна прыгать, пока крутят поля.
*/
const { data: pending } = useQuery({
queryKey: qk.groups.pending(groupId),
queryFn: () => findGroupCandidates(groupId, null),
// Динамической группе подсказка не нужна: правило применяется само при каждом обращении.
enabled: !!group?.filter && group?.mode === 'Static',
})
const pendingFresh = (pending ?? []).filter((c) => !c.alreadyInGroup)
const saveMutation = useMutation({
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,
onError,
})
const addMutation = useMutation({
mutationFn: (elements: GroupCandidateDto[]) =>
addGroupElements(
groupId,
elements.map((c) => ({ elementKind: c.elementKind, elementId: c.elementId })),
),
onSuccess: (result) => {
toast.success(t('admin.groups.added', { count: result.added }))
setCandidates(null)
invalidate()
},
onError,
})
const removeMutation = useMutation({
mutationFn: (itemId: string) => removeGroupItem(groupId, itemId),
onSuccess: invalidate,
onError,
})
const weightMutation = useMutation({
mutationFn: ({ itemId, weight }: Readonly<{ itemId: string; weight: number }>) =>
setGroupItemWeight(groupId, itemId, weight),
onSuccess: invalidate,
onError,
})
const reorderMutation = useMutation({
mutationFn: (order: string[]) => reorderGroup(groupId, order),
onSuccess: invalidate,
onError,
})
if (isLoading || !group) return <p className="text-muted-foreground">{t('common.loading')}</p>
const dropOn = (targetItemId: string) => {
if (!dragged || dragged === targetItemId) return
// Порядок задаётся идентификаторами позиций, поэтому найденное правилом (без своей строки)
// в перестановку не входит — его место определяет алфавит.
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'
// Закреплённое, которого черновик правила не находит, в составе всё равно останется.
const pinnedNotMatched = group.items.filter(
(i) => i.pinned && !(candidates ?? []).some((c) => c.elementId === i.elementId),
).length
return (
<div className="flex flex-col gap-6">
<div>
<Button asChild size="sm" variant="ghost">
<Link to="/admin/groups">
<ChevronLeft className="h-4 w-4" />
{t('admin.groups.title')}
</Link>
</Button>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.groups.name')}</Label>
<Input
className="w-64"
value={name ?? group.name}
maxLength={256}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<Label>{t('admin.groups.description')}</Label>
<Input
value={description ?? group.description ?? ''}
maxLength={2048}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<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}
</Badge>
<Badge variant="muted">
{t('admin.groups.units')}: {group.unitCount}
</Badge>
<Badge variant="muted">
<DurationLabel seconds={group.totalDurationSeconds} />
</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">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-semibold uppercase tracking-wide">
{t('admin.groups.filter.title')}
</h3>
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.hint')}</p>
</div>
<GroupFilterPanel filter={filter ?? EMPTY_FILTER} onChange={setFilter} />
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={findMutation.isPending}
onClick={() => findMutation.mutate()}
>
<Search className="h-4 w-4" /> {t('admin.groups.find')}
</Button>
{candidates !== null && isDynamic && (
// У группы по правилу «добавить найденное» не имеет смысла — правило и есть состав.
// Показываем, каким станет состав, если сохранить черновик правила.
<span className="text-sm text-muted-foreground">
{t('admin.groups.previewComposition', {
count: candidates.length + pinnedNotMatched,
})}
</span>
)}
{candidates !== null && !isDynamic && (
<>
<span className="text-sm text-muted-foreground">
{t('admin.groups.found', { total: candidates.length, fresh: fresh.length })}
</span>
<Button
size="sm"
disabled={fresh.length === 0 || addMutation.isPending}
onClick={() => addMutation.mutate(fresh)}
>
{t('admin.groups.addFound')}
</Button>
</>
)}
</div>
{candidates !== null && candidates.length > 0 && (
<ul className="max-h-72 divide-y divide-border overflow-y-auto rounded border border-border text-sm">
{candidates.map((candidate) => (
<li
key={`${candidate.elementKind}:${candidate.elementId}`}
className="flex items-center gap-2 px-3 py-1.5"
>
<span className="min-w-0 flex-1 truncate">{candidate.elementName}</span>
{candidate.year && (
<span className="text-muted-foreground">{candidate.year}</span>
)}
<Badge variant="muted">
{t(`admin.groups.elementKinds.${candidate.elementKind}`)}
</Badge>
{candidate.alreadyInGroup && (
<Badge variant="muted">{t('admin.groups.alreadyIn')}</Badge>
)}
</li>
))}
</ul>
)}
</div>
{/* Правая панель — состав */}
<div className="crt-panel flex flex-col gap-3 rounded-md p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold uppercase tracking-wide">
{t('admin.groups.composition')}
</h3>
<Button size="sm" variant="ghost" onClick={() => setShowWeights((v) => !v)}>
{showWeights ? t('admin.groups.hideAdvanced') : t('admin.groups.showAdvanced')}
</Button>
</div>
<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) => {
// Перетаскивать и взвешивать можно только то, у чего есть своя строка: найденному
// правилом порядок задаёт алфавит, а вес всегда единица.
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'
}`}
/>
<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>
)
}