Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
@@ -0,0 +1,203 @@
import { Anchor, Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { cn } from '@/shared/lib/cn'
const HOUR_HEIGHT = 44
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */
const DAYPART_CLASS: Record<string, string> = {
Morning: 'bg-amber-500/20 border-amber-500/40',
Day: 'bg-sky-500/20 border-sky-500/40',
Prime: 'bg-violet-500/25 border-violet-500/50',
Night: 'bg-slate-500/20 border-slate-500/40',
}
function minutesOf(time: string): number {
const [h, m] = time.split(':')
return Number(h) * 60 + Number(m)
}
/**
* Смещение слота от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток в 06:00)
* принадлежит предыдущему дню, поэтому его смещение больше суточного, а не отрицательное.
*/
function offsetInDay(slotStart: string, dayStart: string): number {
const diff = minutesOf(slotStart) - minutesOf(dayStart)
return diff >= 0 ? diff : diff + 24 * 60
}
/** Слоты, попадающие в колонку дня: слот без дня недели идёт каждый день. */
function slotsOfDay(layers: GridLayerDto[], weekday: number) {
return layers
.filter((layer) => layer.isEnabled)
.flatMap((layer) =>
layer.slots
.filter((slot) => slot.weekday === null || slot.weekday === weekday)
.map((slot) => ({ slot, layer })),
)
}
export function ScheduleGrid({
template,
selectedSlotId,
onSelectSlot,
onAddSlot,
}: {
template: ScheduleTemplateDto
selectedSlotId: string | null
onSelectSlot: (slot: SlotDto) => void
onAddSlot: (weekday: number, startMinutes: number) => void
}) {
const { t } = useTranslation()
const dayStart = template.dayStartTime.slice(0, 5)
const dayStartMinutes = minutesOf(dayStart)
// Подписи часов идут от начала вещательных суток, а не от полуночи.
const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24)
// Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем.
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => {
const from = offsetInDay(slot.targetStart, dayStart)
const to = from + slot.targetDurationMinutes
return ordered
.filter((other) => other.isEnabled && other.priority > layer.priority)
.some((other) =>
other.slots
.filter((s) => s.weekday === null || s.weekday === weekday)
.some((s) => {
const otherFrom = offsetInDay(s.targetStart, dayStart)
return from < otherFrom + s.targetDurationMinutes && otherFrom < to
}),
)
}
return (
<div className="crt-panel overflow-x-auto rounded-md">
<div className="min-w-[720px]">
<div className="grid grid-cols-[56px_repeat(7,1fr)] border-b border-border text-xs text-muted-foreground">
<div className="px-2 py-1">{dayStart}</div>
{WEEKDAYS.map((weekday) => (
<div key={weekday} className="px-2 py-1 text-center font-medium">
{t(`admin.channels.weekdays.${weekday}`)}
</div>
))}
</div>
<div className="grid grid-cols-[56px_repeat(7,1fr)]">
<div>
{hours.map((hour, index) => (
<div
key={index}
className="border-b border-border/40 px-2 text-[11px] text-muted-foreground"
style={{ height: HOUR_HEIGHT }}
>
{hour.toString().padStart(2, '0')}:00
</div>
))}
</div>
{WEEKDAYS.map((weekday) => (
<div
key={weekday}
className="relative border-l border-border"
style={{ height: HOUR_HEIGHT * 24 }}
>
{hours.map((_, index) => (
<button
key={index}
type="button"
title={t('admin.channels.addSlotHere')}
className="group absolute inset-x-0 border-b border-border/40 hover:bg-muted/30"
style={{ top: HOUR_HEIGHT * index, height: HOUR_HEIGHT }}
onClick={() => onAddSlot(weekday, (dayStartMinutes + index * 60) % (24 * 60))}
>
<Plus className="mx-auto h-3 w-3 opacity-0 group-hover:opacity-40" />
</button>
))}
{slotsOfDay(ordered, weekday).map(({ slot, layer }) => {
const from = offsetInDay(slot.targetStart, dayStart)
const covered = isCovered(slot, layer, weekday)
return (
<button
key={`${slot.id}-${weekday}`}
type="button"
onClick={() => onSelectSlot(slot)}
className={cn(
'absolute inset-x-1 overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight',
DAYPART_CLASS[slot.daypart] ?? DAYPART_CLASS.Day,
selectedSlotId === slot.id && 'ring-2 ring-primary',
// Перекрытый слот виден, но приглушён: он не сыграет, пока лежит под старшим слоем.
covered && 'opacity-40 [background-image:repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(0,0,0,.15)_4px,rgba(0,0,0,.15)_8px)]',
)}
style={{
top: (from / 60) * HOUR_HEIGHT,
height: Math.max(16, (slot.targetDurationMinutes / 60) * HOUR_HEIGHT - 2),
}}
>
<span className="flex items-center gap-1 font-medium">
{slot.isAnchor && <Anchor className="h-3 w-3 shrink-0" />}
{slot.targetStart.slice(0, 5)}
</span>
<span className="block truncate">{slot.title}</span>
</button>
)
})}
</div>
))}
</div>
</div>
</div>
)
}
/** Панель слоёв: видимость, приоритет и выбор редактируемого. */
export function LayerList({
template,
activeLayerId,
onSelect,
onDelete,
}: {
template: ScheduleTemplateDto
activeLayerId: string | null
onSelect: (layer: GridLayerDto) => void
onDelete: (layer: GridLayerDto) => void
}) {
const { t } = useTranslation()
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
return (
<ul className="divide-y divide-border text-sm">
{ordered.map((layer) => (
<li key={layer.id} className="flex items-center gap-2 py-1.5">
<button
type="button"
className={cn(
'min-w-0 flex-1 truncate text-left',
activeLayerId === layer.id && 'text-primary',
)}
onClick={() => onSelect(layer)}
>
{layer.name}
</button>
<span className="shrink-0 text-xs text-muted-foreground">
{layer.isBackground ? t('admin.channels.background') : layer.priority}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{layer.slots.length}
</span>
{!layer.isBackground && (
<Button size="sm" variant="ghost" onClick={() => onDelete(layer)}>
×
</Button>
)}
</li>
))}
</ul>
)
}