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,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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user