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.
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
Reference in New Issue
Block a user