Files
TeleWave/frontend/src/features/admin/channels/ChannelDetail.tsx
T
Leonid Pershin 0fd4e762f4
ci / build-backend (push) Successful in 2m47s
ci / build-frontend (push) Successful in 1m8s
ci / tests (push) Successful in 2m28s
ci / sonar (push) Successful in 6m49s
Add restore functionality for template management and enhance related components
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.
2026-07-27 08:43:53 +03:00

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>
)
}