Enhance scheduling and trace functionalities: add CollectionId to ScheduleEntry and PlannedItem models, update related query handlers and frontend components to support collection information in entry traces. Improve data handling in scheduling logic and enhance user experience in trace display.
build / backend (push) Successful in 2m4s
build / frontend (push) Successful in 39s
tests / backend-tests (push) Successful in 2m10s

This commit is contained in:
Leonid Pershin
2026-07-26 14:59:54 +03:00
parent f699576582
commit 2445ba56b5
16 changed files with 2467 additions and 580 deletions
@@ -1,104 +1,105 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { getEntryTrace } from '../api'
import { formatChannelTime } from '../lib/format'
/**
* «Почему это здесь» (см. 6.5): цепочка происхождения записи. Трейс пишется в момент генерации —
* восстановить его потом нельзя, поэтому у старых записей часть строк будет пустой.
*/
export function EntryTraceDialog({
entryId,
utcOffsetMinutes,
onClose,
}: {
entryId: string
utcOffsetMinutes: number
onClose: () => void
}) {
const { t } = useTranslation()
const { data } = useQuery({
queryKey: ['admin', 'entries', entryId, 'trace'],
queryFn: () => getEntryTrace(entryId),
})
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{data
? `${data.showName ?? '—'} · ${formatChannelTime(data.startsAtUtc, utcOffsetMinutes)}`
: t('common.loading')}
</DialogTitle>
</DialogHeader>
{data && (
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1.5 text-sm">
<Row label={t('admin.channels.traceLayer')}>
{data.layerName
? `${data.layerName}${data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''}`
: null}
</Row>
<Row label={t('admin.channels.traceSlot')}>
{data.slotTitle
? [
data.slotTitle,
data.slotWeekday === null
? t('admin.channels.everyDay')
: t(`admin.channels.weekdays.${data.slotWeekday}`),
data.slotTargetStart?.slice(0, 5),
data.slotDurationMinutes ? `${data.slotDurationMinutes} мин` : null,
data.driftMinutes !== 0
? t('admin.channels.traceDrift', { minutes: data.driftMinutes })
: null,
data.snapped ? t('admin.channels.traceSnapped') : null,
]
.filter(Boolean)
.join(' · ')
: null}
</Row>
<Row label={t('admin.channels.traceGroup')}>
{data.groupName
? `${data.groupName}${data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''}`
: null}
</Row>
<Row label={t('admin.channels.traceStrategy')}>
{data.strategy
? [
t(`admin.channels.strategies.${data.strategy}`),
data.cooldownDays
? t('admin.channels.traceCooldown', { days: data.cooldownDays })
: null,
data.candidatesAfterCooldown !== null
? t('admin.channels.traceCandidates', {
count: data.candidatesAfterCooldown,
})
: null,
]
.filter(Boolean)
.join(' · ')
: null}
</Row>
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
</dl>
)}
</DialogContent>
</Dialog>
)
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<>
<dt className="text-muted-foreground">{label}</dt>
<dd>{children || '—'}</dd>
</>
)
}
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { getEntryTrace } from '../api'
import { formatChannelTime } from '../lib/format'
/**
* «Почему это здесь» (см. 6.5): цепочка происхождения записи. Трейс пишется в момент генерации —
* восстановить его потом нельзя, поэтому у старых записей часть строк будет пустой.
*/
export function EntryTraceDialog({
entryId,
utcOffsetMinutes,
onClose,
}: {
entryId: string
utcOffsetMinutes: number
onClose: () => void
}) {
const { t } = useTranslation()
const { data } = useQuery({
queryKey: ['admin', 'entries', entryId, 'trace'],
queryFn: () => getEntryTrace(entryId),
})
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{data
? `${data.showName ?? '—'} · ${formatChannelTime(data.startsAtUtc, utcOffsetMinutes)}`
: t('common.loading')}
</DialogTitle>
</DialogHeader>
{data && (
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1.5 text-sm">
<Row label={t('admin.channels.traceLayer')}>
{data.layerName
? `${data.layerName}${data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''}`
: null}
</Row>
<Row label={t('admin.channels.traceSlot')}>
{data.slotTitle
? [
data.slotTitle,
data.slotWeekday === null
? t('admin.channels.everyDay')
: t(`admin.channels.weekdays.${data.slotWeekday}`),
data.slotTargetStart?.slice(0, 5),
data.slotDurationMinutes ? `${data.slotDurationMinutes} мин` : null,
data.driftMinutes !== 0
? t('admin.channels.traceDrift', { minutes: data.driftMinutes })
: null,
data.snapped ? t('admin.channels.traceSnapped') : null,
]
.filter(Boolean)
.join(' · ')
: null}
</Row>
<Row label={t('admin.channels.traceGroup')}>
{data.groupName
? `${data.groupName}${data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''}`
: null}
</Row>
<Row label={t('admin.channels.traceCollection')}>{data.collectionName}</Row>
<Row label={t('admin.channels.traceStrategy')}>
{data.strategy
? [
t(`admin.channels.strategies.${data.strategy}`),
data.cooldownDays
? t('admin.channels.traceCooldown', { days: data.cooldownDays })
: null,
data.candidatesAfterCooldown !== null
? t('admin.channels.traceCandidates', {
count: data.candidatesAfterCooldown,
})
: null,
]
.filter(Boolean)
.join(' · ')
: null}
</Row>
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
</dl>
)}
</DialogContent>
</Dialog>
)
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<>
<dt className="text-muted-foreground">{label}</dt>
<dd>{children || '—'}</dd>
</>
)
}
+2
View File
@@ -559,6 +559,8 @@ export type EntryTraceDto = {
slotDurationMinutes: number | null
groupName: string | null
groupItemCount: number | null
/** Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу. */
collectionName: string | null
strategy: SlotStrategyType | null
cooldownDays: number | null
candidatesAfterCooldown: number | null
+2
View File
@@ -417,6 +417,7 @@ const resources = {
traceLayer: 'Слой',
traceSlot: 'Слот',
traceGroup: 'Группа',
traceCollection: 'Коллекция',
traceStrategy: 'Стратегия',
traceJunction: 'Врезки',
traceDrift: 'дрейф {{minutes}} мин',
@@ -1077,6 +1078,7 @@ const resources = {
traceLayer: 'Layer',
traceSlot: 'Slot',
traceGroup: 'Group',
traceCollection: 'Collection',
traceStrategy: 'Strategy',
traceJunction: 'Breaks',
traceDrift: 'drift {{minutes}} min',