514 lines
20 KiB
TypeScript
514 lines
20 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { Link } from '@tanstack/react-router'
|
|
import { ChevronLeft, Plus, Send } from 'lucide-react'
|
|
import { useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { listAllMedia } from '@/features/admin/media/api'
|
|
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 { Card, CardContent } from '@/shared/ui/card'
|
|
import { Input } from '@/shared/ui/input'
|
|
import { cn } from '@/shared/lib/cn'
|
|
import { toast } from '@/shared/ui/toast-store'
|
|
import {
|
|
applyChannelTemplate,
|
|
copyTemplateTo,
|
|
createLayer,
|
|
createSlot,
|
|
deleteLayer,
|
|
getChannel,
|
|
getChannelTemplate,
|
|
getSchedule,
|
|
listChannels,
|
|
toSlotBody,
|
|
updateLayer,
|
|
updateSlot,
|
|
} from './api'
|
|
import { ApplyDialog } from './components/ApplyDialog'
|
|
import { BumperCard } from './components/BumperCard'
|
|
import { EntryTraceDialog } from './components/EntryTraceDialog'
|
|
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 { TemplateIssues } from './components/TemplateIssues'
|
|
import { TemplatePreview } from './components/TemplatePreview'
|
|
import { ViewerCard } from './components/ViewerCard'
|
|
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
|
|
import { toTime } from './lib/format'
|
|
|
|
/** Вкладки экрана канала — в порядке частоты правки. */
|
|
const TABS = ['grid', 'rules', 'junctions', 'bumpers', 'viewer', 'settings', 'air'] as const
|
|
type ChannelTab = (typeof TABS)[number]
|
|
|
|
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 [applyOpen, setApplyOpen] = useState(false)
|
|
const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
|
|
const [copyToChannel, setCopyToChannel] = useState('')
|
|
const [tab, setTab] = useState<ChannelTab>('grid')
|
|
|
|
const { data: channel, isLoading } = useQuery({
|
|
queryKey: ['admin', 'channels', channelId],
|
|
queryFn: () => getChannel(channelId),
|
|
})
|
|
const { data: template, error: templateError } = useQuery({
|
|
queryKey: ['admin', 'channels', channelId, 'template'],
|
|
queryFn: () => getChannelTemplate(channelId),
|
|
})
|
|
const { data: ready } = useQuery({
|
|
queryKey: ['admin', 'media', 'ready', 'all'],
|
|
queryFn: () => listAllMedia({ statuses: ['Ready'] }),
|
|
})
|
|
const { data: schedule } = useQuery({
|
|
queryKey: ['admin', 'channels', channelId, 'schedule'],
|
|
queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3600_000)),
|
|
})
|
|
|
|
const invalidate = () => {
|
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'channels', channelId] })
|
|
}
|
|
const onError = (error: unknown) =>
|
|
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
|
|
|
const { data: channels } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels })
|
|
|
|
const applyMutation = useMutation({
|
|
mutationFn: () => applyChannelTemplate(channelId),
|
|
onSuccess: (result) => {
|
|
setApplyOpen(false)
|
|
toast.success(t('admin.channels.applied', { count: result.added }))
|
|
// Предупреждения показываем по одному: каждое указывает на конкретный слот.
|
|
for (const warning of result.warnings)
|
|
toast.error(`${t(`admin.channels.warnings.${warning.kind}`)}: ${warning.details}`)
|
|
invalidate()
|
|
},
|
|
onError,
|
|
})
|
|
|
|
const addLayerMutation = useMutation({
|
|
mutationFn: () => {
|
|
const nextPriority = Math.max(0, ...(template?.layers.map((l) => l.priority) ?? [0])) + 10
|
|
return createLayer(template!.id, {
|
|
name: t('admin.channels.newLayerName'),
|
|
priority: nextPriority,
|
|
})
|
|
},
|
|
onSuccess: invalidate,
|
|
onError,
|
|
})
|
|
|
|
const deleteLayerMutation = useMutation({
|
|
mutationFn: (layer: GridLayerDto) => deleteLayer(layer.id),
|
|
onSuccess: invalidate,
|
|
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 copyTemplateMutation = useMutation({
|
|
mutationFn: (targetChannelId: string) => copyTemplateTo(channelId, targetChannelId),
|
|
onSuccess: (result) => {
|
|
setCopyToChannel('')
|
|
toast.success(
|
|
t('admin.channels.templateCopied', { layers: result.layers, slots: result.slots }),
|
|
)
|
|
if (result.droppedBumperRefs > 0)
|
|
toast.error(
|
|
t('admin.channels.copyDroppedBumpers', { count: result.droppedBumperRefs }),
|
|
)
|
|
},
|
|
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 =
|
|
activeLayerId ?? template?.layers.find((l) => !l.isBackground)?.id ?? template?.layers[0]?.id
|
|
|
|
const openNewSlot = (weekday: number, startMinutes: number) => {
|
|
if (!layerForNewSlot) return
|
|
const hh = Math.floor(startMinutes / 60)
|
|
.toString()
|
|
.padStart(2, '0')
|
|
const mm = (startMinutes % 60).toString().padStart(2, '0')
|
|
setDraft({
|
|
layerId: layerForNewSlot,
|
|
slot: null,
|
|
defaults: { weekday, targetStart: `${hh}:${mm}:00`, title: t('admin.channels.newSlot') },
|
|
})
|
|
}
|
|
|
|
const openSlot = (slot: SlotDto) => setDraft({ layerId: slot.layerId, slot })
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<Button asChild size="sm" variant="ghost">
|
|
<Link to="/admin/channels">
|
|
<ChevronLeft className="h-4 w-4" />
|
|
{t('admin.channels.title')}
|
|
</Link>
|
|
</Button>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h2 className="crt-glow text-xl font-semibold">{channel.name}</h2>
|
|
{channel.number !== null && <Badge variant="muted">№ {channel.number}</Badge>}
|
|
{!channel.isEnabled && <Badge variant="muted">{t('admin.channels.disabled')}</Badge>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Правка правил эфира не двигает — применение отдельной кнопкой. */}
|
|
{template?.hasPendingChanges && (
|
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-amber-500/50 bg-amber-500/10 px-4 py-2 text-sm">
|
|
<span>{t('admin.channels.pendingChanges')}</span>
|
|
<Button size="sm" disabled={applyMutation.isPending} onClick={() => setApplyOpen(true)}>
|
|
<Send className="h-4 w-4" /> {t('admin.channels.apply')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Вкладки вместо колонки карточек: экран канала перестал помещаться в один свиток. */}
|
|
<nav className="flex flex-wrap gap-4 border-b border-border text-xs uppercase tracking-wide">
|
|
{TABS.map((value) => (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
onClick={() => setTab(value)}
|
|
className={cn(
|
|
'pb-2 text-muted-foreground hover:text-foreground',
|
|
tab === value && 'border-b-2 border-primary text-primary',
|
|
)}
|
|
>
|
|
{t(`admin.channels.tabs.${value}`)}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
{tab === 'settings' && (
|
|
<SettingsCard
|
|
channel={channel}
|
|
readyAssets={ready?.items ?? []}
|
|
bare
|
|
onSaved={invalidate}
|
|
onError={onError}
|
|
/>
|
|
)}
|
|
|
|
{/* Шаблон мог не загрузиться — раньше вкладка сетки просто оказывалась пустой. */}
|
|
{tab === 'grid' && !template && (
|
|
<p className="text-sm text-muted-foreground">
|
|
{templateError instanceof HttpError
|
|
? templateError.detail
|
|
: t('admin.channels.noTemplate')}
|
|
</p>
|
|
)}
|
|
|
|
{tab === 'grid' && template && (
|
|
<Card>
|
|
<CardContent>
|
|
<div className="grid gap-4 lg:grid-cols-[220px_1fr]">
|
|
<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.layers')}
|
|
</h3>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => addLayerMutation.mutate()}
|
|
disabled={addLayerMutation.isPending}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<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 className="flex flex-col gap-1.5 border-t border-border pt-2">
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{t('admin.channels.copyTemplate')}
|
|
</span>
|
|
<select
|
|
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
|
value={copyToChannel}
|
|
onChange={(e) => setCopyToChannel(e.target.value)}
|
|
>
|
|
<option value="">{t('admin.channels.pickTargetChannel')}</option>
|
|
{(channels ?? [])
|
|
.filter((c) => c.id !== channelId)
|
|
.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={!copyToChannel || copyTemplateMutation.isPending}
|
|
onClick={() => copyTemplateMutation.mutate(copyToChannel)}
|
|
>
|
|
{t('admin.channels.copy')}
|
|
</Button>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('admin.channels.copyTemplateHint')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<TemplateIssues
|
|
channelId={channelId}
|
|
slotsById={
|
|
new Map(template.layers.flatMap((l) => l.slots).map((slot) => [slot.id, slot]))
|
|
}
|
|
onGoToSlot={openSlot}
|
|
/>
|
|
<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
|
|
channelId={channelId}
|
|
draft={draft}
|
|
onClose={() => setDraft(null)}
|
|
onChanged={invalidate}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{tab === 'rules' &&
|
|
(template ? (
|
|
<RulesCard template={template} bare onChanged={invalidate} onError={onError} />
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">{t('admin.channels.noTemplate')}</p>
|
|
))}
|
|
|
|
{tab === 'junctions' && (
|
|
<JunctionsCard
|
|
channel={channel}
|
|
template={template}
|
|
bare
|
|
onChanged={invalidate}
|
|
onError={onError}
|
|
/>
|
|
)}
|
|
|
|
{tab === 'bumpers' && (
|
|
<BumperCard channel={channel} bare onSaved={invalidate} onError={onError} />
|
|
)}
|
|
|
|
{tab === 'viewer' && (
|
|
<ViewerCard channel={channel} bare onSaved={invalidate} onError={onError} />
|
|
)}
|
|
|
|
{tab === 'air' && (
|
|
<Card>
|
|
<CardContent>
|
|
<SchedulePreview entries={schedule ?? []} onShowTrace={setTraceEntryId} />
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{applicabilityLayer && (
|
|
<LayerApplicabilityDialog
|
|
layer={applicabilityLayer}
|
|
onClose={() => setApplicabilityLayer(null)}
|
|
onChanged={invalidate}
|
|
onError={onError}
|
|
/>
|
|
)}
|
|
|
|
{applyOpen && (
|
|
<ApplyDialog
|
|
channelId={channelId}
|
|
utcOffsetMinutes={channel.utcOffsetMinutes}
|
|
pending={applyMutation.isPending}
|
|
onApply={() => applyMutation.mutate()}
|
|
onClose={() => setApplyOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{traceEntryId && (
|
|
<EntryTraceDialog
|
|
entryId={traceEntryId}
|
|
utcOffsetMinutes={channel.utcOffsetMinutes}
|
|
onClose={() => setTraceEntryId(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|