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.
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user