Enhance channel and settings functionalities: introduce viewer settings in ChannelEndpoints, update SiteSettings to include channel number toggling, and refactor related data structures. Implement new endpoints for validating templates and diffing scheduling changes, improving overall user experience and configuration management.
build / backend (push) Successful in 1m54s
build / frontend (push) Successful in 43s
tests / backend-tests (push) Successful in 2m4s

This commit is contained in:
Leonid Pershin
2026-07-26 14:50:32 +03:00
parent 8494eb5e6a
commit f699576582
54 changed files with 5857 additions and 1631 deletions
@@ -12,25 +12,31 @@ import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import {
applyChannelTemplate,
copyTemplateTo,
createLayer,
createSlot,
deleteLayer,
getChannel,
getChannelTemplate,
getSchedule,
listChannels,
toSlotBody,
updateLayer,
updateSlot,
} from './api'
import { ApplyDialog } from './components/ApplyDialog'
import { BumperCard } from './components/BumperCard'
import { CollapsibleCard } from './components/CollapsibleCard'
import { EntryTraceDialog } from './components/EntryTraceDialog'
import { JunctionsCard } from './components/JunctionsCard'
import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog'
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
import { SchedulePreview } from './components/SchedulePreview'
import { RulesCard } from './components/RulesCard'
import { SettingsCard } from './components/SettingsCard'
import { TemplateIssues } from './components/TemplateIssues'
import { TemplatePreview } from './components/TemplatePreview'
import { ViewerCard } from './components/ViewerCard'
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
import { toTime } from './lib/format'
@@ -44,6 +50,9 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
// День, который копируем, и отмеченные дни-приёмники.
const [copySource, setCopySource] = useState<number | null>(null)
const [copyTargets, setCopyTargets] = useState<number[]>([])
const [applyOpen, setApplyOpen] = useState(false)
const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
const [copyToChannel, setCopyToChannel] = useState('')
const { data: channel, isLoading } = useQuery({
queryKey: ['admin', 'channels', channelId],
@@ -68,9 +77,12 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const { data: channels } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels })
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)
@@ -136,6 +148,21 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
onError,
})
const copyTemplateMutation = useMutation({
mutationFn: (targetChannelId: string) => copyTemplateTo(channelId, targetChannelId),
onSuccess: (result) => {
setCopyToChannel('')
toast.success(
t('admin.channels.templateCopied', { layers: result.layers, slots: result.slots }),
)
if (result.droppedBumperRefs > 0)
toast.error(
t('admin.channels.copyDroppedBumpers', { count: result.droppedBumperRefs }),
)
},
onError,
})
const moveSlotMutation = useMutation({
mutationFn: ({
slot,
@@ -215,7 +242,7 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
{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>
<Button size="sm" disabled={applyMutation.isPending} onClick={() => applyMutation.mutate()}>
<Button size="sm" disabled={applyMutation.isPending} onClick={() => setApplyOpen(true)}>
<Send className="h-4 w-4" /> {t('admin.channels.apply')}
</Button>
</div>
@@ -256,9 +283,48 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
onEditApplicability={setApplicabilityLayer}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
{/* Копия сетки на другой канал: группы общие, поэтому переносятся только правила. */}
<div className="flex flex-col gap-1.5 border-t border-border pt-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.copyTemplate')}
</span>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value={copyToChannel}
onChange={(e) => setCopyToChannel(e.target.value)}
>
<option value="">{t('admin.channels.pickTargetChannel')}</option>
{(channels ?? [])
.filter((c) => c.id !== channelId)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<Button
size="sm"
variant="outline"
disabled={!copyToChannel || copyTemplateMutation.isPending}
onClick={() => copyTemplateMutation.mutate(copyToChannel)}
>
{t('admin.channels.copy')}
</Button>
<p className="text-xs text-muted-foreground">
{t('admin.channels.copyTemplateHint')}
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<TemplateIssues
channelId={channelId}
slotsById={
new Map(template.layers.flatMap((l) => l.slots).map((slot) => [slot.id, slot]))
}
onGoToSlot={openSlot}
/>
<TemplatePreview channelId={channelId} />
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
@@ -359,6 +425,8 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
<ViewerCard channel={channel} onSaved={invalidate} onError={onError} />
{applicabilityLayer && (
<LayerApplicabilityDialog
layer={applicabilityLayer}
@@ -368,7 +436,25 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
/>
)}
<SchedulePreview entries={schedule ?? []} />
<SchedulePreview entries={schedule ?? []} onShowTrace={setTraceEntryId} />
{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>
)
}
@@ -6,17 +6,22 @@ import type {
BumperTrigger,
ChannelDto,
ChannelSummaryDto,
CopyTemplateResultDto,
CreatedIdResponse,
EntryTraceDto,
JunctionAmountMode,
JunctionConditions,
JunctionElementKind,
JunctionTemplateDto,
LayerApplicability,
PlanningRules,
ScheduleDiffDto,
ScheduleEntryDto,
SchedulePreviewDto,
ScheduleTemplateDto,
SlotDto,
TemplateIssueDto,
ViewerSettings,
} from '@/shared/api/types'
export function listChannels() {
@@ -43,6 +48,11 @@ export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
}
/** Оверлеи и аналоговый фильтр канала — как он выглядит у зрителя. */
export function updateViewerSettings(id: string, body: ViewerSettings) {
return apiRequest<void>(`/admin/channels/${id}/viewer`, { method: 'PUT', body })
}
/** Номер канала и его время: смещение от UTC и начало вещательных суток. */
export function updateChannelTime(
id: string,
@@ -64,6 +74,29 @@ export function applyChannelTemplate(channelId: string) {
})
}
/** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */
export function getTemplateIssues(channelId: string) {
return apiRequest<TemplateIssueDto[]>(`/admin/channels/${channelId}/template/issues`)
}
/** Что изменится в эфире, если применить сейчас. Прогон сухой — лента не трогается. */
export function getApplyDiff(channelId: string) {
return apiRequest<ScheduleDiffDto>(`/admin/channels/${channelId}/template/diff`)
}
/** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */
export function copyTemplateTo(channelId: string, targetChannelId: string) {
return apiRequest<CopyTemplateResultDto>(
`/admin/channels/${channelId}/template/copy-to/${targetChannelId}`,
{ method: 'POST' },
)
}
/** Цепочка происхождения записи, записанная в момент генерации. */
export function getEntryTrace(entryId: string) {
return apiRequest<EntryTraceDto>(`/admin/channels/entries/${entryId}/trace`)
}
/** Сухой прогон по текущим правилам: ничего не пишет и не двигает курсоры слотов. */
export function previewTemplate(channelId: string, days: number) {
const query = new URLSearchParams({ days: String(days) })
@@ -0,0 +1,102 @@
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { cn } from '@/shared/lib/cn'
import { getApplyDiff } from '../api'
import { formatChannelTime } from '../lib/format'
/**
* Диф перед применением (см. 6.6). Изменения в ближайшие сутки подсвечены отдельно — это самая
* частая причина случайного ущерба: у зрителя из-под носа уезжает то, что он уже видит в программе.
*/
export function ApplyDialog({
channelId,
utcOffsetMinutes,
pending,
onApply,
onClose,
}: {
channelId: string
utcOffsetMinutes: number
pending: boolean
onApply: () => void
onClose: () => void
}) {
const { t } = useTranslation()
const { data, isFetching } = useQuery({
queryKey: ['admin', 'channels', channelId, 'diff'],
queryFn: () => getApplyDiff(channelId),
staleTime: 0,
gcTime: 0,
})
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('admin.channels.apply')}</DialogTitle>
<DialogDescription>
{isFetching || !data
? t('common.loading')
: t('admin.channels.diffSummary', { total: data.total, changed: data.changed })}
</DialogDescription>
</DialogHeader>
{data && (
<div className="flex flex-col gap-2 text-sm">
{data.changedSoon > 0 && (
<p className="flex items-center gap-1.5 text-amber-500">
<AlertTriangle className="h-4 w-4 shrink-0" />
{t('admin.channels.diffSoon', { count: data.changedSoon })}
</p>
)}
{data.changes.length === 0 ? (
<p className="text-muted-foreground">{t('admin.channels.diffNoChanges')}</p>
) : (
<ul className="max-h-80 divide-y divide-border overflow-y-auto text-xs">
{data.changes.map((change, index) => (
<li
key={`${change.startsAtUtc}-${index}`}
className={cn('flex items-center gap-2 py-1', change.soon && 'text-amber-500')}
>
<span className="w-24 shrink-0 tabular-nums text-muted-foreground">
{formatChannelTime(change.startsAtUtc, utcOffsetMinutes)}
</span>
<span className="min-w-0 flex-1 truncate">{change.before ?? '—'}</span>
<span className="shrink-0 text-muted-foreground"></span>
<span className="min-w-0 flex-1 truncate">{change.after ?? '—'}</span>
</li>
))}
</ul>
)}
{data.changed > data.changes.length && (
<p className="text-xs text-muted-foreground">
{t('admin.channels.andMore', { count: data.changed - data.changes.length })}
</p>
)}
</div>
)}
<DialogFooter>
<Button size="sm" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button size="sm" disabled={pending} onClick={onApply}>
{t('admin.channels.apply')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,104 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { getEntryTrace } from '../api'
import { formatChannelTime } from '../lib/format'
/**
* «Почему это здесь» (см. 6.5): цепочка происхождения записи. Трейс пишется в момент генерации —
* восстановить его потом нельзя, поэтому у старых записей часть строк будет пустой.
*/
export function EntryTraceDialog({
entryId,
utcOffsetMinutes,
onClose,
}: {
entryId: string
utcOffsetMinutes: number
onClose: () => void
}) {
const { t } = useTranslation()
const { data } = useQuery({
queryKey: ['admin', 'entries', entryId, 'trace'],
queryFn: () => getEntryTrace(entryId),
})
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{data
? `${data.showName ?? '—'} · ${formatChannelTime(data.startsAtUtc, utcOffsetMinutes)}`
: t('common.loading')}
</DialogTitle>
</DialogHeader>
{data && (
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1.5 text-sm">
<Row label={t('admin.channels.traceLayer')}>
{data.layerName
? `${data.layerName}${data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''}`
: null}
</Row>
<Row label={t('admin.channels.traceSlot')}>
{data.slotTitle
? [
data.slotTitle,
data.slotWeekday === null
? t('admin.channels.everyDay')
: t(`admin.channels.weekdays.${data.slotWeekday}`),
data.slotTargetStart?.slice(0, 5),
data.slotDurationMinutes ? `${data.slotDurationMinutes} мин` : null,
data.driftMinutes !== 0
? t('admin.channels.traceDrift', { minutes: data.driftMinutes })
: null,
data.snapped ? t('admin.channels.traceSnapped') : null,
]
.filter(Boolean)
.join(' · ')
: null}
</Row>
<Row label={t('admin.channels.traceGroup')}>
{data.groupName
? `${data.groupName}${data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''}`
: null}
</Row>
<Row label={t('admin.channels.traceStrategy')}>
{data.strategy
? [
t(`admin.channels.strategies.${data.strategy}`),
data.cooldownDays
? t('admin.channels.traceCooldown', { days: data.cooldownDays })
: null,
data.candidatesAfterCooldown !== null
? t('admin.channels.traceCandidates', {
count: data.candidatesAfterCooldown,
})
: null,
]
.filter(Boolean)
.join(' · ')
: null}
</Row>
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
</dl>
)}
</DialogContent>
</Dialog>
)
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<>
<dt className="text-muted-foreground">{label}</dt>
<dd>{children || '—'}</dd>
</>
)
}
@@ -1,189 +1,240 @@
import { useMutation } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
SHOW_AUDIENCES,
type AudienceWindow,
type PlanningRules,
type ScheduleTemplateDto,
type ShowAudience,
} from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { updateTemplate } from '../api'
import { CollapsibleCard } from './CollapsibleCard'
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'Teen' }
/**
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
*/
export function RulesCard({
template,
onChanged,
onError,
}: {
template: ScheduleTemplateDto
onChanged: () => void
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [windows, setWindows] = useState<AudienceWindow[]>(
() => template.rules?.maxAudienceByTime ?? [],
)
const [limitOn, setLimitOn] = useState(() => template.rules?.maxRepeatsInWindow != null)
const [windowDays, setWindowDays] = useState(
() => template.rules?.maxRepeatsInWindow?.windowDays ?? 7,
)
const [max, setMax] = useState(() => template.rules?.maxRepeatsInWindow?.max ?? 2)
useEffect(() => {
setWindows(template.rules?.maxAudienceByTime ?? [])
setLimitOn(template.rules?.maxRepeatsInWindow != null)
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
}, [template])
const save = useMutation({
mutationFn: () => {
const rules: PlanningRules = {
maxAudienceByTime: windows.length > 0 ? windows : null,
maxRepeatsInWindow: limitOn ? { windowDays, max } : null,
}
return updateTemplate(template.id, {
name: template.name,
fallbackGroupId: template.fallbackGroupId,
defaultJunctionId: template.defaultJunctionId,
rules,
})
},
onSuccess: onChanged,
onError,
})
const patchWindow = (index: number, part: Partial<AudienceWindow>) =>
setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w)))
return (
<CollapsibleCard title={t('admin.channels.rules')}>
<div className="flex flex-col gap-4 text-sm">
<p className="text-xs text-muted-foreground">{t('admin.channels.rulesHint')}</p>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.audienceWindows')}
</h3>
<Button
size="sm"
variant="ghost"
onClick={() => setWindows((c) => [...c, EMPTY_WINDOW])}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{windows.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('admin.channels.noAudienceWindows')}</p>
) : (
<ul className="flex flex-col gap-2">
{windows.map((window, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.from')}</Label>
<Input
type="time"
className="w-28"
value={window.from.slice(0, 5)}
onChange={(e) => patchWindow(index, { from: `${e.target.value}:00` })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.to')}</Label>
<Input
type="time"
className="w-28"
value={window.to.slice(0, 5)}
onChange={(e) => patchWindow(index, { to: `${e.target.value}:00` })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.maxAudience')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={window.maxAudience}
onChange={(e) =>
patchWindow(index, { maxAudience: e.target.value as ShowAudience })
}
>
{SHOW_AUDIENCES.map((value) => (
<option key={value} value={value}>
{t(`admin.shows.audiences.${value}`)}
</option>
))}
</select>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => setWindows((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
<p className="text-xs text-muted-foreground">{t('admin.channels.audienceWindowsHint')}</p>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={limitOn}
onChange={(e) => setLimitOn(e.target.checked)}
/>
{t('admin.channels.repeatLimit')}
</label>
{limitOn && (
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatWindowDays')}</Label>
<Input
type="number"
min={1}
max={365}
className="w-28"
value={windowDays}
onChange={(e) => setWindowDays(Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatMax')}</Label>
<Input
type="number"
min={1}
className="w-28"
value={max}
onChange={(e) => setMax(Number(e.target.value))}
/>
</div>
</div>
)}
<p className="text-xs text-muted-foreground">{t('admin.channels.repeatLimitHint')}</p>
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
</CollapsibleCard>
)
}
import { useMutation } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
SHOW_AUDIENCES,
type AudienceWindow,
type PlanningRules,
type ScheduleTemplateDto,
type ShowAudience,
} from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { updateTemplate } from '../api'
import { CollapsibleCard } from './CollapsibleCard'
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'Teen' }
/**
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
*/
export function RulesCard({
template,
onChanged,
onError,
}: {
template: ScheduleTemplateDto
onChanged: () => void
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [windows, setWindows] = useState<AudienceWindow[]>(
() => template.rules?.maxAudienceByTime ?? [],
)
const [limitOn, setLimitOn] = useState(() => template.rules?.maxRepeatsInWindow != null)
const [windowDays, setWindowDays] = useState(
() => template.rules?.maxRepeatsInWindow?.windowDays ?? 7,
)
const [max, setMax] = useState(() => template.rules?.maxRepeatsInWindow?.max ?? 2)
const [breakCap, setBreakCap] = useState(() => template.rules?.maxBreakMinutesPerHour ?? 0)
const [genreCap, setGenreCap] = useState(() => template.rules?.maxGenreSharePercent ?? 0)
const [fallbackCap, setFallbackCap] = useState(() => template.rules?.maxFallbackSharePercent ?? 0)
useEffect(() => {
setWindows(template.rules?.maxAudienceByTime ?? [])
setLimitOn(template.rules?.maxRepeatsInWindow != null)
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
setBreakCap(template.rules?.maxBreakMinutesPerHour ?? 0)
setGenreCap(template.rules?.maxGenreSharePercent ?? 0)
setFallbackCap(template.rules?.maxFallbackSharePercent ?? 0)
}, [template])
const save = useMutation({
mutationFn: () => {
const rules: PlanningRules = {
maxAudienceByTime: windows.length > 0 ? windows : null,
maxRepeatsInWindow: limitOn ? { windowDays, max } : null,
// Ноль означает «не проверять»: отдельного выключателя на каждый порог не нужно.
maxBreakMinutesPerHour: breakCap > 0 ? breakCap : null,
maxGenreSharePercent: genreCap > 0 ? genreCap : null,
maxFallbackSharePercent: fallbackCap > 0 ? fallbackCap : null,
}
return updateTemplate(template.id, {
name: template.name,
fallbackGroupId: template.fallbackGroupId,
defaultJunctionId: template.defaultJunctionId,
rules,
})
},
onSuccess: onChanged,
onError,
})
const patchWindow = (index: number, part: Partial<AudienceWindow>) =>
setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w)))
return (
<CollapsibleCard title={t('admin.channels.rules')}>
<div className="flex flex-col gap-4 text-sm">
<p className="text-xs text-muted-foreground">{t('admin.channels.rulesHint')}</p>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.audienceWindows')}
</h3>
<Button
size="sm"
variant="ghost"
onClick={() => setWindows((c) => [...c, EMPTY_WINDOW])}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{windows.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('admin.channels.noAudienceWindows')}</p>
) : (
<ul className="flex flex-col gap-2">
{windows.map((window, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.from')}</Label>
<Input
type="time"
className="w-28"
value={window.from.slice(0, 5)}
onChange={(e) => patchWindow(index, { from: `${e.target.value}:00` })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.to')}</Label>
<Input
type="time"
className="w-28"
value={window.to.slice(0, 5)}
onChange={(e) => patchWindow(index, { to: `${e.target.value}:00` })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.maxAudience')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={window.maxAudience}
onChange={(e) =>
patchWindow(index, { maxAudience: e.target.value as ShowAudience })
}
>
{SHOW_AUDIENCES.map((value) => (
<option key={value} value={value}>
{t(`admin.shows.audiences.${value}`)}
</option>
))}
</select>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => setWindows((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
<p className="text-xs text-muted-foreground">{t('admin.channels.audienceWindowsHint')}</p>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={limitOn}
onChange={(e) => setLimitOn(e.target.checked)}
/>
{t('admin.channels.repeatLimit')}
</label>
{limitOn && (
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatWindowDays')}</Label>
<Input
type="number"
min={1}
max={365}
className="w-28"
value={windowDays}
onChange={(e) => setWindowDays(Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatMax')}</Label>
<Input
type="number"
min={1}
className="w-28"
value={max}
onChange={(e) => setMax(Number(e.target.value))}
/>
</div>
</div>
)}
<p className="text-xs text-muted-foreground">{t('admin.channels.repeatLimitHint')}</p>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.postChecks')}
</h3>
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.breakLimit')}</Label>
<Input
type="number"
min={0}
className="w-28"
value={breakCap}
onChange={(e) => setBreakCap(Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.genreShare')}</Label>
<Input
type="number"
min={0}
max={100}
className="w-28"
value={genreCap}
onChange={(e) => setGenreCap(Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.fallbackShare')}</Label>
<Input
type="number"
min={0}
max={100}
className="w-28"
value={fallbackCap}
onChange={(e) => setFallbackCap(Number(e.target.value))}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('admin.channels.postChecksHint')}</p>
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
</CollapsibleCard>
)
}
@@ -1,52 +1,67 @@
import { useTranslation } from 'react-i18next'
import type { ScheduleEntryDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { formatTime } from '../lib/format'
export function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
const { t } = useTranslation()
if (entries.length === 0)
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return (
<ul className="flex flex-col divide-y divide-border text-sm">
{entries.slice(0, 40).map((e) => (
<li key={e.id} className="flex items-center gap-3 py-1.5">
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
{formatTime(e.startsAtUtc)}
</span>
{e.kind === 'Ad' ? (
<Badge variant="muted">{t('air.ad')}</Badge>
) : e.kind === 'Bumper' ? (
<span className="flex min-w-0 items-center gap-2">
<Badge variant="muted" className="shrink-0">
{t('air.bumper')}
</Badge>
{(e.bumperName || e.bumperText) && (
<span className="min-w-0 truncate text-muted-foreground">
{e.bumperName}
{e.bumperName && e.bumperText ? ' · ' : ''}
{e.bumperText}
</span>
)}
</span>
) : (
<span>
{e.showName ?? ''}
{e.seasonEpisode ? (
<span className="text-muted-foreground"> · {e.seasonEpisode}</span>
) : (
e.episodeIndex != null && (
<span className="text-muted-foreground">
{' '}
· {t('air.episode')} {e.episodeIndex + 1}
</span>
)
)}
</span>
)}
</li>
))}
</ul>
)
}
import { HelpCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { ScheduleEntryDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { formatTime } from '../lib/format'
export function SchedulePreview({
entries,
onShowTrace,
}: {
entries: ScheduleEntryDto[]
onShowTrace: (entryId: string) => void
}) {
const { t } = useTranslation()
if (entries.length === 0)
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return (
<ul className="flex flex-col divide-y divide-border text-sm">
{entries.slice(0, 40).map((e) => (
<li key={e.id} className="group flex items-center gap-3 py-1.5">
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
{formatTime(e.startsAtUtc)}
</span>
{e.kind === 'Ad' ? (
<Badge variant="muted">{t('air.ad')}</Badge>
) : e.kind === 'Bumper' ? (
<span className="flex min-w-0 items-center gap-2">
<Badge variant="muted" className="shrink-0">
{t('air.bumper')}
</Badge>
{(e.bumperName || e.bumperText) && (
<span className="min-w-0 truncate text-muted-foreground">
{e.bumperName}
{e.bumperName && e.bumperText ? ' · ' : ''}
{e.bumperText}
</span>
)}
</span>
) : (
<span>
{e.showName ?? '—'}
{e.seasonEpisode ? (
<span className="text-muted-foreground"> · {e.seasonEpisode}</span>
) : (
e.episodeIndex != null && (
<span className="text-muted-foreground">
{' '}
· {t('air.episode')} {e.episodeIndex + 1}
</span>
)
)}
</span>
)}
<button
type="button"
title={t('admin.channels.whyHere')}
className="ml-auto shrink-0 text-muted-foreground opacity-0 hover:text-foreground group-hover:opacity-100"
onClick={() => onShowTrace(e.id)}
>
<HelpCircle className="h-4 w-4" />
</button>
</li>
))}
</ul>
)
}
@@ -0,0 +1,88 @@
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, CircleAlert } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { SlotDto, TemplateIssueDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { getTemplateIssues } from '../api'
/**
* Проверки по правилам (см. 5.1). Считаются на сервере по шаблону, без прогона генератора, поэтому
* показываются прямо в редакторе и обновляются вместе с сеткой.
*/
export function TemplateIssues({
channelId,
slotsById,
onGoToSlot,
}: {
channelId: string
slotsById: Map<string, SlotDto>
onGoToSlot: (slot: SlotDto) => void
}) {
const { t } = useTranslation()
const { data: issues } = useQuery({
queryKey: ['admin', 'channels', channelId, 'issues'],
queryFn: () => getTemplateIssues(channelId),
})
if (!issues || issues.length === 0) return null
const errors = issues.filter((i) => i.severity === 'Error')
const warnings = issues.filter((i) => i.severity === 'Warning')
return (
<div className="flex flex-col gap-1 rounded-md border border-border px-3 py-2 text-xs">
<span className="font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.issues', { errors: errors.length, warnings: warnings.length })}
</span>
<ul className="flex flex-col gap-0.5">
{[...errors, ...warnings].map((issue, index) => (
<IssueRow
key={`${issue.kind}-${index}`}
issue={issue}
slot={issue.slotId ? slotsById.get(issue.slotId) : undefined}
onGoToSlot={onGoToSlot}
/>
))}
</ul>
</div>
)
}
function IssueRow({
issue,
slot,
onGoToSlot,
}: {
issue: TemplateIssueDto
slot: SlotDto | undefined
onGoToSlot: (slot: SlotDto) => void
}) {
const { t } = useTranslation()
const Icon = issue.severity === 'Error' ? CircleAlert : AlertTriangle
return (
<li className="flex items-start gap-1.5">
<Icon
className={cn(
'mt-0.5 h-3 w-3 shrink-0',
issue.severity === 'Error' ? 'text-red-500' : 'text-amber-500',
)}
/>
<span className="min-w-0 flex-1">
<span className="text-muted-foreground">
{t(`admin.channels.issueKinds.${issue.kind}`)}:{' '}
</span>
{issue.details}
</span>
{slot && (
<button
type="button"
className="shrink-0 text-primary hover:underline"
onClick={() => onGoToSlot(slot)}
>
{t('admin.channels.goToSlot')}
</button>
)}
</li>
)
}
@@ -1,205 +1,327 @@
import { useQuery } from '@tanstack/react-query'
import { Eye } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { cn } from '@/shared/lib/cn'
import { previewTemplate } from '../api'
import { channelTime, formatChannelTime } from '../lib/format'
const KIND_COLORS: Record<PlannedItemKind, string> = {
Program: 'bg-primary/70',
Fallback: 'bg-muted-foreground/40',
SignOff: 'bg-slate-500/60',
Ad: 'bg-amber-500/70',
Promo: 'bg-sky-500/70',
Bumper: 'bg-violet-500/70',
}
/** Что видит зритель как программу — врезки в программу передач не попадают. */
const PROGRAMME_KINDS: PlannedItemKind[] = ['Program', 'Fallback', 'SignOff']
/**
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
*/
export function TemplatePreview({ channelId }: { channelId: string }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [days, setDays] = useState(1)
const [tab, setTab] = useState<'programme' | 'tape'>('programme')
const { data, isFetching } = useQuery({
queryKey: ['admin', 'channels', channelId, 'preview', days],
queryFn: () => previewTemplate(channelId, days),
enabled: open,
// Черновик правил может меняться между открытиями — кэшировать прогон смысла нет.
staleTime: 0,
gcTime: 0,
})
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setOpen((v) => !v)}>
<Eye className="h-4 w-4" />
{open ? t('admin.channels.previewHide') : t('admin.channels.preview')}
</Button>
{open && (
<>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value={days}
onChange={(e) => setDays(Number(e.target.value))}
>
{[1, 3, 7].map((value) => (
<option key={value} value={value}>
{t('admin.channels.previewDays', { count: value })}
</option>
))}
</select>
<span className="text-xs text-muted-foreground">
{isFetching ? t('common.loading') : t('admin.channels.previewHint')}
</span>
</>
)}
</div>
{open && data && (
<div className="flex flex-col gap-3">
<div className="flex gap-2 border-b border-border text-xs uppercase tracking-wide">
{(['programme', 'tape'] as const).map((value) => (
<button
key={value}
type="button"
onClick={() => setTab(value)}
className={cn(
'pb-1 text-muted-foreground hover:text-foreground',
tab === value && 'border-b-2 border-primary text-primary',
)}
>
{t(`admin.channels.previewTabs.${value}`)}
</button>
))}
</div>
{tab === 'programme' ? <Programme preview={data} /> : <Tape preview={data} />}
{data.warnings.length > 0 && (
<ul className="flex flex-col gap-1 text-xs text-amber-500">
{data.warnings.map((warning, index) => (
<li key={`${warning.kind}-${index}`}>
{t(`admin.channels.warnings.${warning.kind}`)}: {warning.details}
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
function Programme({ preview }: { preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const items = preview.items.filter((i) => PROGRAMME_KINDS.includes(i.kind))
if (items.length === 0)
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return (
<ul className="flex flex-col divide-y divide-border text-sm">
{items.map((item, index) => (
<li key={`${item.startsAtUtc}-${index}`} className="flex items-center gap-3 py-1.5">
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
</span>
<span className="min-w-0 flex-1 truncate">
{item.title ?? t(`admin.channels.previewKinds.${item.kind}`)}
</span>
{item.slotTitle && (
<span className="shrink-0 text-xs text-muted-foreground">{item.slotTitle}</span>
)}
</li>
))}
</ul>
)
}
/** Час вещания → сколько в нём минут врезок. По ним же строится гистограмма нагрузки. */
function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number }[] {
const buckets = new Map<number, number>()
for (const item of preview.items) {
if (item.kind !== 'Ad' && item.kind !== 'Promo' && item.kind !== 'Bumper') continue
const start = channelTime(item.startsAtUtc, preview.utcOffsetMinutes)
const hour = Date.UTC(
start.getUTCFullYear(),
start.getUTCMonth(),
start.getUTCDate(),
start.getUTCHours(),
)
const minutes = (new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
buckets.set(hour, (buckets.get(hour) ?? 0) + minutes)
}
return [...buckets.entries()]
.sort((a, b) => a[0] - b[0])
.map(([hour, minutes]) => ({ hour: new Date(hour), minutes }))
}
function Tape({ preview }: { preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const load = useMemo(() => loadByHour(preview), [preview])
const peak = Math.max(1, ...load.map((l) => l.minutes))
if (preview.items.length === 0)
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return (
<div className="flex flex-col gap-4">
{load.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
{t('admin.channels.previewLoad', { peak: Math.round(peak) })}
</span>
<div className="flex h-16 items-end gap-px">
{load.map((bucket) => (
<div
key={bucket.hour.toISOString()}
className="flex-1 bg-amber-500/70"
style={{ height: `${(bucket.minutes / peak) * 100}%` }}
title={`${String(bucket.hour.getUTCHours()).padStart(2, '0')}:00 · ${Math.round(bucket.minutes)} ${t('admin.groups.minutesShort')}`}
/>
))}
</div>
</div>
)}
<ul className="flex flex-col gap-0.5 text-xs">
{preview.items.map((item, index) => (
<TapeRow key={`${item.startsAtUtc}-${index}`} item={item} preview={preview} />
))}
</ul>
</div>
)
}
function TapeRow({ item, preview }: { item: PreviewItemDto; preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const minutes =
(new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
return (
<li className="flex items-center gap-2">
<span className="w-10 shrink-0 tabular-nums text-muted-foreground">
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
</span>
<span className={cn('h-2 shrink-0 rounded-sm', KIND_COLORS[item.kind])} style={{ width: `${Math.max(4, minutes * 2)}px` }} />
<Badge variant="muted" className="shrink-0">
{t(`admin.channels.previewKinds.${item.kind}`)}
</Badge>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{item.title ?? ''}</span>
</li>
)
}
import { useQuery } from '@tanstack/react-query'
import { Eye } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { cn } from '@/shared/lib/cn'
import { previewTemplate } from '../api'
import { channelTime, formatChannelTime } from '../lib/format'
import { toIsoDate } from '../lib/applicability'
const KIND_COLORS: Record<PlannedItemKind, string> = {
Program: 'bg-primary/70',
Fallback: 'bg-muted-foreground/40',
SignOff: 'bg-slate-500/60',
Ad: 'bg-amber-500/70',
Promo: 'bg-sky-500/70',
Bumper: 'bg-violet-500/70',
}
/** Что видит зритель как программу — врезки в программу передач не попадают. */
const PROGRAMME_KINDS: PlannedItemKind[] = ['Program', 'Fallback', 'SignOff']
/**
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
*/
export function TemplatePreview({ channelId }: { channelId: string }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [days, setDays] = useState(1)
const [tab, setTab] = useState<'programme' | 'tape' | 'problems'>('programme')
const { data, isFetching } = useQuery({
queryKey: ['admin', 'channels', channelId, 'preview', days],
queryFn: () => previewTemplate(channelId, days),
enabled: open,
// Черновик правил может меняться между открытиями — кэшировать прогон смысла нет.
staleTime: 0,
gcTime: 0,
})
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setOpen((v) => !v)}>
<Eye className="h-4 w-4" />
{open ? t('admin.channels.previewHide') : t('admin.channels.preview')}
</Button>
{open && (
<>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value={days}
onChange={(e) => setDays(Number(e.target.value))}
>
{[1, 3, 7].map((value) => (
<option key={value} value={value}>
{t('admin.channels.previewDays', { count: value })}
</option>
))}
</select>
<span className="text-xs text-muted-foreground">
{isFetching ? t('common.loading') : t('admin.channels.previewHint')}
</span>
</>
)}
</div>
{open && data && (
<div className="flex flex-col gap-3">
<div className="flex gap-2 border-b border-border text-xs uppercase tracking-wide">
{(['programme', 'tape', 'problems'] as const).map((value) => (
<button
key={value}
type="button"
onClick={() => setTab(value)}
className={cn(
'pb-1 text-muted-foreground hover:text-foreground',
tab === value && 'border-b-2 border-primary text-primary',
)}
>
{t(`admin.channels.previewTabs.${value}`)}
{value === 'problems' && data.warnings.length > 0 && ` · ${data.warnings.length}`}
</button>
))}
</div>
{tab === 'programme' && <Programme preview={data} />}
{tab === 'tape' && <Tape preview={data} />}
{tab === 'problems' && <Problems preview={data} />}
</div>
)}
</div>
)
}
function Programme({ preview }: { preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const items = preview.items.filter((i) => PROGRAMME_KINDS.includes(i.kind))
if (items.length === 0)
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return (
<ul className="flex flex-col divide-y divide-border text-sm">
{items.map((item, index) => (
<li key={`${item.startsAtUtc}-${index}`} className="flex items-center gap-3 py-1.5">
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
</span>
<span className="min-w-0 flex-1 truncate">
{item.title ?? t(`admin.channels.previewKinds.${item.kind}`)}
</span>
{item.slotTitle && (
<span className="shrink-0 text-xs text-muted-foreground">{item.slotTitle}</span>
)}
</li>
))}
</ul>
)
}
/** Час вещания → сколько в нём минут врезок. По ним же строится гистограмма нагрузки. */
function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number }[] {
const buckets = new Map<number, number>()
for (const item of preview.items) {
if (item.kind !== 'Ad' && item.kind !== 'Promo' && item.kind !== 'Bumper') continue
const start = channelTime(item.startsAtUtc, preview.utcOffsetMinutes)
const hour = Date.UTC(
start.getUTCFullYear(),
start.getUTCMonth(),
start.getUTCDate(),
start.getUTCHours(),
)
const minutes = (new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
buckets.set(hour, (buckets.get(hour) ?? 0) + minutes)
}
return [...buckets.entries()]
.sort((a, b) => a[0] - b[0])
.map(([hour, minutes]) => ({ hour: new Date(hour), minutes }))
}
function Tape({ preview }: { preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const load = useMemo(() => loadByHour(preview), [preview])
const peak = Math.max(1, ...load.map((l) => l.minutes))
if (preview.items.length === 0)
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return (
<div className="flex flex-col gap-4">
{load.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
{t('admin.channels.previewLoad', { peak: Math.round(peak) })}
</span>
<div className="flex h-16 items-end gap-px">
{load.map((bucket) => (
<div
key={bucket.hour.toISOString()}
className="flex-1 bg-amber-500/70"
style={{ height: `${(bucket.minutes / peak) * 100}%` }}
title={`${String(bucket.hour.getUTCHours()).padStart(2, '0')}:00 · ${Math.round(bucket.minutes)} ${t('admin.groups.minutesShort')}`}
/>
))}
</div>
</div>
)}
<ul className="flex flex-col gap-0.5 text-xs">
{preview.items.map((item, index) => (
<TapeRow key={`${item.startsAtUtc}-${index}`} item={item} preview={preview} />
))}
</ul>
</div>
)
}
function TapeRow({ item, preview }: { item: PreviewItemDto; preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const minutes =
(new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
return (
<li className="flex items-center gap-2">
<span className="w-10 shrink-0 tabular-nums text-muted-foreground">
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
</span>
<span className={cn('h-2 shrink-0 rounded-sm', KIND_COLORS[item.kind])} style={{ width: `${Math.max(4, minutes * 2)}px` }} />
<Badge variant="muted" className="shrink-0">
{t(`admin.channels.previewKinds.${item.kind}`)}
</Badge>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{item.title ?? ''}</span>
</li>
)
}
/** Предупреждения, сгруппированные по виду: десять однотипных строк читаются как одна проблема. */
function Problems({ preview }: { preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const grouped = useMemo(() => {
const map = new Map<string, string[]>()
for (const warning of preview.warnings) {
const list = map.get(warning.kind) ?? []
list.push(warning.details)
map.set(warning.kind, list)
}
return [...map.entries()]
}, [preview])
return (
<div className="flex flex-col gap-4">
{grouped.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.channels.noProblems')}</p>
) : (
<ul className="flex flex-col gap-2 text-xs">
{grouped.map(([kind, details]) => (
<li key={kind} className="flex flex-col gap-0.5">
<span className="font-medium text-amber-500">
{t(`admin.channels.warnings.${kind}`)} · {details.length}
</span>
{details.slice(0, 20).map((detail, index) => (
<span key={index} className="text-muted-foreground">
{detail}
</span>
))}
{details.length > 20 && (
<span className="text-muted-foreground">
{t('admin.channels.andMore', { count: details.length - 20 })}
</span>
)}
</li>
))}
</ul>
)}
<RepeatHeatmap preview={preview} />
</div>
)
}
/**
* Тепловая карта повторов: матрица «шоу × вещательные сутки», яркость — число показов. Сразу видно,
* что один фильм крутится четыре раза за неделю.
*/
function RepeatHeatmap({ preview }: { preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const { days, rows } = useMemo(() => {
const counts = new Map<string, Map<string, number>>()
const dayKeys = new Set<string>()
for (const item of preview.items) {
if (item.kind !== 'Program' || !item.title) continue
const day = toIsoDate(channelTime(item.startsAtUtc, preview.utcOffsetMinutes))
dayKeys.add(day)
const row = counts.get(item.title) ?? new Map<string, number>()
row.set(day, (row.get(day) ?? 0) + 1)
counts.set(item.title, row)
}
const sortedDays = [...dayKeys].sort()
const sortedRows = [...counts.entries()]
.map(([title, byDay]) => ({
title,
byDay,
total: [...byDay.values()].reduce((sum, n) => sum + n, 0),
}))
.sort((a, b) => b.total - a.total)
.slice(0, 25)
return { days: sortedDays, rows: sortedRows }
}, [preview])
if (rows.length === 0) return null
const peak = Math.max(1, ...rows.flatMap((row) => [...row.byDay.values()]))
return (
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.heatmap')}
</span>
<div className="overflow-x-auto">
<table className="text-[11px]">
<thead className="text-muted-foreground">
<tr>
<th className="px-1 text-left font-medium" />
{days.map((day) => (
<th key={day} className="px-1 font-medium">
{day.slice(8)}.{day.slice(5, 7)}
</th>
))}
<th className="px-1 font-medium">{t('admin.channels.heatmapTotal')}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.title}>
<td className="max-w-56 truncate px-1" title={row.title}>
{row.title}
</td>
{days.map((day) => {
const count = row.byDay.get(day) ?? 0
return (
<td key={day} className="px-0.5 py-0.5">
<span
className="block h-4 w-6 rounded-sm bg-primary text-center text-[10px] leading-4"
style={{ opacity: count === 0 ? 0.06 : 0.25 + (count / peak) * 0.75 }}
>
{count > 0 ? count : ''}
</span>
</td>
)
})}
<td className="px-1 tabular-nums text-muted-foreground">{row.total}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,146 @@
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery'
import type { ChannelDto, LogoCorner, ViewerSettings } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { updateViewerSettings } from '../api'
import { CollapsibleCard } from './CollapsibleCard'
const CORNERS: LogoCorner[] = ['TopLeft', 'TopRight', 'BottomLeft', 'BottomRight']
/**
* Как канал выглядит у зрителя (см. 6.8): логотип, часы, аналоговый фильтр. Всё рисуется на клиенте
* поверх видео и по умолчанию выключено — канал без логотипа и без шума остаётся нормальным каналом.
*/
export function ViewerCard({
channel,
onSaved,
onError,
}: {
channel: ChannelDto
onSaved: () => void
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [viewer, setViewer] = useState<ViewerSettings>(channel.viewer)
const [galleryOpen, setGalleryOpen] = useState(false)
useEffect(() => setViewer(channel.viewer), [channel])
const patch = (part: Partial<ViewerSettings>) => setViewer((prev) => ({ ...prev, ...part }))
const save = useMutation({
mutationFn: () => updateViewerSettings(channel.id, viewer),
onSuccess: onSaved,
onError,
})
return (
<CollapsibleCard title={t('admin.channels.viewer')}>
<div className="flex flex-col gap-4 text-sm">
<p className="text-xs text-muted-foreground">{t('admin.channels.viewerHint')}</p>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.logo')}</Label>
<div className="flex h-16 w-24 items-center justify-center rounded-md border border-border bg-muted/30">
{viewer.logoImageId ? (
<img
src={imageUrl(viewer.logoImageId)}
alt=""
className="h-full w-full object-contain"
/>
) : (
<span className="text-xs text-muted-foreground">{t('admin.channels.noLogo')}</span>
)}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{t('admin.channels.pickLogo')}
</Button>
{viewer.logoImageId && (
<Button size="sm" variant="ghost" onClick={() => patch({ logoImageId: null })}>
{t('common.delete')}
</Button>
)}
<ImageGallery
open={galleryOpen}
onOpenChange={setGalleryOpen}
category="Library"
onSelect={(image) => patch({ logoImageId: image.id })}
/>
</div>
{viewer.logoImageId && (
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.logoCorner')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={viewer.logoCorner}
onChange={(e) => patch({ logoCorner: e.target.value as LogoCorner })}
>
{CORNERS.map((corner) => (
<option key={corner} value={corner}>
{t(`admin.channels.corners.${corner}`)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.logoOpacity')}</Label>
<Input
type="number"
min={0}
max={1}
step={0.05}
className="w-28"
value={viewer.logoOpacity}
onChange={(e) => patch({ logoOpacity: clamp01(e.target.value) })}
/>
</div>
</div>
)}
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={viewer.showClock}
onChange={(e) => patch({ showClock: e.target.checked })}
/>
{t('admin.channels.showClock')}
</label>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.analogFilter')}</Label>
<Input
type="number"
min={0}
max={1}
step={0.1}
className="w-28"
value={viewer.analogFilterStrength}
onChange={(e) => patch({ analogFilterStrength: clamp01(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.analogFilterHint')}</p>
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
</CollapsibleCard>
)
}
/** Сила и прозрачность живут в 0..1: пустой ввод трактуем как ноль, а не как NaN. */
function clamp01(value: string): number {
const n = Number(value)
return Number.isNaN(n) ? 0 : Math.min(1, Math.max(0, n))
}