Implemented a new endpoint for restoring templates to their last applied state, allowing users to revert changes made since the last application. Updated the ScheduleTemplate class to include a snapshot of the last applied state, enabling rollback capabilities. Enhanced the frontend with a restore button in the ChannelDetail component, providing users with a confirmation prompt before executing the restore action. Localization updates were made to support new UI strings in both English and Russian, improving the overall user experience in template management.
228 lines
8.6 KiB
TypeScript
228 lines
8.6 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 { BumperCard } from './components/BumperCard'
|
|
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', 'bumpers', '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
|
|
channel={channel}
|
|
template={template}
|
|
bare
|
|
onChanged={invalidate}
|
|
onError={onError}
|
|
/>
|
|
)}
|
|
|
|
{tab === 'bumpers' && (
|
|
<BumperCard channel={channel} bare onSaved={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>
|
|
)
|
|
}
|