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,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