Add Prettier to the frontend and gate formatting in CI

Форматтера у фронтенда не было: стиль держался вручную и успел разъехаться в 50 файлах.
Ставим Prettier с настройками под уже сложившийся стиль (без точек с запятой, одинарные кавычки,
ширина 100 — подобрана замером: при 100 расходится меньше файлов, чем при 96 или 110) и
прогоняем его по коду. `src/routeTree.gen.ts` исключён — его переписывает плагин роутера.

Чтобы форматирование больше не расходилось незаметно, добавлены проверки в CI: `csharpier check`
для бэкенда (его отсутствие и позволило накопиться 79 неотформатированным файлам) и
`prettier --check` для фронтенда. Версии форматтеров прибиты точно, без кареток: минорка меняет
вывод и красит CI на файлах, которых никто не трогал.

`.editorconfig` задаёт редакторам те же отступы и LF ещё до форматтера; значения совпадают с
настройками csharpier и Prettier намеренно — оба его читают.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-07-27 01:38:12 +03:00
co-authored by Claude Opus 5
parent 0442056367
commit 0606ea3e6e
58 changed files with 5158 additions and 4903 deletions
@@ -11,12 +11,7 @@ import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Card, CardContent } from '@/shared/ui/card'
import { toast } from '@/shared/ui/toast-store'
import {
applyChannelTemplate,
getChannel,
getChannelTemplate,
getSchedule,
} from './api'
import { applyChannelTemplate, getChannel, getChannelTemplate, getSchedule } from './api'
import { ApplyDialog } from './components/ApplyDialog'
import { BumperCard } from './components/BumperCard'
import { EntryTraceDialog } from './components/EntryTraceDialog'
+375 -371
View File
@@ -1,371 +1,375 @@
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import type {
ApplyResultDto,
BumperSettings,
BumperTextKind,
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() {
return apiRequest<ChannelSummaryDto[]>('/admin/channels')
}
export function getChannel(id: string) {
return apiRequest<ChannelDto>(`/admin/channels/${id}`)
}
export function createChannel(body: { name: string; slug: string }) {
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
}
type ChannelSettingsBody = {
name: string
isEnabled: boolean
bumpersEnabled: boolean
bumper: BumperSettings
fillerAssetId: string | null
}
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,
body: { number: number | null; utcOffsetMinutes: number; dayStartTime: string },
) {
return apiRequest<void>(`/admin/channels/${id}/time`, { method: 'PUT', body })
}
// ── Сетка канала ──────────────────────────────────────────────────────────
export function getChannelTemplate(channelId: string) {
return apiRequest<ScheduleTemplateDto>(`/admin/channels/${channelId}/template`)
}
/** Заводит каналу сетку, если её нет: у каналов из старой ротации шаблона может не быть. */
export function createChannelTemplate(channelId: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${channelId}/template`, { method: 'POST' })
}
/** Применяет правила к эфиру: пересобирает будущий хвост. Правка слотов эфир не двигает. */
export function applyChannelTemplate(channelId: string) {
return apiRequest<ApplyResultDto>(`/admin/channels/${channelId}/template/apply`, {
method: 'POST',
})
}
/** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */
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) })
return apiRequest<SchedulePreviewDto>(
`/admin/channels/${channelId}/template/preview?${query.toString()}`,
)
}
export function updateTemplate(
templateId: string,
body: {
name: string
fallbackGroupId: string | null
defaultJunctionId: string | null
rules: PlanningRules | null
},
) {
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
}
export function createLayer(templateId: string, body: { name: string; priority: number }) {
return apiRequest<CreatedIdResponse>(`/admin/templates/${templateId}/layers`, {
method: 'POST',
body,
})
}
export function updateLayer(
layerId: string,
body: {
name: string
priority: number
applicability: LayerApplicability | null
isEnabled: boolean
},
) {
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'PUT', body })
}
export function deleteLayer(layerId: string) {
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'DELETE' })
}
/** Тело слота: то же для создания и правки (см. 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 })
}
export function updateSlot(slotId: string, body: SlotBody) {
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'PUT', body })
}
export function deleteSlot(slotId: string) {
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'DELETE' })
}
// ── Стыки канала ──────────────────────────────────────────────────────────
export function listJunctions(channelId: string) {
return apiRequest<JunctionTemplateDto[]>(`/admin/channels/${channelId}/junctions`)
}
export function createJunction(channelId: string, name: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${channelId}/junctions`, {
method: 'POST',
body: { name },
})
}
export function renameJunction(junctionId: string, name: string) {
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'PUT', body: { name } })
}
export function deleteJunction(junctionId: string) {
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'DELETE' })
}
export function addJunctionElement(junctionId: string, kind: JunctionElementKind) {
return apiRequest<CreatedIdResponse>(`/admin/junctions/${junctionId}/elements`, {
method: 'POST',
body: { kind },
})
}
/** Тело врезки: то же для любого типа — лишние поля сервер обнуляет сам (см. JunctionElement.Update). */
export type JunctionElementBody = {
kind: JunctionElementKind
groupId: string | null
bumperTemplateId: string | null
amountMode: JunctionAmountMode
amountValue: number
isRequired: boolean
conditions: JunctionConditions | null
}
export function updateJunctionElement(
junctionId: string,
elementId: string,
body: JunctionElementBody,
) {
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
method: 'PUT',
body,
})
}
export function removeJunctionElement(junctionId: string, elementId: string) {
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
method: 'DELETE',
})
}
/** Порядок врезок: не упомянутые остаются после перечисленных. */
export function reorderJunction(junctionId: string, elementIdsInOrder: string[]) {
return apiRequest<void>(`/admin/junctions/${junctionId}/order`, {
method: 'PUT',
body: { elementIdsInOrder },
})
}
type BumperTemplateStyleBody = {
name: string
backgroundColor: string
backgroundColor2: string
accentColor: string
textColor: string
}
export function addBumperTemplate(id: string, name: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
method: 'POST',
body: { name },
})
}
export function updateBumperTemplate(id: string, templateId: string, body: BumperTemplateStyleBody) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
method: 'PUT',
body,
})
}
export function removeBumperTemplate(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
method: 'DELETE',
})
}
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
function uploadBumperTemplateFile(
id: string,
templateId: string,
kind: 'audio' | 'background',
file: File,
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const query = new URLSearchParams({ fileName: file.name })
xhr.open(
'PUT',
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
let detail = `HTTP ${xhr.status}`
try {
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
detail = problem.detail ?? problem.title ?? detail
} catch {
/* пусто */
}
reject(new HttpError({ detail }, xhr.status))
}
}
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
xhr.send(file)
})
}
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
return uploadBumperTemplateFile(id, templateId, 'audio', file)
}
type BumperVariantBody = {
name: string
kind: BumperTextKind
nowLabel: string
nextLabel: string
line1: string
line2: string
trigger: BumperTrigger
weight: number
}
export function addBumperVariant(id: string, templateId: string, name: string) {
return apiRequest<CreatedIdResponse>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
{ method: 'POST', body: { name } },
)
}
export function updateBumperVariant(
id: string,
templateId: string,
variantId: string,
body: BumperVariantBody,
) {
return apiRequest<void>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
{ method: 'PUT', body },
)
}
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
return apiRequest<void>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
{ method: 'DELETE' },
)
}
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
method: 'PUT',
body: { imageId },
})
}
export function clearBumperTemplateAudio(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
method: 'DELETE',
})
}
export function clearBumperTemplateBackground(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
method: 'DELETE',
})
}
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
export function renderBumperPreviews(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
method: 'POST',
})
}
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
}
export function getSchedule(id: string, from: Date, to: Date) {
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
}
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import type {
ApplyResultDto,
BumperSettings,
BumperTextKind,
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() {
return apiRequest<ChannelSummaryDto[]>('/admin/channels')
}
export function getChannel(id: string) {
return apiRequest<ChannelDto>(`/admin/channels/${id}`)
}
export function createChannel(body: { name: string; slug: string }) {
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
}
type ChannelSettingsBody = {
name: string
isEnabled: boolean
bumpersEnabled: boolean
bumper: BumperSettings
fillerAssetId: string | null
}
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,
body: { number: number | null; utcOffsetMinutes: number; dayStartTime: string },
) {
return apiRequest<void>(`/admin/channels/${id}/time`, { method: 'PUT', body })
}
// ── Сетка канала ──────────────────────────────────────────────────────────
export function getChannelTemplate(channelId: string) {
return apiRequest<ScheduleTemplateDto>(`/admin/channels/${channelId}/template`)
}
/** Заводит каналу сетку, если её нет: у каналов из старой ротации шаблона может не быть. */
export function createChannelTemplate(channelId: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${channelId}/template`, { method: 'POST' })
}
/** Применяет правила к эфиру: пересобирает будущий хвост. Правка слотов эфир не двигает. */
export function applyChannelTemplate(channelId: string) {
return apiRequest<ApplyResultDto>(`/admin/channels/${channelId}/template/apply`, {
method: 'POST',
})
}
/** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */
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) })
return apiRequest<SchedulePreviewDto>(
`/admin/channels/${channelId}/template/preview?${query.toString()}`,
)
}
export function updateTemplate(
templateId: string,
body: {
name: string
fallbackGroupId: string | null
defaultJunctionId: string | null
rules: PlanningRules | null
},
) {
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
}
export function createLayer(templateId: string, body: { name: string; priority: number }) {
return apiRequest<CreatedIdResponse>(`/admin/templates/${templateId}/layers`, {
method: 'POST',
body,
})
}
export function updateLayer(
layerId: string,
body: {
name: string
priority: number
applicability: LayerApplicability | null
isEnabled: boolean
},
) {
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'PUT', body })
}
export function deleteLayer(layerId: string) {
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'DELETE' })
}
/** Тело слота: то же для создания и правки (см. 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 })
}
export function updateSlot(slotId: string, body: SlotBody) {
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'PUT', body })
}
export function deleteSlot(slotId: string) {
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'DELETE' })
}
// ── Стыки канала ──────────────────────────────────────────────────────────
export function listJunctions(channelId: string) {
return apiRequest<JunctionTemplateDto[]>(`/admin/channels/${channelId}/junctions`)
}
export function createJunction(channelId: string, name: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${channelId}/junctions`, {
method: 'POST',
body: { name },
})
}
export function renameJunction(junctionId: string, name: string) {
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'PUT', body: { name } })
}
export function deleteJunction(junctionId: string) {
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'DELETE' })
}
export function addJunctionElement(junctionId: string, kind: JunctionElementKind) {
return apiRequest<CreatedIdResponse>(`/admin/junctions/${junctionId}/elements`, {
method: 'POST',
body: { kind },
})
}
/** Тело врезки: то же для любого типа — лишние поля сервер обнуляет сам (см. JunctionElement.Update). */
export type JunctionElementBody = {
kind: JunctionElementKind
groupId: string | null
bumperTemplateId: string | null
amountMode: JunctionAmountMode
amountValue: number
isRequired: boolean
conditions: JunctionConditions | null
}
export function updateJunctionElement(
junctionId: string,
elementId: string,
body: JunctionElementBody,
) {
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
method: 'PUT',
body,
})
}
export function removeJunctionElement(junctionId: string, elementId: string) {
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
method: 'DELETE',
})
}
/** Порядок врезок: не упомянутые остаются после перечисленных. */
export function reorderJunction(junctionId: string, elementIdsInOrder: string[]) {
return apiRequest<void>(`/admin/junctions/${junctionId}/order`, {
method: 'PUT',
body: { elementIdsInOrder },
})
}
type BumperTemplateStyleBody = {
name: string
backgroundColor: string
backgroundColor2: string
accentColor: string
textColor: string
}
export function addBumperTemplate(id: string, name: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
method: 'POST',
body: { name },
})
}
export function updateBumperTemplate(
id: string,
templateId: string,
body: BumperTemplateStyleBody,
) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
method: 'PUT',
body,
})
}
export function removeBumperTemplate(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
method: 'DELETE',
})
}
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
function uploadBumperTemplateFile(
id: string,
templateId: string,
kind: 'audio' | 'background',
file: File,
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const query = new URLSearchParams({ fileName: file.name })
xhr.open(
'PUT',
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
let detail = `HTTP ${xhr.status}`
try {
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
detail = problem.detail ?? problem.title ?? detail
} catch {
/* пусто */
}
reject(new HttpError({ detail }, xhr.status))
}
}
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
xhr.send(file)
})
}
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
return uploadBumperTemplateFile(id, templateId, 'audio', file)
}
type BumperVariantBody = {
name: string
kind: BumperTextKind
nowLabel: string
nextLabel: string
line1: string
line2: string
trigger: BumperTrigger
weight: number
}
export function addBumperVariant(id: string, templateId: string, name: string) {
return apiRequest<CreatedIdResponse>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
{ method: 'POST', body: { name } },
)
}
export function updateBumperVariant(
id: string,
templateId: string,
variantId: string,
body: BumperVariantBody,
) {
return apiRequest<void>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
{ method: 'PUT', body },
)
}
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
return apiRequest<void>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
{ method: 'DELETE' },
)
}
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
method: 'PUT',
body: { imageId },
})
}
export function clearBumperTemplateAudio(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
method: 'DELETE',
})
}
export function clearBumperTemplateBackground(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
method: 'DELETE',
})
}
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
export function renderBumperPreviews(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
method: 'POST',
})
}
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
}
export function getSchedule(id: string, from: Date, to: Date) {
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
}
@@ -44,7 +44,9 @@ export function BumperBackgroundField({
: `· ${t('admin.channels.bumperFileDefault')}`}
</span>
</Label>
<span className="text-xs text-muted-foreground">{t('admin.channels.bumperBackgroundHint')}</span>
<span className="text-xs text-muted-foreground">
{t('admin.channels.bumperBackgroundHint')}
</span>
<div className="flex items-center gap-2">
{backgroundImageId && (
<img
@@ -57,7 +59,12 @@ export function BumperBackgroundField({
{t('admin.channels.bumperBackgroundPick')}
</Button>
{backgroundImageId && (
<Button size="sm" variant="ghost" disabled={clearBg.isPending} onClick={() => clearBg.mutate()}>
<Button
size="sm"
variant="ghost"
disabled={clearBg.isPending}
onClick={() => clearBg.mutate()}
>
{t('admin.channels.bumperReset')}
</Button>
)}
@@ -1,145 +1,153 @@
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { addBumperTemplate, updateChannelSettings } from '../api'
import { BumperTemplateEditor } from './BumperTemplateEditor'
import { CollapsibleCard } from './CollapsibleCard'
export function BumperCard({
channel,
bare,
onSaved,
onError,
}: Readonly<{
channel: ChannelDto
bare?: boolean
onSaved: () => void
onError: (e: unknown) => void
}>) {
const { t } = useTranslation()
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
setBumper((prev) => ({ ...prev, [key]: value }))
useEffect(() => {
setBumpersEnabled(channel.bumpersEnabled)
setBumper(channel.bumper)
}, [channel])
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
// поля берём из канала без изменений (они правятся в своей карточке).
const save = useMutation({
mutationFn: () =>
updateChannelSettings(channel.id, {
name: channel.name,
isEnabled: channel.isEnabled,
bumpersEnabled,
bumper,
fillerAssetId: channel.fillerAssetId,
}),
onSuccess: () => {
toast.success(t('settings.saved'))
onSaved()
},
onError,
})
const addTemplate = useMutation({
mutationFn: () => addBumperTemplate(channel.id, ''),
onSuccess: onSaved,
onError,
})
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
return (
<CollapsibleCard
title={t('admin.channels.bumpers')}
bare={bare}
contentClassName="flex flex-col gap-4"
>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-1"
checked={bumpersEnabled}
onChange={(e) => setBumpersEnabled(e.target.checked)}
/>
<span>
{t('admin.channels.bumpersLabel')}
<span className="block text-xs text-muted-foreground">
{t('admin.channels.bumpersHint')}
</span>
</span>
</label>
{/* Общие настройки. Как часто ставить заставку — не здесь: это условие элемента стыка. */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperSelection')}</Label>
<Select value={bumper.selection} onValueChange={(v) => setField('selection', v as BumperSelection)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
<SelectItem value="WeightedRandom">
{t('admin.channels.bumperSelectionWeighted')}
</SelectItem>
<SelectItem value="AlwaysFirst">
{t('admin.channels.bumperSelectionAlwaysFirst')}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperFont')}</Label>
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperConditionsHint')}</p>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
{/* Блоки заставок */}
<div className="border-t border-border pt-4">
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
</div>
<div className="flex flex-col gap-3">
{templates.map((template) => (
<BumperTemplateEditor
key={template.id}
channelId={channel.id}
template={template}
onChanged={onSaved}
onError={onError}
/>
))}
</div>
<div className="flex justify-center">
<Button size="sm" variant="outline" disabled={addTemplate.isPending} onClick={() => addTemplate.mutate()}>
{t('admin.channels.bumperAddTemplate')}
</Button>
</div>
</CollapsibleCard>
)
}
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { addBumperTemplate, updateChannelSettings } from '../api'
import { BumperTemplateEditor } from './BumperTemplateEditor'
import { CollapsibleCard } from './CollapsibleCard'
export function BumperCard({
channel,
bare,
onSaved,
onError,
}: Readonly<{
channel: ChannelDto
bare?: boolean
onSaved: () => void
onError: (e: unknown) => void
}>) {
const { t } = useTranslation()
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
setBumper((prev) => ({ ...prev, [key]: value }))
useEffect(() => {
setBumpersEnabled(channel.bumpersEnabled)
setBumper(channel.bumper)
}, [channel])
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
// поля берём из канала без изменений (они правятся в своей карточке).
const save = useMutation({
mutationFn: () =>
updateChannelSettings(channel.id, {
name: channel.name,
isEnabled: channel.isEnabled,
bumpersEnabled,
bumper,
fillerAssetId: channel.fillerAssetId,
}),
onSuccess: () => {
toast.success(t('settings.saved'))
onSaved()
},
onError,
})
const addTemplate = useMutation({
mutationFn: () => addBumperTemplate(channel.id, ''),
onSuccess: onSaved,
onError,
})
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
return (
<CollapsibleCard
title={t('admin.channels.bumpers')}
bare={bare}
contentClassName="flex flex-col gap-4"
>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-1"
checked={bumpersEnabled}
onChange={(e) => setBumpersEnabled(e.target.checked)}
/>
<span>
{t('admin.channels.bumpersLabel')}
<span className="block text-xs text-muted-foreground">
{t('admin.channels.bumpersHint')}
</span>
</span>
</label>
{/* Общие настройки. Как часто ставить заставку — не здесь: это условие элемента стыка. */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperSelection')}</Label>
<Select
value={bumper.selection}
onValueChange={(v) => setField('selection', v as BumperSelection)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
<SelectItem value="WeightedRandom">
{t('admin.channels.bumperSelectionWeighted')}
</SelectItem>
<SelectItem value="AlwaysFirst">
{t('admin.channels.bumperSelectionAlwaysFirst')}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperFont')}</Label>
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperConditionsHint')}</p>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
{/* Блоки заставок */}
<div className="border-t border-border pt-4">
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
</div>
<div className="flex flex-col gap-3">
{templates.map((template) => (
<BumperTemplateEditor
key={template.id}
channelId={channel.id}
template={template}
onChanged={onSaved}
onError={onError}
/>
))}
</div>
<div className="flex justify-center">
<Button
size="sm"
variant="outline"
disabled={addTemplate.isPending}
onClick={() => addTemplate.mutate()}
>
{t('admin.channels.bumperAddTemplate')}
</Button>
</div>
</CollapsibleCard>
)
}
@@ -33,12 +33,19 @@ export function BumperPreviewPlayer({
return (
<>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" disabled={render.isPending} onClick={() => render.mutate()}>
<Button
size="sm"
variant="outline"
disabled={render.isPending}
onClick={() => render.mutate()}
>
{render.isPending
? t('admin.channels.bumperPreviewRendering')
: t('admin.channels.bumperPreview')}
</Button>
<span className="text-xs text-muted-foreground">{t('admin.channels.bumperPreviewHint')}</span>
<span className="text-xs text-muted-foreground">
{t('admin.channels.bumperPreviewHint')}
</span>
</div>
{ready && (
<div className="grid gap-3 sm:grid-cols-2">
@@ -47,7 +54,9 @@ export function BumperPreviewPlayer({
.map((v) => (
<div key={v.id} className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{v.name}</span>
<HlsVideo src={`${bumperPreviewPlaylistUrl(channelId, templateId, v.id)}?t=${bust}`} />
<HlsVideo
src={`${bumperPreviewPlaylistUrl(channelId, templateId, v.id)}?t=${bust}`}
/>
</div>
))}
</div>
@@ -53,7 +53,8 @@ export function BumperTemplateEditor({
}, [template])
const save = useMutation({
mutationFn: () => updateBumperTemplate(channelId, template.id, { name: name.trim(), ...colors }),
mutationFn: () =>
updateBumperTemplate(channelId, template.id, { name: name.trim(), ...colors }),
onSuccess: () => {
toast.success(t('settings.saved'))
onChanged()
@@ -163,7 +164,9 @@ export function BumperTemplateEditor({
{/* Подблоки (текст-варианты) */}
<div className="flex flex-col gap-2 border-t border-border pt-3">
<p className="text-sm font-medium">{t('admin.channels.bumperVariants')}</p>
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperVariantsHint')}</p>
<p className="text-xs text-muted-foreground">
{t('admin.channels.bumperVariantsHint')}
</p>
{[...template.variants]
.sort((a, b) => a.position - b.position)
.map((variant) => (
@@ -142,11 +142,19 @@ export function BumperVariantEditor({
<>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperLine1')}</Label>
<Input value={form.line1} maxLength={120} onChange={(e) => set('line1', e.target.value)} />
<Input
value={form.line1}
maxLength={120}
onChange={(e) => set('line1', e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperLine2')}</Label>
<Input value={form.line2} maxLength={120} onChange={(e) => set('line2', e.target.value)} />
<Input
value={form.line2}
maxLength={120}
onChange={(e) => set('line2', e.target.value)}
/>
</div>
</>
)}
@@ -154,7 +162,12 @@ export function BumperVariantEditor({
<div className="flex items-center justify-end gap-2">
{canRemove && (
<Button size="sm" variant="ghost" disabled={remove.isPending} onClick={() => remove.mutate()}>
<Button
size="sm"
variant="ghost"
disabled={remove.isPending}
onClick={() => remove.mutate()}
>
{t('common.delete')}
</Button>
)}
@@ -1,109 +1,105 @@
import { useQuery } from '@tanstack/react-query'
import { qk } from '@/shared/api/query-keys'
import { useTranslation } from 'react-i18next'
import type { EntryTraceDto } from '@/shared/api/types'
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,
}: Readonly<{
entryId: string
utcOffsetMinutes: number
onClose: () => void
}>) {
const { t } = useTranslation()
const { data } = useQuery({
queryKey: qk.entries.trace(entryId),
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')}>{layerSummary(data, t)}</Row>
<Row label={t('admin.channels.traceSlot')}>{slotSummary(data, t)}</Row>
<Row label={t('admin.channels.traceGroup')}>{groupSummary(data)}</Row>
<Row label={t('admin.channels.traceCollection')}>{data.collectionName}</Row>
<Row label={t('admin.channels.traceStrategy')}>{strategySummary(data, t)}</Row>
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
</dl>
)}
</DialogContent>
</Dialog>
)
}
type Translate = ReturnType<typeof useTranslation>['t']
/** Склейка непустых частей строки трейса; пусто — значит строка не заполнена (покажем «—»). */
const joinParts = (parts: (string | null | undefined)[]) => parts.filter(Boolean).join(' · ') || null
function layerSummary(data: EntryTraceDto, t: Translate) {
if (!data.layerName) return null
const priority =
data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''
return `${data.layerName}${priority}`
}
function slotSummary(data: EntryTraceDto, t: Translate) {
if (!data.slotTitle) return null
return joinParts([
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,
])
}
function groupSummary(data: EntryTraceDto) {
if (!data.groupName) return null
const count = data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''
return `${data.groupName}${count}`
}
function strategySummary(data: EntryTraceDto, t: Translate) {
if (!data.strategy) return null
return joinParts([
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,
])
}
function Row({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) {
return (
<>
<dt className="text-muted-foreground">{label}</dt>
<dd>{children || '—'}</dd>
</>
)
}
import { useQuery } from '@tanstack/react-query'
import { qk } from '@/shared/api/query-keys'
import { useTranslation } from 'react-i18next'
import type { EntryTraceDto } from '@/shared/api/types'
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,
}: Readonly<{
entryId: string
utcOffsetMinutes: number
onClose: () => void
}>) {
const { t } = useTranslation()
const { data } = useQuery({
queryKey: qk.entries.trace(entryId),
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')}>{layerSummary(data, t)}</Row>
<Row label={t('admin.channels.traceSlot')}>{slotSummary(data, t)}</Row>
<Row label={t('admin.channels.traceGroup')}>{groupSummary(data)}</Row>
<Row label={t('admin.channels.traceCollection')}>{data.collectionName}</Row>
<Row label={t('admin.channels.traceStrategy')}>{strategySummary(data, t)}</Row>
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
</dl>
)}
</DialogContent>
</Dialog>
)
}
type Translate = ReturnType<typeof useTranslation>['t']
/** Склейка непустых частей строки трейса; пусто — значит строка не заполнена (покажем «—»). */
const joinParts = (parts: (string | null | undefined)[]) =>
parts.filter(Boolean).join(' · ') || null
function layerSummary(data: EntryTraceDto, t: Translate) {
if (!data.layerName) return null
const priority =
data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''
return `${data.layerName}${priority}`
}
function slotSummary(data: EntryTraceDto, t: Translate) {
if (!data.slotTitle) return null
return joinParts([
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,
])
}
function groupSummary(data: EntryTraceDto) {
if (!data.groupName) return null
const count = data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''
return `${data.groupName}${count}`
}
function strategySummary(data: EntryTraceDto, t: Translate) {
if (!data.strategy) return null
return joinParts([
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,
])
}
function Row({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) {
return (
<>
<dt className="text-muted-foreground">{label}</dt>
<dd>{children || '—'}</dd>
</>
)
}
@@ -10,13 +10,7 @@ import type {
} from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { removeJunctionElement, updateJunctionElement, type JunctionElementBody } from '../api'
@@ -1,337 +1,339 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { ChevronRight, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listGroups } from '@/features/admin/groups/api'
import { formatClock } from '@/features/admin/interstitials/format'
import type {
ChannelDto,
GroupSummaryDto,
JunctionElementDto,
JunctionElementKind,
JunctionTemplateDto,
ScheduleTemplateDto,
} from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { cn } from '@/shared/lib/cn'
import {
addJunctionElement,
createJunction,
deleteJunction,
listJunctions,
renameJunction,
reorderJunction,
updateTemplate,
} from '../api'
import { CollapsibleCard } from './CollapsibleCard'
import { JunctionElementDialog } from './JunctionElementDialog'
/** Заставка без загруженного звука синтезируется фиксированной длины — та же цифра, что на сервере. */
const DEFAULT_BUMPER_SECONDS = 8
const KIND_COLORS: Record<JunctionElementKind, string> = {
Ad: 'bg-amber-500/70',
Promo: 'bg-sky-500/70',
Bumper: 'bg-violet-500/70',
Filler: 'bg-muted-foreground/40',
}
type Translate = ReturnType<typeof useTranslation>['t']
/** Что уточняет подпись врезки: имя блока заставки либо объём («×3» / «×5 мин»). */
function elementSuffix(element: JunctionElementDto, t: Translate) {
if (element.kind === 'Bumper')
return element.bumperTemplateName ? ` · ${element.bumperTemplateName}` : ''
const units = element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : ''
return ` ×${element.amountValue}${units}`
}
/** Подсказка сегмента линейки: вид врезки и её оценочная длительность. */
function elementTitle(element: JunctionElementDto, seconds: number, t: Translate) {
const kind = t(`admin.channels.junctionKinds.${element.kind}`)
return `${kind} · ${formatClock(seconds)}`
}
/**
* Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы
* группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо
* приблизительное и помечается как оценка.
*/
function estimateSeconds(
element: JunctionElementDto,
groups: GroupSummaryDto[] | undefined,
channel: ChannelDto,
): { seconds: number; exact: boolean } {
if (element.kind === 'Bumper') {
const template = channel.bumperTemplates.find((b) => b.id === element.bumperTemplateId)
return { seconds: template?.audioDurationSeconds ?? DEFAULT_BUMPER_SECONDS, exact: true }
}
if (element.amountMode === 'Duration') return { seconds: element.amountValue * 60, exact: true }
const group = groups?.find((g) => g.id === element.groupId)
if (!group || group.unitCount === 0) return { seconds: 0, exact: false }
return {
seconds: (element.amountValue * group.totalDurationSeconds) / group.unitCount,
exact: false,
}
}
export function JunctionsCard({
channel,
template,
bare,
onChanged,
onError,
}: Readonly<{
channel: ChannelDto
template: ScheduleTemplateDto | undefined
bare?: boolean
onChanged: () => void
onError: (error: unknown) => void
}>) {
const { t } = useTranslation()
const [newName, setNewName] = useState('')
const { data: junctions } = useQuery({
queryKey: qk.channels.junctions(channel.id),
queryFn: () => listJunctions(channel.id),
})
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const createMutation = useMutation({
mutationFn: () => createJunction(channel.id, newName.trim()),
onSuccess: () => {
setNewName('')
onChanged()
},
onError,
})
const defaultMutation = useMutation({
mutationFn: (junctionId: string | null) =>
updateTemplate(template!.id, {
name: template!.name,
fallbackGroupId: template!.fallbackGroupId,
defaultJunctionId: junctionId,
rules: template!.rules,
}),
onSuccess: onChanged,
onError,
})
return (
<CollapsibleCard title={t('admin.channels.junctions')} bare={bare}>
<div className="flex flex-col gap-4">
<p className="text-xs text-muted-foreground">{t('admin.channels.junctionsHint')}</p>
{template && (
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.defaultJunction')}</Label>
<select
className="h-9 max-w-xs rounded-md border border-border bg-transparent px-2 text-sm"
value={template.defaultJunctionId ?? ''}
onChange={(e) => defaultMutation.mutate(e.target.value || null)}
>
<option value="">{t('admin.channels.noJunction')}</option>
{(junctions ?? []).map((junction) => (
<option key={junction.id} value={junction.id}>
{junction.name}
</option>
))}
</select>
</div>
)}
{(junctions ?? []).map((junction) => (
<JunctionChain
key={junction.id}
junction={junction}
channel={channel}
groups={groups}
onChanged={onChanged}
onError={onError}
/>
))}
<div className="flex max-w-md gap-2">
<Input
placeholder={t('admin.channels.newJunctionName')}
value={newName}
maxLength={128}
onChange={(e) => setNewName(e.target.value)}
/>
<Button
size="sm"
variant="outline"
disabled={!newName.trim() || createMutation.isPending}
onClick={() => createMutation.mutate()}
>
<Plus className="h-4 w-4" /> {t('common.create')}
</Button>
</div>
</div>
</CollapsibleCard>
)
}
const ADDABLE: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler']
function JunctionChain({
junction,
channel,
groups,
onChanged,
onError,
}: Readonly<{
junction: JunctionTemplateDto
channel: ChannelDto
groups: GroupSummaryDto[] | undefined
onChanged: () => void
onError: (error: unknown) => void
}>) {
const { t } = useTranslation()
const [name, setName] = useState<string | null>(null)
const [dragged, setDragged] = useState<string | null>(null)
const [editing, setEditing] = useState<JunctionElementDto | null>(null)
const renameMutation = useMutation({
mutationFn: (value: string) => renameJunction(junction.id, value),
onSuccess: () => {
setName(null)
onChanged()
},
onError,
})
const deleteMutation = useMutation({
mutationFn: () => deleteJunction(junction.id),
onSuccess: onChanged,
onError,
})
const addMutation = useMutation({
mutationFn: (kind: JunctionElementKind) => addJunctionElement(junction.id, kind),
onSuccess: onChanged,
onError,
})
const reorderMutation = useMutation({
mutationFn: (order: string[]) => reorderJunction(junction.id, order),
onSuccess: onChanged,
onError,
})
const elements = [...junction.elements].sort((a, b) => a.position - b.position)
const estimates = elements.map((element) => estimateSeconds(element, groups, channel))
const total = estimates.reduce((sum, e) => sum + e.seconds, 0)
const exact = estimates.every((e) => e.exact)
const dropOn = (targetId: string) => {
if (!dragged || dragged === targetId) return
const order = elements.map((e) => e.id).filter((id) => id !== dragged)
order.splice(order.indexOf(targetId), 0, dragged)
setDragged(null)
reorderMutation.mutate(order)
}
return (
<div className="crt-panel flex flex-col gap-2 rounded-md p-3">
<div className="flex flex-wrap items-center gap-2">
<Input
className="h-8 max-w-56"
value={name ?? junction.name}
maxLength={128}
onChange={(e) => setName(e.target.value)}
onBlur={() =>
name !== null && name.trim() && name !== junction.name
? renameMutation.mutate(name.trim())
: setName(null)
}
/>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value=""
onChange={(e) => e.target.value && addMutation.mutate(e.target.value as JunctionElementKind)}
>
<option value="">{t('admin.channels.addJunctionElement')}</option>
{ADDABLE.map((kind) => (
<option key={kind} value={kind}>
{t(`admin.channels.junctionKinds.${kind}`)}
</option>
))}
</select>
<span className="ml-auto text-xs text-muted-foreground">
{exact ? '' : '≈ '}
{formatClock(total)}
</span>
<Button size="sm" variant="ghost" onClick={() => deleteMutation.mutate()}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Цепочка: что играет между концом одной программы и началом следующей. */}
<div className="flex flex-wrap items-center gap-1 text-xs">
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
{t('admin.channels.junctionFrom')}
</span>
{elements.length === 0 && (
<>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">{t('admin.channels.junctionEmpty')}</span>
</>
)}
{elements.map((element) => (
<span key={element.id} className="flex items-center gap-1">
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
<button
type="button"
draggable
onDragStart={() => setDragged(element.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => dropOn(element.id)}
onClick={() => setEditing(element)}
className={cn(
'cursor-grab rounded border border-border px-2 py-1 hover:border-primary',
element.isRequired && 'border-primary/70',
)}
>
{t(`admin.channels.junctionKinds.${element.kind}`)}
{elementSuffix(element, t)}
{element.isRequired && ' *'}
</button>
</span>
))}
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
{t('admin.channels.junctionTo')}
</span>
</div>
{/* Линейка: доля каждой врезки в стыке. Пустые (без группы) в неё не попадают. */}
{total > 0 && (
<div className="flex h-2 overflow-hidden rounded-full bg-muted/40">
{elements.map((element, index) => (
<div
key={element.id}
className={KIND_COLORS[element.kind]}
style={{ width: `${(estimates[index].seconds / total) * 100}%` }}
title={elementTitle(element, estimates[index].seconds, t)}
/>
))}
</div>
)}
{editing && (
<JunctionElementDialog
junctionId={junction.id}
element={editing}
bumperTemplates={channel.bumperTemplates}
onClose={() => setEditing(null)}
onChanged={onChanged}
onError={onError}
/>
)}
</div>
)
}
import { useMutation, useQuery } from '@tanstack/react-query'
import { ChevronRight, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listGroups } from '@/features/admin/groups/api'
import { formatClock } from '@/features/admin/interstitials/format'
import type {
ChannelDto,
GroupSummaryDto,
JunctionElementDto,
JunctionElementKind,
JunctionTemplateDto,
ScheduleTemplateDto,
} from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { cn } from '@/shared/lib/cn'
import {
addJunctionElement,
createJunction,
deleteJunction,
listJunctions,
renameJunction,
reorderJunction,
updateTemplate,
} from '../api'
import { CollapsibleCard } from './CollapsibleCard'
import { JunctionElementDialog } from './JunctionElementDialog'
/** Заставка без загруженного звука синтезируется фиксированной длины — та же цифра, что на сервере. */
const DEFAULT_BUMPER_SECONDS = 8
const KIND_COLORS: Record<JunctionElementKind, string> = {
Ad: 'bg-amber-500/70',
Promo: 'bg-sky-500/70',
Bumper: 'bg-violet-500/70',
Filler: 'bg-muted-foreground/40',
}
type Translate = ReturnType<typeof useTranslation>['t']
/** Что уточняет подпись врезки: имя блока заставки либо объём («×3» / «×5 мин»). */
function elementSuffix(element: JunctionElementDto, t: Translate) {
if (element.kind === 'Bumper')
return element.bumperTemplateName ? ` · ${element.bumperTemplateName}` : ''
const units = element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : ''
return ` ×${element.amountValue}${units}`
}
/** Подсказка сегмента линейки: вид врезки и её оценочная длительность. */
function elementTitle(element: JunctionElementDto, seconds: number, t: Translate) {
const kind = t(`admin.channels.junctionKinds.${element.kind}`)
return `${kind} · ${formatClock(seconds)}`
}
/**
* Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы
* группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо
* приблизительное и помечается как оценка.
*/
function estimateSeconds(
element: JunctionElementDto,
groups: GroupSummaryDto[] | undefined,
channel: ChannelDto,
): { seconds: number; exact: boolean } {
if (element.kind === 'Bumper') {
const template = channel.bumperTemplates.find((b) => b.id === element.bumperTemplateId)
return { seconds: template?.audioDurationSeconds ?? DEFAULT_BUMPER_SECONDS, exact: true }
}
if (element.amountMode === 'Duration') return { seconds: element.amountValue * 60, exact: true }
const group = groups?.find((g) => g.id === element.groupId)
if (!group || group.unitCount === 0) return { seconds: 0, exact: false }
return {
seconds: (element.amountValue * group.totalDurationSeconds) / group.unitCount,
exact: false,
}
}
export function JunctionsCard({
channel,
template,
bare,
onChanged,
onError,
}: Readonly<{
channel: ChannelDto
template: ScheduleTemplateDto | undefined
bare?: boolean
onChanged: () => void
onError: (error: unknown) => void
}>) {
const { t } = useTranslation()
const [newName, setNewName] = useState('')
const { data: junctions } = useQuery({
queryKey: qk.channels.junctions(channel.id),
queryFn: () => listJunctions(channel.id),
})
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const createMutation = useMutation({
mutationFn: () => createJunction(channel.id, newName.trim()),
onSuccess: () => {
setNewName('')
onChanged()
},
onError,
})
const defaultMutation = useMutation({
mutationFn: (junctionId: string | null) =>
updateTemplate(template!.id, {
name: template!.name,
fallbackGroupId: template!.fallbackGroupId,
defaultJunctionId: junctionId,
rules: template!.rules,
}),
onSuccess: onChanged,
onError,
})
return (
<CollapsibleCard title={t('admin.channels.junctions')} bare={bare}>
<div className="flex flex-col gap-4">
<p className="text-xs text-muted-foreground">{t('admin.channels.junctionsHint')}</p>
{template && (
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.defaultJunction')}</Label>
<select
className="h-9 max-w-xs rounded-md border border-border bg-transparent px-2 text-sm"
value={template.defaultJunctionId ?? ''}
onChange={(e) => defaultMutation.mutate(e.target.value || null)}
>
<option value="">{t('admin.channels.noJunction')}</option>
{(junctions ?? []).map((junction) => (
<option key={junction.id} value={junction.id}>
{junction.name}
</option>
))}
</select>
</div>
)}
{(junctions ?? []).map((junction) => (
<JunctionChain
key={junction.id}
junction={junction}
channel={channel}
groups={groups}
onChanged={onChanged}
onError={onError}
/>
))}
<div className="flex max-w-md gap-2">
<Input
placeholder={t('admin.channels.newJunctionName')}
value={newName}
maxLength={128}
onChange={(e) => setNewName(e.target.value)}
/>
<Button
size="sm"
variant="outline"
disabled={!newName.trim() || createMutation.isPending}
onClick={() => createMutation.mutate()}
>
<Plus className="h-4 w-4" /> {t('common.create')}
</Button>
</div>
</div>
</CollapsibleCard>
)
}
const ADDABLE: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler']
function JunctionChain({
junction,
channel,
groups,
onChanged,
onError,
}: Readonly<{
junction: JunctionTemplateDto
channel: ChannelDto
groups: GroupSummaryDto[] | undefined
onChanged: () => void
onError: (error: unknown) => void
}>) {
const { t } = useTranslation()
const [name, setName] = useState<string | null>(null)
const [dragged, setDragged] = useState<string | null>(null)
const [editing, setEditing] = useState<JunctionElementDto | null>(null)
const renameMutation = useMutation({
mutationFn: (value: string) => renameJunction(junction.id, value),
onSuccess: () => {
setName(null)
onChanged()
},
onError,
})
const deleteMutation = useMutation({
mutationFn: () => deleteJunction(junction.id),
onSuccess: onChanged,
onError,
})
const addMutation = useMutation({
mutationFn: (kind: JunctionElementKind) => addJunctionElement(junction.id, kind),
onSuccess: onChanged,
onError,
})
const reorderMutation = useMutation({
mutationFn: (order: string[]) => reorderJunction(junction.id, order),
onSuccess: onChanged,
onError,
})
const elements = [...junction.elements].sort((a, b) => a.position - b.position)
const estimates = elements.map((element) => estimateSeconds(element, groups, channel))
const total = estimates.reduce((sum, e) => sum + e.seconds, 0)
const exact = estimates.every((e) => e.exact)
const dropOn = (targetId: string) => {
if (!dragged || dragged === targetId) return
const order = elements.map((e) => e.id).filter((id) => id !== dragged)
order.splice(order.indexOf(targetId), 0, dragged)
setDragged(null)
reorderMutation.mutate(order)
}
return (
<div className="crt-panel flex flex-col gap-2 rounded-md p-3">
<div className="flex flex-wrap items-center gap-2">
<Input
className="h-8 max-w-56"
value={name ?? junction.name}
maxLength={128}
onChange={(e) => setName(e.target.value)}
onBlur={() =>
name !== null && name.trim() && name !== junction.name
? renameMutation.mutate(name.trim())
: setName(null)
}
/>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value=""
onChange={(e) =>
e.target.value && addMutation.mutate(e.target.value as JunctionElementKind)
}
>
<option value="">{t('admin.channels.addJunctionElement')}</option>
{ADDABLE.map((kind) => (
<option key={kind} value={kind}>
{t(`admin.channels.junctionKinds.${kind}`)}
</option>
))}
</select>
<span className="ml-auto text-xs text-muted-foreground">
{exact ? '' : '≈ '}
{formatClock(total)}
</span>
<Button size="sm" variant="ghost" onClick={() => deleteMutation.mutate()}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Цепочка: что играет между концом одной программы и началом следующей. */}
<div className="flex flex-wrap items-center gap-1 text-xs">
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
{t('admin.channels.junctionFrom')}
</span>
{elements.length === 0 && (
<>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">{t('admin.channels.junctionEmpty')}</span>
</>
)}
{elements.map((element) => (
<span key={element.id} className="flex items-center gap-1">
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
<button
type="button"
draggable
onDragStart={() => setDragged(element.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => dropOn(element.id)}
onClick={() => setEditing(element)}
className={cn(
'cursor-grab rounded border border-border px-2 py-1 hover:border-primary',
element.isRequired && 'border-primary/70',
)}
>
{t(`admin.channels.junctionKinds.${element.kind}`)}
{elementSuffix(element, t)}
{element.isRequired && ' *'}
</button>
</span>
))}
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
{t('admin.channels.junctionTo')}
</span>
</div>
{/* Линейка: доля каждой врезки в стыке. Пустые (без группы) в неё не попадают. */}
{total > 0 && (
<div className="flex h-2 overflow-hidden rounded-full bg-muted/40">
{elements.map((element, index) => (
<div
key={element.id}
className={KIND_COLORS[element.kind]}
style={{ width: `${(estimates[index].seconds / total) * 100}%` }}
title={elementTitle(element, estimates[index].seconds, t)}
/>
))}
</div>
)}
{editing && (
<JunctionElementDialog
junctionId={junction.id}
element={editing}
bumperTemplates={channel.bumperTemplates}
onClose={() => setEditing(null)}
onChanged={onChanged}
onError={onError}
/>
)}
</div>
)
}
@@ -118,9 +118,7 @@ export function LayerApplicabilityDialog({
type="date"
className="w-40"
value={range.from}
onChange={(e) =>
dateRanges.patch(key, (r) => ({ ...r, from: e.target.value }))
}
onChange={(e) => dateRanges.patch(key, (r) => ({ ...r, from: e.target.value }))}
/>
<Input
type="date"
@@ -1,259 +1,255 @@
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: 'PG-13' }
/**
* Окно со стабильным ключом. Индекс в качестве key не годится: строки удаляются из середины, и React
* сопоставил бы уцелевшие узлы не с теми окнами — фокус и внутреннее состояние полей переехали бы
* в соседнюю строку. Ключ живёт только на клиенте и в API не уезжает.
*/
type WindowRow = { key: string; window: AudienceWindow }
const toRows = (windows: AudienceWindow[]): WindowRow[] =>
windows.map((window) => ({ key: crypto.randomUUID(), window }))
/**
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
*/
export function RulesCard({
template,
bare,
onChanged,
onError,
}: Readonly<{
template: ScheduleTemplateDto
bare?: boolean
onChanged: () => void
onError: (error: unknown) => void
}>) {
const { t } = useTranslation()
const [windows, setWindows] = useState<WindowRow[]>(() =>
toRows(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(toRows(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.map((row) => row.window) : 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 removeWindow = (key: string) =>
setWindows((current) => current.filter((row) => row.key !== key))
const patchWindow = (key: string, part: Partial<AudienceWindow>) =>
setWindows((current) =>
current.map((row) =>
row.key === key ? { ...row, window: { ...row.window, ...part } } : row,
),
)
return (
<CollapsibleCard title={t('admin.channels.rules')} bare={bare}>
<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, ...toRows([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(({ key, window }) => (
<li key={key} 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(key, { 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(key, { 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(key, { 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={() => removeWindow(key)}
>
<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>
)
}
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: 'PG-13' }
/**
* Окно со стабильным ключом. Индекс в качестве key не годится: строки удаляются из середины, и React
* сопоставил бы уцелевшие узлы не с теми окнами — фокус и внутреннее состояние полей переехали бы
* в соседнюю строку. Ключ живёт только на клиенте и в API не уезжает.
*/
type WindowRow = { key: string; window: AudienceWindow }
const toRows = (windows: AudienceWindow[]): WindowRow[] =>
windows.map((window) => ({ key: crypto.randomUUID(), window }))
/**
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
*/
export function RulesCard({
template,
bare,
onChanged,
onError,
}: Readonly<{
template: ScheduleTemplateDto
bare?: boolean
onChanged: () => void
onError: (error: unknown) => void
}>) {
const { t } = useTranslation()
const [windows, setWindows] = useState<WindowRow[]>(() =>
toRows(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(toRows(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.map((row) => row.window) : 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 removeWindow = (key: string) =>
setWindows((current) => current.filter((row) => row.key !== key))
const patchWindow = (key: string, part: Partial<AudienceWindow>) =>
setWindows((current) =>
current.map((row) =>
row.key === key ? { ...row, window: { ...row.window, ...part } } : row,
),
)
return (
<CollapsibleCard title={t('admin.channels.rules')} bare={bare}>
<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, ...toRows([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(({ key, window }) => (
<li key={key} 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(key, { 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(key, { 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(key, { 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={() => removeWindow(key)}>
<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,379 +1,380 @@
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,
}: Readonly<{
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) => (
<div
key={hour}
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((hour, index) => (
<button
key={hour}
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: слот выбирается кликом, и с клавиатуры это должно работать
// тоже. Перетаскивание на кнопке сохраняется — draggable к роли не привязан.
<button
key={`${slot.id}-${weekday}`}
type="button"
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>
{/* Ручка растягивания — исключительно мышиная: role="presentation" на элементе
с обработчиком противоречив (роль говорит «меня нет», а элемент реагирует),
поэтому прячем её от вспомогательных технологий. Длительность слота
правится с клавиатуры в инспекторе — доступный путь есть. */}
<span
aria-hidden="true"
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!)
}
/>
</button>
)
})}
</div>
))}
</div>
</div>
</div>
)
}
/**
* Панель слоёв: видимость, приоритет (перетаскиванием), применимость и выбор редактируемого.
* Выше в списке — приоритетнее; фоновый слой всегда внизу и не двигается.
*/
export function LayerList({
template,
activeLayerId,
viewDate,
onSelect,
onDelete,
onToggle,
onReorder,
onEditApplicability,
}: Readonly<{
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)
}
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,
}: Readonly<{
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) => (
<div
key={hour}
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((hour, index) => (
<button
key={hour}
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: слот выбирается кликом, и с клавиатуры это должно работать
// тоже. Перетаскивание на кнопке сохраняется — draggable к роли не привязан.
<button
key={`${slot.id}-${weekday}`}
type="button"
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>
{/* Ручка растягивания — исключительно мышиная: role="presentation" на элементе
с обработчиком противоречив (роль говорит «меня нет», а элемент реагирует),
поэтому прячем её от вспомогательных технологий. Длительность слота
правится с клавиатуры в инспекторе — доступный путь есть. */}
<span
aria-hidden="true"
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!)
}
/>
</button>
)
})}
</div>
))}
</div>
</div>
</div>
)
}
/**
* Панель слоёв: видимость, приоритет (перетаскиванием), применимость и выбор редактируемого.
* Выше в списке — приоритетнее; фоновый слой всегда внизу и не двигается.
*/
export function LayerList({
template,
activeLayerId,
viewDate,
onSelect,
onDelete,
onToggle,
onReorder,
onEditApplicability,
}: Readonly<{
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)
}
@@ -1,136 +1,140 @@
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ChannelDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { updateChannelSettings, updateChannelTime } from '../api'
import { CollapsibleCard } from './CollapsibleCard'
export function SettingsCard({
channel,
readyAssets,
bare,
onSaved,
onError,
}: Readonly<{
channel: ChannelDto
readyAssets: { id: string; originalFileName: string }[]
bare?: boolean
onSaved: () => void
onError: (e: unknown) => void
}>) {
const { t } = useTranslation()
const [name, setName] = useState(channel.name)
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
const [number, setNumber] = useState(channel.number?.toString() ?? '')
const [offsetHours, setOffsetHours] = useState(channel.utcOffsetMinutes / 60)
// Начало вещательных суток приходит как «06:00:00» — в поле нужен формат «06:00».
const [dayStart, setDayStart] = useState(channel.dayStartTime.slice(0, 5))
useEffect(() => {
setName(channel.name)
setIsEnabled(channel.isEnabled)
setFillerAssetId(channel.fillerAssetId ?? '')
setNumber(channel.number?.toString() ?? '')
setOffsetHours(channel.utcOffsetMinutes / 60)
setDayStart(channel.dayStartTime.slice(0, 5))
}, [channel])
const save = useMutation({
// Время канала живёт отдельной командой — сохраняем обе за одно нажатие.
mutationFn: async () => {
await updateChannelSettings(channel.id, {
name: name.trim(),
isEnabled,
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
bumpersEnabled: channel.bumpersEnabled,
bumper: channel.bumper,
fillerAssetId: fillerAssetId || null,
})
await updateChannelTime(channel.id, {
number: number.trim() === '' ? null : Number(number),
utcOffsetMinutes: Math.round(offsetHours * 60),
dayStartTime: `${dayStart}:00`,
})
},
onSuccess: () => {
toast.success(t('settings.saved'))
onSaved()
},
onError,
})
return (
<CollapsibleCard
title={t('admin.channels.settings')}
defaultOpen
bare={bare}
contentClassName="grid gap-4 sm:grid-cols-2"
>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.name')}</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.number')}</Label>
<Input
type="number"
min={1}
value={number}
placeholder={t('admin.channels.numberPlaceholder')}
onChange={(e) => setNumber(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.utcOffset')}</Label>
<Input
type="number"
min={-12}
max={14}
step={1}
value={offsetHours}
onChange={(e) => setOffsetHours(Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.utcOffsetHint')}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.dayStart')}</Label>
<Input type="time" value={dayStart} onChange={(e) => setDayStart(e.target.value)} />
<p className="text-xs text-muted-foreground">{t('admin.channels.dayStartHint')}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.filler')}</Label>
<Select
value={fillerAssetId || 'none'}
onValueChange={(v) => setFillerAssetId(v === 'none' ? '' : v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('admin.channels.noFiller')}</SelectItem>
{readyAssets.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.originalFileName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
{t('admin.channels.enabledLabel')}
</label>
<div className="flex items-end justify-end sm:col-span-2">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</CollapsibleCard>
)
}
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ChannelDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { updateChannelSettings, updateChannelTime } from '../api'
import { CollapsibleCard } from './CollapsibleCard'
export function SettingsCard({
channel,
readyAssets,
bare,
onSaved,
onError,
}: Readonly<{
channel: ChannelDto
readyAssets: { id: string; originalFileName: string }[]
bare?: boolean
onSaved: () => void
onError: (e: unknown) => void
}>) {
const { t } = useTranslation()
const [name, setName] = useState(channel.name)
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
const [number, setNumber] = useState(channel.number?.toString() ?? '')
const [offsetHours, setOffsetHours] = useState(channel.utcOffsetMinutes / 60)
// Начало вещательных суток приходит как «06:00:00» — в поле нужен формат «06:00».
const [dayStart, setDayStart] = useState(channel.dayStartTime.slice(0, 5))
useEffect(() => {
setName(channel.name)
setIsEnabled(channel.isEnabled)
setFillerAssetId(channel.fillerAssetId ?? '')
setNumber(channel.number?.toString() ?? '')
setOffsetHours(channel.utcOffsetMinutes / 60)
setDayStart(channel.dayStartTime.slice(0, 5))
}, [channel])
const save = useMutation({
// Время канала живёт отдельной командой — сохраняем обе за одно нажатие.
mutationFn: async () => {
await updateChannelSettings(channel.id, {
name: name.trim(),
isEnabled,
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
bumpersEnabled: channel.bumpersEnabled,
bumper: channel.bumper,
fillerAssetId: fillerAssetId || null,
})
await updateChannelTime(channel.id, {
number: number.trim() === '' ? null : Number(number),
utcOffsetMinutes: Math.round(offsetHours * 60),
dayStartTime: `${dayStart}:00`,
})
},
onSuccess: () => {
toast.success(t('settings.saved'))
onSaved()
},
onError,
})
return (
<CollapsibleCard
title={t('admin.channels.settings')}
defaultOpen
bare={bare}
contentClassName="grid gap-4 sm:grid-cols-2"
>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.name')}</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.number')}</Label>
<Input
type="number"
min={1}
value={number}
placeholder={t('admin.channels.numberPlaceholder')}
onChange={(e) => setNumber(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.utcOffset')}</Label>
<Input
type="number"
min={-12}
max={14}
step={1}
value={offsetHours}
onChange={(e) => setOffsetHours(Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.utcOffsetHint')}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.dayStart')}</Label>
<Input type="time" value={dayStart} onChange={(e) => setDayStart(e.target.value)} />
<p className="text-xs text-muted-foreground">{t('admin.channels.dayStartHint')}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.filler')}</Label>
<Select
value={fillerAssetId || 'none'}
onValueChange={(v) => setFillerAssetId(v === 'none' ? '' : v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('admin.channels.noFiller')}</SelectItem>
{readyAssets.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.originalFileName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isEnabled}
onChange={(e) => setIsEnabled(e.target.checked)}
/>
{t('admin.channels.enabledLabel')}
</label>
<div className="flex items-end justify-end sm:col-span-2">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</CollapsibleCard>
)
}
@@ -1,334 +1,338 @@
import { useQuery } from '@tanstack/react-query'
import { Eye } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
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 = new Set<PlannedItemKind>(['Program', 'Fallback', 'SignOff'])
/**
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
*/
export function TemplatePreview({ channelId }: Readonly<{ 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: qk.channels.preview(channelId, 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 }: Readonly<{ preview: SchedulePreviewDto }>) {
const { t } = useTranslation()
const items = preview.items.filter((i) => PROGRAMME_KINDS.has(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 }: Readonly<{ 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,
}: Readonly<{ 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 }: Readonly<{ preview: SchedulePreviewDto }>) {
const { t } = useTranslation()
// Ключ строки — «вид + порядковый номер внутри вида»: у предупреждения нет своего id, а текст
// повторяется (одна и та же причина на разных слотах), и позиция здесь — единственное, что его
// отличает. Считаем ключ при группировке, чтобы список не пересобирался на каждый рендер.
const grouped = useMemo(() => {
const map = new Map<string, { key: string; text: string }[]>()
for (const warning of preview.warnings) {
const list = map.get(warning.kind) ?? []
list.push({ key: `${warning.kind}#${list.length}`, text: 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) => (
<span key={detail.key} className="text-muted-foreground">
{detail.text}
</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 }: Readonly<{ 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((a, b) => a.localeCompare(b))
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>
)
}
import { useQuery } from '@tanstack/react-query'
import { Eye } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
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 = new Set<PlannedItemKind>(['Program', 'Fallback', 'SignOff'])
/**
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
*/
export function TemplatePreview({ channelId }: Readonly<{ 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: qk.channels.preview(channelId, 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 }: Readonly<{ preview: SchedulePreviewDto }>) {
const { t } = useTranslation()
const items = preview.items.filter((i) => PROGRAMME_KINDS.has(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 }: Readonly<{ 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,
}: Readonly<{ 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 }: Readonly<{ preview: SchedulePreviewDto }>) {
const { t } = useTranslation()
// Ключ строки — «вид + порядковый номер внутри вида»: у предупреждения нет своего id, а текст
// повторяется (одна и та же причина на разных слотах), и позиция здесь — единственное, что его
// отличает. Считаем ключ при группировке, чтобы список не пересобирался на каждый рендер.
const grouped = useMemo(() => {
const map = new Map<string, { key: string; text: string }[]>()
for (const warning of preview.warnings) {
const list = map.get(warning.kind) ?? []
list.push({ key: `${warning.kind}#${list.length}`, text: 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) => (
<span key={detail.key} className="text-muted-foreground">
{detail.text}
</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 }: Readonly<{ 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((a, b) => a.localeCompare(b))
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>
)
}