Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
@@ -0,0 +1,13 @@
import { useTranslation } from 'react-i18next'
import { splitDuration } from './format'
/** Объём эфира: «118 ч 40 мин». Ноль — прочерк, потому что «0 ч» читается как сбой подсчёта. */
export function DurationLabel({ seconds }: { seconds: number }) {
const { t } = useTranslation()
const parts = splitDuration(seconds)
if (!parts) return <></>
const hours = parts.hours > 0 ? `${parts.hours} ${t('admin.groups.hoursShort')}` : ''
const minutes = parts.minutes > 0 ? `${parts.minutes} ${t('admin.groups.minutesShort')}` : ''
return <>{[hours, minutes].filter(Boolean).join(' ')}</>
}
@@ -0,0 +1,273 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { ChevronLeft, GripVertical, Search, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import type { GroupCandidateDto, GroupFilter } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { toast } from '@/shared/ui/toast-store'
import {
addGroupElements,
findGroupCandidates,
getGroup,
removeGroupItem,
reorderGroup,
setGroupItemWeight,
updateGroup,
} from './api'
import { DurationLabel } from './DurationLabel'
import { GroupFilterPanel } from './GroupFilterPanel'
const EMPTY_FILTER: GroupFilter = {}
export function GroupDetail({ groupId }: { groupId: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [name, setName] = useState<string | null>(null)
const [description, setDescription] = useState<string | null>(null)
const [filter, setFilter] = useState<GroupFilter | null>(null)
const [candidates, setCandidates] = useState<GroupCandidateDto[] | null>(null)
const [showWeights, setShowWeights] = useState(false)
const [dragged, setDragged] = useState<string | null>(null)
const { data: group, isLoading } = useQuery({
queryKey: ['admin', 'groups', groupId],
queryFn: () => getGroup(groupId),
})
// Черновик правила поднимаем из сохранённого один раз — дальше им владеет форма.
useEffect(() => {
if (group && filter === null) setFilter(group.filter ?? EMPTY_FILTER)
}, [group, filter])
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] })
}
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const saveMutation = useMutation({
mutationFn: () =>
updateGroup(groupId, {
name: (name ?? group?.name ?? '').trim(),
description: description ?? group?.description ?? null,
filter: filter ?? null,
}),
onSuccess: invalidate,
onError,
})
const findMutation = useMutation({
mutationFn: () => findGroupCandidates(groupId, filter ?? EMPTY_FILTER),
onSuccess: setCandidates,
onError,
})
const addMutation = useMutation({
mutationFn: (elements: GroupCandidateDto[]) =>
addGroupElements(
groupId,
elements.map((c) => ({ elementKind: c.elementKind, elementId: c.elementId })),
),
onSuccess: (result) => {
toast.success(t('admin.groups.added', { count: result.added }))
setCandidates(null)
invalidate()
},
onError,
})
const removeMutation = useMutation({
mutationFn: (itemId: string) => removeGroupItem(groupId, itemId),
onSuccess: invalidate,
onError,
})
const weightMutation = useMutation({
mutationFn: ({ itemId, weight }: { itemId: string; weight: number }) =>
setGroupItemWeight(groupId, itemId, weight),
onSuccess: invalidate,
onError,
})
const reorderMutation = useMutation({
mutationFn: (order: string[]) => reorderGroup(groupId, order),
onSuccess: invalidate,
onError,
})
if (isLoading || !group) return <p className="text-muted-foreground">{t('common.loading')}</p>
const dropOn = (targetItemId: string) => {
if (!dragged || dragged === targetItemId) return
const order = group.items.map((i) => i.id).filter((id) => id !== dragged)
order.splice(order.indexOf(targetItemId), 0, dragged)
setDragged(null)
reorderMutation.mutate(order)
}
const fresh = (candidates ?? []).filter((c) => !c.alreadyInGroup)
return (
<div className="flex flex-col gap-6">
<div>
<Button asChild size="sm" variant="ghost">
<Link to="/admin/groups">
<ChevronLeft className="h-4 w-4" />
{t('admin.groups.title')}
</Link>
</Button>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.groups.name')}</Label>
<Input
className="w-64"
value={name ?? group.name}
maxLength={256}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<Label>{t('admin.groups.description')}</Label>
<Input
value={description ?? group.description ?? ''}
maxLength={2048}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<Button size="sm" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
{t('common.save')}
</Button>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<Badge variant="muted">
{t('admin.groups.items')}: {group.itemCount}
</Badge>
<Badge variant="muted">
{t('admin.groups.units')}: {group.unitCount}
</Badge>
<Badge variant="muted">
<DurationLabel seconds={group.totalDurationSeconds} />
</Badge>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Левая панель — конструктор правила набора */}
<div className="crt-panel flex flex-col gap-4 rounded-md p-4">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-semibold uppercase tracking-wide">
{t('admin.groups.filter.title')}
</h3>
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.hint')}</p>
</div>
<GroupFilterPanel filter={filter ?? EMPTY_FILTER} onChange={setFilter} />
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" disabled={findMutation.isPending} onClick={() => findMutation.mutate()}>
<Search className="h-4 w-4" /> {t('admin.groups.find')}
</Button>
{candidates !== null && (
<>
<span className="text-sm text-muted-foreground">
{t('admin.groups.found', { total: candidates.length, fresh: fresh.length })}
</span>
<Button
size="sm"
disabled={fresh.length === 0 || addMutation.isPending}
onClick={() => addMutation.mutate(fresh)}
>
{t('admin.groups.addFound')}
</Button>
</>
)}
</div>
{candidates !== null && candidates.length > 0 && (
<ul className="max-h-72 divide-y divide-border overflow-y-auto rounded border border-border text-sm">
{candidates.map((candidate) => (
<li
key={`${candidate.elementKind}:${candidate.elementId}`}
className="flex items-center gap-2 px-3 py-1.5"
>
<span className="min-w-0 flex-1 truncate">{candidate.elementName}</span>
{candidate.year && (
<span className="text-muted-foreground">{candidate.year}</span>
)}
<Badge variant="muted">
{t(`admin.groups.elementKinds.${candidate.elementKind}`)}
</Badge>
{candidate.alreadyInGroup && (
<Badge variant="muted">{t('admin.groups.alreadyIn')}</Badge>
)}
</li>
))}
</ul>
)}
</div>
{/* Правая панель — состав */}
<div className="crt-panel flex flex-col gap-3 rounded-md p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold uppercase tracking-wide">
{t('admin.groups.composition')}
</h3>
<Button size="sm" variant="ghost" onClick={() => setShowWeights((v) => !v)}>
{showWeights ? t('admin.groups.hideAdvanced') : t('admin.groups.showAdvanced')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('admin.groups.orderHint')}</p>
{group.items.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.groups.empty')}</p>
) : (
<ul className="divide-y divide-border text-sm">
{group.items.map((item, index) => (
<li
key={item.id}
draggable
onDragStart={() => setDragged(item.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => dropOn(item.id)}
className="flex items-center gap-2 py-2"
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
<span className="w-6 shrink-0 text-muted-foreground">{index + 1}</span>
<span className="min-w-0 flex-1 truncate">{item.elementName}</span>
<Badge variant="muted">
{t(`admin.groups.elementKinds.${item.elementKind}`)}
</Badge>
<span className="text-muted-foreground">
{t('admin.groups.units')}: {item.unitCount}
</span>
{showWeights && (
<Input
type="number"
min={0}
className="h-8 w-20"
defaultValue={item.weight}
onBlur={(e) => {
const weight = Number(e.target.value)
if (Number.isFinite(weight) && weight !== item.weight)
weightMutation.mutate({ itemId: item.id, weight })
}}
/>
)}
<Button size="sm" variant="ghost" onClick={() => removeMutation.mutate(item.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,145 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { listGenres } from '@/features/admin/genres/api'
import type { GroupElementKind, GroupFilter, ShowAudience, ShowKind } from '@/shared/api/types'
import { SHOW_AUDIENCES } from '@/shared/api/types'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
const ELEMENT_KINDS: GroupElementKind[] = ['Show', 'Collection']
const SHOW_KINDS: ShowKind[] = ['Series', 'Single']
/** Конструктор правила набора. Правило не применяется само — оно только ищет кандидатов. */
export function GroupFilterPanel({
filter,
onChange,
}: {
filter: GroupFilter
onChange: (next: GroupFilter) => void
}) {
const { t } = useTranslation()
const { data: genres } = useQuery({ queryKey: ['admin', 'genres'], queryFn: listGenres })
const patch = (part: Partial<GroupFilter>) => onChange({ ...filter, ...part })
const toggleIn = <T,>(list: T[] | null | undefined, value: T): T[] => {
const current = list ?? []
return current.includes(value) ? current.filter((x) => x !== value) : [...current, value]
}
// Пустая строка означает «не ограничивать», поэтому 0 и «не задано» различаются явно.
const numberOrNull = (value: string) => (value.trim() === '' ? null : Number(value))
return (
<div className="flex flex-col gap-4 text-sm">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.groups.filter.elementKinds')}</Label>
<div className="flex flex-wrap gap-3">
{ELEMENT_KINDS.map((kind) => (
<label key={kind} className="flex items-center gap-1.5">
<input
type="checkbox"
checked={(filter.elementKinds ?? []).includes(kind)}
onChange={() => patch({ elementKinds: toggleIn(filter.elementKinds, kind) })}
/>
{t(`admin.groups.elementKinds.${kind}`)}
</label>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.groups.filter.showKinds')}</Label>
<div className="flex flex-wrap gap-3">
{SHOW_KINDS.map((kind) => (
<label key={kind} className="flex items-center gap-1.5">
<input
type="checkbox"
checked={(filter.showKinds ?? []).includes(kind)}
onChange={() => patch({ showKinds: toggleIn(filter.showKinds, kind) })}
/>
{t(`admin.shows.kinds.${kind}`)}
</label>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.groups.filter.genres')}</Label>
<div className="flex max-h-40 flex-wrap gap-x-3 gap-y-1 overflow-y-auto">
{(genres ?? []).map((genre) => (
<label key={genre.id} className="flex items-center gap-1.5">
<input
type="checkbox"
checked={(filter.genreIds ?? []).includes(genre.id)}
onChange={() => patch({ genreIds: toggleIn(filter.genreIds, genre.id) })}
/>
{genre.name}
</label>
))}
</div>
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.genresHint')}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.groups.filter.maxAudience')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={filter.maxAudience ?? ''}
onChange={(e) =>
patch({ maxAudience: e.target.value === '' ? null : (e.target.value as ShowAudience) })
}
>
<option value="">{t('admin.groups.filter.any')}</option>
{SHOW_AUDIENCES.map((value) => (
<option key={value} value={value}>
{t(`admin.shows.audiences.${value}`)}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.audienceHint')}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.groups.filter.year')}</Label>
<div className="flex items-center gap-2">
<Input
type="number"
className="w-28"
placeholder={t('admin.groups.filter.from')}
value={filter.yearMin ?? ''}
onChange={(e) => patch({ yearMin: numberOrNull(e.target.value) })}
/>
<Input
type="number"
className="w-28"
placeholder={t('admin.groups.filter.to')}
value={filter.yearMax ?? ''}
onChange={(e) => patch({ yearMax: numberOrNull(e.target.value) })}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.groups.filter.unitMinutes')}</Label>
<div className="flex items-center gap-2">
<Input
type="number"
className="w-28"
placeholder={t('admin.groups.filter.from')}
value={filter.unitMinutesMin ?? ''}
onChange={(e) => patch({ unitMinutesMin: numberOrNull(e.target.value) })}
/>
<Input
type="number"
className="w-28"
placeholder={t('admin.groups.filter.to')}
value={filter.unitMinutesMax ?? ''}
onChange={(e) => patch({ unitMinutesMax: numberOrNull(e.target.value) })}
/>
</div>
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.unitMinutesHint')}</p>
</div>
</div>
)
}
@@ -0,0 +1,142 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { createGroup, deleteGroup, listGroups } from './api'
import { DurationLabel } from './DurationLabel'
export function GroupsPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [name, setName] = useState('')
const { sort, toggle } = useTableSort('name', false)
const { data, isLoading } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const createMutation = useMutation({
mutationFn: () => createGroup({ name: name.trim() }),
onSuccess: () => {
setName('')
invalidate()
},
onError,
})
const deleteMutation = useMutation({ mutationFn: deleteGroup, onSuccess: invalidate, onError })
const rows = sortRows(data ?? [], sort, {
name: (g) => g.name.toLowerCase(),
items: (g) => g.itemCount,
units: (g) => g.unitCount,
duration: (g) => g.totalDurationSeconds,
})
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h2 className="crt-glow text-xl font-semibold">{t('admin.groups.title')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.groups.hint')}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
className="max-w-xs"
placeholder={t('admin.groups.name')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button
size="sm"
disabled={!name.trim() || createMutation.isPending}
onClick={() => createMutation.mutate()}
>
{t('common.create')}
</Button>
</div>
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<SortHeader
label={t('admin.groups.name')}
sortKey="name"
sort={sort}
onToggle={toggle}
/>
<SortHeader
label={t('admin.groups.items')}
sortKey="items"
sort={sort}
onToggle={toggle}
/>
<SortHeader
label={t('admin.groups.units')}
sortKey="units"
sort={sort}
onToggle={toggle}
/>
<SortHeader
label={t('admin.groups.duration')}
sortKey="duration"
sort={sort}
onToggle={toggle}
/>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
{t('common.loading')}
</td>
</tr>
)}
{rows.map((group) => (
<tr key={group.id} className="border-b border-border last:border-0">
<td className="px-4 py-2">
<div className="flex items-center gap-2">
<Link
to="/admin/groups/$groupId"
params={{ groupId: group.id }}
className="text-primary hover:underline"
>
{group.name}
</Link>
{group.hasFilter && (
<Badge variant="muted">{t('admin.groups.hasFilter')}</Badge>
)}
</div>
</td>
<td className="px-4 py-2 text-muted-foreground">{group.itemCount}</td>
<td className="px-4 py-2 text-muted-foreground">{group.unitCount}</td>
<td className="px-4 py-2 text-muted-foreground">
<DurationLabel seconds={group.totalDurationSeconds} />
</td>
<td className="px-4 py-2">
<Button
size="sm"
variant="destructive"
onClick={() => deleteMutation.mutate(group.id)}
>
{t('common.delete')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { apiRequest } from '@/shared/api/client'
import type {
CreatedIdResponse,
GroupCandidateDto,
GroupDto,
GroupElementKind,
GroupFilter,
GroupSummaryDto,
} from '@/shared/api/types'
export function listGroups() {
return apiRequest<GroupSummaryDto[]>('/admin/groups')
}
export function getGroup(id: string) {
return apiRequest<GroupDto>(`/admin/groups/${id}`)
}
export function createGroup(body: { name: string; description?: string }) {
return apiRequest<CreatedIdResponse>('/admin/groups', { method: 'POST', body })
}
export function updateGroup(
id: string,
body: { name: string; description: string | null; filter: GroupFilter | null },
) {
return apiRequest<void>(`/admin/groups/${id}`, { method: 'PUT', body })
}
export function deleteGroup(id: string) {
return apiRequest<void>(`/admin/groups/${id}`, { method: 'DELETE' })
}
/** Подбор по правилу набора. Фильтр передаётся явно — редактор крутит его до сохранения. */
export function findGroupCandidates(id: string, filter: GroupFilter | null) {
return apiRequest<GroupCandidateDto[]>(`/admin/groups/${id}/candidates`, {
method: 'POST',
body: { filter },
})
}
export function addGroupElements(
id: string,
elements: { elementKind: GroupElementKind; elementId: string }[],
) {
return apiRequest<{ added: number }>(`/admin/groups/${id}/items`, {
method: 'POST',
body: { elements },
})
}
export function removeGroupItem(id: string, itemId: string) {
return apiRequest<void>(`/admin/groups/${id}/items/${itemId}`, { method: 'DELETE' })
}
export function setGroupItemWeight(id: string, itemId: string, weight: number) {
return apiRequest<void>(`/admin/groups/${id}/items/${itemId}/weight`, {
method: 'PUT',
body: { weight },
})
}
/** Порядок позиций: не упомянутые остаются после перечисленных. */
export function reorderGroup(id: string, itemIdsInOrder: string[]) {
return apiRequest<void>(`/admin/groups/${id}/order`, {
method: 'PUT',
body: { itemIdsInOrder },
})
}
@@ -0,0 +1,9 @@
/**
* Разбивает объём эфира на часы и минуты. Единицы измерения подставляет вызывающий из переводов —
* функция намеренно не знает языка.
*/
export function splitDuration(totalSeconds: number): { hours: number; minutes: number } | null {
if (!totalSeconds || totalSeconds <= 0) return null
const minutes = Math.round(totalSeconds / 60)
return { hours: Math.floor(minutes / 60), minutes: minutes % 60 }
}