From 46c0169dea9f594fe3c59cc4187b8855b1bcb74e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 29 Jul 2026 08:15:45 +0300 Subject: [PATCH] Enhance channel management with apply report feature 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. --- docs/tv-scheduler-architecture.md | 4 ++ .../features/admin/channels/ChannelDetail.tsx | 17 +++-- .../channels/components/ApplyReportDialog.tsx | 59 +++++++++++++++ .../channels/components/PlanningWarnings.tsx | 51 +++++++++++++ .../channels/components/TemplatePreview.tsx | 42 +---------- .../admin/storage/StoragePanelSkeleton.tsx | 72 +++++++++++++------ frontend/src/shared/api/types.ts | 2 +- frontend/src/shared/lib/locales/en.ts | 3 + frontend/src/shared/lib/locales/ru.ts | 3 + 9 files changed, 184 insertions(+), 69 deletions(-) create mode 100644 frontend/src/features/admin/channels/components/ApplyReportDialog.tsx create mode 100644 frontend/src/features/admin/channels/components/PlanningWarnings.tsx diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index cf13707..7bfd268 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -961,6 +961,10 @@ seed = hash(channelId, date, slotId, occurrenceInDay) Изменения в ближайшие сутки подсвечиваются отдельно — самая частая причина случайного ущерба. +После применения предупреждения планировщика показываются **отчётом в окне**, сгруппированные по +виду, — так же, как на вкладке «Проблемы» предпросмотра. Уведомлением на каждое предупреждение это +не показать: на неделе эфира их набираются десятки, и они успевают только перекрыть экран. + ### 6.7. Ролики и рекламные блоки Отдельный раздел админки, хотя под капотом это те же `Show(Kind = Interstitial)` и `Collection`. diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index 1f3ad52..fd6a64a 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -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(null) const [traceEntryId, setTraceEntryId] = useState(null) const [tab, setTab] = useState('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 && ( + setApplyReport(null)} /> + )} + {traceEntryId && ( void }>) { + const { t } = useTranslation() + + return ( + !open && onClose()}> + + + {t('admin.channels.applyReport')} + + {t('admin.channels.applied', { count: result.added })} + + + +
+ {result.warnings.length > 0 && ( +

+ + {t('admin.channels.applyReportWarnings', { count: result.warnings.length })} +

+ )} + + {/* Список длинный по природе — скроллим его, а не всё окно: шапка и кнопка должны + оставаться на месте. */} +
+ +
+
+ + + + +
+
+ ) +} diff --git a/frontend/src/features/admin/channels/components/PlanningWarnings.tsx b/frontend/src/features/admin/channels/components/PlanningWarnings.tsx new file mode 100644 index 0000000..39efc4d --- /dev/null +++ b/frontend/src/features/admin/channels/components/PlanningWarnings.tsx @@ -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() + 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

{t('admin.channels.noProblems')}

+ + return ( +
    + {grouped.map(([kind, details]) => ( +
  • + + {t(`admin.channels.warnings.${kind}`)} · {details.length} + + {details.slice(0, DETAILS_LIMIT).map((detail) => ( + + {detail.text} + + ))} + {details.length > DETAILS_LIMIT && ( + + {t('admin.channels.andMore', { count: details.length - DETAILS_LIMIT })} + + )} +
  • + ))} +
+ ) +} diff --git a/frontend/src/features/admin/channels/components/TemplatePreview.tsx b/frontend/src/features/admin/channels/components/TemplatePreview.tsx index 8686cc6..d392936 100644 --- a/frontend/src/features/admin/channels/components/TemplatePreview.tsx +++ b/frontend/src/features/admin/channels/components/TemplatePreview.tsx @@ -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 = { 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() - 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 (
- {grouped.length === 0 ? ( -

{t('admin.channels.noProblems')}

- ) : ( -
    - {grouped.map(([kind, details]) => ( -
  • - - {t(`admin.channels.warnings.${kind}`)} · {details.length} - - {details.slice(0, 20).map((detail) => ( - - {detail.text} - - ))} - {details.length > 20 && ( - - {t('admin.channels.andMore', { count: details.length - 20 })} - - )} -
  • - ))} -
- )} - +
) diff --git a/frontend/src/features/admin/storage/StoragePanelSkeleton.tsx b/frontend/src/features/admin/storage/StoragePanelSkeleton.tsx index 6193aed..f7dc811 100644 --- a/frontend/src/features/admin/storage/StoragePanelSkeleton.tsx +++ b/frontend/src/features/admin/storage/StoragePanelSkeleton.tsx @@ -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 ( + + + + ) +} /** * Скелетон панели хранилища: подсчёт размеров идёт по диску и занимает секунды. Повторяет вёрстку @@ -17,24 +37,25 @@ export function StoragePanelSkeleton() {
-
- - +
+ +
- - + + {/* Кнопка «Пересчитать» — размер sm, иначе шапка подпрыгнет на загрузке. */} +
- {Array.from({ length: 4 }, (_, i) => ( -
- - - + {['w-28', 'w-32', 'w-32', 'w-24'].map((label, i) => ( +
+ + +
))}
@@ -43,24 +64,29 @@ export function StoragePanelSkeleton() { -
- - + {/* У реального заголовка выравнивание по базовой линии; у полос базовой линии нет, + поэтому центрируем — разница в высоте строк тут меньше пяти пикселей. */} +
+ +
- {Array.from({ length: AREA_ROWS }, (_, i) => ( -
+ {AREA_ROWS.map((name, i) => ( +
-
- - +
+ +
- - - + + +
))}
diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 493d8bc..c92ee2b 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -730,7 +730,7 @@ type PlanningWarningKind = | 'GenreShareExceeded' | 'FallbackShareExceeded' -type PlanningWarningDto = { +export type PlanningWarningDto = { kind: PlanningWarningKind slotId: string | null details: string diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index 19e6400..3b53213 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -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', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index bae4ae2..65d245a 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -710,6 +710,9 @@ export const ru = { 'Ссылок не восстановлено: {{count}} — группа или стык были удалены после применения.', apply: 'Применить', applied: 'Эфир пересобран, записей: {{count}}', + applyReport: 'Отчёт о применении', + applyReportWarnings: + 'Планировщик выполнил не все правила — предупреждений: {{count}}. Эфир собран, но эти места стоит посмотреть.', weekdays: { 0: 'Вс', 1: 'Пн',