Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
This commit is contained in:
@@ -1,197 +1,186 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChevronLeft, RefreshCw } from 'lucide-react'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { listAllMedia } from '@/features/admin/media/api'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import { deleteOverride, getChannel, getSchedule, regenerateSchedule, removeChannelAd } from './api'
|
||||
import { AddAdForm } from './components/AddAdForm'
|
||||
import { AddShowForm } from './components/AddShowForm'
|
||||
import { BumperCard } from './components/BumperCard'
|
||||
import { ChannelShowRow } from './components/ChannelShowRow'
|
||||
import { CollapsibleCard } from './components/CollapsibleCard'
|
||||
import { RemoveButton } from './components/fields'
|
||||
import { OverrideForm } from './components/OverrideForm'
|
||||
import { SchedulePreview } from './components/SchedulePreview'
|
||||
import { SettingsCard } from './components/SettingsCard'
|
||||
import { formatMinute, formatTime } from './lib/format'
|
||||
|
||||
export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: channel, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId],
|
||||
queryFn: () => getChannel(channelId),
|
||||
})
|
||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
||||
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 invalidateSchedule = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'channels', channelId, 'schedule'] })
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: () => regenerateSchedule(channelId),
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.channels.regenerated'))
|
||||
void invalidateSchedule()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
|
||||
const availableShows = shows?.filter((s) => !channel.shows.some((cs) => cs.showId === s.id)) ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to="/admin/channels">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t('admin.channels.title')}
|
||||
</Link>
|
||||
</Button>
|
||||
<h2 className="crt-glow text-xl font-semibold">{channel.name}</h2>
|
||||
<Badge variant="muted">{channel.slug}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={regenerateMutation.isPending}
|
||||
onClick={() => regenerateMutation.mutate()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('admin.channels.regenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingsCard
|
||||
channel={channel}
|
||||
readyAssets={ready?.items ?? []}
|
||||
onSaved={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||
|
||||
{/* Шоу канала */}
|
||||
<CollapsibleCard title={t('admin.channels.shows')} contentClassName="flex flex-col gap-3">
|
||||
<AddShowForm
|
||||
channelId={channelId}
|
||||
options={availableShows.map((s) => ({ id: s.id, name: s.name }))}
|
||||
onAdded={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2 font-medium">{t('admin.channels.show')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.channels.weight')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.channels.block')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.channels.on')}</th>
|
||||
<th className="py-2 font-medium">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{channel.shows.map((row) => (
|
||||
<ChannelShowRow
|
||||
key={row.id}
|
||||
channelId={channelId}
|
||||
row={row}
|
||||
onChanged={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
{channel.shows.length === 0 && (
|
||||
<tr>
|
||||
<td className="py-3 text-muted-foreground" colSpan={5}>
|
||||
{t('admin.channels.noShows')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</CollapsibleCard>
|
||||
|
||||
{/* Реклама */}
|
||||
<CollapsibleCard title={t('admin.channels.ads')} contentClassName="flex flex-col gap-3">
|
||||
<AddAdForm
|
||||
channelId={channelId}
|
||||
options={(ready?.items ?? [])
|
||||
.filter((a) => !channel.ads.some((ad) => ad.mediaAssetId === a.id))
|
||||
.map((a) => ({ id: a.id, name: a.originalFileName }))}
|
||||
onAdded={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
{ready?.truncated && (
|
||||
<p className="text-xs text-amber-500">{t('admin.shows.candidatesTruncated')}</p>
|
||||
)}
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{channel.ads.map((ad) => (
|
||||
<li key={ad.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<span>{ad.assetName ?? '—'}</span>
|
||||
<RemoveButton
|
||||
onClick={() => removeChannelAd(channelId, ad.id).then(invalidate).catch(onError)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{channel.ads.length === 0 && (
|
||||
<li className="py-2 text-muted-foreground">{t('admin.channels.noAds')}</li>
|
||||
)}
|
||||
</ul>
|
||||
</CollapsibleCard>
|
||||
|
||||
{/* Override'ы / марафоны */}
|
||||
<CollapsibleCard title={t('admin.channels.overrides')} contentClassName="flex flex-col gap-3">
|
||||
<OverrideForm
|
||||
channelId={channelId}
|
||||
options={channel.shows.map((cs) => ({ id: cs.showId, name: cs.showName }))}
|
||||
onCreated={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{channel.overrides.map((o) => (
|
||||
<li key={o.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<span>
|
||||
<Badge variant="muted">{t(`admin.channels.modes.${o.mode}`)}</Badge>{' '}
|
||||
{o.recurrence === 'Weekly'
|
||||
? `${t(`admin.channels.weekdays.${o.dayOfWeek}`)} ${formatMinute(o.startMinute)}–${formatMinute(o.endMinute)}`
|
||||
: `${formatTime(o.startsAtUtc)} – ${formatTime(o.endsAtUtc)}`}{' '}
|
||||
· {o.shows.map((s) => s.showName).join(', ')}
|
||||
</span>
|
||||
<RemoveButton
|
||||
onClick={() => deleteOverride(channelId, o.id).then(invalidate).catch(onError)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{channel.overrides.length === 0 && (
|
||||
<li className="py-2 text-muted-foreground">{t('admin.channels.noOverrides')}</li>
|
||||
)}
|
||||
</ul>
|
||||
</CollapsibleCard>
|
||||
|
||||
{/* Предпросмотр расписания */}
|
||||
<CollapsibleCard title={t('admin.channels.schedule')}>
|
||||
<SchedulePreview entries={schedule ?? []} />
|
||||
</CollapsibleCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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 { toast } from '@/shared/ui/toast-store'
|
||||
import {
|
||||
applyChannelTemplate,
|
||||
createLayer,
|
||||
deleteLayer,
|
||||
getChannel,
|
||||
getChannelTemplate,
|
||||
getSchedule,
|
||||
} from './api'
|
||||
import { BumperCard } from './components/BumperCard'
|
||||
import { CollapsibleCard } from './components/CollapsibleCard'
|
||||
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
|
||||
import { SchedulePreview } from './components/SchedulePreview'
|
||||
import { SettingsCard } from './components/SettingsCard'
|
||||
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
|
||||
|
||||
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 { data: channel, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId],
|
||||
queryFn: () => getChannel(channelId),
|
||||
})
|
||||
const { data: template } = 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 applyMutation = useMutation({
|
||||
mutationFn: () => applyChannelTemplate(channelId),
|
||||
onSuccess: (result) => {
|
||||
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,
|
||||
})
|
||||
|
||||
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={() => applyMutation.mutate()}>
|
||||
<Send className="h-4 w-4" /> {t('admin.channels.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsCard
|
||||
channel={channel}
|
||||
readyAssets={ready?.items ?? []}
|
||||
onSaved={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
{template && (
|
||||
<CollapsibleCard title={t('admin.channels.grid')} defaultOpen>
|
||||
<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}
|
||||
onSelect={(layer) => setActiveLayerId(layer.id)}
|
||||
onDelete={(layer) => deleteLayerMutation.mutate(layer)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<ScheduleGrid
|
||||
template={template}
|
||||
selectedSlotId={draft?.slot?.id ?? null}
|
||||
onSelectSlot={openSlot}
|
||||
onAddSlot={openNewSlot}
|
||||
/>
|
||||
{draft && (
|
||||
<SlotInspector draft={draft} onClose={() => setDraft(null)} onChanged={invalidate} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)}
|
||||
|
||||
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||
|
||||
<SchedulePreview entries={schedule ?? []} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,245 +1,244 @@
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
AdInsertion,
|
||||
BlockMode,
|
||||
BumperSettings,
|
||||
BumperTextKind,
|
||||
BumperTrigger,
|
||||
ChannelDto,
|
||||
ChannelSummaryDto,
|
||||
CreatedIdResponse,
|
||||
HourWindow,
|
||||
OverrideMode,
|
||||
OverrideRecurrence,
|
||||
ScheduleEntryDto,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export function listChannels() {
|
||||
return apiRequest<ChannelSummaryDto[]>('/admin/channels')
|
||||
}
|
||||
|
||||
export function getChannel(id: string) {
|
||||
return apiRequest<ChannelDto>(`/admin/channels/${id}`)
|
||||
}
|
||||
|
||||
export function createChannel(body: { name: string; slug: string }) {
|
||||
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export type ChannelSettingsBody = {
|
||||
name: string
|
||||
isEnabled: boolean
|
||||
adInsertion: AdInsertion
|
||||
adsPerBreak: number
|
||||
bumpersEnabled: boolean
|
||||
bumper: BumperSettings
|
||||
fillerAssetId: string | null
|
||||
}
|
||||
|
||||
export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export type ChannelShowBody = {
|
||||
showId: string
|
||||
weight: number
|
||||
blockMode: BlockMode
|
||||
blockValue: number
|
||||
}
|
||||
|
||||
export function addChannelShow(id: string, body: ChannelShowBody) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/shows`, { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateChannelShow(
|
||||
id: string,
|
||||
channelShowId: string,
|
||||
body: {
|
||||
weight: number
|
||||
blockMode: BlockMode
|
||||
blockValue: number
|
||||
isEnabled: boolean
|
||||
preferredWeightMultiplier: number
|
||||
preferredHours: HourWindow[]
|
||||
},
|
||||
) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function removeChannelShow(id: string, channelShowId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function addChannelAd(id: string, mediaAssetId: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/ads`, {
|
||||
method: 'POST',
|
||||
body: { mediaAssetId },
|
||||
})
|
||||
}
|
||||
|
||||
export function removeChannelAd(id: string, channelAdId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/ads/${channelAdId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export type BumperTemplateStyleBody = {
|
||||
name: string
|
||||
backgroundColor: string
|
||||
backgroundColor2: string
|
||||
accentColor: string
|
||||
textColor: string
|
||||
}
|
||||
|
||||
export function addBumperTemplate(id: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateBumperTemplate(id: string, templateId: string, body: BumperTemplateStyleBody) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeBumperTemplate(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
|
||||
function uploadBumperTemplateFile(
|
||||
id: string,
|
||||
templateId: string,
|
||||
kind: 'audio' | 'background',
|
||||
file: File,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const query = new URLSearchParams({ fileName: file.name })
|
||||
xhr.open(
|
||||
'PUT',
|
||||
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
|
||||
)
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} else {
|
||||
let detail = `HTTP ${xhr.status}`
|
||||
try {
|
||||
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
||||
detail = problem.detail ?? problem.title ?? detail
|
||||
} catch {
|
||||
/* пусто */
|
||||
}
|
||||
reject(new HttpError({ detail }, xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
|
||||
return uploadBumperTemplateFile(id, templateId, 'audio', file)
|
||||
}
|
||||
|
||||
export type BumperVariantBody = {
|
||||
name: string
|
||||
kind: BumperTextKind
|
||||
nowLabel: string
|
||||
nextLabel: string
|
||||
line1: string
|
||||
line2: string
|
||||
trigger: BumperTrigger
|
||||
weight: number
|
||||
}
|
||||
|
||||
export function addBumperVariant(id: string, templateId: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
|
||||
{ method: 'POST', body: { name } },
|
||||
)
|
||||
}
|
||||
|
||||
export function updateBumperVariant(
|
||||
id: string,
|
||||
templateId: string,
|
||||
variantId: string,
|
||||
body: BumperVariantBody,
|
||||
) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'PUT', body },
|
||||
)
|
||||
}
|
||||
|
||||
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
}
|
||||
|
||||
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
|
||||
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'PUT',
|
||||
body: { imageId },
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperTemplateAudio(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperTemplateBackground(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
|
||||
export function renderBumperPreviews(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
|
||||
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
|
||||
}
|
||||
|
||||
export type OverrideBody = {
|
||||
mode: OverrideMode
|
||||
recurrence: OverrideRecurrence
|
||||
startsAtUtc?: string | null
|
||||
endsAtUtc?: string | null
|
||||
dayOfWeek?: number | null
|
||||
startMinute?: number | null
|
||||
endMinute?: number | null
|
||||
shows: { showId: string; weight: number }[]
|
||||
}
|
||||
|
||||
export function createOverride(id: string, body: OverrideBody) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/overrides`, { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function deleteOverride(id: string, overrideId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/overrides/${overrideId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function regenerateSchedule(id: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/regenerate`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getSchedule(id: string, from: Date, to: Date) {
|
||||
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
|
||||
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
|
||||
}
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
ApplyResultDto,
|
||||
BumperSettings,
|
||||
BumperTextKind,
|
||||
BumperTrigger,
|
||||
ChannelDto,
|
||||
ChannelSummaryDto,
|
||||
CreatedIdResponse,
|
||||
LayerApplicability,
|
||||
ScheduleEntryDto,
|
||||
ScheduleTemplateDto,
|
||||
SlotDto,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export function listChannels() {
|
||||
return apiRequest<ChannelSummaryDto[]>('/admin/channels')
|
||||
}
|
||||
|
||||
export function getChannel(id: string) {
|
||||
return apiRequest<ChannelDto>(`/admin/channels/${id}`)
|
||||
}
|
||||
|
||||
export function createChannel(body: { name: string; slug: string }) {
|
||||
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export type ChannelSettingsBody = {
|
||||
name: string
|
||||
isEnabled: boolean
|
||||
bumpersEnabled: boolean
|
||||
bumper: BumperSettings
|
||||
fillerAssetId: string | null
|
||||
}
|
||||
|
||||
export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
/** Номер канала и его время: смещение от UTC и начало вещательных суток. */
|
||||
export function updateChannelTime(
|
||||
id: string,
|
||||
body: { number: number | null; utcOffsetMinutes: number; dayStartTime: string },
|
||||
) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/time`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
// ── Сетка канала ──────────────────────────────────────────────────────────
|
||||
|
||||
export function getChannelTemplate(channelId: string) {
|
||||
return apiRequest<ScheduleTemplateDto>(`/admin/channels/${channelId}/template`)
|
||||
}
|
||||
|
||||
/** Применяет правила к эфиру: пересобирает будущий хвост. Правка слотов эфир не двигает. */
|
||||
export function applyChannelTemplate(channelId: string) {
|
||||
return apiRequest<ApplyResultDto>(`/admin/channels/${channelId}/template/apply`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTemplate(
|
||||
templateId: string,
|
||||
body: { name: string; fallbackGroupId: string | null },
|
||||
) {
|
||||
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function createLayer(templateId: string, body: { name: string; priority: number }) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/templates/${templateId}/layers`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateLayer(
|
||||
layerId: string,
|
||||
body: {
|
||||
name: string
|
||||
priority: number
|
||||
applicability: LayerApplicability | null
|
||||
isEnabled: boolean
|
||||
},
|
||||
) {
|
||||
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteLayer(layerId: string) {
|
||||
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Тело слота: то же для создания и правки (см. SlotInput на сервере). */
|
||||
export type SlotBody = Omit<SlotDto, 'id' | 'layerId' | 'groupName'>
|
||||
|
||||
export function createSlot(layerId: string, body: SlotBody) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/layers/${layerId}/slots`, { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateSlot(slotId: string, body: SlotBody) {
|
||||
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteSlot(slotId: string) {
|
||||
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export type BumperTemplateStyleBody = {
|
||||
name: string
|
||||
backgroundColor: string
|
||||
backgroundColor2: string
|
||||
accentColor: string
|
||||
textColor: string
|
||||
}
|
||||
|
||||
export function addBumperTemplate(id: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateBumperTemplate(id: string, templateId: string, body: BumperTemplateStyleBody) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeBumperTemplate(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
|
||||
function uploadBumperTemplateFile(
|
||||
id: string,
|
||||
templateId: string,
|
||||
kind: 'audio' | 'background',
|
||||
file: File,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const query = new URLSearchParams({ fileName: file.name })
|
||||
xhr.open(
|
||||
'PUT',
|
||||
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
|
||||
)
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} else {
|
||||
let detail = `HTTP ${xhr.status}`
|
||||
try {
|
||||
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
||||
detail = problem.detail ?? problem.title ?? detail
|
||||
} catch {
|
||||
/* пусто */
|
||||
}
|
||||
reject(new HttpError({ detail }, xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
|
||||
return uploadBumperTemplateFile(id, templateId, 'audio', file)
|
||||
}
|
||||
|
||||
export type BumperVariantBody = {
|
||||
name: string
|
||||
kind: BumperTextKind
|
||||
nowLabel: string
|
||||
nextLabel: string
|
||||
line1: string
|
||||
line2: string
|
||||
trigger: BumperTrigger
|
||||
weight: number
|
||||
}
|
||||
|
||||
export function addBumperVariant(id: string, templateId: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
|
||||
{ method: 'POST', body: { name } },
|
||||
)
|
||||
}
|
||||
|
||||
export function updateBumperVariant(
|
||||
id: string,
|
||||
templateId: string,
|
||||
variantId: string,
|
||||
body: BumperVariantBody,
|
||||
) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'PUT', body },
|
||||
)
|
||||
}
|
||||
|
||||
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
}
|
||||
|
||||
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
|
||||
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'PUT',
|
||||
body: { imageId },
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperTemplateAudio(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperTemplateBackground(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
|
||||
export function renderBumperPreviews(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
|
||||
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
|
||||
}
|
||||
|
||||
export function getSchedule(id: string, from: Date, to: Date) {
|
||||
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
|
||||
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { addChannelAd } from '../api'
|
||||
|
||||
export function AddAdForm({
|
||||
channelId,
|
||||
options,
|
||||
onAdded,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
options: { id: string; name: string }[]
|
||||
onAdded: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [assetId, setAssetId] = useState('')
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () => addChannelAd(channelId, assetId),
|
||||
onSuccess: () => {
|
||||
setAssetId('')
|
||||
onAdded()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Select value={assetId} onValueChange={setAssetId}>
|
||||
<SelectTrigger className="max-w-md">
|
||||
<SelectValue placeholder={t('admin.channels.pickAd')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" disabled={!assetId || add.isPending} onClick={() => add.mutate()}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BlockMode } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { addChannelShow } from '../api'
|
||||
import { NumberField } from './fields'
|
||||
|
||||
export function AddShowForm({
|
||||
channelId,
|
||||
options,
|
||||
onAdded,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
options: { id: string; name: string }[]
|
||||
onAdded: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [showId, setShowId] = useState('')
|
||||
const [weight, setWeight] = useState(1)
|
||||
const [blockMode, setBlockMode] = useState<BlockMode>('Count')
|
||||
const [blockValue, setBlockValue] = useState(1)
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () => addChannelShow(channelId, { showId, weight, blockMode, blockValue }),
|
||||
onSuccess: () => {
|
||||
setShowId('')
|
||||
onAdded()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Select value={showId} onValueChange={setShowId}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder={t('admin.channels.pickShow')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
|
||||
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
|
||||
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<NumberField
|
||||
label={blockMode === 'Count' ? t('admin.channels.episodes') : t('admin.channels.minutes')}
|
||||
value={blockValue}
|
||||
onChange={setBlockValue}
|
||||
min={1}
|
||||
/>
|
||||
<Button size="sm" disabled={!showId || add.isPending} onClick={() => add.mutate()}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,181 +1,179 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { addBumperTemplate, updateChannelSettings } from '../api'
|
||||
import { clampChance } from '../lib/format'
|
||||
import { BumperTemplateEditor } from './BumperTemplateEditor'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function BumperCard({
|
||||
channel,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channel: ChannelDto
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
|
||||
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
|
||||
|
||||
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
|
||||
setBumper((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
useEffect(() => {
|
||||
setBumpersEnabled(channel.bumpersEnabled)
|
||||
setBumper(channel.bumper)
|
||||
}, [channel])
|
||||
|
||||
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
|
||||
// поля берём из канала без изменений (они правятся в своей карточке).
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelSettings(channel.id, {
|
||||
name: channel.name,
|
||||
isEnabled: channel.isEnabled,
|
||||
adInsertion: channel.adInsertion,
|
||||
adsPerBreak: channel.adsPerBreak,
|
||||
bumpersEnabled,
|
||||
bumper,
|
||||
fillerAssetId: channel.fillerAssetId,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const addTemplate = useMutation({
|
||||
mutationFn: () => addBumperTemplate(channel.id, ''),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
|
||||
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.bumpers')} contentClassName="flex flex-col gap-4">
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={bumpersEnabled}
|
||||
onChange={(e) => setBumpersEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('admin.channels.bumpersLabel')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumpersHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Общие настройки */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperSelection')}</Label>
|
||||
<Select value={bumper.selection} onValueChange={(v) => setField('selection', v as BumperSelection)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
|
||||
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
||||
<SelectItem value="WeightedRandom">
|
||||
{t('admin.channels.bumperSelectionWeighted')}
|
||||
</SelectItem>
|
||||
<SelectItem value="AlwaysFirst">
|
||||
{t('admin.channels.bumperSelectionAlwaysFirst')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperFont')}</Label>
|
||||
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
|
||||
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperMinInterval')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
value={bumper.minIntervalMinutes}
|
||||
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperShowChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.showChangeChance}
|
||||
onChange={(e) => setField('showChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperShowChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperEpisodeChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.episodeChangeChance}
|
||||
onChange={(e) => setField('episodeChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperEpisodeChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Блоки заставок */}
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{templates.map((template) => (
|
||||
<BumperTemplateEditor
|
||||
key={template.id}
|
||||
channelId={channel.id}
|
||||
template={template}
|
||||
onChanged={onSaved}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button size="sm" variant="outline" disabled={addTemplate.isPending} onClick={() => addTemplate.mutate()}>
|
||||
{t('admin.channels.bumperAddTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { addBumperTemplate, updateChannelSettings } from '../api'
|
||||
import { clampChance } from '../lib/format'
|
||||
import { BumperTemplateEditor } from './BumperTemplateEditor'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function BumperCard({
|
||||
channel,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channel: ChannelDto
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
|
||||
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
|
||||
|
||||
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
|
||||
setBumper((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
useEffect(() => {
|
||||
setBumpersEnabled(channel.bumpersEnabled)
|
||||
setBumper(channel.bumper)
|
||||
}, [channel])
|
||||
|
||||
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
|
||||
// поля берём из канала без изменений (они правятся в своей карточке).
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelSettings(channel.id, {
|
||||
name: channel.name,
|
||||
isEnabled: channel.isEnabled,
|
||||
bumpersEnabled,
|
||||
bumper,
|
||||
fillerAssetId: channel.fillerAssetId,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const addTemplate = useMutation({
|
||||
mutationFn: () => addBumperTemplate(channel.id, ''),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
|
||||
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.bumpers')} contentClassName="flex flex-col gap-4">
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={bumpersEnabled}
|
||||
onChange={(e) => setBumpersEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('admin.channels.bumpersLabel')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumpersHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Общие настройки */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperSelection')}</Label>
|
||||
<Select value={bumper.selection} onValueChange={(v) => setField('selection', v as BumperSelection)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
|
||||
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
||||
<SelectItem value="WeightedRandom">
|
||||
{t('admin.channels.bumperSelectionWeighted')}
|
||||
</SelectItem>
|
||||
<SelectItem value="AlwaysFirst">
|
||||
{t('admin.channels.bumperSelectionAlwaysFirst')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperFont')}</Label>
|
||||
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
|
||||
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperMinInterval')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
value={bumper.minIntervalMinutes}
|
||||
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperShowChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.showChangeChance}
|
||||
onChange={(e) => setField('showChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperShowChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperEpisodeChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.episodeChangeChance}
|
||||
onChange={(e) => setField('episodeChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperEpisodeChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Блоки заставок */}
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{templates.map((template) => (
|
||||
<BumperTemplateEditor
|
||||
key={template.id}
|
||||
channelId={channel.id}
|
||||
template={template}
|
||||
onChanged={onSaved}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button size="sm" variant="outline" disabled={addTemplate.isPending} onClick={() => addTemplate.mutate()}>
|
||||
{t('admin.channels.bumperAddTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BlockMode, ChannelShowDto, HourWindow } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { removeChannelShow, updateChannelShow } from '../api'
|
||||
import { RemoveButton } from './fields'
|
||||
|
||||
export function ChannelShowRow({
|
||||
channelId,
|
||||
row,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
row: ChannelShowDto
|
||||
onChanged: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [weight, setWeight] = useState(row.weight)
|
||||
const [blockMode, setBlockMode] = useState<BlockMode>(row.blockMode)
|
||||
const [blockValue, setBlockValue] = useState(row.blockValue)
|
||||
const [isEnabled, setIsEnabled] = useState(row.isEnabled)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [multiplier, setMultiplier] = useState(row.preferredWeightMultiplier)
|
||||
const [hours, setHours] = useState<HourWindow[]>(row.preferredHours)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelShow(channelId, row.id, {
|
||||
weight,
|
||||
blockMode,
|
||||
blockValue,
|
||||
isEnabled,
|
||||
preferredWeightMultiplier: multiplier,
|
||||
preferredHours: hours.filter((h) => h.startHour < h.endHour),
|
||||
}),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const addHour = () => setHours((h) => [...h, { startHour: 18, endHour: 23 }])
|
||||
const setHour = (i: number, patch: Partial<HourWindow>) =>
|
||||
setHours((h) => h.map((w, idx) => (idx === i ? { ...w, ...patch } : w)))
|
||||
const removeHour = (i: number) => setHours((h) => h.filter((_, idx) => idx !== i))
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td className="py-2">{row.showName}</td>
|
||||
<td className="py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(Number(e.target.value))}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
|
||||
<SelectTrigger className="h-8 w-36 whitespace-nowrap">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
|
||||
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={blockValue}
|
||||
onChange={(e) => setBlockValue(Number(e.target.value))}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{t('admin.channels.preferredHours')}
|
||||
{hours.length > 0 ? ` (${hours.length})` : ''}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<RemoveButton
|
||||
onClick={() => removeChannelShow(channelId, row.id).then(onChanged).catch(onError)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td colSpan={5} className="bg-muted/30 py-3">
|
||||
<div className="flex flex-col gap-3 pl-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">{t('admin.channels.preferredMultiplier')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={multiplier}
|
||||
onChange={(e) => setMultiplier(Math.max(1, Math.round(Number(e.target.value)) || 1))}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.preferredHoursHint')}
|
||||
</span>
|
||||
</div>
|
||||
{hours.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.preferredNone')}</p>
|
||||
)}
|
||||
{hours.map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<HourSelect value={w.startHour} from={0} to={23} onChange={(v) => setHour(i, { startHour: v })} />
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<HourSelect value={w.endHour} from={1} to={24} onChange={(v) => setHour(i, { endHour: v })} />
|
||||
{w.startHour >= w.endHour && (
|
||||
<span className="text-xs text-destructive">
|
||||
{t('admin.channels.preferredBadRange')}
|
||||
</span>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={() => removeHour(i)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Button size="sm" variant="outline" onClick={addHour}>
|
||||
{t('admin.channels.preferredAddWindow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Выпадающий выбор часа суток (значения from..to включительно), формат «HH:00». */
|
||||
function HourSelect({
|
||||
value,
|
||||
from,
|
||||
to,
|
||||
onChange,
|
||||
}: {
|
||||
value: number
|
||||
from: number
|
||||
to: number
|
||||
onChange: (v: number) => void
|
||||
}) {
|
||||
const options = Array.from({ length: to - from + 1 }, (_, i) => from + i)
|
||||
return (
|
||||
<Select value={String(value)} onValueChange={(v) => onChange(Number(v))}>
|
||||
<SelectTrigger className="h-8 w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((h) => (
|
||||
<SelectItem key={h} value={String(h)}>
|
||||
{String(h).padStart(2, '0')}:00
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { OverrideMode, OverrideRecurrence } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { createOverride } from '../api'
|
||||
import { NumberField } from './fields'
|
||||
|
||||
export function OverrideForm({
|
||||
channelId,
|
||||
options,
|
||||
onCreated,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
options: { id: string; name: string }[]
|
||||
onCreated: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [mode, setMode] = useState<OverrideMode>('Exclusive')
|
||||
const [recurrence, setRecurrence] = useState<OverrideRecurrence>('OneTime')
|
||||
const [showId, setShowId] = useState('')
|
||||
const [weight, setWeight] = useState(1)
|
||||
const [start, setStart] = useState('')
|
||||
const [end, setEnd] = useState('')
|
||||
// Weekly: день недели (0=Вс..6=Сб) + окна времени суток «HH:MM».
|
||||
const [dayOfWeek, setDayOfWeek] = useState(6)
|
||||
const [startTime, setStartTime] = useState('')
|
||||
const [endTime, setEndTime] = useState('')
|
||||
|
||||
const toMinutes = (hhmm: string) => {
|
||||
const [h, m] = hhmm.split(':').map(Number)
|
||||
return h * 60 + m
|
||||
}
|
||||
const weekly = recurrence === 'Weekly'
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
createOverride(
|
||||
channelId,
|
||||
weekly
|
||||
? {
|
||||
mode,
|
||||
recurrence,
|
||||
dayOfWeek,
|
||||
startMinute: toMinutes(startTime),
|
||||
endMinute: toMinutes(endTime),
|
||||
shows: [{ showId, weight }],
|
||||
}
|
||||
: {
|
||||
mode,
|
||||
recurrence,
|
||||
startsAtUtc: new Date(start).toISOString(),
|
||||
endsAtUtc: new Date(end).toISOString(),
|
||||
shows: [{ showId, weight }],
|
||||
},
|
||||
),
|
||||
onSuccess: () => {
|
||||
setShowId('')
|
||||
setStart('')
|
||||
setEnd('')
|
||||
setStartTime('')
|
||||
setEndTime('')
|
||||
onCreated()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const valid = weekly
|
||||
? showId && startTime && endTime && toMinutes(endTime) > toMinutes(startTime)
|
||||
: showId && start && end && new Date(end) > new Date(start)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.overrideRecurrence')}</Label>
|
||||
<Select value={recurrence} onValueChange={(v) => setRecurrence(v as OverrideRecurrence)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="OneTime">{t('admin.channels.recurrenceOneTime')}</SelectItem>
|
||||
<SelectItem value="Weekly">{t('admin.channels.recurrenceWeekly')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={mode} onValueChange={(v) => setMode(v as OverrideMode)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Exclusive">{t('admin.channels.modes.Exclusive')}</SelectItem>
|
||||
<SelectItem value="Boost">{t('admin.channels.modes.Boost')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={showId} onValueChange={setShowId}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder={t('admin.channels.pickShow')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{mode === 'Boost' && (
|
||||
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
|
||||
)}
|
||||
{weekly ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.weekday')}</Label>
|
||||
<Select value={String(dayOfWeek)} onValueChange={(v) => setDayOfWeek(Number(v))}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||
<SelectItem key={d} value={String(d)}>
|
||||
{t(`admin.channels.weekdays.${d}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.from')}</Label>
|
||||
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} className="w-32" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.to')}</Label>
|
||||
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} className="w-32" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.from')}</Label>
|
||||
<Input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} className="w-60" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.to')}</Label>
|
||||
<Input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} className="w-60" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,120 +1,133 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AdInsertion, ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { updateChannelSettings } from '../api'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function SettingsCard({
|
||||
channel,
|
||||
readyAssets,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channel: ChannelDto
|
||||
readyAssets: { id: string; originalFileName: string }[]
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState(channel.name)
|
||||
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
|
||||
const [adInsertion, setAdInsertion] = useState<AdInsertion>(channel.adInsertion)
|
||||
const [adsPerBreak, setAdsPerBreak] = useState(channel.adsPerBreak)
|
||||
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
setName(channel.name)
|
||||
setIsEnabled(channel.isEnabled)
|
||||
setAdInsertion(channel.adInsertion)
|
||||
setAdsPerBreak(channel.adsPerBreak)
|
||||
setFillerAssetId(channel.fillerAssetId ?? '')
|
||||
}, [channel])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelSettings(channel.id, {
|
||||
name: name.trim(),
|
||||
isEnabled,
|
||||
adInsertion,
|
||||
adsPerBreak,
|
||||
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
|
||||
bumpersEnabled: channel.bumpersEnabled,
|
||||
bumper: channel.bumper,
|
||||
fillerAssetId: fillerAssetId || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleCard
|
||||
title={t('admin.channels.settings')}
|
||||
defaultOpen
|
||||
contentClassName="grid gap-4 sm:grid-cols-2"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.name')}</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.adPolicy')}</Label>
|
||||
<Select value={adInsertion} onValueChange={(v) => setAdInsertion(v as AdInsertion)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="BetweenBlocks">{t('admin.channels.betweenBlocks')}</SelectItem>
|
||||
<SelectItem value="BetweenEpisodes">{t('admin.channels.betweenEpisodes')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.adsPerBreak')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={adsPerBreak}
|
||||
onChange={(e) => setAdsPerBreak(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.filler')}</Label>
|
||||
<Select
|
||||
value={fillerAssetId || 'none'}
|
||||
onValueChange={(v) => setFillerAssetId(v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('admin.channels.noFiller')}</SelectItem>
|
||||
{readyAssets.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.originalFileName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||
{t('admin.channels.enabledLabel')}
|
||||
</label>
|
||||
<div className="flex items-end justify-end sm:col-span-2">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { updateChannelSettings, updateChannelTime } from '../api'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function SettingsCard({
|
||||
channel,
|
||||
readyAssets,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channel: ChannelDto
|
||||
readyAssets: { id: string; originalFileName: string }[]
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState(channel.name)
|
||||
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
|
||||
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
|
||||
const [number, setNumber] = useState(channel.number?.toString() ?? '')
|
||||
const [offsetHours, setOffsetHours] = useState(channel.utcOffsetMinutes / 60)
|
||||
// Начало вещательных суток приходит как «06:00:00» — в поле нужен формат «06:00».
|
||||
const [dayStart, setDayStart] = useState(channel.dayStartTime.slice(0, 5))
|
||||
|
||||
useEffect(() => {
|
||||
setName(channel.name)
|
||||
setIsEnabled(channel.isEnabled)
|
||||
setFillerAssetId(channel.fillerAssetId ?? '')
|
||||
setNumber(channel.number?.toString() ?? '')
|
||||
setOffsetHours(channel.utcOffsetMinutes / 60)
|
||||
setDayStart(channel.dayStartTime.slice(0, 5))
|
||||
}, [channel])
|
||||
|
||||
const save = useMutation({
|
||||
// Время канала живёт отдельной командой — сохраняем обе за одно нажатие.
|
||||
mutationFn: async () => {
|
||||
await updateChannelSettings(channel.id, {
|
||||
name: name.trim(),
|
||||
isEnabled,
|
||||
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
|
||||
bumpersEnabled: channel.bumpersEnabled,
|
||||
bumper: channel.bumper,
|
||||
fillerAssetId: fillerAssetId || null,
|
||||
})
|
||||
await updateChannelTime(channel.id, {
|
||||
number: number.trim() === '' ? null : Number(number),
|
||||
utcOffsetMinutes: Math.round(offsetHours * 60),
|
||||
dayStartTime: `${dayStart}:00`,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleCard
|
||||
title={t('admin.channels.settings')}
|
||||
defaultOpen
|
||||
contentClassName="grid gap-4 sm:grid-cols-2"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.name')}</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.number')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={number}
|
||||
placeholder={t('admin.channels.numberPlaceholder')}
|
||||
onChange={(e) => setNumber(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.utcOffset')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={-12}
|
||||
max={14}
|
||||
step={1}
|
||||
value={offsetHours}
|
||||
onChange={(e) => setOffsetHours(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.utcOffsetHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.dayStart')}</Label>
|
||||
<Input type="time" value={dayStart} onChange={(e) => setDayStart(e.target.value)} />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.dayStartHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.filler')}</Label>
|
||||
<Select
|
||||
value={fillerAssetId || 'none'}
|
||||
onValueChange={(v) => setFillerAssetId(v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('admin.channels.noFiller')}</SelectItem>
|
||||
{readyAssets.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.originalFileName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||
{t('admin.channels.enabledLabel')}
|
||||
</label>
|
||||
<div className="flex items-end justify-end sm:col-span-2">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
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,
|
||||
SlotBlockMode,
|
||||
SlotDto,
|
||||
SlotKind,
|
||||
SlotStrategyType,
|
||||
} from '@/shared/api/types'
|
||||
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, updateSlot, type SlotBody } from '../api'
|
||||
|
||||
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
|
||||
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
|
||||
const BLOCK_MODES: SlotBlockMode[] = ['Count', 'Duration', 'FillSlot']
|
||||
const OVERFLOW: OverflowPolicy[] = ['ContinueNext', 'ExtendSlot', 'SkipIfNotFits']
|
||||
const STRATEGIES: SlotStrategyType[] = ['Sequential', 'RandomWithCooldown', 'Fixed']
|
||||
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: '',
|
||||
weekday: null,
|
||||
targetStart: '20:00:00',
|
||||
targetDurationMinutes: 60,
|
||||
daypart: 'Day',
|
||||
slotKind: 'Content',
|
||||
groupId: null,
|
||||
strategy: {
|
||||
type: 'Sequential',
|
||||
restartOnEnd: true,
|
||||
cooldownDays: 0,
|
||||
fallback: 'OldestFirst',
|
||||
},
|
||||
repeatSource: null,
|
||||
blockMode: 'FillSlot',
|
||||
blockValue: 1,
|
||||
overflowPolicy: 'ContinueNext',
|
||||
isAnchor: false,
|
||||
maxDriftMinutes: 5,
|
||||
snapToMinutes: null,
|
||||
...defaults,
|
||||
}
|
||||
}
|
||||
|
||||
export function SlotInspector({
|
||||
draft,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
draft: SlotDraft
|
||||
onClose: () => void
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [body, setBody] = useState<SlotBody>(() =>
|
||||
draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setBody(draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults))
|
||||
}, [draft])
|
||||
|
||||
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (draft.slot) await updateSlot(draft.slot.id, body)
|
||||
else await createSlot(draft.layerId, body)
|
||||
},
|
||||
onSuccess: () => {
|
||||
onChanged()
|
||||
onClose()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => deleteSlot(draft.slot!.id),
|
||||
onSuccess: () => {
|
||||
onChanged()
|
||||
onClose()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const patch = (part: Partial<SlotBody>) => setBody((prev) => ({ ...prev, ...part }))
|
||||
const isContent = body.slotKind === 'Content'
|
||||
|
||||
return (
|
||||
<div className="crt-panel flex flex-col gap-3 rounded-md p-4 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide">
|
||||
{draft.slot ? t('admin.channels.editSlot') : t('admin.channels.newSlot')}
|
||||
</h3>
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotTitle')}</Label>
|
||||
<Input value={body.title} onChange={(e) => patch({ title: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotStart')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={body.targetStart.slice(0, 5)}
|
||||
onChange={(e) => patch({ targetStart: `${e.target.value}:00` })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotDuration')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.targetDurationMinutes}
|
||||
onChange={(e) => patch({ targetDurationMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.weekday')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.weekday ?? 'any'}
|
||||
onChange={(e) =>
|
||||
patch({ weekday: e.target.value === 'any' ? null : Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
<option value="any">{t('admin.channels.everyDay')}</option>
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((day) => (
|
||||
<option key={day} value={day}>
|
||||
{t(`admin.channels.weekdays.${day}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.daypart')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.daypart}
|
||||
onChange={(e) => patch({ daypart: e.target.value as Daypart })}
|
||||
>
|
||||
{DAYPARTS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.dayparts.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotKind')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.slotKind}
|
||||
onChange={(e) => {
|
||||
const slotKind = e.target.value as SlotKind
|
||||
patch({
|
||||
slotKind,
|
||||
// Повтору нужен источник, конец вещания не берёт контент вовсе.
|
||||
repeatSource:
|
||||
slotKind === 'Repeat'
|
||||
? (body.repeatSource ?? { daysAgo: 1, time: '20:00:00', durationMinutes: 90 })
|
||||
: null,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{SLOT_KINDS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.slotKinds.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isContent && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.group')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.groupId ?? ''}
|
||||
onChange={(e) => patch({ groupId: e.target.value || null })}
|
||||
>
|
||||
<option value="">{t('admin.channels.pickGroup')}</option>
|
||||
{(groups ?? []).map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name} · {group.itemCount}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.strategy')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.strategy?.type ?? 'Sequential'}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
strategy: {
|
||||
...(body.strategy ?? {
|
||||
restartOnEnd: true,
|
||||
cooldownDays: 0,
|
||||
fallback: 'OldestFirst',
|
||||
type: 'Sequential',
|
||||
}),
|
||||
type: e.target.value as SlotStrategyType,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{STRATEGIES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.strategies.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{body.strategy?.type === 'RandomWithCooldown' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.cooldownDays')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={body.strategy.cooldownDays}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
strategy: { ...body.strategy!, cooldownDays: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.cooldownHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.blockMode')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.blockMode}
|
||||
onChange={(e) => patch({ blockMode: e.target.value as SlotBlockMode })}
|
||||
>
|
||||
{BLOCK_MODES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.blockModes.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{body.blockMode !== 'FillSlot' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.blockValue')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.blockValue}
|
||||
onChange={(e) => patch({ blockValue: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.overflow')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.overflowPolicy}
|
||||
onChange={(e) => patch({ overflowPolicy: e.target.value as OverflowPolicy })}
|
||||
>
|
||||
{OVERFLOW.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.overflows.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.overflowHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{body.slotKind === 'Repeat' && body.repeatSource && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatDaysAgo')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.repeatSource.daysAgo}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
repeatSource: { ...body.repeatSource!, daysAgo: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatTime')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={body.repeatSource.time.slice(0, 5)}
|
||||
onChange={(e) =>
|
||||
patch({ repeatSource: { ...body.repeatSource!, time: `${e.target.value}:00` } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatDuration')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.repeatSource.durationMinutes}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
repeatSource: {
|
||||
...body.repeatSource!,
|
||||
durationMinutes: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={body.isAnchor}
|
||||
onChange={(e) => patch({ isAnchor: e.target.checked })}
|
||||
/>
|
||||
{t('admin.channels.anchor')}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.maxDrift')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-24"
|
||||
value={body.maxDriftMinutes}
|
||||
onChange={(e) => patch({ maxDriftMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.snap')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.snapToMinutes ?? 0}
|
||||
onChange={(e) =>
|
||||
patch({ snapToMinutes: Number(e.target.value) === 0 ? null : Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
{SNAP_OPTIONS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value === 0 ? t('admin.channels.snapOff') : `${value}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.anchorHint')}</p>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{draft.slot ? (
|
||||
<Button size="sm" variant="destructive" onClick={() => remove.mutate()}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user