Refactor .gitignore to streamline ignored files and enhance clarity. Update CLAUDE.md to improve unit test instructions and add coverage reporting details. Revise README.md for better project overview and deployment instructions. Refactor ChannelEndpoints and StreamingEndpoints to utilize SegmentFiles for file resolution, improving code maintainability. Remove unused JunctionHandlers and update DependencyInjection for cleaner service registration. Enhance media processing services for better job handling and error management. Update frontend API types for consistency and clarity.
This commit is contained in:
@@ -1,535 +1,194 @@
|
||||
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,
|
||||
createChannelTemplate,
|
||||
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 = ['settings', 'grid', 'rules', 'junctions', 'bumpers', 'viewer', '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 [copyFromChannel, setCopyFromChannel] = useState('')
|
||||
const [tab, setTab] = useState<ChannelTab>('settings')
|
||||
|
||||
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 createTemplateMutation = useMutation({
|
||||
mutationFn: () => createChannelTemplate(channelId),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
const copyTemplateMutation = useMutation({
|
||||
mutationFn: (sourceChannelId: string) => copyTemplateTo(sourceChannelId, channelId),
|
||||
onSuccess: (result) => {
|
||||
setCopyFromChannel('')
|
||||
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 && (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{templateError instanceof HttpError
|
||||
? templateError.detail
|
||||
: t('admin.channels.noTemplate')}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={createTemplateMutation.isPending}
|
||||
onClick={() => createTemplateMutation.mutate()}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> {t('admin.channels.createTemplate')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.createTemplateHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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={copyFromChannel}
|
||||
onChange={(e) => setCopyFromChannel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('admin.channels.pickSourceChannel')}</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={!copyFromChannel || copyTemplateMutation.isPending}
|
||||
onClick={() => {
|
||||
// Замена своей сетки — необратимая правка, поэтому спрашиваем перед ней.
|
||||
if (!window.confirm(t('admin.channels.copyTemplateConfirm'))) return
|
||||
copyTemplateMutation.mutate(copyFromChannel)
|
||||
}}
|
||||
>
|
||||
{t('admin.channels.copyHere')}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronLeft, Send } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listAllMedia } from '@/features/admin/media/api'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent } from '@/shared/ui/card'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import {
|
||||
applyChannelTemplate,
|
||||
getChannel,
|
||||
getChannelTemplate,
|
||||
getSchedule,
|
||||
} from './api'
|
||||
import { ApplyDialog } from './components/ApplyDialog'
|
||||
import { BumperCard } from './components/BumperCard'
|
||||
import { EntryTraceDialog } from './components/EntryTraceDialog'
|
||||
import { GridTab } from './components/GridTab'
|
||||
import { JunctionsCard } from './components/JunctionsCard'
|
||||
import { RulesCard } from './components/RulesCard'
|
||||
import { SchedulePreview } from './components/SchedulePreview'
|
||||
import { SettingsCard } from './components/SettingsCard'
|
||||
import { ViewerCard } from './components/ViewerCard'
|
||||
|
||||
/** Вкладки экрана канала: настройки первыми — с них канал и начинается. */
|
||||
const TABS = ['settings', 'grid', 'rules', 'junctions', 'bumpers', 'viewer', 'air'] as const
|
||||
type ChannelTab = (typeof TABS)[number]
|
||||
|
||||
export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [applyOpen, setApplyOpen] = useState(false)
|
||||
const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState<ChannelTab>('settings')
|
||||
|
||||
const { data: channel, isLoading } = useQuery({
|
||||
queryKey: qk.channels.detail(channelId),
|
||||
queryFn: () => getChannel(channelId),
|
||||
})
|
||||
const { data: template, error: templateError } = useQuery({
|
||||
queryKey: qk.channels.template(channelId),
|
||||
queryFn: () => getChannelTemplate(channelId),
|
||||
})
|
||||
const { data: ready } = useQuery({
|
||||
queryKey: qk.media.ready,
|
||||
queryFn: () => listAllMedia({ statuses: ['Ready'] }),
|
||||
})
|
||||
const { data: schedule } = useQuery({
|
||||
queryKey: qk.channels.schedule(channelId),
|
||||
queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3600_000)),
|
||||
})
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: qk.channels.detail(channelId) })
|
||||
}
|
||||
const onError = useApiError()
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
|
||||
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' && (
|
||||
<GridTab
|
||||
channelId={channelId}
|
||||
template={template}
|
||||
templateError={templateError}
|
||||
onChanged={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
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 { createChannel, listChannels } from './api'
|
||||
|
||||
function slugify(value: string) {
|
||||
@@ -22,10 +22,9 @@ export function ChannelsPanel() {
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
|
||||
const { data, isLoading } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels })
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'channels'] })
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
const { data, isLoading } = useQuery({ queryKey: qk.channels.all, queryFn: listChannels })
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.channels.all })
|
||||
const onError = useApiError()
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createChannel({ name: name.trim(), slug: slug || slugify(name) }),
|
||||
|
||||
@@ -36,7 +36,7 @@ export function createChannel(body: { name: string; slug: string }) {
|
||||
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export type ChannelSettingsBody = {
|
||||
type ChannelSettingsBody = {
|
||||
name: string
|
||||
isEnabled: boolean
|
||||
bumpersEnabled: boolean
|
||||
@@ -230,7 +230,7 @@ export function reorderJunction(junctionId: string, elementIdsInOrder: string[])
|
||||
})
|
||||
}
|
||||
|
||||
export type BumperTemplateStyleBody = {
|
||||
type BumperTemplateStyleBody = {
|
||||
name: string
|
||||
backgroundColor: string
|
||||
backgroundColor2: string
|
||||
@@ -297,7 +297,7 @@ export function uploadBumperTemplateAudio(id: string, templateId: string, file:
|
||||
return uploadBumperTemplateFile(id, templateId, 'audio', file)
|
||||
}
|
||||
|
||||
export type BumperVariantBody = {
|
||||
type BumperVariantBody = {
|
||||
name: string
|
||||
kind: BumperTextKind
|
||||
nowLabel: string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -33,7 +34,7 @@ export function ApplyDialog({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'diff'],
|
||||
queryKey: qk.channels.diff(channelId),
|
||||
queryFn: () => getApplyDiff(channelId),
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -24,7 +25,7 @@ export function EntryTraceDialog({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { data } = useQuery({
|
||||
queryKey: ['admin', 'entries', entryId, 'trace'],
|
||||
queryKey: qk.entries.trace(entryId),
|
||||
queryFn: () => getEntryTrace(entryId),
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import {
|
||||
copyTemplateTo,
|
||||
createChannelTemplate,
|
||||
createLayer,
|
||||
createSlot,
|
||||
deleteLayer,
|
||||
listChannels,
|
||||
toSlotBody,
|
||||
updateLayer,
|
||||
updateSlot,
|
||||
} from '../api'
|
||||
import { toTime } from '../lib/format'
|
||||
import { LayerApplicabilityDialog } from './LayerApplicabilityDialog'
|
||||
import { LayerList, ScheduleGrid } from './ScheduleGrid'
|
||||
import { SlotInspector, type SlotDraft } from './SlotInspector'
|
||||
import { TemplateIssues } from './TemplateIssues'
|
||||
import { TemplatePreview } from './TemplatePreview'
|
||||
|
||||
/**
|
||||
* Вкладка «Сетка»: слои, слоты и всё, что их правит. Вынесена из экрана канала целиком со своим
|
||||
* состоянием — остальным вкладкам ни черновик слота, ни выбранный день копирования не нужны, а
|
||||
* держать их в родителе значило перерисовывать весь экран на каждое движение мыши по сетке.
|
||||
*/
|
||||
export function GridTab({
|
||||
channelId,
|
||||
template,
|
||||
templateError,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
template: ScheduleTemplateDto | undefined
|
||||
templateError: unknown
|
||||
onChanged: () => void
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
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 [copyFromChannel, setCopyFromChannel] = useState('')
|
||||
|
||||
const { data: channels } = useQuery({ queryKey: qk.channels.all, queryFn: listChannels })
|
||||
|
||||
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: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const deleteLayerMutation = useMutation({
|
||||
mutationFn: (layer: GridLayerDto) => deleteLayer(layer.id),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const toggleLayerMutation = useMutation({
|
||||
mutationFn: (layer: GridLayerDto) =>
|
||||
updateLayer(layer.id, {
|
||||
name: layer.name,
|
||||
priority: layer.priority,
|
||||
applicability: layer.applicability,
|
||||
isEnabled: !layer.isEnabled,
|
||||
}),
|
||||
onSuccess: onChanged,
|
||||
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: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
// Канал без сетки — наследство старой ротации: заводим шаблон на месте, а не пересоздаём канал.
|
||||
const createTemplateMutation = useMutation({
|
||||
mutationFn: () => createChannelTemplate(channelId),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const copyTemplateMutation = useMutation({
|
||||
mutationFn: (sourceChannelId: string) => copyTemplateTo(sourceChannelId, channelId),
|
||||
onSuccess: (result) => {
|
||||
setCopyFromChannel('')
|
||||
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: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const resizeSlotMutation = useMutation({
|
||||
mutationFn: ({ slot, minutes }: { slot: SlotDto; minutes: number }) =>
|
||||
updateSlot(slot.id, { ...toSlotBody(slot), targetDurationMinutes: minutes }),
|
||||
onSuccess: onChanged,
|
||||
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)
|
||||
onChanged()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
// Шаблон мог не загрузиться — раньше вкладка сетки просто оказывалась пустой.
|
||||
if (!template)
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{templateError instanceof HttpError
|
||||
? templateError.detail
|
||||
: t('admin.channels.noTemplate')}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={createTemplateMutation.isPending}
|
||||
onClick={() => createTemplateMutation.mutate()}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> {t('admin.channels.createTemplate')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.createTemplateHint')}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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={copyFromChannel}
|
||||
onChange={(e) => setCopyFromChannel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('admin.channels.pickSourceChannel')}</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={!copyFromChannel || copyTemplateMutation.isPending}
|
||||
onClick={() => {
|
||||
// Замена своей сетки — необратимая правка, поэтому спрашиваем перед ней.
|
||||
if (!window.confirm(t('admin.channels.copyTemplateConfirm'))) return
|
||||
copyTemplateMutation.mutate(copyFromChannel)
|
||||
}}
|
||||
>
|
||||
{t('admin.channels.copyHere')}
|
||||
</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={onChanged}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{applicabilityLayer && (
|
||||
<LayerApplicabilityDialog
|
||||
layer={applicabilityLayer}
|
||||
onClose={() => setApplicabilityLayer(null)}
|
||||
onChanged={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
JunctionElementDto,
|
||||
JunctionElementKind,
|
||||
} from '@/shared/api/types'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -53,7 +54,7 @@ export function JunctionElementDialog({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [body, setBody] = useState<JunctionElementBody>(() => toBody(element))
|
||||
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
|
||||
|
||||
const patch = (part: Partial<JunctionElementBody>) => setBody((prev) => ({ ...prev, ...part }))
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
JunctionTemplateDto,
|
||||
ScheduleTemplateDto,
|
||||
} from '@/shared/api/types'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
@@ -79,10 +80,10 @@ export function JunctionsCard({
|
||||
const [newName, setNewName] = useState('')
|
||||
|
||||
const { data: junctions } = useQuery({
|
||||
queryKey: ['admin', 'channels', channel.id, 'junctions'],
|
||||
queryKey: qk.channels.junctions(channel.id),
|
||||
queryFn: () => listJunctions(channel.id),
|
||||
})
|
||||
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createJunction(channel.id, newName.trim()),
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listGroups } from '@/features/admin/groups/api'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
Daypart,
|
||||
OverflowPolicy,
|
||||
@@ -12,10 +11,11 @@ import type {
|
||||
SlotKind,
|
||||
SlotStrategyType,
|
||||
} from '@/shared/api/types'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
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,
|
||||
@@ -83,14 +83,13 @@ export function SlotInspector({
|
||||
setBody(draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults))
|
||||
}, [draft])
|
||||
|
||||
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
|
||||
const { data: junctions } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'junctions'],
|
||||
queryKey: qk.channels.junctions(channelId),
|
||||
queryFn: () => listJunctions(channelId),
|
||||
})
|
||||
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
const onError = useApiError()
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangle, CircleAlert } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import type { SlotDto, TemplateIssueDto } from '@/shared/api/types'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { getTemplateIssues } from '../api'
|
||||
@@ -20,7 +21,7 @@ export function TemplateIssues({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { data: issues } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'issues'],
|
||||
queryKey: qk.channels.issues(channelId),
|
||||
queryFn: () => getTemplateIssues(channelId),
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { Eye } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
@@ -33,7 +34,7 @@ export function TemplatePreview({ channelId }: { channelId: string }) {
|
||||
const [tab, setTab] = useState<'programme' | 'tape' | 'problems'>('programme')
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'preview', days],
|
||||
queryKey: qk.channels.preview(channelId, days),
|
||||
queryFn: () => previewTemplate(channelId, days),
|
||||
enabled: open,
|
||||
// Черновик правил может меняться между открытиями — кэшировать прогон смысла нет.
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
|
||||
export function NumberField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (v: number) => void
|
||||
min?: number
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={min}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RemoveButton({ onClick }: { onClick: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button size="sm" variant="destructive" onClick={onClick}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -16,14 +16,6 @@ export function toTime(minutes: number): string {
|
||||
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 '—'
|
||||
const h = Math.floor(minute / 60)
|
||||
const m = minute % 60
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Момент UTC во времени канала. Сетка задаётся в нём, поэтому предпросмотр показывается так же:
|
||||
* локальное время админа тут только запутало бы.
|
||||
|
||||
Reference in New Issue
Block a user