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: 'Пн',