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.
446 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|