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.
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user