Enhance template and scheduling functionalities: add PlanningRules to template endpoints and commands, implement repeat limits and audience filtering in scheduling logic, and update related data structures. Refactor frontend components to support new rules and improve user experience in channel management.

This commit is contained in:
Leonid Pershin
2026-07-26 14:25:32 +03:00
parent 7b12a06d1b
commit 8494eb5e6a
30 changed files with 3400 additions and 438 deletions
@@ -8,29 +8,42 @@ import { HttpError } from '@/shared/api/client'
import type { GridLayerDto, SlotDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import {
applyChannelTemplate,
createLayer,
createSlot,
deleteLayer,
getChannel,
getChannelTemplate,
getSchedule,
toSlotBody,
updateLayer,
updateSlot,
} from './api'
import { BumperCard } from './components/BumperCard'
import { CollapsibleCard } from './components/CollapsibleCard'
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 { TemplatePreview } from './components/TemplatePreview'
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
import { toTime } from './lib/format'
export function ChannelDetail({ channelId }: { channelId: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [draft, setDraft] = useState<SlotDraft | null>(null)
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
const [viewDate, setViewDate] = useState<string>('')
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
// День, который копируем, и отмеченные дни-приёмники.
const [copySource, setCopySource] = useState<number | null>(null)
const [copyTargets, setCopyTargets] = useState<number[]>([])
const { data: channel, isLoading } = useQuery({
queryKey: ['admin', 'channels', channelId],
@@ -85,6 +98,82 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
onError,
})
const toggleLayerMutation = useMutation({
mutationFn: (layer: GridLayerDto) =>
updateLayer(layer.id, {
name: layer.name,
priority: layer.priority,
applicability: layer.applicability,
isEnabled: !layer.isEnabled,
}),
onSuccess: invalidate,
onError,
})
/**
* Порядок слоёв задаётся перетаскиванием, а хранится приоритетом. Раздаём приоритеты с шагом 10
* снизу вверх: шаг оставляет место, чтобы следующая вставка не переписывала весь список.
*/
const reorderLayersMutation = useMutation({
mutationFn: async (layerIdsTopFirst: string[]) => {
const byId = new Map(template!.layers.map((l) => [l.id, l]))
const total = layerIdsTopFirst.length
await Promise.all(
layerIdsTopFirst.map((id, index) => {
const layer = byId.get(id)
const priority = (total - index) * 10
if (!layer || layer.priority === priority) return Promise.resolve()
return updateLayer(id, {
name: layer.name,
priority,
applicability: layer.applicability,
isEnabled: layer.isEnabled,
})
}),
)
},
onSuccess: invalidate,
onError,
})
const moveSlotMutation = useMutation({
mutationFn: ({
slot,
weekday,
startMinutes,
}: {
slot: SlotDto
weekday: number
startMinutes: number
}) => updateSlot(slot.id, { ...toSlotBody(slot), weekday, targetStart: toTime(startMinutes) }),
onSuccess: invalidate,
onError,
})
const resizeSlotMutation = useMutation({
mutationFn: ({ slot, minutes }: { slot: SlotDto; minutes: number }) =>
updateSlot(slot.id, { ...toSlotBody(slot), targetDurationMinutes: minutes }),
onSuccess: invalidate,
onError,
})
/** Копирование дня: слоты «каждый день» не копируются — они и так есть во всех колонках. */
const copyDayMutation = useMutation({
mutationFn: async ({ from, to }: { from: number; to: number[] }) => {
const sources = (template?.layers ?? []).flatMap((layer) =>
layer.slots.filter((slot) => slot.weekday === from).map((slot) => ({ layer, slot })),
)
for (const weekday of to)
for (const { layer, slot } of sources)
await createSlot(layer.id, { ...toSlotBody(slot), weekday })
},
onSuccess: () => {
setCopySource(null)
invalidate()
},
onError,
})
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
const layerForNewSlot =
@@ -159,19 +248,90 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
<LayerList
template={template}
activeLayerId={layerForNewSlot ?? null}
viewDate={viewDate || null}
onSelect={(layer) => setActiveLayerId(layer.id)}
onDelete={(layer) => deleteLayerMutation.mutate(layer)}
onToggle={(layer) => toggleLayerMutation.mutate(layer)}
onReorder={(order) => reorderLayersMutation.mutate(order)}
onEditApplicability={setApplicabilityLayer}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
</div>
<div className="flex flex-col gap-3">
<TemplatePreview channelId={channelId} />
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
<Input
type="date"
className="h-8 w-40"
value={viewDate}
onChange={(e) => setViewDate(e.target.value)}
/>
{viewDate && (
<Button size="sm" variant="ghost" onClick={() => setViewDate('')}>
{t('admin.channels.allDates')}
</Button>
)}
</div>
{/* Копирование дня: сначала выбирается источник, потом дни-приёмники. */}
{copySource !== null && (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<span>
{t('admin.channels.copyDayFrom', {
day: t(`admin.channels.weekdays.${copySource}`),
})}
</span>
{[1, 2, 3, 4, 5, 6, 0]
.filter((day) => day !== copySource)
.map((day) => (
<label key={day} className="flex items-center gap-1">
<input
type="checkbox"
checked={copyTargets.includes(day)}
onChange={(e) =>
setCopyTargets((current) =>
e.target.checked
? [...current, day]
: current.filter((d) => d !== day),
)
}
/>
{t(`admin.channels.weekdays.${day}`)}
</label>
))}
<Button
size="sm"
disabled={copyTargets.length === 0 || copyDayMutation.isPending}
onClick={() =>
copyDayMutation.mutate({ from: copySource, to: copyTargets })
}
>
{t('admin.channels.copy')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setCopySource(null)}>
{t('common.cancel')}
</Button>
</div>
)}
<ScheduleGrid
template={template}
selectedSlotId={draft?.slot?.id ?? null}
viewDate={viewDate || null}
onSelectSlot={openSlot}
onAddSlot={openNewSlot}
onMoveSlot={(slot, weekday, startMinutes) =>
moveSlotMutation.mutate({ slot, weekday, startMinutes })
}
onResizeSlot={(slot, minutes) => resizeSlotMutation.mutate({ slot, minutes })}
onCopyDay={(weekday) => {
setCopySource(weekday)
setCopyTargets([])
}}
/>
{draft && (
<SlotInspector
@@ -186,6 +346,10 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
</CollapsibleCard>
)}
{template && (
<RulesCard template={template} onChanged={invalidate} onError={onError} />
)}
<JunctionsCard
channel={channel}
template={template}
@@ -195,6 +359,15 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
{applicabilityLayer && (
<LayerApplicabilityDialog
layer={applicabilityLayer}
onClose={() => setApplicabilityLayer(null)}
onChanged={invalidate}
onError={onError}
/>
)}
<SchedulePreview entries={schedule ?? []} />
</div>
)
+13 -1
View File
@@ -12,6 +12,7 @@ import type {
JunctionElementKind,
JunctionTemplateDto,
LayerApplicability,
PlanningRules,
ScheduleEntryDto,
SchedulePreviewDto,
ScheduleTemplateDto,
@@ -73,7 +74,12 @@ export function previewTemplate(channelId: string, days: number) {
export function updateTemplate(
templateId: string,
body: { name: string; fallbackGroupId: string | null; defaultJunctionId: string | null },
body: {
name: string
fallbackGroupId: string | null
defaultJunctionId: string | null
rules: PlanningRules | null
},
) {
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
}
@@ -104,6 +110,12 @@ export function deleteLayer(layerId: string) {
/** Тело слота: то же для создания и правки (см. SlotInput на сервере). */
export type SlotBody = Omit<SlotDto, 'id' | 'layerId' | 'groupName'>
/** Слот из ответа сервера → тело запроса: отбрасываем то, что сервер проставляет сам. */
export function toSlotBody(slot: SlotDto): SlotBody {
const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot
return body
}
export function createSlot(layerId: string, body: SlotBody) {
return apiRequest<CreatedIdResponse>(`/admin/layers/${layerId}/slots`, { method: 'POST', body })
}
@@ -97,6 +97,7 @@ export function JunctionsCard({
name: template!.name,
fallbackGroupId: template!.fallbackGroupId,
defaultJunctionId: junctionId,
rules: template!.rules,
}),
onSuccess: onChanged,
onError,
@@ -0,0 +1,300 @@
import { useMutation } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { AnnualRange, DateRange, GridLayerDto, LayerApplicability } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { updateLayer } from '../api'
import { isEmpty, toIsoDate } from '../lib/applicability'
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
/**
* Когда действует слой (см. 3.4). Разделы объединяются по ИЛИ: слой применим, если дата подходит
* хотя бы под одно условие. Пустая применимость — слой действует всегда.
*/
export function LayerApplicabilityDialog({
layer,
onClose,
onChanged,
onError,
}: {
layer: GridLayerDto
onClose: () => void
onChanged: () => void
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [name, setName] = useState(layer.name)
const [weekdays, setWeekdays] = useState<number[]>(layer.applicability?.weekdays ?? [])
const [dateRanges, setDateRanges] = useState<DateRange[]>(layer.applicability?.dateRanges ?? [])
const [annualRanges, setAnnualRanges] = useState<AnnualRange[]>(
layer.applicability?.annualRanges ?? [],
)
const [specificDates, setSpecificDates] = useState<string[]>(
layer.applicability?.specificDates ?? [],
)
const save = useMutation({
mutationFn: () => {
const applicability: LayerApplicability = {
weekdays: weekdays.length > 0 ? [...weekdays].sort((a, b) => a - b) : null,
dateRanges: dateRanges.length > 0 ? dateRanges : null,
annualRanges: annualRanges.length > 0 ? annualRanges : null,
specificDates: specificDates.length > 0 ? specificDates : null,
}
return updateLayer(layer.id, {
name: name.trim() || layer.name,
priority: layer.priority,
// Пустую применимость отправляем как null — «действует всегда» и «пустые списки» это одно
// и то же, но null читается однозначно.
applicability: isEmpty(applicability) ? null : applicability,
isEnabled: layer.isEnabled,
})
},
onSuccess: () => {
onChanged()
onClose()
},
onError,
})
const toggleWeekday = (day: number) =>
setWeekdays((current) =>
current.includes(day) ? current.filter((d) => d !== day) : [...current, day],
)
const today = toIsoDate(new Date())
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{t('admin.channels.layerApplicability')}</DialogTitle>
<DialogDescription>{t('admin.channels.applicabilityHint')}</DialogDescription>
</DialogHeader>
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto text-sm">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.layerName')}</Label>
<Input value={name} maxLength={128} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.applicabilityWeekdays')}</Label>
<div className="flex flex-wrap gap-1">
{WEEKDAYS.map((day) => (
<button
key={day}
type="button"
onClick={() => toggleWeekday(day)}
className={
weekdays.includes(day)
? 'rounded border border-primary bg-primary/15 px-2 py-1 text-xs text-primary'
: 'rounded border border-border px-2 py-1 text-xs text-muted-foreground'
}
>
{t(`admin.channels.weekdays.${day}`)}
</button>
))}
</div>
</div>
<Section
title={t('admin.channels.applicabilityDateRanges')}
onAdd={() => setDateRanges((c) => [...c, { from: today, to: today }])}
empty={dateRanges.length === 0}
>
{dateRanges.map((range, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
<Input
type="date"
className="w-40"
value={range.from}
onChange={(e) =>
setDateRanges((c) =>
c.map((r, i) => (i === index ? { ...r, from: e.target.value } : r)),
)
}
/>
<Input
type="date"
className="w-40"
value={range.to}
onChange={(e) =>
setDateRanges((c) =>
c.map((r, i) => (i === index ? { ...r, to: e.target.value } : r)),
)
}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setDateRanges((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</Section>
<Section
title={t('admin.channels.applicabilityAnnual')}
onAdd={() =>
setAnnualRanges((c) => [...c, { fromMonth: 12, fromDay: 20, toMonth: 1, toDay: 8 }])
}
empty={annualRanges.length === 0}
>
{annualRanges.map((range, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
<MonthDay
value={range}
prefix="from"
onChange={(part) =>
setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r)))
}
/>
<span className="pb-2 text-muted-foreground"></span>
<MonthDay
value={range}
prefix="to"
onChange={(part) =>
setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r)))
}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setAnnualRanges((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</Section>
<Section
title={t('admin.channels.applicabilityDates')}
onAdd={() => setSpecificDates((c) => [...c, today])}
empty={specificDates.length === 0}
>
{specificDates.map((date, index) => (
<li key={index} className="flex items-end gap-2">
<Input
type="date"
className="w-40"
value={date}
onChange={(e) =>
setSpecificDates((c) => c.map((d, i) => (i === index ? e.target.value : d)))
}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setSpecificDates((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</Section>
</div>
<DialogFooter>
<Button size="sm" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function Section({
title,
onAdd,
empty,
children,
}: {
title: string
onAdd: () => void
empty: boolean
children: React.ReactNode
}) {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<Label>{title}</Label>
<Button size="sm" variant="ghost" onClick={onAdd}>
<Plus className="h-4 w-4" />
</Button>
</div>
{empty ? (
<p className="text-xs text-muted-foreground">{t('admin.channels.applicabilityNone')}</p>
) : (
<ul className="flex flex-col gap-2">{children}</ul>
)}
</div>
)
}
/** Пара «месяц / день» ежегодного периода — год у него намеренно отсутствует. */
function MonthDay({
value,
prefix,
onChange,
}: {
value: AnnualRange
prefix: 'from' | 'to'
onChange: (part: Partial<AnnualRange>) => void
}) {
const month = prefix === 'from' ? value.fromMonth : value.toMonth
const day = prefix === 'from' ? value.fromDay : value.toDay
return (
<span className="flex items-end gap-1">
<Input
type="number"
min={1}
max={12}
className="w-16"
value={month}
onChange={(e) =>
onChange(
prefix === 'from'
? { fromMonth: Number(e.target.value) }
: { toMonth: Number(e.target.value) },
)
}
/>
<Input
type="number"
min={1}
max={31}
className="w-16"
value={day}
onChange={(e) =>
onChange(
prefix === 'from'
? { fromDay: Number(e.target.value) }
: { toDay: Number(e.target.value) },
)
}
/>
</span>
)
}
@@ -0,0 +1,189 @@
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>
)
}
@@ -1,203 +1,372 @@
import { Anchor, Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { cn } from '@/shared/lib/cn'
const HOUR_HEIGHT = 44
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */
const DAYPART_CLASS: Record<string, string> = {
Morning: 'bg-amber-500/20 border-amber-500/40',
Day: 'bg-sky-500/20 border-sky-500/40',
Prime: 'bg-violet-500/25 border-violet-500/50',
Night: 'bg-slate-500/20 border-slate-500/40',
}
function minutesOf(time: string): number {
const [h, m] = time.split(':')
return Number(h) * 60 + Number(m)
}
/**
* Смещение слота от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток в 06:00)
* принадлежит предыдущему дню, поэтому его смещение больше суточного, а не отрицательное.
*/
function offsetInDay(slotStart: string, dayStart: string): number {
const diff = minutesOf(slotStart) - minutesOf(dayStart)
return diff >= 0 ? diff : diff + 24 * 60
}
/** Слоты, попадающие в колонку дня: слот без дня недели идёт каждый день. */
function slotsOfDay(layers: GridLayerDto[], weekday: number) {
return layers
.filter((layer) => layer.isEnabled)
.flatMap((layer) =>
layer.slots
.filter((slot) => slot.weekday === null || slot.weekday === weekday)
.map((slot) => ({ slot, layer })),
)
}
export function ScheduleGrid({
template,
selectedSlotId,
onSelectSlot,
onAddSlot,
}: {
template: ScheduleTemplateDto
selectedSlotId: string | null
onSelectSlot: (slot: SlotDto) => void
onAddSlot: (weekday: number, startMinutes: number) => void
}) {
const { t } = useTranslation()
const dayStart = template.dayStartTime.slice(0, 5)
const dayStartMinutes = minutesOf(dayStart)
// Подписи часов идут от начала вещательных суток, а не от полуночи.
const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24)
// Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем.
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => {
const from = offsetInDay(slot.targetStart, dayStart)
const to = from + slot.targetDurationMinutes
return ordered
.filter((other) => other.isEnabled && other.priority > layer.priority)
.some((other) =>
other.slots
.filter((s) => s.weekday === null || s.weekday === weekday)
.some((s) => {
const otherFrom = offsetInDay(s.targetStart, dayStart)
return from < otherFrom + s.targetDurationMinutes && otherFrom < to
}),
)
}
return (
<div className="crt-panel overflow-x-auto rounded-md">
<div className="min-w-[720px]">
<div className="grid grid-cols-[56px_repeat(7,1fr)] border-b border-border text-xs text-muted-foreground">
<div className="px-2 py-1">{dayStart}</div>
{WEEKDAYS.map((weekday) => (
<div key={weekday} className="px-2 py-1 text-center font-medium">
{t(`admin.channels.weekdays.${weekday}`)}
</div>
))}
</div>
<div className="grid grid-cols-[56px_repeat(7,1fr)]">
<div>
{hours.map((hour, index) => (
<div
key={index}
className="border-b border-border/40 px-2 text-[11px] text-muted-foreground"
style={{ height: HOUR_HEIGHT }}
>
{hour.toString().padStart(2, '0')}:00
</div>
))}
</div>
{WEEKDAYS.map((weekday) => (
<div
key={weekday}
className="relative border-l border-border"
style={{ height: HOUR_HEIGHT * 24 }}
>
{hours.map((_, index) => (
<button
key={index}
type="button"
title={t('admin.channels.addSlotHere')}
className="group absolute inset-x-0 border-b border-border/40 hover:bg-muted/30"
style={{ top: HOUR_HEIGHT * index, height: HOUR_HEIGHT }}
onClick={() => onAddSlot(weekday, (dayStartMinutes + index * 60) % (24 * 60))}
>
<Plus className="mx-auto h-3 w-3 opacity-0 group-hover:opacity-40" />
</button>
))}
{slotsOfDay(ordered, weekday).map(({ slot, layer }) => {
const from = offsetInDay(slot.targetStart, dayStart)
const covered = isCovered(slot, layer, weekday)
return (
<button
key={`${slot.id}-${weekday}`}
type="button"
onClick={() => onSelectSlot(slot)}
className={cn(
'absolute inset-x-1 overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight',
DAYPART_CLASS[slot.daypart] ?? DAYPART_CLASS.Day,
selectedSlotId === slot.id && 'ring-2 ring-primary',
// Перекрытый слот виден, но приглушён: он не сыграет, пока лежит под старшим слоем.
covered && 'opacity-40 [background-image:repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(0,0,0,.15)_4px,rgba(0,0,0,.15)_8px)]',
)}
style={{
top: (from / 60) * HOUR_HEIGHT,
height: Math.max(16, (slot.targetDurationMinutes / 60) * HOUR_HEIGHT - 2),
}}
>
<span className="flex items-center gap-1 font-medium">
{slot.isAnchor && <Anchor className="h-3 w-3 shrink-0" />}
{slot.targetStart.slice(0, 5)}
</span>
<span className="block truncate">{slot.title}</span>
</button>
)
})}
</div>
))}
</div>
</div>
</div>
)
}
/** Панель слоёв: видимость, приоритет и выбор редактируемого. */
export function LayerList({
template,
activeLayerId,
onSelect,
onDelete,
}: {
template: ScheduleTemplateDto
activeLayerId: string | null
onSelect: (layer: GridLayerDto) => void
onDelete: (layer: GridLayerDto) => void
}) {
const { t } = useTranslation()
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
return (
<ul className="divide-y divide-border text-sm">
{ordered.map((layer) => (
<li key={layer.id} className="flex items-center gap-2 py-1.5">
<button
type="button"
className={cn(
'min-w-0 flex-1 truncate text-left',
activeLayerId === layer.id && 'text-primary',
)}
onClick={() => onSelect(layer)}
>
{layer.name}
</button>
<span className="shrink-0 text-xs text-muted-foreground">
{layer.isBackground ? t('admin.channels.background') : layer.priority}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{layer.slots.length}
</span>
{!layer.isBackground && (
<Button size="sm" variant="ghost" onClick={() => onDelete(layer)}>
×
</Button>
)}
</li>
))}
</ul>
)
}
import { Anchor, CalendarRange, Copy, GripVertical, Plus, Repeat } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { cn } from '@/shared/lib/cn'
import { coversDate, isEmpty } from '../lib/applicability'
const HOUR_HEIGHT = 44
/** Шаг сетки при перетаскивании и растягивании — минуты. */
const SNAP_MINUTES = 15
const snap = (minutes: number) => Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */
const DAYPART_CLASS: Record<string, string> = {
Morning: 'bg-amber-500/20 border-amber-500/40',
Day: 'bg-sky-500/20 border-sky-500/40',
Prime: 'bg-violet-500/25 border-violet-500/50',
Night: 'bg-slate-500/20 border-slate-500/40',
}
function minutesOf(time: string): number {
const [h, m] = time.split(':')
return Number(h) * 60 + Number(m)
}
/**
* Смещение слота от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток в 06:00)
* принадлежит предыдущему дню, поэтому его смещение больше суточного, а не отрицательное.
*/
function offsetInDay(slotStart: string, dayStart: string): number {
const diff = minutesOf(slotStart) - minutesOf(dayStart)
return diff >= 0 ? diff : diff + 24 * 60
}
/** Слоты, попадающие в колонку дня: слот без дня недели идёт каждый день. */
function slotsOfDay(layers: GridLayerDto[], weekday: number) {
return layers
.filter((layer) => layer.isEnabled)
.flatMap((layer) =>
layer.slots
.filter((slot) => slot.weekday === null || slot.weekday === weekday)
.map((slot) => ({ slot, layer })),
)
}
export function ScheduleGrid({
template,
selectedSlotId,
viewDate,
onSelectSlot,
onAddSlot,
onMoveSlot,
onResizeSlot,
onCopyDay,
}: {
template: ScheduleTemplateDto
selectedSlotId: string | null
/** Дата, на которую смотрим сетку («показать 25 декабря»); null — все слои разом. */
viewDate: string | null
onSelectSlot: (slot: SlotDto) => void
onAddSlot: (weekday: number, startMinutes: number) => void
/** Перенос слота: новое время старта и (для слота с днём недели) новый день. */
onMoveSlot: (slot: SlotDto, weekday: number, startMinutes: number) => void
onResizeSlot: (slot: SlotDto, durationMinutes: number) => void
onCopyDay: (fromWeekday: number) => void
}) {
const { t } = useTranslation()
const dayStart = template.dayStartTime.slice(0, 5)
const dayStartMinutes = minutesOf(dayStart)
// Подписи часов идут от начала вещательных суток, а не от полуночи.
const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24)
// На выбранную дату показываем только те слои, которые в этот день действуют, — иначе сетка
// «на 25 декабря» показывала бы и обычный день, и новогодний одновременно.
const day = viewDate ? parseIsoDate(viewDate) : null
const applicable = day
? template.layers.filter((layer) => coversDate(layer.applicability, day))
: template.layers
// Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем.
const ordered = [...applicable].sort((a, b) => b.priority - a.priority)
const highlightWeekday = day?.getDay() ?? null
const [dragged, setDragged] = useState<SlotDto | null>(null)
const [resizing, setResizing] = useState<{ slot: SlotDto; minutes: number } | null>(null)
/** Позиция курсора в колонке дня — минуты суток, округлённые до шага сетки. */
const minutesAt = (clientY: number, column: HTMLElement) => {
const rect = column.getBoundingClientRect()
const offset = Math.max(0, Math.min(rect.height, clientY - rect.top))
const fromDayStart = snap((offset / HOUR_HEIGHT) * 60)
return (dayStartMinutes + fromDayStart) % (24 * 60)
}
const drop = (event: React.DragEvent<HTMLDivElement>, weekday: number) => {
event.preventDefault()
if (!dragged) return
// Слот «каждый день» при переносе таким и остаётся: молча превратить его в слот одного дня
// значило бы убрать его сразу из шести колонок.
onMoveSlot(dragged, dragged.weekday ?? weekday, minutesAt(event.clientY, event.currentTarget))
setDragged(null)
}
/** Растягивание за нижний край: пока тянем — видно новую высоту, отпустили — сохраняем. */
const startResize = (event: React.MouseEvent, slot: SlotDto, column: HTMLElement) => {
event.preventDefault()
event.stopPropagation()
const from = offsetInDay(slot.targetStart, dayStart)
const move = (moveEvent: MouseEvent) => {
const rect = column.getBoundingClientRect()
const offset = Math.max(0, Math.min(rect.height, moveEvent.clientY - rect.top))
const end = snap((offset / HOUR_HEIGHT) * 60)
setResizing({ slot, minutes: Math.max(SNAP_MINUTES, end - from) })
}
const up = () => {
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', up)
setResizing((current) => {
if (current && current.minutes !== slot.targetDurationMinutes)
onResizeSlot(slot, current.minutes)
return null
})
}
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', up)
}
const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => {
const from = offsetInDay(slot.targetStart, dayStart)
const to = from + slot.targetDurationMinutes
return ordered
.filter((other) => other.isEnabled && other.priority > layer.priority)
.some((other) =>
other.slots
.filter((s) => s.weekday === null || s.weekday === weekday)
.some((s) => {
const otherFrom = offsetInDay(s.targetStart, dayStart)
return from < otherFrom + s.targetDurationMinutes && otherFrom < to
}),
)
}
return (
<div className="crt-panel overflow-x-auto rounded-md">
<div className="min-w-[720px]">
<div className="grid grid-cols-[56px_repeat(7,1fr)] border-b border-border text-xs text-muted-foreground">
<div className="px-2 py-1">{dayStart}</div>
{WEEKDAYS.map((weekday) => (
<div
key={weekday}
className={cn(
'flex items-center justify-center gap-1 px-2 py-1 font-medium',
highlightWeekday === weekday && 'text-primary',
)}
>
{t(`admin.channels.weekdays.${weekday}`)}
<button
type="button"
title={t('admin.channels.copyDay')}
className="opacity-40 hover:opacity-100"
onClick={() => onCopyDay(weekday)}
>
<Copy className="h-3 w-3" />
</button>
</div>
))}
</div>
<div className="grid grid-cols-[56px_repeat(7,1fr)]">
<div>
{hours.map((hour, index) => (
<div
key={index}
className="border-b border-border/40 px-2 text-[11px] text-muted-foreground"
style={{ height: HOUR_HEIGHT }}
>
{hour.toString().padStart(2, '0')}:00
</div>
))}
</div>
{WEEKDAYS.map((weekday) => (
<div
key={weekday}
className={cn(
'relative border-l border-border',
highlightWeekday === weekday && 'bg-primary/5',
dragged && 'bg-primary/10',
)}
style={{ height: HOUR_HEIGHT * 24 }}
onDragOver={(e) => dragged && e.preventDefault()}
onDrop={(e) => drop(e, weekday)}
>
{hours.map((_, index) => (
<button
key={index}
type="button"
title={t('admin.channels.addSlotHere')}
className="group absolute inset-x-0 border-b border-border/40 hover:bg-muted/30"
style={{ top: HOUR_HEIGHT * index, height: HOUR_HEIGHT }}
onClick={() => onAddSlot(weekday, (dayStartMinutes + index * 60) % (24 * 60))}
>
<Plus className="mx-auto h-3 w-3 opacity-0 group-hover:opacity-40" />
</button>
))}
{slotsOfDay(ordered, weekday).map(({ slot, layer }) => {
const from = offsetInDay(slot.targetStart, dayStart)
const covered = isCovered(slot, layer, weekday)
const minutes =
resizing?.slot.id === slot.id ? resizing.minutes : slot.targetDurationMinutes
return (
<div
key={`${slot.id}-${weekday}`}
draggable
onDragStart={() => setDragged(slot)}
onDragEnd={() => setDragged(null)}
onClick={() => onSelectSlot(slot)}
className={cn(
'absolute inset-x-1 cursor-grab overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight',
DAYPART_CLASS[slot.daypart] ?? DAYPART_CLASS.Day,
selectedSlotId === slot.id && 'ring-2 ring-primary',
dragged?.id === slot.id && 'opacity-50',
// Перекрытый слот виден, но приглушён: он не сыграет, пока лежит под старшим слоем.
covered && 'opacity-40 [background-image:repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(0,0,0,.15)_4px,rgba(0,0,0,.15)_8px)]',
)}
style={{
top: (from / 60) * HOUR_HEIGHT,
height: Math.max(16, (minutes / 60) * HOUR_HEIGHT - 2),
}}
>
<span className="flex items-center gap-1 font-medium">
{slot.isAnchor && <Anchor className="h-3 w-3 shrink-0" />}
{slot.targetStart.slice(0, 5)}
{slot.weekday === null && <Repeat className="h-3 w-3 shrink-0 opacity-60" />}
</span>
<span className="block truncate">{slot.title}</span>
<span
role="presentation"
title={t('admin.channels.resizeSlot')}
className="absolute inset-x-0 bottom-0 h-1.5 cursor-ns-resize hover:bg-primary/40"
onMouseDown={(e) =>
startResize(e, slot, e.currentTarget.parentElement!.parentElement!)
}
/>
</div>
)
})}
</div>
))}
</div>
</div>
</div>
)
}
/**
* Панель слоёв: видимость, приоритет (перетаскиванием), применимость и выбор редактируемого.
* Выше в списке — приоритетнее; фоновый слой всегда внизу и не двигается.
*/
export function LayerList({
template,
activeLayerId,
viewDate,
onSelect,
onDelete,
onToggle,
onReorder,
onEditApplicability,
}: {
template: ScheduleTemplateDto
activeLayerId: string | null
viewDate: string | null
onSelect: (layer: GridLayerDto) => void
onDelete: (layer: GridLayerDto) => void
onToggle: (layer: GridLayerDto) => void
onReorder: (layerIdsTopFirst: string[]) => void
onEditApplicability: (layer: GridLayerDto) => void
}) {
const { t } = useTranslation()
const [dragged, setDragged] = useState<string | null>(null)
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
const day = viewDate ? parseIsoDate(viewDate) : null
const dropOn = (targetId: string) => {
if (!dragged || dragged === targetId) return
const movable = ordered.filter((l) => !l.isBackground).map((l) => l.id)
const order = movable.filter((id) => id !== dragged)
const at = order.indexOf(targetId)
// Бросок на фоновый слой означает «в самый низ»: он в порядке не участвует.
order.splice(at === -1 ? order.length : at, 0, dragged)
setDragged(null)
onReorder(order)
}
return (
<ul className="divide-y divide-border text-sm">
{ordered.map((layer) => {
const inactiveToday = day !== null && !coversDate(layer.applicability, day)
return (
<li
key={layer.id}
draggable={!layer.isBackground}
onDragStart={() => setDragged(layer.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => dropOn(layer.id)}
className={cn('flex items-center gap-1.5 py-1.5', inactiveToday && 'opacity-40')}
>
{layer.isBackground ? (
<span className="w-4 shrink-0" />
) : (
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
)}
<input
type="checkbox"
className="shrink-0"
title={t('admin.channels.layerVisible')}
checked={layer.isEnabled}
onChange={() => onToggle(layer)}
/>
<button
type="button"
className={cn(
'min-w-0 flex-1 truncate text-left',
activeLayerId === layer.id && 'text-primary',
)}
onClick={() => onSelect(layer)}
>
{layer.name}
</button>
<span className="shrink-0 text-xs text-muted-foreground">
{layer.isBackground ? t('admin.channels.background') : layer.slots.length}
</span>
{!layer.isBackground && (
<>
<Button
size="sm"
variant="ghost"
title={t('admin.channels.layerApplicability')}
onClick={() => onEditApplicability(layer)}
>
<CalendarRange
className={cn(
'h-4 w-4',
isEmpty(layer.applicability) ? 'text-muted-foreground' : 'text-primary',
)}
/>
</Button>
<Button size="sm" variant="ghost" onClick={() => onDelete(layer)}>
×
</Button>
</>
)}
</li>
)
})}
</ul>
)
}
/** «2026-12-25» → локальная дата. `new Date(iso)` разобрал бы её как UTC и сместил день. */
function parseIsoDate(iso: string): Date {
const [year, month, day] = iso.split('-').map(Number)
return new Date(year, month - 1, day)
}
@@ -16,7 +16,14 @@ import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { toast } from '@/shared/ui/toast-store'
import { createSlot, deleteSlot, listJunctions, updateSlot, type SlotBody } from '../api'
import {
createSlot,
deleteSlot,
listJunctions,
toSlotBody,
updateSlot,
type SlotBody,
} from '../api'
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
@@ -28,11 +35,6 @@ const SNAP_OPTIONS = [0, 5, 10, 15, 30]
/** Черновик слота: новый (layerId + предзаполненные время/день) либо существующий. */
export type SlotDraft = { layerId: string; slot: SlotDto | null; defaults?: Partial<SlotBody> }
function toBody(slot: SlotDto): SlotBody {
const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot
return body
}
function emptyBody(defaults?: Partial<SlotBody>): SlotBody {
return {
title: '',
@@ -74,11 +76,11 @@ export function SlotInspector({
}) {
const { t } = useTranslation()
const [body, setBody] = useState<SlotBody>(() =>
draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults),
draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults),
)
useEffect(() => {
setBody(draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults))
setBody(draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults))
}, [draft])
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
@@ -0,0 +1,45 @@
import type { LayerApplicability } from '@/shared/api/types'
/**
* Действует ли слой в эту дату вещательных суток. Зеркало серверного `LayerApplicability.Covers`
* (`TeleWave.Application/Programming/Templates/LayerApplicability.cs`) — источник правды там,
* здесь только подсветка сетки. Меняя одно, правьте второе.
*/
export function coversDate(applicability: LayerApplicability | null, date: Date): boolean {
if (!applicability || isEmpty(applicability)) return true
const weekday = date.getDay()
if (applicability.weekdays?.includes(weekday)) return true
const iso = toIsoDate(date)
if (applicability.specificDates?.includes(iso)) return true
if (applicability.dateRanges?.some((r) => iso >= r.from && iso <= r.to)) return true
// Ежегодный период может пересекать Новый год — сравниваем по паре (месяц, день).
const value = (date.getMonth() + 1) * 100 + date.getDate()
return (
applicability.annualRanges?.some((r) => {
const from = r.fromMonth * 100 + r.fromDay
const to = r.toMonth * 100 + r.toDay
return from <= to ? value >= from && value <= to : value >= from || value <= to
}) ?? false
)
}
export function isEmpty(applicability: LayerApplicability | null): boolean {
if (!applicability) return true
return (
!applicability.weekdays?.length &&
!applicability.dateRanges?.length &&
!applicability.annualRanges?.length &&
!applicability.specificDates?.length
)
}
/** «2026-12-25» из локальной даты — без ухода в UTC, иначе вечер уезжает на день назад. */
export function toIsoDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
date.getDate(),
).padStart(2, '0')}`
}
@@ -10,6 +10,12 @@ export function formatTime(iso: string | null) {
})
}
/** Минуты суток → «HH:MM:00» — формат `TimeOnly` на сервере. */
export function toTime(minutes: number): string {
const m = ((minutes % (24 * 60)) + 24 * 60) % (24 * 60)
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}:00`
}
/** Минуты суток → «HH:MM». */
export function formatMinute(minute: number | null) {
if (minute == null) return '—'
+12
View File
@@ -459,6 +459,15 @@ export type GridLayerDto = {
slots: SlotDto[]
}
/** Окно детского времени: до `to` в эфир идёт контент не строже `maxAudience`. */
export type AudienceWindow = { from: string; to: string; maxAudience: ShowAudience }
/** Правила отбора кандидатов канала — жёсткие фильтры, применяются до жребия. */
export type PlanningRules = {
maxAudienceByTime?: AudienceWindow[] | null
maxRepeatsInWindow?: { windowDays: number; max: number } | null
}
export type ScheduleTemplateDto = {
id: string
channelId: string
@@ -466,6 +475,8 @@ export type ScheduleTemplateDto = {
fallbackGroupId: string | null
/** Стык, который берётся, когда слот своего не задал. */
defaultJunctionId: string | null
/** Детское время и потолок повторов. */
rules: PlanningRules | null
revision: number
appliedRevision: number
/** Есть ли правки правил, не применённые к эфиру. */
@@ -481,6 +492,7 @@ export type PlanningWarningKind =
| 'CooldownExhausted'
| 'RepeatSourceEmpty'
| 'FallbackEmpty'
| 'CandidatesFiltered'
export type PlanningWarningDto = {
kind: PlanningWarningKind
+66 -26
View File
@@ -341,6 +341,37 @@ const resources = {
snapOff: 'нет',
bumperConditionsHint:
'Как часто ставить заставку и на каких переходах — условия элемента стыка, а не настройка канала.',
resizeSlot: 'Потянуть за край — длительность',
copyDay: 'Копировать день',
copyDayFrom: 'Копировать {{day}} в:',
copy: 'Копировать',
layerVisible: 'Показывать слой',
layerName: 'Название слоя',
layerApplicability: 'Когда действует',
applicabilityHint:
'Разделы объединяются по ИЛИ: слой действует, если дата подходит хотя бы под одно условие. Ничего не заполнено — действует всегда.',
applicabilityWeekdays: 'Дни недели',
applicabilityDateRanges: 'Диапазоны дат',
applicabilityAnnual: 'Ежегодно (месяц / день)',
applicabilityDates: 'Конкретные даты',
applicabilityNone: 'не задано',
showForDate: 'Сетка на дату',
allDates: 'Все слои',
rules: 'Правила отбора',
rulesHint:
'Жёсткие фильтры: отсекают неподходящее до жребия. Как и правка сетки, эфир не двигают — нужно применить.',
audienceWindows: 'Детское время',
noAudienceWindows: 'Окон нет — возраст ничем не ограничен.',
audienceWindowsHint:
'В окне в эфир идёт контент не строже выбранной категории. Окно может переходить через полночь. Контент без категории не отсекается.',
from: 'С',
to: 'До',
maxAudience: 'Не строже',
repeatLimit: 'Потолок повторов',
repeatWindowDays: 'Окно, суток',
repeatMax: 'Не чаще, раз',
repeatLimitHint:
'Считается по уже записанной ленте. Если потолка достигли все кандидаты, слот всё равно заполняется: пустой эфир хуже раннего повтора.',
preview: 'Предпросмотр',
previewHide: 'Свернуть предпросмотр',
previewHint: 'Прогон по текущим правилам: ничего не пишется, курсоры слотов не двигаются.',
@@ -422,6 +453,7 @@ const resources = {
CooldownExhausted: 'Остывание отсекло всех',
RepeatSourceEmpty: 'Нечего повторять',
FallbackEmpty: 'Нечем закрыть паузы',
CandidatesFiltered: 'Возрастной потолок отсёк всех',
},
title: 'Каналы',
@@ -509,19 +541,7 @@ const resources = {
preferredNone: 'Окна не заданы — предпочтений по времени нет.',
preferredAddWindow: 'Добавить окно',
preferredBadRange: 'начало ≥ конца',
ads: 'Реклама',
pickAd: 'Выберите ролик',
noAds: 'Пул рекламы пуст',
overrides: 'Марафоны / override',
modes: { Exclusive: 'Эксклюзив', Boost: 'Буст' },
overrideRecurrence: 'Повтор',
recurrenceOneTime: 'Разово',
recurrenceWeekly: 'Еженедельно',
from: 'С',
to: 'По',
noOverrides: 'Override не заданы',
schedule: 'Расписание (12 ч)',
noSchedule: 'Расписание ещё не построено — нажмите «Пересобрать»',
noSchedule: 'Расписание ещё не построено',
},
maintenance: {
title: 'Обслуживание',
@@ -918,6 +938,37 @@ const resources = {
snapOff: 'off',
bumperConditionsHint:
'How often a bumper is inserted and on which transitions is a junction-element condition, not a channel setting.',
resizeSlot: 'Drag the edge to change duration',
copyDay: 'Copy day',
copyDayFrom: 'Copy {{day}} to:',
copy: 'Copy',
layerVisible: 'Show layer',
layerName: 'Layer name',
layerApplicability: 'When it applies',
applicabilityHint:
'Sections are OR-ed: the layer applies when the date matches at least one condition. Nothing filled in — it always applies.',
applicabilityWeekdays: 'Weekdays',
applicabilityDateRanges: 'Date ranges',
applicabilityAnnual: 'Yearly (month / day)',
applicabilityDates: 'Specific dates',
applicabilityNone: 'not set',
showForDate: 'Grid for date',
allDates: 'All layers',
rules: 'Candidate rules',
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.',
audienceWindows: 'Family hours',
noAudienceWindows: 'No windows — the age is not limited.',
audienceWindowsHint:
'Inside a window only content no stricter than the chosen category airs. A window may cross midnight. Content with no category is never dropped.',
from: 'From',
to: 'To',
maxAudience: 'No stricter than',
repeatLimit: 'Repeat cap',
repeatWindowDays: 'Window, days',
repeatMax: 'At most, times',
repeatLimitHint:
'Counted against the already recorded tape. If every candidate hits the cap the slot is still filled: empty air is worse than an early repeat.',
preview: 'Preview',
previewHide: 'Hide preview',
previewHint: 'A run against the current rules: nothing is written, slot cursors do not move.',
@@ -993,6 +1044,7 @@ const resources = {
CooldownExhausted: 'Cooldown ruled out every candidate',
RepeatSourceEmpty: 'Nothing to repeat',
FallbackEmpty: 'Nothing to fill pauses with',
CandidatesFiltered: 'The age cap ruled out every candidate',
},
title: 'Channels',
@@ -1080,19 +1132,7 @@ const resources = {
preferredNone: 'No windows set — no time preference.',
preferredAddWindow: 'Add window',
preferredBadRange: 'start ≥ end',
ads: 'Ads',
pickAd: 'Pick an ad',
noAds: 'Ad pool is empty',
overrides: 'Marathons / overrides',
modes: { Exclusive: 'Exclusive', Boost: 'Boost' },
overrideRecurrence: 'Repeat',
recurrenceOneTime: 'One-time',
recurrenceWeekly: 'Weekly',
from: 'From',
to: 'To',
noOverrides: 'No overrides set',
schedule: 'Schedule (12h)',
noSchedule: 'Schedule not built yet — click “Rebuild”',
noSchedule: 'Schedule not built yet',
},
maintenance: {
title: 'Maintenance',