Files
TeleWave/frontend/src/features/admin/channels/ChannelDetail.tsx
T

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>
)
}