Refactor .gitignore to streamline ignored files and enhance clarity. Update CLAUDE.md to improve unit test instructions and add coverage reporting details. Revise README.md for better project overview and deployment instructions. Refactor ChannelEndpoints and StreamingEndpoints to utilize SegmentFiles for file resolution, improving code maintainability. Remove unused JunctionHandlers and update DependencyInjection for cleaner service registration. Enhance media processing services for better job handling and error management. Update frontend API types for consistency and clarity.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -33,7 +34,7 @@ export function ApplyDialog({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'diff'],
|
||||
queryKey: qk.channels.diff(channelId),
|
||||
queryFn: () => getApplyDiff(channelId),
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -24,7 +25,7 @@ export function EntryTraceDialog({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { data } = useQuery({
|
||||
queryKey: ['admin', 'entries', entryId, 'trace'],
|
||||
queryKey: qk.entries.trace(entryId),
|
||||
queryFn: () => getEntryTrace(entryId),
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import {
|
||||
copyTemplateTo,
|
||||
createChannelTemplate,
|
||||
createLayer,
|
||||
createSlot,
|
||||
deleteLayer,
|
||||
listChannels,
|
||||
toSlotBody,
|
||||
updateLayer,
|
||||
updateSlot,
|
||||
} from '../api'
|
||||
import { toTime } from '../lib/format'
|
||||
import { LayerApplicabilityDialog } from './LayerApplicabilityDialog'
|
||||
import { LayerList, ScheduleGrid } from './ScheduleGrid'
|
||||
import { SlotInspector, type SlotDraft } from './SlotInspector'
|
||||
import { TemplateIssues } from './TemplateIssues'
|
||||
import { TemplatePreview } from './TemplatePreview'
|
||||
|
||||
/**
|
||||
* Вкладка «Сетка»: слои, слоты и всё, что их правит. Вынесена из экрана канала целиком со своим
|
||||
* состоянием — остальным вкладкам ни черновик слота, ни выбранный день копирования не нужны, а
|
||||
* держать их в родителе значило перерисовывать весь экран на каждое движение мыши по сетке.
|
||||
*/
|
||||
export function GridTab({
|
||||
channelId,
|
||||
template,
|
||||
templateError,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
template: ScheduleTemplateDto | undefined
|
||||
templateError: unknown
|
||||
onChanged: () => void
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [draft, setDraft] = useState<SlotDraft | null>(null)
|
||||
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
|
||||
const [viewDate, setViewDate] = useState<string>('')
|
||||
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
|
||||
// День, который копируем, и отмеченные дни-приёмники.
|
||||
const [copySource, setCopySource] = useState<number | null>(null)
|
||||
const [copyTargets, setCopyTargets] = useState<number[]>([])
|
||||
const [copyFromChannel, setCopyFromChannel] = useState('')
|
||||
|
||||
const { data: channels } = useQuery({ queryKey: qk.channels.all, queryFn: listChannels })
|
||||
|
||||
const addLayerMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const nextPriority = Math.max(0, ...(template?.layers.map((l) => l.priority) ?? [0])) + 10
|
||||
return createLayer(template!.id, {
|
||||
name: t('admin.channels.newLayerName'),
|
||||
priority: nextPriority,
|
||||
})
|
||||
},
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const deleteLayerMutation = useMutation({
|
||||
mutationFn: (layer: GridLayerDto) => deleteLayer(layer.id),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const toggleLayerMutation = useMutation({
|
||||
mutationFn: (layer: GridLayerDto) =>
|
||||
updateLayer(layer.id, {
|
||||
name: layer.name,
|
||||
priority: layer.priority,
|
||||
applicability: layer.applicability,
|
||||
isEnabled: !layer.isEnabled,
|
||||
}),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
/**
|
||||
* Порядок слоёв задаётся перетаскиванием, а хранится приоритетом. Раздаём приоритеты с шагом 10
|
||||
* снизу вверх: шаг оставляет место, чтобы следующая вставка не переписывала весь список.
|
||||
*/
|
||||
const reorderLayersMutation = useMutation({
|
||||
mutationFn: async (layerIdsTopFirst: string[]) => {
|
||||
const byId = new Map(template!.layers.map((l) => [l.id, l]))
|
||||
const total = layerIdsTopFirst.length
|
||||
await Promise.all(
|
||||
layerIdsTopFirst.map((id, index) => {
|
||||
const layer = byId.get(id)
|
||||
const priority = (total - index) * 10
|
||||
if (!layer || layer.priority === priority) return Promise.resolve()
|
||||
return updateLayer(id, {
|
||||
name: layer.name,
|
||||
priority,
|
||||
applicability: layer.applicability,
|
||||
isEnabled: layer.isEnabled,
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
// Канал без сетки — наследство старой ротации: заводим шаблон на месте, а не пересоздаём канал.
|
||||
const createTemplateMutation = useMutation({
|
||||
mutationFn: () => createChannelTemplate(channelId),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const copyTemplateMutation = useMutation({
|
||||
mutationFn: (sourceChannelId: string) => copyTemplateTo(sourceChannelId, channelId),
|
||||
onSuccess: (result) => {
|
||||
setCopyFromChannel('')
|
||||
toast.success(
|
||||
t('admin.channels.templateCopied', { layers: result.layers, slots: result.slots }),
|
||||
)
|
||||
if (result.droppedBumperRefs > 0)
|
||||
toast.error(t('admin.channels.copyDroppedBumpers', { count: result.droppedBumperRefs }))
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const moveSlotMutation = useMutation({
|
||||
mutationFn: ({
|
||||
slot,
|
||||
weekday,
|
||||
startMinutes,
|
||||
}: {
|
||||
slot: SlotDto
|
||||
weekday: number
|
||||
startMinutes: number
|
||||
}) => updateSlot(slot.id, { ...toSlotBody(slot), weekday, targetStart: toTime(startMinutes) }),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const resizeSlotMutation = useMutation({
|
||||
mutationFn: ({ slot, minutes }: { slot: SlotDto; minutes: number }) =>
|
||||
updateSlot(slot.id, { ...toSlotBody(slot), targetDurationMinutes: minutes }),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
/** Копирование дня: слоты «каждый день» не копируются — они и так есть во всех колонках. */
|
||||
const copyDayMutation = useMutation({
|
||||
mutationFn: async ({ from, to }: { from: number; to: number[] }) => {
|
||||
const sources = (template?.layers ?? []).flatMap((layer) =>
|
||||
layer.slots.filter((slot) => slot.weekday === from).map((slot) => ({ layer, slot })),
|
||||
)
|
||||
for (const weekday of to)
|
||||
for (const { layer, slot } of sources)
|
||||
await createSlot(layer.id, { ...toSlotBody(slot), weekday })
|
||||
},
|
||||
onSuccess: () => {
|
||||
setCopySource(null)
|
||||
onChanged()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
// Шаблон мог не загрузиться — раньше вкладка сетки просто оказывалась пустой.
|
||||
if (!template)
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{templateError instanceof HttpError
|
||||
? templateError.detail
|
||||
: t('admin.channels.noTemplate')}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={createTemplateMutation.isPending}
|
||||
onClick={() => createTemplateMutation.mutate()}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> {t('admin.channels.createTemplate')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.createTemplateHint')}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
const layerForNewSlot =
|
||||
activeLayerId ?? template.layers.find((l) => !l.isBackground)?.id ?? template.layers[0]?.id
|
||||
|
||||
const openNewSlot = (weekday: number, startMinutes: number) => {
|
||||
if (!layerForNewSlot) return
|
||||
const hh = Math.floor(startMinutes / 60)
|
||||
.toString()
|
||||
.padStart(2, '0')
|
||||
const mm = (startMinutes % 60).toString().padStart(2, '0')
|
||||
setDraft({
|
||||
layerId: layerForNewSlot,
|
||||
slot: null,
|
||||
defaults: { weekday, targetStart: `${hh}:${mm}:00`, title: t('admin.channels.newSlot') },
|
||||
})
|
||||
}
|
||||
|
||||
const openSlot = (slot: SlotDto) => setDraft({ layerId: slot.layerId, slot })
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 lg:grid-cols-[220px_1fr]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('admin.channels.layers')}
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => addLayerMutation.mutate()}
|
||||
disabled={addLayerMutation.isPending}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<LayerList
|
||||
template={template}
|
||||
activeLayerId={layerForNewSlot ?? null}
|
||||
viewDate={viewDate || null}
|
||||
onSelect={(layer) => setActiveLayerId(layer.id)}
|
||||
onDelete={(layer) => deleteLayerMutation.mutate(layer)}
|
||||
onToggle={(layer) => toggleLayerMutation.mutate(layer)}
|
||||
onReorder={(order) => reorderLayersMutation.mutate(order)}
|
||||
onEditApplicability={setApplicabilityLayer}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
|
||||
|
||||
{/* Копия сетки с другого канала: группы общие, поэтому переносятся только правила. */}
|
||||
<div className="flex flex-col gap-1.5 border-t border-border pt-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('admin.channels.copyTemplate')}
|
||||
</span>
|
||||
<select
|
||||
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
||||
value={copyFromChannel}
|
||||
onChange={(e) => setCopyFromChannel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('admin.channels.pickSourceChannel')}</option>
|
||||
{(channels ?? [])
|
||||
.filter((c) => c.id !== channelId)
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!copyFromChannel || copyTemplateMutation.isPending}
|
||||
onClick={() => {
|
||||
// Замена своей сетки — необратимая правка, поэтому спрашиваем перед ней.
|
||||
if (!window.confirm(t('admin.channels.copyTemplateConfirm'))) return
|
||||
copyTemplateMutation.mutate(copyFromChannel)
|
||||
}}
|
||||
>
|
||||
{t('admin.channels.copyHere')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.copyTemplateHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<TemplateIssues
|
||||
channelId={channelId}
|
||||
slotsById={
|
||||
new Map(template.layers.flatMap((l) => l.slots).map((slot) => [slot.id, slot]))
|
||||
}
|
||||
onGoToSlot={openSlot}
|
||||
/>
|
||||
<TemplatePreview channelId={channelId} />
|
||||
|
||||
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 w-40"
|
||||
value={viewDate}
|
||||
onChange={(e) => setViewDate(e.target.value)}
|
||||
/>
|
||||
{viewDate && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setViewDate('')}>
|
||||
{t('admin.channels.allDates')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Копирование дня: сначала выбирается источник, потом дни-приёмники. */}
|
||||
{copySource !== null && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
|
||||
<span>
|
||||
{t('admin.channels.copyDayFrom', {
|
||||
day: t(`admin.channels.weekdays.${copySource}`),
|
||||
})}
|
||||
</span>
|
||||
{[1, 2, 3, 4, 5, 6, 0]
|
||||
.filter((day) => day !== copySource)
|
||||
.map((day) => (
|
||||
<label key={day} className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={copyTargets.includes(day)}
|
||||
onChange={(e) =>
|
||||
setCopyTargets((current) =>
|
||||
e.target.checked
|
||||
? [...current, day]
|
||||
: current.filter((d) => d !== day),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{t(`admin.channels.weekdays.${day}`)}
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={copyTargets.length === 0 || copyDayMutation.isPending}
|
||||
onClick={() => copyDayMutation.mutate({ from: copySource, to: copyTargets })}
|
||||
>
|
||||
{t('admin.channels.copy')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setCopySource(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScheduleGrid
|
||||
template={template}
|
||||
selectedSlotId={draft?.slot?.id ?? null}
|
||||
viewDate={viewDate || null}
|
||||
onSelectSlot={openSlot}
|
||||
onAddSlot={openNewSlot}
|
||||
onMoveSlot={(slot, weekday, startMinutes) =>
|
||||
moveSlotMutation.mutate({ slot, weekday, startMinutes })
|
||||
}
|
||||
onResizeSlot={(slot, minutes) => resizeSlotMutation.mutate({ slot, minutes })}
|
||||
onCopyDay={(weekday) => {
|
||||
setCopySource(weekday)
|
||||
setCopyTargets([])
|
||||
}}
|
||||
/>
|
||||
{draft && (
|
||||
<SlotInspector
|
||||
channelId={channelId}
|
||||
draft={draft}
|
||||
onClose={() => setDraft(null)}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{applicabilityLayer && (
|
||||
<LayerApplicabilityDialog
|
||||
layer={applicabilityLayer}
|
||||
onClose={() => setApplicabilityLayer(null)}
|
||||
onChanged={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
JunctionElementDto,
|
||||
JunctionElementKind,
|
||||
} from '@/shared/api/types'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -53,7 +54,7 @@ export function JunctionElementDialog({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [body, setBody] = useState<JunctionElementBody>(() => toBody(element))
|
||||
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
|
||||
|
||||
const patch = (part: Partial<JunctionElementBody>) => setBody((prev) => ({ ...prev, ...part }))
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
JunctionTemplateDto,
|
||||
ScheduleTemplateDto,
|
||||
} from '@/shared/api/types'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
@@ -79,10 +80,10 @@ export function JunctionsCard({
|
||||
const [newName, setNewName] = useState('')
|
||||
|
||||
const { data: junctions } = useQuery({
|
||||
queryKey: ['admin', 'channels', channel.id, 'junctions'],
|
||||
queryKey: qk.channels.junctions(channel.id),
|
||||
queryFn: () => listJunctions(channel.id),
|
||||
})
|
||||
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createJunction(channel.id, newName.trim()),
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listGroups } from '@/features/admin/groups/api'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
Daypart,
|
||||
OverflowPolicy,
|
||||
@@ -12,10 +11,11 @@ import type {
|
||||
SlotKind,
|
||||
SlotStrategyType,
|
||||
} from '@/shared/api/types'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import {
|
||||
createSlot,
|
||||
deleteSlot,
|
||||
@@ -83,14 +83,13 @@ export function SlotInspector({
|
||||
setBody(draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults))
|
||||
}, [draft])
|
||||
|
||||
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
|
||||
const { data: junctions } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'junctions'],
|
||||
queryKey: qk.channels.junctions(channelId),
|
||||
queryFn: () => listJunctions(channelId),
|
||||
})
|
||||
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
const onError = useApiError()
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangle, CircleAlert } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import type { SlotDto, TemplateIssueDto } from '@/shared/api/types'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { getTemplateIssues } from '../api'
|
||||
@@ -20,7 +21,7 @@ export function TemplateIssues({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { data: issues } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'issues'],
|
||||
queryKey: qk.channels.issues(channelId),
|
||||
queryFn: () => getTemplateIssues(channelId),
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { Eye } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
@@ -33,7 +34,7 @@ export function TemplatePreview({ channelId }: { channelId: string }) {
|
||||
const [tab, setTab] = useState<'programme' | 'tape' | 'problems'>('programme')
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'preview', days],
|
||||
queryKey: qk.channels.preview(channelId, days),
|
||||
queryFn: () => previewTemplate(channelId, days),
|
||||
enabled: open,
|
||||
// Черновик правил может меняться между открытиями — кэшировать прогон смысла нет.
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
|
||||
export function NumberField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (v: number) => void
|
||||
min?: number
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={min}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RemoveButton({ onClick }: { onClick: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button size="sm" variant="destructive" onClick={onClick}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user