Enhance template and scheduling functionalities: add PlanningRules to template endpoints and commands, implement repeat limits and audience filtering in scheduling logic, and update related data structures. Refactor frontend components to support new rules and improve user experience in channel management.
This commit is contained in:
@@ -8,29 +8,42 @@ 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 { Input } from '@/shared/ui/input'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import {
|
||||
applyChannelTemplate,
|
||||
createLayer,
|
||||
createSlot,
|
||||
deleteLayer,
|
||||
getChannel,
|
||||
getChannelTemplate,
|
||||
getSchedule,
|
||||
toSlotBody,
|
||||
updateLayer,
|
||||
updateSlot,
|
||||
} from './api'
|
||||
import { BumperCard } from './components/BumperCard'
|
||||
import { CollapsibleCard } from './components/CollapsibleCard'
|
||||
import { JunctionsCard } from './components/JunctionsCard'
|
||||
import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog'
|
||||
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
|
||||
import { SchedulePreview } from './components/SchedulePreview'
|
||||
import { RulesCard } from './components/RulesCard'
|
||||
import { SettingsCard } from './components/SettingsCard'
|
||||
import { TemplatePreview } from './components/TemplatePreview'
|
||||
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
|
||||
import { toTime } from './lib/format'
|
||||
|
||||
export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [draft, setDraft] = useState<SlotDraft | null>(null)
|
||||
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
|
||||
const [viewDate, setViewDate] = useState<string>('')
|
||||
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
|
||||
// День, который копируем, и отмеченные дни-приёмники.
|
||||
const [copySource, setCopySource] = useState<number | null>(null)
|
||||
const [copyTargets, setCopyTargets] = useState<number[]>([])
|
||||
|
||||
const { data: channel, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId],
|
||||
@@ -85,6 +98,82 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
onError,
|
||||
})
|
||||
|
||||
const toggleLayerMutation = useMutation({
|
||||
mutationFn: (layer: GridLayerDto) =>
|
||||
updateLayer(layer.id, {
|
||||
name: layer.name,
|
||||
priority: layer.priority,
|
||||
applicability: layer.applicability,
|
||||
isEnabled: !layer.isEnabled,
|
||||
}),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
/**
|
||||
* Порядок слоёв задаётся перетаскиванием, а хранится приоритетом. Раздаём приоритеты с шагом 10
|
||||
* снизу вверх: шаг оставляет место, чтобы следующая вставка не переписывала весь список.
|
||||
*/
|
||||
const reorderLayersMutation = useMutation({
|
||||
mutationFn: async (layerIdsTopFirst: string[]) => {
|
||||
const byId = new Map(template!.layers.map((l) => [l.id, l]))
|
||||
const total = layerIdsTopFirst.length
|
||||
await Promise.all(
|
||||
layerIdsTopFirst.map((id, index) => {
|
||||
const layer = byId.get(id)
|
||||
const priority = (total - index) * 10
|
||||
if (!layer || layer.priority === priority) return Promise.resolve()
|
||||
return updateLayer(id, {
|
||||
name: layer.name,
|
||||
priority,
|
||||
applicability: layer.applicability,
|
||||
isEnabled: layer.isEnabled,
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
const moveSlotMutation = useMutation({
|
||||
mutationFn: ({
|
||||
slot,
|
||||
weekday,
|
||||
startMinutes,
|
||||
}: {
|
||||
slot: SlotDto
|
||||
weekday: number
|
||||
startMinutes: number
|
||||
}) => updateSlot(slot.id, { ...toSlotBody(slot), weekday, targetStart: toTime(startMinutes) }),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
const resizeSlotMutation = useMutation({
|
||||
mutationFn: ({ slot, minutes }: { slot: SlotDto; minutes: number }) =>
|
||||
updateSlot(slot.id, { ...toSlotBody(slot), targetDurationMinutes: minutes }),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
/** Копирование дня: слоты «каждый день» не копируются — они и так есть во всех колонках. */
|
||||
const copyDayMutation = useMutation({
|
||||
mutationFn: async ({ from, to }: { from: number; to: number[] }) => {
|
||||
const sources = (template?.layers ?? []).flatMap((layer) =>
|
||||
layer.slots.filter((slot) => slot.weekday === from).map((slot) => ({ layer, slot })),
|
||||
)
|
||||
for (const weekday of to)
|
||||
for (const { layer, slot } of sources)
|
||||
await createSlot(layer.id, { ...toSlotBody(slot), weekday })
|
||||
},
|
||||
onSuccess: () => {
|
||||
setCopySource(null)
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
|
||||
const layerForNewSlot =
|
||||
@@ -159,19 +248,90 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
<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>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<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
|
||||
@@ -186,6 +346,10 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
</CollapsibleCard>
|
||||
)}
|
||||
|
||||
{template && (
|
||||
<RulesCard template={template} onChanged={invalidate} onError={onError} />
|
||||
)}
|
||||
|
||||
<JunctionsCard
|
||||
channel={channel}
|
||||
template={template}
|
||||
@@ -195,6 +359,15 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
|
||||
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||
|
||||
{applicabilityLayer && (
|
||||
<LayerApplicabilityDialog
|
||||
layer={applicabilityLayer}
|
||||
onClose={() => setApplicabilityLayer(null)}
|
||||
onChanged={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SchedulePreview entries={schedule ?? []} />
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user