Enhance channel management with apply report feature
ci / build-backend (push) Successful in 1m37s
ci / build-frontend (push) Successful in 57s
ci / tests (push) Successful in 1m59s
ci / sonar (push) Successful in 4m37s

Added functionality to display a consolidated apply report for warnings after applying channel templates, improving user experience by reducing clutter from individual warning notifications. Updated the ChannelDetail component to manage the new report state and integrated the ApplyReportDialog for displaying the report. Refactored the Problems component to utilize a new PlanningWarnings component for better organization of warning messages. Localization strings were updated to include new report-related texts in both English and Russian.
This commit is contained in:
Leonid Pershin
2026-07-29 08:15:45 +03:00
parent 3601404383
commit 46c0169dea
9 changed files with 184 additions and 69 deletions
+4
View File
@@ -961,6 +961,10 @@ seed = hash(channelId, date, slotId, occurrenceInDay)
Изменения в ближайшие сутки подсвечиваются отдельно — самая частая причина случайного ущерба.
После применения предупреждения планировщика показываются **отчётом в окне**, сгруппированные по
виду, — так же, как на вкладке «Проблемы» предпросмотра. Уведомлением на каждое предупреждение это
не показать: на неделе эфира их набираются десятки, и они успевают только перекрыть экран.
### 6.7. Ролики и рекламные блоки
Отдельный раздел админки, хотя под капотом это те же `Show(Kind = Interstitial)` и `Collection`.
@@ -5,6 +5,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listAllMedia } from '@/features/admin/media/api'
import { qk } from '@/shared/api/query-keys'
import type { ApplyResultDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
@@ -14,6 +15,7 @@ import { toast } from '@/shared/ui/toast-store'
import { applyChannelTemplate, getChannel, getChannelTemplate, restoreChannelTemplate } from './api'
import { AirSchedule } from './components/AirSchedule'
import { ApplyDialog } from './components/ApplyDialog'
import { ApplyReportDialog } from './components/ApplyReportDialog'
import { EntryTraceDialog } from './components/EntryTraceDialog'
import { GridTab } from './components/GridTab'
import { JunctionsCard } from './components/JunctionsCard'
@@ -29,6 +31,7 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [applyOpen, setApplyOpen] = useState(false)
const [applyReport, setApplyReport] = useState<ApplyResultDto | null>(null)
const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
const [tab, setTab] = useState<ChannelTab>('settings')
@@ -56,12 +59,10 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
mutationFn: () => applyChannelTemplate(channelId),
onSuccess: (result) => {
setApplyOpen(false)
toast.success(t('admin.channels.applied', { count: result.added }))
// Предупреждения показываем по одному: каждое указывает на конкретный слот.
for (const warning of result.warnings) {
const kind = t(`admin.channels.warnings.${warning.kind}`)
toast.error(`${kind}: ${warning.details}`)
}
// Предупреждений бывают десятки — тостом на каждое экран превращается в стену. Есть
// предупреждения — открываем отчёт, нет — хватает одной строки об успехе.
if (result.warnings.length > 0) setApplyReport(result)
else toast.success(t('admin.channels.applied', { count: result.added }))
invalidate()
},
onError,
@@ -202,6 +203,10 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
/>
)}
{applyReport && (
<ApplyReportDialog result={applyReport} onClose={() => setApplyReport(null)} />
)}
{traceEntryId && (
<EntryTraceDialog
entryId={traceEntryId}
@@ -0,0 +1,59 @@
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { ApplyResultDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { PlanningWarnings } from './PlanningWarnings'
/**
* Отчёт о применении. Планировщик выдаёт предупреждение на каждый проблемный слот, и за неделю
* эфира их набираются десятки — тостами это нечитаемо: они перекрывают экран и уезжают раньше,
* чем их успеваешь прочесть. Здесь то же самое лежит в окне, сгруппированное по виду.
*/
export function ApplyReportDialog({
result,
onClose,
}: Readonly<{ result: ApplyResultDto; onClose: () => void }>) {
const { t } = useTranslation()
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('admin.channels.applyReport')}</DialogTitle>
<DialogDescription>
{t('admin.channels.applied', { count: result.added })}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
{result.warnings.length > 0 && (
<p className="flex items-center gap-1.5 text-sm text-amber-500">
<AlertTriangle className="h-4 w-4 shrink-0" />
{t('admin.channels.applyReportWarnings', { count: result.warnings.length })}
</p>
)}
{/* Список длинный по природе — скроллим его, а не всё окно: шапка и кнопка должны
оставаться на месте. */}
<div className="max-h-96 overflow-y-auto pr-1">
<PlanningWarnings warnings={result.warnings} />
</div>
</div>
<DialogFooter>
<Button size="sm" onClick={onClose}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,51 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import type { PlanningWarningDto } from '@/shared/api/types'
/** Сколько строк одного вида показываем целиком: дальше это уже не чтение, а простыня. */
const DETAILS_LIMIT = 20
/**
* Предупреждения планировщика, сгруппированные по виду: десять однотипных строк — это одна
* проблема, и читать их надо вместе, а не по одной.
*/
export function PlanningWarnings({ warnings }: Readonly<{ warnings: PlanningWarningDto[] }>) {
const { t } = useTranslation()
// Ключ строки — «вид + порядковый номер внутри вида»: у предупреждения нет своего id, а текст
// повторяется (одна и та же причина на разных слотах), и позиция здесь — единственное, что его
// отличает. Считаем ключ при группировке, чтобы список не пересобирался на каждый рендер.
const grouped = useMemo(() => {
const map = new Map<string, { key: string; text: string }[]>()
for (const warning of warnings) {
const list = map.get(warning.kind) ?? []
list.push({ key: `${warning.kind}#${list.length}`, text: warning.details })
map.set(warning.kind, list)
}
return [...map.entries()]
}, [warnings])
if (grouped.length === 0)
return <p className="text-sm text-muted-foreground">{t('admin.channels.noProblems')}</p>
return (
<ul className="flex flex-col gap-2 text-xs">
{grouped.map(([kind, details]) => (
<li key={kind} className="flex flex-col gap-0.5">
<span className="font-medium text-amber-500">
{t(`admin.channels.warnings.${kind}`)} · {details.length}
</span>
{details.slice(0, DETAILS_LIMIT).map((detail) => (
<span key={detail.key} className="text-muted-foreground">
{detail.text}
</span>
))}
{details.length > DETAILS_LIMIT && (
<span className="text-muted-foreground">
{t('admin.channels.andMore', { count: details.length - DETAILS_LIMIT })}
</span>
)}
</li>
))}
</ul>
)
}
@@ -10,6 +10,7 @@ import { cn } from '@/shared/lib/cn'
import { previewTemplate } from '../api'
import { channelTime, formatChannelTime } from '../lib/format'
import { toIsoDate } from '../lib/applicability'
import { PlanningWarnings } from './PlanningWarnings'
const KIND_COLORS: Record<PlannedItemKind, string> = {
Program: 'bg-primary/70',
@@ -206,48 +207,11 @@ function TapeRow({
)
}
/** Предупреждения, сгруппированные по виду: десять однотипных строк читаются как одна проблема. */
/** Проблемы прогона: те же предупреждения, что и в отчёте о применении, плюс карта повторов. */
function Problems({ preview }: Readonly<{ preview: SchedulePreviewDto }>) {
const { t } = useTranslation()
// Ключ строки — «вид + порядковый номер внутри вида»: у предупреждения нет своего id, а текст
// повторяется (одна и та же причина на разных слотах), и позиция здесь — единственное, что его
// отличает. Считаем ключ при группировке, чтобы список не пересобирался на каждый рендер.
const grouped = useMemo(() => {
const map = new Map<string, { key: string; text: string }[]>()
for (const warning of preview.warnings) {
const list = map.get(warning.kind) ?? []
list.push({ key: `${warning.kind}#${list.length}`, text: warning.details })
map.set(warning.kind, list)
}
return [...map.entries()]
}, [preview])
return (
<div className="flex flex-col gap-4">
{grouped.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.channels.noProblems')}</p>
) : (
<ul className="flex flex-col gap-2 text-xs">
{grouped.map(([kind, details]) => (
<li key={kind} className="flex flex-col gap-0.5">
<span className="font-medium text-amber-500">
{t(`admin.channels.warnings.${kind}`)} · {details.length}
</span>
{details.slice(0, 20).map((detail) => (
<span key={detail.key} className="text-muted-foreground">
{detail.text}
</span>
))}
{details.length > 20 && (
<span className="text-muted-foreground">
{t('admin.channels.andMore', { count: details.length - 20 })}
</span>
)}
</li>
))}
</ul>
)}
<PlanningWarnings warnings={preview.warnings} />
<RepeatHeatmap preview={preview} />
</div>
)
@@ -1,9 +1,29 @@
import { useTranslation } from 'react-i18next'
import { cn } from '@/shared/lib/cn'
import { Card, CardContent } from '@/shared/ui/card'
import { Skeleton } from '@/shared/ui/skeleton'
/** Строк в составе хранилища заранее не знаем — берём типичное число, чтобы не пустовала карточка. */
const AREA_ROWS = 5
/**
* Сколько областей вернёт сервер, заранее не знаем — берём типичные пять. Ширины разные: названия
* областей тоже разной длины, и одинаковые полосы выглядят как сломанная таблица.
*/
const AREA_ROWS = ['w-40', 'w-40', 'w-28', 'w-24', 'w-36']
/** Подсказки областей разной длины — одинаковые полосы выдают заглушку сильнее, чем нужно. */
const AREA_HINTS = ['w-72', 'w-80', 'w-64', 'w-56', 'w-72']
/**
* Строка-заглушка ростом ровно в одну строку текста (`1lh`), полоса внутри — доля кегля. Так
* скелетон занимает ту же высоту, что и будущий текст, и заодно получает то же внутреннее поле
* над и под, а не упирается в край карточки.
*/
function TextLine({ className, width }: Readonly<{ className?: string; width: string }>) {
return (
<span className={cn('flex h-[1lh] items-center', className)}>
<Skeleton className={cn('h-[0.7em]', width)} />
</span>
)
}
/**
* Скелетон панели хранилища: подсчёт размеров идёт по диску и занимает секунды. Повторяет вёрстку
@@ -17,24 +37,25 @@ export function StoragePanelSkeleton() {
<Card>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-56" />
<div className="flex flex-col">
<TextLine className="text-sm" width="w-32" />
<TextLine className="text-xs" width="w-44" />
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-8 w-28 rounded-md" />
<TextLine className="text-xs" width="w-36" />
{/* Кнопка «Пересчитать» — размер sm, иначе шапка подпрыгнет на загрузке. */}
<Skeleton className="h-9 w-40" />
</div>
</div>
<Skeleton className="h-4 w-full rounded-full" />
<div className="grid gap-3 text-sm sm:grid-cols-4">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="flex flex-col gap-1.5">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-5 w-24" />
<Skeleton className="h-3 w-12" />
{['w-28', 'w-32', 'w-32', 'w-24'].map((label, i) => (
<div key={label + i} className="flex flex-col">
<TextLine className="text-xs" width={label} />
<TextLine className="text-lg" width="w-24" />
<TextLine className="text-xs" width={i === 2 ? 'w-40' : 'w-12'} />
</div>
))}
</div>
@@ -43,24 +64,29 @@ export function StoragePanelSkeleton() {
<Card>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-3 w-40" />
{/* У реального заголовка выравнивание по базовой линии; у полос базовой линии нет,
поэтому центрируем — разница в высоте строк тут меньше пяти пикселей. */}
<div className="flex flex-wrap items-center justify-between gap-2">
<TextLine className="text-sm" width="w-48" />
<TextLine className="text-xs" width="w-48" />
</div>
<Skeleton className="h-3 w-full rounded-full" />
<div className="flex flex-col divide-y divide-border">
{Array.from({ length: AREA_ROWS }, (_, i) => (
<div key={i} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2">
{AREA_ROWS.map((name, i) => (
<div
key={name + i}
className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 text-sm"
>
<Skeleton className="h-3 w-3 shrink-0" />
<div className="flex min-w-48 flex-col gap-1.5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-44" />
<div className="flex min-w-48 flex-col">
<TextLine className="text-sm" width={name} />
<TextLine className="text-xs" width={AREA_HINTS[i]} />
</div>
<Skeleton className="ml-auto h-4 w-20" />
<Skeleton className="h-3 w-14" />
<Skeleton className="h-3 w-24" />
<TextLine className="ml-auto text-sm" width="w-20" />
<TextLine className="w-14 justify-end text-xs" width="w-10" />
<TextLine className="w-24 justify-end text-xs" width="w-20" />
</div>
))}
</div>
+1 -1
View File
@@ -730,7 +730,7 @@ type PlanningWarningKind =
| 'GenreShareExceeded'
| 'FallbackShareExceeded'
type PlanningWarningDto = {
export type PlanningWarningDto = {
kind: PlanningWarningKind
slotId: string | null
details: string
+3
View File
@@ -715,6 +715,9 @@ export const en = {
'References not restored: {{count}} — the group or junction was deleted after the apply.',
apply: 'Apply',
applied: 'Air rebuilt, entries: {{count}}',
applyReport: 'Apply report',
applyReportWarnings:
'The planner could not honour every rule — {{count}} warnings. The air is built, but these spots are worth a look.',
weekdays: {
0: 'Sun',
1: 'Mon',
+3
View File
@@ -710,6 +710,9 @@ export const ru = {
'Ссылок не восстановлено: {{count}} — группа или стык были удалены после применения.',
apply: 'Применить',
applied: 'Эфир пересобран, записей: {{count}}',
applyReport: 'Отчёт о применении',
applyReportWarnings:
'Планировщик выполнил не все правила — предупреждений: {{count}}. Эфир собран, но эти места стоит посмотреть.',
weekdays: {
0: 'Вс',
1: 'Пн',