Enhance template and scheduling functionalities: add PlanningRules to template endpoints and commands, implement repeat limits and audience filtering in scheduling logic, and update related data structures. Refactor frontend components to support new rules and improve user experience in channel management.

This commit is contained in:
Leonid Pershin
2026-07-26 14:25:32 +03:00
parent 7b12a06d1b
commit 8494eb5e6a
30 changed files with 3400 additions and 438 deletions
@@ -97,6 +97,7 @@ export function JunctionsCard({
name: template!.name,
fallbackGroupId: template!.fallbackGroupId,
defaultJunctionId: junctionId,
rules: template!.rules,
}),
onSuccess: onChanged,
onError,
@@ -0,0 +1,300 @@
import { useMutation } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { AnnualRange, DateRange, GridLayerDto, LayerApplicability } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { updateLayer } from '../api'
import { isEmpty, toIsoDate } from '../lib/applicability'
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
/**
* Когда действует слой (см. 3.4). Разделы объединяются по ИЛИ: слой применим, если дата подходит
* хотя бы под одно условие. Пустая применимость — слой действует всегда.
*/
export function LayerApplicabilityDialog({
layer,
onClose,
onChanged,
onError,
}: {
layer: GridLayerDto
onClose: () => void
onChanged: () => void
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [name, setName] = useState(layer.name)
const [weekdays, setWeekdays] = useState<number[]>(layer.applicability?.weekdays ?? [])
const [dateRanges, setDateRanges] = useState<DateRange[]>(layer.applicability?.dateRanges ?? [])
const [annualRanges, setAnnualRanges] = useState<AnnualRange[]>(
layer.applicability?.annualRanges ?? [],
)
const [specificDates, setSpecificDates] = useState<string[]>(
layer.applicability?.specificDates ?? [],
)
const save = useMutation({
mutationFn: () => {
const applicability: LayerApplicability = {
weekdays: weekdays.length > 0 ? [...weekdays].sort((a, b) => a - b) : null,
dateRanges: dateRanges.length > 0 ? dateRanges : null,
annualRanges: annualRanges.length > 0 ? annualRanges : null,
specificDates: specificDates.length > 0 ? specificDates : null,
}
return updateLayer(layer.id, {
name: name.trim() || layer.name,
priority: layer.priority,
// Пустую применимость отправляем как null — «действует всегда» и «пустые списки» это одно
// и то же, но null читается однозначно.
applicability: isEmpty(applicability) ? null : applicability,
isEnabled: layer.isEnabled,
})
},
onSuccess: () => {
onChanged()
onClose()
},
onError,
})
const toggleWeekday = (day: number) =>
setWeekdays((current) =>
current.includes(day) ? current.filter((d) => d !== day) : [...current, day],
)
const today = toIsoDate(new Date())
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{t('admin.channels.layerApplicability')}</DialogTitle>
<DialogDescription>{t('admin.channels.applicabilityHint')}</DialogDescription>
</DialogHeader>
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto text-sm">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.layerName')}</Label>
<Input value={name} maxLength={128} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.applicabilityWeekdays')}</Label>
<div className="flex flex-wrap gap-1">
{WEEKDAYS.map((day) => (
<button
key={day}
type="button"
onClick={() => toggleWeekday(day)}
className={
weekdays.includes(day)
? 'rounded border border-primary bg-primary/15 px-2 py-1 text-xs text-primary'
: 'rounded border border-border px-2 py-1 text-xs text-muted-foreground'
}
>
{t(`admin.channels.weekdays.${day}`)}
</button>
))}
</div>
</div>
<Section
title={t('admin.channels.applicabilityDateRanges')}
onAdd={() => setDateRanges((c) => [...c, { from: today, to: today }])}
empty={dateRanges.length === 0}
>
{dateRanges.map((range, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
<Input
type="date"
className="w-40"
value={range.from}
onChange={(e) =>
setDateRanges((c) =>
c.map((r, i) => (i === index ? { ...r, from: e.target.value } : r)),
)
}
/>
<Input
type="date"
className="w-40"
value={range.to}
onChange={(e) =>
setDateRanges((c) =>
c.map((r, i) => (i === index ? { ...r, to: e.target.value } : r)),
)
}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setDateRanges((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</Section>
<Section
title={t('admin.channels.applicabilityAnnual')}
onAdd={() =>
setAnnualRanges((c) => [...c, { fromMonth: 12, fromDay: 20, toMonth: 1, toDay: 8 }])
}
empty={annualRanges.length === 0}
>
{annualRanges.map((range, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
<MonthDay
value={range}
prefix="from"
onChange={(part) =>
setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r)))
}
/>
<span className="pb-2 text-muted-foreground"></span>
<MonthDay
value={range}
prefix="to"
onChange={(part) =>
setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r)))
}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setAnnualRanges((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</Section>
<Section
title={t('admin.channels.applicabilityDates')}
onAdd={() => setSpecificDates((c) => [...c, today])}
empty={specificDates.length === 0}
>
{specificDates.map((date, index) => (
<li key={index} className="flex items-end gap-2">
<Input
type="date"
className="w-40"
value={date}
onChange={(e) =>
setSpecificDates((c) => c.map((d, i) => (i === index ? e.target.value : d)))
}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setSpecificDates((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</Section>
</div>
<DialogFooter>
<Button size="sm" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function Section({
title,
onAdd,
empty,
children,
}: {
title: string
onAdd: () => void
empty: boolean
children: React.ReactNode
}) {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<Label>{title}</Label>
<Button size="sm" variant="ghost" onClick={onAdd}>
<Plus className="h-4 w-4" />
</Button>
</div>
{empty ? (
<p className="text-xs text-muted-foreground">{t('admin.channels.applicabilityNone')}</p>
) : (
<ul className="flex flex-col gap-2">{children}</ul>
)}
</div>
)
}
/** Пара «месяц / день» ежегодного периода — год у него намеренно отсутствует. */
function MonthDay({
value,
prefix,
onChange,
}: {
value: AnnualRange
prefix: 'from' | 'to'
onChange: (part: Partial<AnnualRange>) => void
}) {
const month = prefix === 'from' ? value.fromMonth : value.toMonth
const day = prefix === 'from' ? value.fromDay : value.toDay
return (
<span className="flex items-end gap-1">
<Input
type="number"
min={1}
max={12}
className="w-16"
value={month}
onChange={(e) =>
onChange(
prefix === 'from'
? { fromMonth: Number(e.target.value) }
: { toMonth: Number(e.target.value) },
)
}
/>
<Input
type="number"
min={1}
max={31}
className="w-16"
value={day}
onChange={(e) =>
onChange(
prefix === 'from'
? { fromDay: Number(e.target.value) }
: { toDay: Number(e.target.value) },
)
}
/>
</span>
)
}
@@ -0,0 +1,189 @@
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: 'Teen' }
/**
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
*/
export function RulesCard({
template,
onChanged,
onError,
}: {
template: ScheduleTemplateDto
onChanged: () => void
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [windows, setWindows] = useState<AudienceWindow[]>(
() => 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)
useEffect(() => {
setWindows(template.rules?.maxAudienceByTime ?? [])
setLimitOn(template.rules?.maxRepeatsInWindow != null)
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
}, [template])
const save = useMutation({
mutationFn: () => {
const rules: PlanningRules = {
maxAudienceByTime: windows.length > 0 ? windows : null,
maxRepeatsInWindow: limitOn ? { windowDays, max } : null,
}
return updateTemplate(template.id, {
name: template.name,
fallbackGroupId: template.fallbackGroupId,
defaultJunctionId: template.defaultJunctionId,
rules,
})
},
onSuccess: onChanged,
onError,
})
const patchWindow = (index: number, part: Partial<AudienceWindow>) =>
setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w)))
return (
<CollapsibleCard title={t('admin.channels.rules')}>
<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, 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((window, index) => (
<li key={index} 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(index, { 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(index, { 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(index, { 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={() => setWindows((c) => c.filter((_, i) => i !== index))}
>
<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 justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
</CollapsibleCard>
)
}
@@ -1,203 +1,372 @@
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>
)
}
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,
}: {
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, 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={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((_, 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)
const minutes =
resizing?.slot.id === slot.id ? resizing.minutes : slot.targetDurationMinutes
return (
<div
key={`${slot.id}-${weekday}`}
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>
<span
role="presentation"
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!)
}
/>
</div>
)
})}
</div>
))}
</div>
</div>
</div>
)
}
/**
* Панель слоёв: видимость, приоритет (перетаскиванием), применимость и выбор редактируемого.
* Выше в списке — приоритетнее; фоновый слой всегда внизу и не двигается.
*/
export function LayerList({
template,
activeLayerId,
viewDate,
onSelect,
onDelete,
onToggle,
onReorder,
onEditApplicability,
}: {
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)
}
@@ -16,7 +16,14 @@ import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { toast } from '@/shared/ui/toast-store'
import { createSlot, deleteSlot, listJunctions, updateSlot, type SlotBody } from '../api'
import {
createSlot,
deleteSlot,
listJunctions,
toSlotBody,
updateSlot,
type SlotBody,
} from '../api'
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
@@ -28,11 +35,6 @@ const SNAP_OPTIONS = [0, 5, 10, 15, 30]
/** Черновик слота: новый (layerId + предзаполненные время/день) либо существующий. */
export type SlotDraft = { layerId: string; slot: SlotDto | null; defaults?: Partial<SlotBody> }
function toBody(slot: SlotDto): SlotBody {
const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot
return body
}
function emptyBody(defaults?: Partial<SlotBody>): SlotBody {
return {
title: '',
@@ -74,11 +76,11 @@ export function SlotInspector({
}) {
const { t } = useTranslation()
const [body, setBody] = useState<SlotBody>(() =>
draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults),
draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults),
)
useEffect(() => {
setBody(draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults))
setBody(draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults))
}, [draft])
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })