Add grid auto-build functionality with profile management and preview capabilities
ci / build-backend (push) Successful in 2m40s
ci / build-frontend (push) Successful in 1m14s
ci / tests (push) Successful in 2m45s
ci / sonar (push) Successful in 7m4s

Implemented new API endpoints and frontend components for grid auto-building based on predefined profiles. Added functionality to list grid profiles, preview generated grids, and generate grids with specified parameters. Enhanced the TemplateEndpoints and related services to support these features, improving the user experience for channel scheduling. Localization updates were made to accommodate new features in both English and Russian.
This commit is contained in:
Leonid Pershin
2026-07-27 08:08:56 +03:00
parent 6fe6404e61
commit bef029b858
33 changed files with 2623 additions and 44 deletions
@@ -9,6 +9,11 @@ import type {
CopyTemplateResultDto,
CreatedIdResponse,
EntryTraceDto,
GenerateGridResultDto,
GridGenerationMode,
GridPlanDto,
GridProfileDto,
GridProfileKind,
JunctionAmountMode,
JunctionConditions,
JunctionElementKind,
@@ -89,6 +94,31 @@ export function getApplyDiff(channelId: string) {
return apiRequest<ScheduleDiffDto>(`/admin/channels/${channelId}/template/diff`)
}
/** Каталог профилей автосборки: имя, на что похоже и для какой библиотеки годится. */
export function listGridProfiles() {
return apiRequest<GridProfileDto[]>('/admin/grid-profiles')
}
/** План автосборки: что будет создано и что снесено. Считается тем же кодом, что и сама сборка. */
export function previewGrid(channelId: string, profile: GridProfileKind, mode: GridGenerationMode) {
const query = new URLSearchParams({ profile, mode })
return apiRequest<GridPlanDto>(
`/admin/channels/${channelId}/template/grid-plan?${query.toString()}`,
)
}
/** Собирает сетку по профилю. План пересчитывается на сервере — с клиента едут только опции. */
export function generateGrid(
channelId: string,
profile: GridProfileKind,
mode: GridGenerationMode,
) {
return apiRequest<GenerateGridResultDto>(`/admin/channels/${channelId}/template/generate`, {
method: 'POST',
body: { profile, mode },
})
}
/** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */
export function copyTemplateTo(channelId: string, targetChannelId: string) {
return apiRequest<CopyTemplateResultDto>(
@@ -0,0 +1,199 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import type { GridGenerationMode, GridPlanSlotDto, GridProfileKind } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { toast } from '@/shared/ui/toast-store'
import { DurationLabel } from '../../groups/DurationLabel'
import { generateGrid, listGridProfiles, previewGrid } from '../api'
/**
* Автосборка сетки: профиль + режим → предпросмотр → создание. План с клиента не уезжает —
* команда пересчитывает его сама, поэтому показанное и созданное не могут разойтись.
*
* Предпросмотр обязателен именно из-за режима пересборки: он сносит все слоты шаблона, и увидеть,
* что взамен, надо до нажатия, а не после.
*/
export function GenerateGridDialog({
channelId,
onClose,
onGenerated,
onError,
}: Readonly<{
channelId: string
onClose: () => void
onGenerated: () => void
onError: (error: unknown) => void
}>) {
const { t } = useTranslation()
const [profile, setProfile] = useState<GridProfileKind>('Mixed')
const [mode, setMode] = useState<GridGenerationMode>('FillGaps')
const { data: profiles } = useQuery({
queryKey: qk.gridProfiles.all,
queryFn: listGridProfiles,
})
const { data: plan, isFetching } = useQuery({
queryKey: qk.channels.gridPlan(channelId, profile, mode),
queryFn: () => previewGrid(channelId, profile, mode),
staleTime: 0,
gcTime: 0,
})
const generate = useMutation({
mutationFn: () => generateGrid(channelId, profile, mode),
onSuccess: (result) => {
toast.success(
t('admin.channels.generate.done', { created: result.created, removed: result.removed }),
)
onGenerated()
onClose()
},
onError,
})
const covered =
plan && plan.freeMinutes > 0 ? Math.round((plan.coveredMinutes / plan.freeMinutes) * 100) : 0
return (
<Dialog open onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t('admin.channels.generate.title')}</DialogTitle>
<DialogDescription>{t('admin.channels.generate.hint')}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 text-sm">
{/* Профиль — то, ради чего окно и открыли: ритм суток задаёт всё остальное. */}
<div className="grid gap-1.5 sm:grid-cols-2">
{(profiles ?? []).map((item) => (
<button
key={item.kind}
type="button"
className={cn(
'flex flex-col items-start gap-0.5 rounded-md border px-3 py-2 text-left transition-colors',
item.kind === profile
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50',
)}
onClick={() => setProfile(item.kind)}
>
<span className="flex items-baseline gap-2">
<span className="font-medium">{item.name}</span>
<span className="text-xs text-muted-foreground">{item.reference}</span>
</span>
<span className="text-xs text-muted-foreground">{item.description}</span>
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
{(['FillGaps', 'Rebuild'] as const).map((value) => (
<Button
key={value}
size="sm"
variant={value === mode ? 'default' : 'outline'}
onClick={() => setMode(value)}
>
{t(`admin.channels.generate.modes.${value}`)}
</Button>
))}
<span className="text-xs text-muted-foreground">
{t(`admin.channels.generate.modeHints.${mode}`)}
</span>
</div>
{isFetching && <p className="text-muted-foreground">{t('common.loading')}</p>}
{plan && !isFetching && (
<>
<p className="text-muted-foreground">
{t('admin.channels.generate.summary', {
slots: plan.slots.length,
percent: covered,
})}
</p>
{plan.slotsToRemove > 0 && (
<p className="flex items-center gap-1.5 text-amber-500">
<AlertTriangle className="h-4 w-4 shrink-0" />
{t('admin.channels.generate.willRemove', { count: plan.slotsToRemove })}
</p>
)}
{plan.fallbackGroupName && (
<p className="text-xs text-muted-foreground">
{t('admin.channels.generate.fallback', { name: plan.fallbackGroupName })}
</p>
)}
{plan.notes.length > 0 && (
<ul className="flex flex-col gap-0.5 text-xs text-amber-500">
{plan.notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
)}
{plan.slots.length === 0 ? (
<p className="text-muted-foreground">{t('admin.channels.generate.nothing')}</p>
) : (
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md text-xs">
{plan.slots.map((slot, index) => (
<PlanRow key={`${slot.weekday}-${slot.start}-${index}`} slot={slot} />
))}
</ul>
)}
</>
)}
</div>
<DialogFooter>
<Button size="sm" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
size="sm"
disabled={generate.isPending || isFetching || !plan || plan.slots.length === 0}
onClick={() => generate.mutate()}
>
{t('admin.channels.generate.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function PlanRow({ slot }: Readonly<{ slot: GridPlanSlotDto }>) {
const { t } = useTranslation()
return (
<li className="flex items-center gap-2 px-3 py-1">
<span className="w-24 shrink-0 text-muted-foreground">
{slot.weekday === null
? t('admin.channels.everyDay')
: t(`admin.channels.weekdays.${slot.weekday}`)}
</span>
<span className="w-12 shrink-0 tabular-nums">{slot.start}</span>
<span className="w-20 shrink-0 tabular-nums text-muted-foreground">
<DurationLabel seconds={slot.durationMinutes * 60} />
</span>
<span className="min-w-0 flex-1 truncate">{slot.title}</span>
<Badge variant="muted">{slot.block}</Badge>
</li>
)
}
@@ -1,5 +1,5 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { Plus } from 'lucide-react'
import { Plus, Wand2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
@@ -21,6 +21,7 @@ import {
updateSlot,
} from '../api'
import { toTime } from '../lib/format'
import { GenerateGridDialog } from './GenerateGridDialog'
import { LayerApplicabilityDialog } from './LayerApplicabilityDialog'
import { LayerList, ScheduleGrid } from './ScheduleGrid'
import { SlotInspector, type SlotDraft } from './SlotInspector'
@@ -50,6 +51,7 @@ export function GridTab({
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
const [viewDate, setViewDate] = useState<string>('')
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
const [generating, setGenerating] = useState(false)
// День, который копируем, и отмеченные дни-приёмники.
const [copySource, setCopySource] = useState<number | null>(null)
const [copyTargets, setCopyTargets] = useState<number[]>([])
@@ -302,6 +304,10 @@ export function GridTab({
<Plus className="h-4 w-4" />
{t('admin.channels.newSlot')}
</Button>
<Button size="sm" variant="outline" onClick={() => setGenerating(true)}>
<Wand2 className="h-4 w-4" />
{t('admin.channels.generate.action')}
</Button>
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
<Input
type="date"
@@ -377,6 +383,15 @@ export function GridTab({
</CardContent>
</Card>
{generating && (
<GenerateGridDialog
channelId={channelId}
onClose={() => setGenerating(false)}
onGenerated={onChanged}
onError={onError}
/>
)}
{applicabilityLayer && (
<LayerApplicabilityDialog
layer={applicabilityLayer}
@@ -13,7 +13,7 @@ import { toast } from '@/shared/ui/toast-store'
import { useTableSort } from '@/shared/lib/table-sort'
import { SortHeader } from '@/shared/ui/sortable'
import { deleteMedia, getMediaStats, listMedia } from './api'
import { formatDuration } from './format'
import { formatDuration, splitEta } from './format'
import { ManualInboxDialog } from './ManualInboxDialog'
import { UploadToShowDialog } from './UploadToShowDialog'
import { useUploadStore } from './upload-store'
@@ -145,6 +145,15 @@ export function MediaPanel() {
{formatDuration(stats.averageProcessingSeconds)}
</span>
</span>
{/* Оценка появляется только когда есть что ждать: «Осталось: —» бесполезно. */}
{stats.estimatedRemainingSeconds !== null && (
<span title={t('admin.media.stats.eta')}>
{t('admin.media.stats.etaShort')}:{' '}
<span className="text-foreground tabular-nums">
<EtaValue seconds={stats.estimatedRemainingSeconds} />
</span>
</span>
)}
</div>
)}
</div>
@@ -267,6 +276,21 @@ export function MediaPanel() {
)
}
/**
* «Осталось: ~2 ч 40 мин». Тильда и округление до минут намеренные: это оценка по среднему времени,
* и точная до секунды подпись читалась бы как обещание.
*/
function EtaValue({ seconds }: Readonly<{ seconds: number }>) {
const { t } = useTranslation()
const parts = splitEta(seconds)
if (!parts) return <></>
if (parts.hours === 0 && parts.minutes === 0) return <>{t('admin.media.stats.etaSoon')}</>
const hours = parts.hours > 0 ? `${parts.hours} ${t('admin.media.stats.hoursShort')}` : ''
const minutes = parts.minutes > 0 ? `${parts.minutes} ${t('admin.media.stats.minutesShort')}` : ''
return <>~{[hours, minutes].filter(Boolean).join(' ')}</>
}
function MediaRow({ asset, onDelete }: Readonly<{ asset: MediaAssetDto; onDelete: () => void }>) {
const { t } = useTranslation()
return (
@@ -8,3 +8,14 @@ export function formatDuration(seconds: number | null): string {
const pad = (n: number) => String(n).padStart(2, '0')
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
}
/**
* Оценка «сколько ещё ждать» — часами и минутами, без секунд. Точность здесь была бы мнимой: оценка
* строится на среднем времени обработки, и «13:27:42» обещало бы то, чего никто не гарантирует.
* Меньше минуты — не разбиваем на части, вернём нули: подпись всё равно скажет «меньше минуты».
*/
export function splitEta(seconds: number | null): { hours: number; minutes: number } | null {
if (seconds == null) return null
const total = Math.max(0, Math.round(seconds))
return { hours: Math.floor(total / 3600), minutes: Math.round((total % 3600) / 60) }
}
+7
View File
@@ -24,6 +24,13 @@ export const qk = {
issues: (id: string) => ['admin', 'channels', id, 'issues'] as const,
diff: (id: string) => ['admin', 'channels', id, 'diff'] as const,
preview: (id: string, days: number) => ['admin', 'channels', id, 'preview', days] as const,
/** План автосборки — свой на каждую комбинацию профиля и режима. */
gridPlan: (id: string, profile: string, mode: string) =>
['admin', 'channels', id, 'grid-plan', profile, mode] as const,
},
gridProfiles: {
all: ['admin', 'grid-profiles'] as const,
},
entries: {
+46
View File
@@ -109,6 +109,8 @@ export type MediaStatsDto = {
queued: number
processing: number
averageProcessingSeconds: number | null
/** Оценка времени до конца очереди, сек; null — очередь пуста либо среднее ещё не набралось. */
estimatedRemainingSeconds: number | null
}
// ── Библиотека (жанры) ─────────────────────────────────────────────────────
@@ -692,6 +694,50 @@ export type CopyTemplateResultDto = {
droppedBumperRefs: number
}
// ── Автосборка сетки ──────────────────────────────────────────────────────
/** Архетип сетки: с какого типа канала списан ритм суток. */
export type GridProfileKind = 'Mixed' | 'Animation' | 'Sitcom' | 'Music' | 'Movies' | 'Kids'
/** Что делать с тем, что в сетке уже есть. */
export type GridGenerationMode = 'FillGaps' | 'Rebuild'
export type GridProfileDto = {
kind: GridProfileKind
name: string
/** На что похоже — «как у 2×2». */
reference: string
description: string
}
/** Строка предпросмотра: будущий слот до того, как он создан. */
export type GridPlanSlotDto = {
layer: 'Main' | 'Weekend'
weekday: number | null
start: string
durationMinutes: number
title: string
daypart: Daypart
slotKind: SlotKind
groupName: string | null
/** Человеческое описание блока: «4 подряд», «до конца слота». */
block: string
}
export type GridPlanDto = {
profile: GridProfileKind
profileName: string
slots: GridPlanSlotDto[]
notes: string[]
/** Больше нуля только при пересборке — столько слотов будет снесено. */
slotsToRemove: number
fallbackGroupName: string | null
freeMinutes: number
coveredMinutes: number
}
export type GenerateGridResultDto = { created: number; removed: number }
/** Проверки сетки по правилам, до генерации (см. 5.1). */
type TemplateIssueKind =
| 'GroupEmpty'
+26
View File
@@ -318,6 +318,11 @@ export const en = {
processingShort: 'Processing',
average: 'Average processing time (recent)',
averageShort: 'Avg time',
eta: 'Estimated time until the queue drains: work left at the average rate, divided by the number of parallel transcodes',
etaShort: 'Left',
etaSoon: 'under a minute',
hoursShort: 'h',
minutesShort: 'min',
},
},
gallery: {
@@ -423,6 +428,27 @@ export const en = {
newLayerName: 'New layer',
addSlotHere: 'Add slot',
newSlot: 'New slot',
/** Grid auto-build by profile: plan preview and creation. */
generate: {
action: 'Build grid',
title: 'Grid auto-build',
hint: 'A profile sets the rhythm of the day: block length, what airs in prime time and what fills the night. Groups are picked from the existing ones by rating, content type and how many units they hold.',
modes: {
FillGaps: 'Fill gaps',
Rebuild: 'Rebuild the week',
},
modeHints: {
FillGaps: 'Existing slots are left alone — only uncovered time is filled.',
Rebuild:
'All template slots are removed; the weekend is built as a separate layer on top.',
},
summary: 'Slots to create: {{slots}} — that is {{percent}}% of the free week.',
willRemove: 'Slots to remove: {{count}}.',
fallback: 'The fallback group will be "{{name}}" — it covers pauses between slots.',
nothing: 'Nothing to create: there is no free time in the grid.',
create: 'Build',
done: 'Created {{created}} slots, removed {{removed}}.',
},
editSlot: 'Slot',
slotTitle: 'Block title',
slotStart: 'Start',
+25
View File
@@ -319,6 +319,11 @@ export const ru = {
processingShort: 'В обработке',
average: 'Среднее время обработки (по недавним)',
averageShort: 'Ср. время',
eta: 'Примерное время до конца очереди: оставшаяся работа по среднему, делённая на число параллельных транскодов',
etaShort: 'Осталось',
etaSoon: 'меньше минуты',
hoursShort: 'ч',
minutesShort: 'мин',
},
},
gallery: {
@@ -424,6 +429,26 @@ export const ru = {
newLayerName: 'Новый слой',
addSlotHere: 'Добавить слот',
newSlot: 'Новый слот',
/** Автосборка сетки по профилю: предпросмотр плана и его создание. */
generate: {
action: 'Собрать сетку',
title: 'Автосборка сетки',
hint: 'Профиль задаёт ритм суток: длину блоков, что стоит в прайме и чем закрыта ночь. Группы под каждую полосу подбираются из существующих — по рейтингу, типу контента и запасу серий.',
modes: {
FillGaps: 'Заполнить дыры',
Rebuild: 'Собрать неделю с нуля',
},
modeHints: {
FillGaps: 'Существующие слоты не трогаются — застраивается только незакрытое время.',
Rebuild: 'Все слоты шаблона сносятся; выходные строятся отдельным слоем поверх будних.',
},
summary: 'Будет создано слотов: {{slots}} — это {{percent}}% свободного времени недели.',
willRemove: 'Будет снесено слотов: {{count}}.',
fallback: 'Аварийной группой станет «{{name}}» — ею закрываются паузы между слотами.',
nothing: 'Создавать нечего: свободного времени в сетке нет.',
create: 'Собрать',
done: 'Создано слотов: {{created}}, снесено: {{removed}}.',
},
editSlot: 'Слот',
slotTitle: 'Название блока',
slotStart: 'Начало',