Add restore functionality for template management and enhance related components
ci / build-backend (push) Successful in 2m47s
ci / build-frontend (push) Successful in 1m8s
ci / tests (push) Successful in 2m28s
ci / sonar (push) Successful in 6m49s

Implemented a new endpoint for restoring templates to their last applied state, allowing users to revert changes made since the last application. Updated the ScheduleTemplate class to include a snapshot of the last applied state, enabling rollback capabilities. Enhanced the frontend with a restore button in the ChannelDetail component, providing users with a confirmation prompt before executing the restore action. Localization updates were made to support new UI strings in both English and Russian, improving the overall user experience in template management.
This commit is contained in:
Leonid Pershin
2026-07-27 08:43:53 +03:00
parent a302750de6
commit 0fd4e762f4
21 changed files with 2227 additions and 133 deletions
@@ -120,37 +120,35 @@ export function SlotInspector({
// Отдельным окном, а не панелью под сеткой: форма длинная, и раньше она уезжала за нижний край,
// а сетка — то, с чем её сверяют, — уходила из виду.
<Dialog open onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-w-2xl">
<DialogContent className="flex max-h-[90vh] w-[calc(100%-2rem)] max-w-4xl flex-col">
<DialogHeader>
<DialogTitle>
{draft.slot ? t('admin.channels.editSlot') : t('admin.channels.newSlot')}
</DialogTitle>
</DialogHeader>
<div className="flex max-h-[65vh] flex-col gap-3 overflow-y-auto pr-1 text-sm">
<div className="flex flex-col gap-1.5">
<div className="grid min-h-0 flex-1 gap-3 overflow-y-auto pr-1 text-sm sm:grid-cols-2">
<div className="flex flex-col gap-1.5 sm:col-span-2">
<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 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 className="flex flex-col gap-1.5">
@@ -171,49 +169,47 @@ export function SlotInspector({
</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 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>
{isContent && (
@@ -280,33 +276,31 @@ export function SlotInspector({
</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 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 className="flex flex-col gap-1.5">
<Label>{t('admin.channels.overflow')}</Label>
@@ -327,7 +321,7 @@ export function SlotInspector({
)}
{body.slotKind === 'Repeat' && body.repeatSource && (
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-3 gap-2 sm:col-span-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatDaysAgo')}</Label>
<Input
@@ -370,7 +364,7 @@ export function SlotInspector({
</div>
)}
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-wrap items-end gap-3 sm:col-span-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
@@ -408,40 +402,40 @@ export function SlotInspector({
</select>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('admin.channels.anchorHint')}</p>
<p className="text-xs text-muted-foreground sm:col-span-2">
{t('admin.channels.anchorHint')}
</p>
{/* Стыки: внутри слота — между единицами, после слота — на переходе к следующему. */}
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.junctionBetween')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.junctionBetweenId ?? ''}
onChange={(e) => patch({ junctionBetweenId: e.target.value || null })}
>
<option value="">{t('admin.channels.junctionDefault')}</option>
{(junctions ?? []).map((junction) => (
<option key={junction.id} value={junction.id}>
{junction.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.junctionAfter')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.junctionAfterId ?? ''}
onChange={(e) => patch({ junctionAfterId: e.target.value || null })}
>
<option value="">{t('admin.channels.junctionDefault')}</option>
{(junctions ?? []).map((junction) => (
<option key={junction.id} value={junction.id}>
{junction.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.junctionBetween')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.junctionBetweenId ?? ''}
onChange={(e) => patch({ junctionBetweenId: e.target.value || null })}
>
<option value="">{t('admin.channels.junctionDefault')}</option>
{(junctions ?? []).map((junction) => (
<option key={junction.id} value={junction.id}>
{junction.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.junctionAfter')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.junctionAfterId ?? ''}
onChange={(e) => patch({ junctionAfterId: e.target.value || null })}
>
<option value="">{t('admin.channels.junctionDefault')}</option>
{(junctions ?? []).map((junction) => (
<option key={junction.id} value={junction.id}>
{junction.name}
</option>
))}
</select>
</div>
</div>