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(null) const [description, setDescription] = useState(null) const [filter, setFilter] = useState(null) const [candidates, setCandidates] = useState(null) const [showWeights, setShowWeights] = useState(false) const [dragged, setDragged] = useState(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

{t('common.loading')}

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 (
setName(e.target.value)} />
setDescription(e.target.value)} />
{/* Режим — первое, что нужно понять про группу: от него зависит, чем является список ниже. */}
{t('admin.groups.mode.label')}: {t(`admin.groups.mode.${group.mode}Hint`)}
{t('admin.groups.items')}: {group.itemCount} {t('admin.groups.units')}: {group.unitCount}
{pendingFresh.length > 0 && (
{t('admin.groups.pendingFound', { count: pendingFresh.length })}{' '} {pendingFresh .slice(0, 3) .map((c) => c.elementName) .join(', ')} {pendingFresh.length > 3 && '…'}
)}
{/* Левая панель — конструктор правила набора */}

{t('admin.groups.filter.title')}

{t('admin.groups.filter.hint')}

{candidates !== null && isDynamic && ( // У группы по правилу «добавить найденное» не имеет смысла — правило и есть состав. // Показываем, каким станет состав, если сохранить черновик правила. {t('admin.groups.previewComposition', { count: candidates.length + pinnedNotMatched, })} )} {candidates !== null && !isDynamic && ( <> {t('admin.groups.found', { total: candidates.length, fresh: fresh.length })} )}
{candidates !== null && candidates.length > 0 && (
    {candidates.map((candidate) => (
  • {candidate.elementName} {candidate.year && ( {candidate.year} )} {t(`admin.groups.elementKinds.${candidate.elementKind}`)} {candidate.alreadyInGroup && ( {t('admin.groups.alreadyIn')} )}
  • ))}
)}
{/* Правая панель — состав */}

{t('admin.groups.composition')}

{isDynamic ? t('admin.groups.dynamicOrderHint') : t('admin.groups.orderHint')}

{group.items.length === 0 ? (

{t('admin.groups.empty')}

) : (
    {group.items.map((item, index) => { // Перетаскивать и взвешивать можно только то, у чего есть своя строка: найденному // правилом порядок задаёт алфавит, а вес всегда единица. const editable = item.id !== null return (
  • item.id && setDragged(item.id)} onDragOver={(e) => e.preventDefault()} onDrop={() => item.id && dropOn(item.id)} className="flex items-center gap-2 py-2" > {index + 1} {item.elementName} {isDynamic && ( {item.pinned ? t('admin.groups.pinned') : t('admin.groups.byRule')} )} {t(`admin.groups.elementKinds.${item.elementKind}`)} {t('admin.groups.units')}: {item.unitCount} {showWeights && editable && ( { const weight = Number(e.target.value) if (item.id && Number.isFinite(weight) && weight !== item.weight) weightMutation.mutate({ itemId: item.id, weight }) }} /> )}
  • ) })}
)} {/* Исключённое видно списком: иначе непонятно, почему шоу нет в составе. */} {isDynamic && group.excluded.length > 0 && (
{t('admin.groups.excluded')}
    {group.excluded.map((element) => (
  • {element.elementName}
  • ))}
)}
) }