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,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>
)
}