Added new BumperEndpoints to the API for managing bumper templates and variants, enhancing the channel management capabilities. Removed outdated bumper-related commands and handlers from the application, streamlining the codebase and improving maintainability. Updated ChannelEndpoints to reflect these changes and ensure proper routing for the new endpoints.
217 lines
8.3 KiB
TypeScript
217 lines
8.3 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { Link } from '@tanstack/react-router'
|
|
import { ChevronLeft, Send, Undo2 } from 'lucide-react'
|
|
import { useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { listAllMedia } from '@/features/admin/media/api'
|
|
import { qk } from '@/shared/api/query-keys'
|
|
import { cn } from '@/shared/lib/cn'
|
|
import { useApiError } from '@/shared/lib/use-api-error'
|
|
import { Badge } from '@/shared/ui/badge'
|
|
import { Button } from '@/shared/ui/button'
|
|
import { Card, CardContent } from '@/shared/ui/card'
|
|
import { toast } from '@/shared/ui/toast-store'
|
|
import {
|
|
applyChannelTemplate,
|
|
getChannel,
|
|
getChannelTemplate,
|
|
getSchedule,
|
|
restoreChannelTemplate,
|
|
} from './api'
|
|
import { ApplyDialog } from './components/ApplyDialog'
|
|
import { EntryTraceDialog } from './components/EntryTraceDialog'
|
|
import { GridTab } from './components/GridTab'
|
|
import { JunctionsCard } from './components/JunctionsCard'
|
|
import { RulesCard } from './components/RulesCard'
|
|
import { SchedulePreview } from './components/SchedulePreview'
|
|
import { SettingsCard } from './components/SettingsCard'
|
|
import { ViewerCard } from './components/ViewerCard'
|
|
|
|
/** Вкладки экрана канала: настройки первыми — с них канал и начинается. */
|
|
const TABS = ['settings', 'grid', 'rules', 'junctions', 'viewer', 'air'] as const
|
|
type ChannelTab = (typeof TABS)[number]
|
|
|
|
export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
|
|
const { t } = useTranslation()
|
|
const queryClient = useQueryClient()
|
|
const [applyOpen, setApplyOpen] = useState(false)
|
|
const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
|
|
const [tab, setTab] = useState<ChannelTab>('settings')
|
|
|
|
const { data: channel, isLoading } = useQuery({
|
|
queryKey: qk.channels.detail(channelId),
|
|
queryFn: () => getChannel(channelId),
|
|
})
|
|
const { data: template, error: templateError } = useQuery({
|
|
queryKey: qk.channels.template(channelId),
|
|
queryFn: () => getChannelTemplate(channelId),
|
|
})
|
|
const { data: ready } = useQuery({
|
|
queryKey: qk.media.ready,
|
|
queryFn: () => listAllMedia({ statuses: ['Ready'] }),
|
|
})
|
|
const { data: schedule } = useQuery({
|
|
queryKey: qk.channels.schedule(channelId),
|
|
queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3_600_000)),
|
|
})
|
|
|
|
const invalidate = () => {
|
|
void queryClient.invalidateQueries({ queryKey: qk.channels.detail(channelId) })
|
|
}
|
|
const onError = useApiError()
|
|
|
|
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) {
|
|
const kind = t(`admin.channels.warnings.${warning.kind}`)
|
|
toast.error(`${kind}: ${warning.details}`)
|
|
}
|
|
invalidate()
|
|
},
|
|
onError,
|
|
})
|
|
|
|
const restoreMutation = useMutation({
|
|
mutationFn: () => restoreChannelTemplate(channelId),
|
|
onSuccess: (result) => {
|
|
toast.success(t('admin.channels.restored', { count: result.slots }))
|
|
// Ссылка на удалённую группу или стык вернуться не может — говорим об этом прямо.
|
|
if (result.droppedRefs > 0)
|
|
toast.error(t('admin.channels.restoreDropped', { count: result.droppedRefs }))
|
|
invalidate()
|
|
},
|
|
onError,
|
|
})
|
|
|
|
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<Button asChild size="sm" variant="ghost">
|
|
<Link to="/admin/channels">
|
|
<ChevronLeft className="h-4 w-4" />
|
|
{t('admin.channels.title')}
|
|
</Link>
|
|
</Button>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h2 className="crt-glow text-xl font-semibold">{channel.name}</h2>
|
|
{channel.number !== null && <Badge variant="muted">№ {channel.number}</Badge>}
|
|
{!channel.isEnabled && <Badge variant="muted">{t('admin.channels.disabled')}</Badge>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Правка правил эфира не двигает — применение отдельной кнопкой. Рядом откат: правки
|
|
сетки правятся на месте, и без этой кнопки вернуть «как в эфире» было бы нечем. */}
|
|
{template?.hasPendingChanges && (
|
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-amber-500/50 bg-amber-500/10 px-4 py-2 text-sm">
|
|
<span>{t('admin.channels.pendingChanges')}</span>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{/* Кнопки отката нет, пока сетку ни разу не применяли: возвращаться некуда. */}
|
|
{template.canRestore && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={restoreMutation.isPending}
|
|
onClick={() => {
|
|
// Откат необратим — он стирает всё, сделанное с последнего применения.
|
|
if (!window.confirm(t('admin.channels.restoreConfirm'))) return
|
|
restoreMutation.mutate()
|
|
}}
|
|
>
|
|
<Undo2 className="h-4 w-4" /> {t('admin.channels.restore')}
|
|
</Button>
|
|
)}
|
|
<Button size="sm" disabled={applyMutation.isPending} onClick={() => setApplyOpen(true)}>
|
|
<Send className="h-4 w-4" /> {t('admin.channels.apply')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Вкладки вместо колонки карточек: экран канала перестал помещаться в один свиток. */}
|
|
<nav className="flex flex-wrap gap-4 border-b border-border text-xs uppercase tracking-wide">
|
|
{TABS.map((value) => (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
onClick={() => setTab(value)}
|
|
className={cn(
|
|
'pb-2 text-muted-foreground hover:text-foreground',
|
|
tab === value && 'border-b-2 border-primary text-primary',
|
|
)}
|
|
>
|
|
{t(`admin.channels.tabs.${value}`)}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
{tab === 'settings' && (
|
|
<SettingsCard
|
|
channel={channel}
|
|
readyAssets={ready?.items ?? []}
|
|
bare
|
|
onSaved={invalidate}
|
|
onError={onError}
|
|
/>
|
|
)}
|
|
|
|
{tab === 'grid' && (
|
|
<GridTab
|
|
channelId={channelId}
|
|
template={template}
|
|
templateError={templateError}
|
|
onChanged={invalidate}
|
|
onError={onError}
|
|
/>
|
|
)}
|
|
|
|
{tab === 'rules' &&
|
|
(template ? (
|
|
<RulesCard template={template} bare onChanged={invalidate} onError={onError} />
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">{t('admin.channels.noTemplate')}</p>
|
|
))}
|
|
|
|
{tab === 'junctions' && (
|
|
<JunctionsCard template={template} bare onChanged={invalidate} onError={onError} />
|
|
)}
|
|
|
|
{tab === 'viewer' && (
|
|
<ViewerCard channel={channel} bare onSaved={invalidate} onError={onError} />
|
|
)}
|
|
|
|
{tab === 'air' && (
|
|
<Card>
|
|
<CardContent>
|
|
<SchedulePreview entries={schedule ?? []} onShowTrace={setTraceEntryId} />
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{applyOpen && (
|
|
<ApplyDialog
|
|
channelId={channelId}
|
|
utcOffsetMinutes={channel.utcOffsetMinutes}
|
|
pending={applyMutation.isPending}
|
|
onApply={() => applyMutation.mutate()}
|
|
onClose={() => setApplyOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{traceEntryId && (
|
|
<EntryTraceDialog
|
|
entryId={traceEntryId}
|
|
utcOffsetMinutes={channel.utcOffsetMinutes}
|
|
onClose={() => setTraceEntryId(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|