Refactor ChannelDetail component to implement tabbed navigation for channel settings, enhancing user experience by organizing content into distinct sections. Introduce a new TABS constant for better maintainability and update related components (BumperCard, RulesCard, ViewerCard, etc.) to support a 'bare' prop for streamlined rendering. Improve error handling for template loading and update translations for better clarity.
build / backend (push) Successful in 1m10s
build / frontend (push) Successful in 35s
tests / backend-tests (push) Successful in 1m31s

This commit is contained in:
Leonid Pershin
2026-07-26 20:03:14 +03:00
parent 2387223f0b
commit 69c236d8cc
8 changed files with 593 additions and 494 deletions
@@ -8,7 +8,9 @@ import { HttpError } from '@/shared/api/client'
import type { GridLayerDto, SlotDto } from '@/shared/api/types' import type { GridLayerDto, SlotDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Card, CardContent } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { cn } from '@/shared/lib/cn'
import { toast } from '@/shared/ui/toast-store' import { toast } from '@/shared/ui/toast-store'
import { import {
applyChannelTemplate, applyChannelTemplate,
@@ -26,7 +28,6 @@ import {
} from './api' } from './api'
import { ApplyDialog } from './components/ApplyDialog' import { ApplyDialog } from './components/ApplyDialog'
import { BumperCard } from './components/BumperCard' import { BumperCard } from './components/BumperCard'
import { CollapsibleCard } from './components/CollapsibleCard'
import { EntryTraceDialog } from './components/EntryTraceDialog' import { EntryTraceDialog } from './components/EntryTraceDialog'
import { JunctionsCard } from './components/JunctionsCard' import { JunctionsCard } from './components/JunctionsCard'
import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog' import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog'
@@ -40,6 +41,10 @@ import { ViewerCard } from './components/ViewerCard'
import { SlotInspector, type SlotDraft } from './components/SlotInspector' import { SlotInspector, type SlotDraft } from './components/SlotInspector'
import { toTime } from './lib/format' 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 }) { export function ChannelDetail({ channelId }: { channelId: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -53,12 +58,13 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
const [applyOpen, setApplyOpen] = useState(false) const [applyOpen, setApplyOpen] = useState(false)
const [traceEntryId, setTraceEntryId] = useState<string | null>(null) const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
const [copyToChannel, setCopyToChannel] = useState('') const [copyToChannel, setCopyToChannel] = useState('')
const [tab, setTab] = useState<ChannelTab>('grid')
const { data: channel, isLoading } = useQuery({ const { data: channel, isLoading } = useQuery({
queryKey: ['admin', 'channels', channelId], queryKey: ['admin', 'channels', channelId],
queryFn: () => getChannel(channelId), queryFn: () => getChannel(channelId),
}) })
const { data: template } = useQuery({ const { data: template, error: templateError } = useQuery({
queryKey: ['admin', 'channels', channelId, 'template'], queryKey: ['admin', 'channels', channelId, 'template'],
queryFn: () => getChannelTemplate(channelId), queryFn: () => getChannelTemplate(channelId),
}) })
@@ -248,16 +254,46 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
</div> </div>
)} )}
<SettingsCard {/* Вкладки вместо колонки карточек: экран канала перестал помещаться в один свиток. */}
channel={channel} <nav className="flex flex-wrap gap-4 border-b border-border text-xs uppercase tracking-wide">
readyAssets={ready?.items ?? []} {TABS.map((value) => (
onSaved={invalidate} <button
onError={onError} 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>
{template && ( {tab === 'settings' && (
<CollapsibleCard title={t('admin.channels.grid')} defaultOpen> <SettingsCard
<div className="grid gap-4 lg:grid-cols-[220px_1fr]"> channel={channel}
readyAssets={ready?.items ?? []}
bare
onSaved={invalidate}
onError={onError}
/>
)}
{/* Шаблон мог не загрузиться — раньше вкладка сетки просто оказывалась пустой. */}
{tab === 'grid' && !template && (
<p className="text-sm text-muted-foreground">
{templateError instanceof HttpError
? templateError.detail
: t('admin.channels.noTemplate')}
</p>
)}
{tab === 'grid' && template && (
<Card>
<CardContent>
<div className="grid gap-4 lg:grid-cols-[220px_1fr]">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
@@ -408,24 +444,43 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
/> />
)} )}
</div> </div>
</div> </div>
</CollapsibleCard> </CardContent>
</Card>
)} )}
{template && ( {tab === 'rules' &&
<RulesCard template={template} onChanged={invalidate} onError={onError} /> (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}
/>
)} )}
<JunctionsCard {tab === 'bumpers' && (
channel={channel} <BumperCard channel={channel} bare onSaved={invalidate} onError={onError} />
template={template} )}
onChanged={invalidate}
onError={onError}
/>
<BumperCard channel={channel} onSaved={invalidate} onError={onError} /> {tab === 'viewer' && (
<ViewerCard channel={channel} bare onSaved={invalidate} onError={onError} />
)}
<ViewerCard channel={channel} onSaved={invalidate} onError={onError} /> {tab === 'air' && (
<Card>
<CardContent>
<SchedulePreview entries={schedule ?? []} onShowTrace={setTraceEntryId} />
</CardContent>
</Card>
)}
{applicabilityLayer && ( {applicabilityLayer && (
<LayerApplicabilityDialog <LayerApplicabilityDialog
@@ -436,8 +491,6 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
/> />
)} )}
<SchedulePreview entries={schedule ?? []} onShowTrace={setTraceEntryId} />
{applyOpen && ( {applyOpen && (
<ApplyDialog <ApplyDialog
channelId={channelId} channelId={channelId}
@@ -12,10 +12,12 @@ import { CollapsibleCard } from './CollapsibleCard'
export function BumperCard({ export function BumperCard({
channel, channel,
bare,
onSaved, onSaved,
onError, onError,
}: { }: {
channel: ChannelDto channel: ChannelDto
bare?: boolean
onSaved: () => void onSaved: () => void
onError: (e: unknown) => void onError: (e: unknown) => void
}) { }) {
@@ -58,7 +60,11 @@ export function BumperCard({
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position) const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
return ( return (
<CollapsibleCard title={t('admin.channels.bumpers')} contentClassName="flex flex-col gap-4"> <CollapsibleCard
title={t('admin.channels.bumpers')}
bare={bare}
contentClassName="flex flex-col gap-4"
>
<label className="flex items-start gap-2 text-sm"> <label className="flex items-start gap-2 text-sm">
<input <input
type="checkbox" type="checkbox"
@@ -6,15 +6,26 @@ import { Card, CardContent, CardTitle } from '@/shared/ui/card'
export function CollapsibleCard({ export function CollapsibleCard({
title, title,
defaultOpen = false, defaultOpen = false,
bare = false,
contentClassName, contentClassName,
children, children,
}: { }: {
title: string title: string
defaultOpen?: boolean defaultOpen?: boolean
/** Без своего заголовка и сворачивания — когда карточка и так лежит во вкладке с этим названием. */
bare?: boolean
contentClassName?: string contentClassName?: string
children: ReactNode children: ReactNode
}) { }) {
const [open, setOpen] = useState(defaultOpen) const [open, setOpen] = useState(defaultOpen)
if (bare)
return (
<Card>
<CardContent className={contentClassName}>{children}</CardContent>
</Card>
)
return ( return (
<Card> <Card>
<button <button
@@ -65,11 +65,13 @@ function estimateSeconds(
export function JunctionsCard({ export function JunctionsCard({
channel, channel,
template, template,
bare,
onChanged, onChanged,
onError, onError,
}: { }: {
channel: ChannelDto channel: ChannelDto
template: ScheduleTemplateDto | undefined template: ScheduleTemplateDto | undefined
bare?: boolean
onChanged: () => void onChanged: () => void
onError: (error: unknown) => void onError: (error: unknown) => void
}) { }) {
@@ -104,7 +106,7 @@ export function JunctionsCard({
}) })
return ( return (
<CollapsibleCard title={t('admin.channels.junctions')}> <CollapsibleCard title={t('admin.channels.junctions')} bare={bare}>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<p className="text-xs text-muted-foreground">{t('admin.channels.junctionsHint')}</p> <p className="text-xs text-muted-foreground">{t('admin.channels.junctionsHint')}</p>
@@ -24,10 +24,12 @@ const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudi
*/ */
export function RulesCard({ export function RulesCard({
template, template,
bare,
onChanged, onChanged,
onError, onError,
}: { }: {
template: ScheduleTemplateDto template: ScheduleTemplateDto
bare?: boolean
onChanged: () => void onChanged: () => void
onError: (error: unknown) => void onError: (error: unknown) => void
}) { }) {
@@ -79,7 +81,7 @@ export function RulesCard({
setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w))) setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w)))
return ( return (
<CollapsibleCard title={t('admin.channels.rules')}> <CollapsibleCard title={t('admin.channels.rules')} bare={bare}>
<div className="flex flex-col gap-4 text-sm"> <div className="flex flex-col gap-4 text-sm">
<p className="text-xs text-muted-foreground">{t('admin.channels.rulesHint')}</p> <p className="text-xs text-muted-foreground">{t('admin.channels.rulesHint')}</p>
@@ -13,11 +13,13 @@ import { CollapsibleCard } from './CollapsibleCard'
export function SettingsCard({ export function SettingsCard({
channel, channel,
readyAssets, readyAssets,
bare,
onSaved, onSaved,
onError, onError,
}: { }: {
channel: ChannelDto channel: ChannelDto
readyAssets: { id: string; originalFileName: string }[] readyAssets: { id: string; originalFileName: string }[]
bare?: boolean
onSaved: () => void onSaved: () => void
onError: (e: unknown) => void onError: (e: unknown) => void
}) { }) {
@@ -67,6 +69,7 @@ export function SettingsCard({
<CollapsibleCard <CollapsibleCard
title={t('admin.channels.settings')} title={t('admin.channels.settings')}
defaultOpen defaultOpen
bare={bare}
contentClassName="grid gap-4 sm:grid-cols-2" contentClassName="grid gap-4 sm:grid-cols-2"
> >
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -18,10 +18,12 @@ const CORNERS: LogoCorner[] = ['TopLeft', 'TopRight', 'BottomLeft', 'BottomRight
*/ */
export function ViewerCard({ export function ViewerCard({
channel, channel,
bare,
onSaved, onSaved,
onError, onError,
}: { }: {
channel: ChannelDto channel: ChannelDto
bare?: boolean
onSaved: () => void onSaved: () => void
onError: (error: unknown) => void onError: (error: unknown) => void
}) { }) {
@@ -40,7 +42,7 @@ export function ViewerCard({
}) })
return ( return (
<CollapsibleCard title={t('admin.channels.viewer')}> <CollapsibleCard title={t('admin.channels.viewer')} bare={bare}>
<div className="flex flex-col gap-4 text-sm"> <div className="flex flex-col gap-4 text-sm">
<p className="text-xs text-muted-foreground">{t('admin.channels.viewerHint')}</p> <p className="text-xs text-muted-foreground">{t('admin.channels.viewerHint')}</p>
+20
View File
@@ -388,6 +388,16 @@ const resources = {
applicabilityNone: 'не задано', applicabilityNone: 'не задано',
showForDate: 'Сетка на дату', showForDate: 'Сетка на дату',
allDates: 'Все слои', allDates: 'Все слои',
tabs: {
grid: 'Сетка',
rules: 'Правила',
junctions: 'Стыки',
bumpers: 'Заставки',
viewer: 'Зритель',
settings: 'Настройки',
air: 'Эфир',
},
noTemplate: 'Сетка канала не загрузилась',
rules: 'Правила отбора', rules: 'Правила отбора',
rulesHint: rulesHint:
'Жёсткие фильтры: отсекают неподходящее до жребия. Как и правка сетки, эфир не двигают — нужно применить.', 'Жёсткие фильтры: отсекают неподходящее до жребия. Как и правка сетки, эфир не двигают — нужно применить.',
@@ -1080,6 +1090,16 @@ const resources = {
applicabilityNone: 'not set', applicabilityNone: 'not set',
showForDate: 'Grid for date', showForDate: 'Grid for date',
allDates: 'All layers', allDates: 'All layers',
tabs: {
grid: 'Grid',
rules: 'Rules',
junctions: 'Junctions',
bumpers: 'Bumpers',
viewer: 'Viewer',
settings: 'Settings',
air: 'On air',
},
noTemplate: 'The channel grid failed to load',
rules: 'Candidate rules', rules: 'Candidate rules',
rulesHint: rulesHint:
'Hard filters: they cut out what is not allowed before the draw. Like grid edits, they do not move the air — apply to take effect.', 'Hard filters: they cut out what is not allowed before the draw. Like grid edits, they do not move the air — apply to take effect.',