import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' import { ChevronLeft, Plus, Send } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { listAllMedia } from '@/features/admin/media/api' import { HttpError } from '@/shared/api/client' import type { GridLayerDto, SlotDto } from '@/shared/api/types' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { Card, CardContent } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' import { cn } from '@/shared/lib/cn' import { toast } from '@/shared/ui/toast-store' import { applyChannelTemplate, copyTemplateTo, createLayer, createSlot, deleteLayer, getChannel, getChannelTemplate, getSchedule, listChannels, toSlotBody, updateLayer, updateSlot, } from './api' import { ApplyDialog } from './components/ApplyDialog' import { BumperCard } from './components/BumperCard' import { EntryTraceDialog } from './components/EntryTraceDialog' import { JunctionsCard } from './components/JunctionsCard' import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog' import { LayerList, ScheduleGrid } from './components/ScheduleGrid' import { SchedulePreview } from './components/SchedulePreview' import { RulesCard } from './components/RulesCard' import { SettingsCard } from './components/SettingsCard' import { TemplateIssues } from './components/TemplateIssues' import { TemplatePreview } from './components/TemplatePreview' import { ViewerCard } from './components/ViewerCard' import { SlotInspector, type SlotDraft } from './components/SlotInspector' import { toTime } from './lib/format' /** Вкладки экрана канала — в порядке частоты правки. */ const TABS = ['grid', 'rules', 'junctions', 'bumpers', 'viewer', 'settings', 'air'] as const type ChannelTab = (typeof TABS)[number] export function ChannelDetail({ channelId }: { channelId: string }) { const { t } = useTranslation() const queryClient = useQueryClient() const [draft, setDraft] = useState(null) const [activeLayerId, setActiveLayerId] = useState(null) const [viewDate, setViewDate] = useState('') const [applicabilityLayer, setApplicabilityLayer] = useState(null) // День, который копируем, и отмеченные дни-приёмники. const [copySource, setCopySource] = useState(null) const [copyTargets, setCopyTargets] = useState([]) const [applyOpen, setApplyOpen] = useState(false) const [traceEntryId, setTraceEntryId] = useState(null) const [copyToChannel, setCopyToChannel] = useState('') const [tab, setTab] = useState('grid') const { data: channel, isLoading } = useQuery({ queryKey: ['admin', 'channels', channelId], queryFn: () => getChannel(channelId), }) const { data: template, error: templateError } = useQuery({ queryKey: ['admin', 'channels', channelId, 'template'], queryFn: () => getChannelTemplate(channelId), }) const { data: ready } = useQuery({ queryKey: ['admin', 'media', 'ready', 'all'], queryFn: () => listAllMedia({ statuses: ['Ready'] }), }) const { data: schedule } = useQuery({ queryKey: ['admin', 'channels', channelId, 'schedule'], queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3600_000)), }) const invalidate = () => { void queryClient.invalidateQueries({ queryKey: ['admin', 'channels', channelId] }) } const onError = (error: unknown) => toast.error(error instanceof HttpError ? error.detail : t('common.error')) const { data: channels } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels }) const applyMutation = useMutation({ mutationFn: () => applyChannelTemplate(channelId), onSuccess: (result) => { setApplyOpen(false) toast.success(t('admin.channels.applied', { count: result.added })) // Предупреждения показываем по одному: каждое указывает на конкретный слот. for (const warning of result.warnings) toast.error(`${t(`admin.channels.warnings.${warning.kind}`)}: ${warning.details}`) invalidate() }, onError, }) const addLayerMutation = useMutation({ mutationFn: () => { const nextPriority = Math.max(0, ...(template?.layers.map((l) => l.priority) ?? [0])) + 10 return createLayer(template!.id, { name: t('admin.channels.newLayerName'), priority: nextPriority, }) }, onSuccess: invalidate, onError, }) const deleteLayerMutation = useMutation({ mutationFn: (layer: GridLayerDto) => deleteLayer(layer.id), onSuccess: invalidate, onError, }) const toggleLayerMutation = useMutation({ mutationFn: (layer: GridLayerDto) => updateLayer(layer.id, { name: layer.name, priority: layer.priority, applicability: layer.applicability, isEnabled: !layer.isEnabled, }), onSuccess: invalidate, onError, }) /** * Порядок слоёв задаётся перетаскиванием, а хранится приоритетом. Раздаём приоритеты с шагом 10 * снизу вверх: шаг оставляет место, чтобы следующая вставка не переписывала весь список. */ const reorderLayersMutation = useMutation({ mutationFn: async (layerIdsTopFirst: string[]) => { const byId = new Map(template!.layers.map((l) => [l.id, l])) const total = layerIdsTopFirst.length await Promise.all( layerIdsTopFirst.map((id, index) => { const layer = byId.get(id) const priority = (total - index) * 10 if (!layer || layer.priority === priority) return Promise.resolve() return updateLayer(id, { name: layer.name, priority, applicability: layer.applicability, isEnabled: layer.isEnabled, }) }), ) }, onSuccess: invalidate, onError, }) const copyTemplateMutation = useMutation({ mutationFn: (targetChannelId: string) => copyTemplateTo(channelId, targetChannelId), onSuccess: (result) => { setCopyToChannel('') toast.success( t('admin.channels.templateCopied', { layers: result.layers, slots: result.slots }), ) if (result.droppedBumperRefs > 0) toast.error( t('admin.channels.copyDroppedBumpers', { count: result.droppedBumperRefs }), ) }, onError, }) const moveSlotMutation = useMutation({ mutationFn: ({ slot, weekday, startMinutes, }: { slot: SlotDto weekday: number startMinutes: number }) => updateSlot(slot.id, { ...toSlotBody(slot), weekday, targetStart: toTime(startMinutes) }), onSuccess: invalidate, onError, }) const resizeSlotMutation = useMutation({ mutationFn: ({ slot, minutes }: { slot: SlotDto; minutes: number }) => updateSlot(slot.id, { ...toSlotBody(slot), targetDurationMinutes: minutes }), onSuccess: invalidate, onError, }) /** Копирование дня: слоты «каждый день» не копируются — они и так есть во всех колонках. */ const copyDayMutation = useMutation({ mutationFn: async ({ from, to }: { from: number; to: number[] }) => { const sources = (template?.layers ?? []).flatMap((layer) => layer.slots.filter((slot) => slot.weekday === from).map((slot) => ({ layer, slot })), ) for (const weekday of to) for (const { layer, slot } of sources) await createSlot(layer.id, { ...toSlotBody(slot), weekday }) }, onSuccess: () => { setCopySource(null) invalidate() }, onError, }) if (isLoading || !channel) return

{t('common.loading')}

const layerForNewSlot = activeLayerId ?? template?.layers.find((l) => !l.isBackground)?.id ?? template?.layers[0]?.id const openNewSlot = (weekday: number, startMinutes: number) => { if (!layerForNewSlot) return const hh = Math.floor(startMinutes / 60) .toString() .padStart(2, '0') const mm = (startMinutes % 60).toString().padStart(2, '0') setDraft({ layerId: layerForNewSlot, slot: null, defaults: { weekday, targetStart: `${hh}:${mm}:00`, title: t('admin.channels.newSlot') }, }) } const openSlot = (slot: SlotDto) => setDraft({ layerId: slot.layerId, slot }) return (

{channel.name}

{channel.number !== null && № {channel.number}} {!channel.isEnabled && {t('admin.channels.disabled')}}
{/* Правка правил эфира не двигает — применение отдельной кнопкой. */} {template?.hasPendingChanges && (
{t('admin.channels.pendingChanges')}
)} {/* Вкладки вместо колонки карточек: экран канала перестал помещаться в один свиток. */} {tab === 'settings' && ( )} {/* Шаблон мог не загрузиться — раньше вкладка сетки просто оказывалась пустой. */} {tab === 'grid' && !template && (

{templateError instanceof HttpError ? templateError.detail : t('admin.channels.noTemplate')}

)} {tab === 'grid' && template && (

{t('admin.channels.layers')}

setActiveLayerId(layer.id)} onDelete={(layer) => deleteLayerMutation.mutate(layer)} onToggle={(layer) => toggleLayerMutation.mutate(layer)} onReorder={(order) => reorderLayersMutation.mutate(order)} onEditApplicability={setApplicabilityLayer} />

{t('admin.channels.layersHint')}

{/* Копия сетки на другой канал: группы общие, поэтому переносятся только правила. */}
{t('admin.channels.copyTemplate')}

{t('admin.channels.copyTemplateHint')}

l.slots).map((slot) => [slot.id, slot])) } onGoToSlot={openSlot} /> {/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
{t('admin.channels.showForDate')} setViewDate(e.target.value)} /> {viewDate && ( )}
{/* Копирование дня: сначала выбирается источник, потом дни-приёмники. */} {copySource !== null && (
{t('admin.channels.copyDayFrom', { day: t(`admin.channels.weekdays.${copySource}`), })} {[1, 2, 3, 4, 5, 6, 0] .filter((day) => day !== copySource) .map((day) => ( ))}
)} moveSlotMutation.mutate({ slot, weekday, startMinutes }) } onResizeSlot={(slot, minutes) => resizeSlotMutation.mutate({ slot, minutes })} onCopyDay={(weekday) => { setCopySource(weekday) setCopyTargets([]) }} /> {draft && ( setDraft(null)} onChanged={invalidate} /> )}
)} {tab === 'rules' && (template ? ( ) : (

{t('admin.channels.noTemplate')}

))} {tab === 'junctions' && ( )} {tab === 'bumpers' && ( )} {tab === 'viewer' && ( )} {tab === 'air' && ( )} {applicabilityLayer && ( setApplicabilityLayer(null)} onChanged={invalidate} onError={onError} /> )} {applyOpen && ( applyMutation.mutate()} onClose={() => setApplyOpen(false)} /> )} {traceEntryId && ( setTraceEntryId(null)} /> )}
) }