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,115 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { Tag } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listGenres } from '@/features/admin/genres/api'
import { HttpError } from '@/shared/api/client'
import type { ShowDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { toast } from '@/shared/ui/toast-store'
import { setShowGenres } from './api'
/**
* Жанры шоу: бейджи в шапке карточки + диалог правки. Основной жанр отмечается отдельно —
* он показывается в списке шоу и участвует в отборе контента наравне с остальными.
*/
export function ShowGenresField({ show, onChanged }: { show: ShowDto; onChanged: () => void }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [selected, setSelected] = useState<string[]>([])
const [primary, setPrimary] = useState<string | null>(null)
const { data: genres } = useQuery({
queryKey: ['admin', 'genres'],
queryFn: listGenres,
enabled: open,
})
const mutation = useMutation({
mutationFn: () => setShowGenres(show.id, selected, primary),
onSuccess: () => {
onChanged()
setOpen(false)
},
onError: (error) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
})
const openDialog = () => {
setSelected(show.genres.map((g) => g.id))
setPrimary(show.genres.find((g) => g.isPrimary)?.id ?? null)
setOpen(true)
}
const toggle = (id: string) => {
setSelected((prev) => {
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
// Снятый жанр не может остаться основным — иначе сервер молча выберет другой.
if (!next.includes(id) && primary === id) setPrimary(next[0] ?? null)
if (next.includes(id) && primary === null) setPrimary(id)
return next
})
}
return (
<>
<div className="flex flex-wrap items-center gap-2">
{show.genres.length === 0 ? (
<span className="text-sm text-muted-foreground">{t('admin.shows.genresEmpty')}</span>
) : (
show.genres.map((genre) => (
<Badge key={genre.id} variant={genre.isPrimary ? 'default' : 'muted'}>
{genre.name}
</Badge>
))
)}
<Button size="sm" variant="outline" onClick={openDialog}>
<Tag className="h-4 w-4" /> {t('admin.shows.genresEdit')}
</Button>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.shows.genresEdit')}</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground">{t('admin.shows.genresHint')}</p>
<div className="flex max-h-80 flex-col gap-1 overflow-y-auto">
{(genres ?? []).map((genre) => {
const checked = selected.includes(genre.id)
return (
<div
key={genre.id}
className="flex items-center justify-between gap-3 rounded px-2 py-1 hover:bg-muted/40"
>
<label className="flex flex-1 items-center gap-2 text-sm">
<input type="checkbox" checked={checked} onChange={() => toggle(genre.id)} />
{genre.name}
</label>
{checked && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
<input
type="radio"
name="primaryGenre"
checked={primary === genre.id}
onChange={() => setPrimary(genre.id)}
/>
{t('admin.shows.genrePrimary')}
</label>
)}
</div>
)
})}
</div>
<DialogFooter>
<Button disabled={mutation.isPending} onClick={() => mutation.mutate()}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}