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:
@@ -8,29 +8,42 @@ import { HttpError } from '@/shared/api/client'
|
||||
import type { GridLayerDto, SlotDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import {
|
||||
applyChannelTemplate,
|
||||
createLayer,
|
||||
createSlot,
|
||||
deleteLayer,
|
||||
getChannel,
|
||||
getChannelTemplate,
|
||||
getSchedule,
|
||||
toSlotBody,
|
||||
updateLayer,
|
||||
updateSlot,
|
||||
} from './api'
|
||||
import { BumperCard } from './components/BumperCard'
|
||||
import { CollapsibleCard } from './components/CollapsibleCard'
|
||||
import { JunctionsCard } from './components/JunctionsCard'
|
||||
import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog'
|
||||
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
|
||||
import { SchedulePreview } from './components/SchedulePreview'
|
||||
import { RulesCard } from './components/RulesCard'
|
||||
import { SettingsCard } from './components/SettingsCard'
|
||||
import { TemplatePreview } from './components/TemplatePreview'
|
||||
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
|
||||
import { toTime } from './lib/format'
|
||||
|
||||
export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [draft, setDraft] = useState<SlotDraft | null>(null)
|
||||
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
|
||||
const [viewDate, setViewDate] = useState<string>('')
|
||||
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
|
||||
// День, который копируем, и отмеченные дни-приёмники.
|
||||
const [copySource, setCopySource] = useState<number | null>(null)
|
||||
const [copyTargets, setCopyTargets] = useState<number[]>([])
|
||||
|
||||
const { data: channel, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId],
|
||||
@@ -85,6 +98,82 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
onError,
|
||||
})
|
||||
|
||||
const toggleLayerMutation = useMutation({
|
||||
mutationFn: (layer: GridLayerDto) =>
|
||||
updateLayer(layer.id, {
|
||||
name: layer.name,
|
||||
priority: layer.priority,
|
||||
applicability: layer.applicability,
|
||||
isEnabled: !layer.isEnabled,
|
||||
}),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
/**
|
||||
* Порядок слоёв задаётся перетаскиванием, а хранится приоритетом. Раздаём приоритеты с шагом 10
|
||||
* снизу вверх: шаг оставляет место, чтобы следующая вставка не переписывала весь список.
|
||||
*/
|
||||
const reorderLayersMutation = useMutation({
|
||||
mutationFn: async (layerIdsTopFirst: string[]) => {
|
||||
const byId = new Map(template!.layers.map((l) => [l.id, l]))
|
||||
const total = layerIdsTopFirst.length
|
||||
await Promise.all(
|
||||
layerIdsTopFirst.map((id, index) => {
|
||||
const layer = byId.get(id)
|
||||
const priority = (total - index) * 10
|
||||
if (!layer || layer.priority === priority) return Promise.resolve()
|
||||
return updateLayer(id, {
|
||||
name: layer.name,
|
||||
priority,
|
||||
applicability: layer.applicability,
|
||||
isEnabled: layer.isEnabled,
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
const moveSlotMutation = useMutation({
|
||||
mutationFn: ({
|
||||
slot,
|
||||
weekday,
|
||||
startMinutes,
|
||||
}: {
|
||||
slot: SlotDto
|
||||
weekday: number
|
||||
startMinutes: number
|
||||
}) => updateSlot(slot.id, { ...toSlotBody(slot), weekday, targetStart: toTime(startMinutes) }),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
const resizeSlotMutation = useMutation({
|
||||
mutationFn: ({ slot, minutes }: { slot: SlotDto; minutes: number }) =>
|
||||
updateSlot(slot.id, { ...toSlotBody(slot), targetDurationMinutes: minutes }),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
/** Копирование дня: слоты «каждый день» не копируются — они и так есть во всех колонках. */
|
||||
const copyDayMutation = useMutation({
|
||||
mutationFn: async ({ from, to }: { from: number; to: number[] }) => {
|
||||
const sources = (template?.layers ?? []).flatMap((layer) =>
|
||||
layer.slots.filter((slot) => slot.weekday === from).map((slot) => ({ layer, slot })),
|
||||
)
|
||||
for (const weekday of to)
|
||||
for (const { layer, slot } of sources)
|
||||
await createSlot(layer.id, { ...toSlotBody(slot), weekday })
|
||||
},
|
||||
onSuccess: () => {
|
||||
setCopySource(null)
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
|
||||
const layerForNewSlot =
|
||||
@@ -159,19 +248,90 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
<LayerList
|
||||
template={template}
|
||||
activeLayerId={layerForNewSlot ?? null}
|
||||
viewDate={viewDate || null}
|
||||
onSelect={(layer) => setActiveLayerId(layer.id)}
|
||||
onDelete={(layer) => deleteLayerMutation.mutate(layer)}
|
||||
onToggle={(layer) => toggleLayerMutation.mutate(layer)}
|
||||
onReorder={(order) => reorderLayersMutation.mutate(order)}
|
||||
onEditApplicability={setApplicabilityLayer}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<TemplatePreview channelId={channelId} />
|
||||
|
||||
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 w-40"
|
||||
value={viewDate}
|
||||
onChange={(e) => setViewDate(e.target.value)}
|
||||
/>
|
||||
{viewDate && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setViewDate('')}>
|
||||
{t('admin.channels.allDates')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Копирование дня: сначала выбирается источник, потом дни-приёмники. */}
|
||||
{copySource !== null && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
|
||||
<span>
|
||||
{t('admin.channels.copyDayFrom', {
|
||||
day: t(`admin.channels.weekdays.${copySource}`),
|
||||
})}
|
||||
</span>
|
||||
{[1, 2, 3, 4, 5, 6, 0]
|
||||
.filter((day) => day !== copySource)
|
||||
.map((day) => (
|
||||
<label key={day} className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={copyTargets.includes(day)}
|
||||
onChange={(e) =>
|
||||
setCopyTargets((current) =>
|
||||
e.target.checked
|
||||
? [...current, day]
|
||||
: current.filter((d) => d !== day),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{t(`admin.channels.weekdays.${day}`)}
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={copyTargets.length === 0 || copyDayMutation.isPending}
|
||||
onClick={() =>
|
||||
copyDayMutation.mutate({ from: copySource, to: copyTargets })
|
||||
}
|
||||
>
|
||||
{t('admin.channels.copy')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setCopySource(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScheduleGrid
|
||||
template={template}
|
||||
selectedSlotId={draft?.slot?.id ?? null}
|
||||
viewDate={viewDate || null}
|
||||
onSelectSlot={openSlot}
|
||||
onAddSlot={openNewSlot}
|
||||
onMoveSlot={(slot, weekday, startMinutes) =>
|
||||
moveSlotMutation.mutate({ slot, weekday, startMinutes })
|
||||
}
|
||||
onResizeSlot={(slot, minutes) => resizeSlotMutation.mutate({ slot, minutes })}
|
||||
onCopyDay={(weekday) => {
|
||||
setCopySource(weekday)
|
||||
setCopyTargets([])
|
||||
}}
|
||||
/>
|
||||
{draft && (
|
||||
<SlotInspector
|
||||
@@ -186,6 +346,10 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
</CollapsibleCard>
|
||||
)}
|
||||
|
||||
{template && (
|
||||
<RulesCard template={template} onChanged={invalidate} onError={onError} />
|
||||
)}
|
||||
|
||||
<JunctionsCard
|
||||
channel={channel}
|
||||
template={template}
|
||||
@@ -195,6 +359,15 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
|
||||
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||
|
||||
{applicabilityLayer && (
|
||||
<LayerApplicabilityDialog
|
||||
layer={applicabilityLayer}
|
||||
onClose={() => setApplicabilityLayer(null)}
|
||||
onChanged={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SchedulePreview entries={schedule ?? []} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
JunctionElementKind,
|
||||
JunctionTemplateDto,
|
||||
LayerApplicability,
|
||||
PlanningRules,
|
||||
ScheduleEntryDto,
|
||||
SchedulePreviewDto,
|
||||
ScheduleTemplateDto,
|
||||
@@ -73,7 +74,12 @@ export function previewTemplate(channelId: string, days: number) {
|
||||
|
||||
export function updateTemplate(
|
||||
templateId: string,
|
||||
body: { name: string; fallbackGroupId: string | null; defaultJunctionId: string | null },
|
||||
body: {
|
||||
name: string
|
||||
fallbackGroupId: string | null
|
||||
defaultJunctionId: string | null
|
||||
rules: PlanningRules | null
|
||||
},
|
||||
) {
|
||||
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
|
||||
}
|
||||
@@ -104,6 +110,12 @@ export function deleteLayer(layerId: string) {
|
||||
/** Тело слота: то же для создания и правки (см. SlotInput на сервере). */
|
||||
export type SlotBody = Omit<SlotDto, 'id' | 'layerId' | 'groupName'>
|
||||
|
||||
/** Слот из ответа сервера → тело запроса: отбрасываем то, что сервер проставляет сам. */
|
||||
export function toSlotBody(slot: SlotDto): SlotBody {
|
||||
const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot
|
||||
return body
|
||||
}
|
||||
|
||||
export function createSlot(layerId: string, body: SlotBody) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/layers/${layerId}/slots`, { method: 'POST', body })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { LayerApplicability } from '@/shared/api/types'
|
||||
|
||||
/**
|
||||
* Действует ли слой в эту дату вещательных суток. Зеркало серверного `LayerApplicability.Covers`
|
||||
* (`TeleWave.Application/Programming/Templates/LayerApplicability.cs`) — источник правды там,
|
||||
* здесь только подсветка сетки. Меняя одно, правьте второе.
|
||||
*/
|
||||
export function coversDate(applicability: LayerApplicability | null, date: Date): boolean {
|
||||
if (!applicability || isEmpty(applicability)) return true
|
||||
|
||||
const weekday = date.getDay()
|
||||
if (applicability.weekdays?.includes(weekday)) return true
|
||||
|
||||
const iso = toIsoDate(date)
|
||||
if (applicability.specificDates?.includes(iso)) return true
|
||||
|
||||
if (applicability.dateRanges?.some((r) => iso >= r.from && iso <= r.to)) return true
|
||||
|
||||
// Ежегодный период может пересекать Новый год — сравниваем по паре (месяц, день).
|
||||
const value = (date.getMonth() + 1) * 100 + date.getDate()
|
||||
return (
|
||||
applicability.annualRanges?.some((r) => {
|
||||
const from = r.fromMonth * 100 + r.fromDay
|
||||
const to = r.toMonth * 100 + r.toDay
|
||||
return from <= to ? value >= from && value <= to : value >= from || value <= to
|
||||
}) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
export function isEmpty(applicability: LayerApplicability | null): boolean {
|
||||
if (!applicability) return true
|
||||
return (
|
||||
!applicability.weekdays?.length &&
|
||||
!applicability.dateRanges?.length &&
|
||||
!applicability.annualRanges?.length &&
|
||||
!applicability.specificDates?.length
|
||||
)
|
||||
}
|
||||
|
||||
/** «2026-12-25» из локальной даты — без ухода в UTC, иначе вечер уезжает на день назад. */
|
||||
export function toIsoDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
date.getDate(),
|
||||
).padStart(2, '0')}`
|
||||
}
|
||||
@@ -10,6 +10,12 @@ export function formatTime(iso: string | null) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Минуты суток → «HH:MM:00» — формат `TimeOnly` на сервере. */
|
||||
export function toTime(minutes: number): string {
|
||||
const m = ((minutes % (24 * 60)) + 24 * 60) % (24 * 60)
|
||||
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}:00`
|
||||
}
|
||||
|
||||
/** Минуты суток → «HH:MM». */
|
||||
export function formatMinute(minute: number | null) {
|
||||
if (minute == null) return '—'
|
||||
|
||||
Reference in New Issue
Block a user