204 lines
7.9 KiB
TypeScript
204 lines
7.9 KiB
TypeScript
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>
|
||
)
|
||
}
|