Add grid auto-build functionality with profile management and preview capabilities
Implemented new API endpoints and frontend components for grid auto-building based on predefined profiles. Added functionality to list grid profiles, preview generated grids, and generate grids with specified parameters. Enhanced the TemplateEndpoints and related services to support these features, improving the user experience for channel scheduling. Localization updates were made to accommodate new features in both English and Russian.
This commit is contained in:
@@ -9,6 +9,11 @@ import type {
|
||||
CopyTemplateResultDto,
|
||||
CreatedIdResponse,
|
||||
EntryTraceDto,
|
||||
GenerateGridResultDto,
|
||||
GridGenerationMode,
|
||||
GridPlanDto,
|
||||
GridProfileDto,
|
||||
GridProfileKind,
|
||||
JunctionAmountMode,
|
||||
JunctionConditions,
|
||||
JunctionElementKind,
|
||||
@@ -89,6 +94,31 @@ export function getApplyDiff(channelId: string) {
|
||||
return apiRequest<ScheduleDiffDto>(`/admin/channels/${channelId}/template/diff`)
|
||||
}
|
||||
|
||||
/** Каталог профилей автосборки: имя, на что похоже и для какой библиотеки годится. */
|
||||
export function listGridProfiles() {
|
||||
return apiRequest<GridProfileDto[]>('/admin/grid-profiles')
|
||||
}
|
||||
|
||||
/** План автосборки: что будет создано и что снесено. Считается тем же кодом, что и сама сборка. */
|
||||
export function previewGrid(channelId: string, profile: GridProfileKind, mode: GridGenerationMode) {
|
||||
const query = new URLSearchParams({ profile, mode })
|
||||
return apiRequest<GridPlanDto>(
|
||||
`/admin/channels/${channelId}/template/grid-plan?${query.toString()}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Собирает сетку по профилю. План пересчитывается на сервере — с клиента едут только опции. */
|
||||
export function generateGrid(
|
||||
channelId: string,
|
||||
profile: GridProfileKind,
|
||||
mode: GridGenerationMode,
|
||||
) {
|
||||
return apiRequest<GenerateGridResultDto>(`/admin/channels/${channelId}/template/generate`, {
|
||||
method: 'POST',
|
||||
body: { profile, mode },
|
||||
})
|
||||
}
|
||||
|
||||
/** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */
|
||||
export function copyTemplateTo(channelId: string, targetChannelId: string) {
|
||||
return apiRequest<CopyTemplateResultDto>(
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import type { GridGenerationMode, GridPlanSlotDto, GridProfileKind } from '@/shared/api/types'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { DurationLabel } from '../../groups/DurationLabel'
|
||||
import { generateGrid, listGridProfiles, previewGrid } from '../api'
|
||||
|
||||
/**
|
||||
* Автосборка сетки: профиль + режим → предпросмотр → создание. План с клиента не уезжает —
|
||||
* команда пересчитывает его сама, поэтому показанное и созданное не могут разойтись.
|
||||
*
|
||||
* Предпросмотр обязателен именно из-за режима пересборки: он сносит все слоты шаблона, и увидеть,
|
||||
* что взамен, надо до нажатия, а не после.
|
||||
*/
|
||||
export function GenerateGridDialog({
|
||||
channelId,
|
||||
onClose,
|
||||
onGenerated,
|
||||
onError,
|
||||
}: Readonly<{
|
||||
channelId: string
|
||||
onClose: () => void
|
||||
onGenerated: () => void
|
||||
onError: (error: unknown) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [profile, setProfile] = useState<GridProfileKind>('Mixed')
|
||||
const [mode, setMode] = useState<GridGenerationMode>('FillGaps')
|
||||
|
||||
const { data: profiles } = useQuery({
|
||||
queryKey: qk.gridProfiles.all,
|
||||
queryFn: listGridProfiles,
|
||||
})
|
||||
|
||||
const { data: plan, isFetching } = useQuery({
|
||||
queryKey: qk.channels.gridPlan(channelId, profile, mode),
|
||||
queryFn: () => previewGrid(channelId, profile, mode),
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
})
|
||||
|
||||
const generate = useMutation({
|
||||
mutationFn: () => generateGrid(channelId, profile, mode),
|
||||
onSuccess: (result) => {
|
||||
toast.success(
|
||||
t('admin.channels.generate.done', { created: result.created, removed: result.removed }),
|
||||
)
|
||||
onGenerated()
|
||||
onClose()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const covered =
|
||||
plan && plan.freeMinutes > 0 ? Math.round((plan.coveredMinutes / plan.freeMinutes) * 100) : 0
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.channels.generate.title')}</DialogTitle>
|
||||
<DialogDescription>{t('admin.channels.generate.hint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
{/* Профиль — то, ради чего окно и открыли: ритм суток задаёт всё остальное. */}
|
||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||
{(profiles ?? []).map((item) => (
|
||||
<button
|
||||
key={item.kind}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-0.5 rounded-md border px-3 py-2 text-left transition-colors',
|
||||
item.kind === profile
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50',
|
||||
)}
|
||||
onClick={() => setProfile(item.kind)}
|
||||
>
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.reference}</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{item.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(['FillGaps', 'Rebuild'] as const).map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
size="sm"
|
||||
variant={value === mode ? 'default' : 'outline'}
|
||||
onClick={() => setMode(value)}
|
||||
>
|
||||
{t(`admin.channels.generate.modes.${value}`)}
|
||||
</Button>
|
||||
))}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(`admin.channels.generate.modeHints.${mode}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isFetching && <p className="text-muted-foreground">{t('common.loading')}</p>}
|
||||
|
||||
{plan && !isFetching && (
|
||||
<>
|
||||
<p className="text-muted-foreground">
|
||||
{t('admin.channels.generate.summary', {
|
||||
slots: plan.slots.length,
|
||||
percent: covered,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{plan.slotsToRemove > 0 && (
|
||||
<p className="flex items-center gap-1.5 text-amber-500">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{t('admin.channels.generate.willRemove', { count: plan.slotsToRemove })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{plan.fallbackGroupName && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.generate.fallback', { name: plan.fallbackGroupName })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{plan.notes.length > 0 && (
|
||||
<ul className="flex flex-col gap-0.5 text-xs text-amber-500">
|
||||
{plan.notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{plan.slots.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t('admin.channels.generate.nothing')}</p>
|
||||
) : (
|
||||
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md text-xs">
|
||||
{plan.slots.map((slot, index) => (
|
||||
<PlanRow key={`${slot.weekday}-${slot.start}-${index}`} slot={slot} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={generate.isPending || isFetching || !plan || plan.slots.length === 0}
|
||||
onClick={() => generate.mutate()}
|
||||
>
|
||||
{t('admin.channels.generate.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function PlanRow({ slot }: Readonly<{ slot: GridPlanSlotDto }>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-2 px-3 py-1">
|
||||
<span className="w-24 shrink-0 text-muted-foreground">
|
||||
{slot.weekday === null
|
||||
? t('admin.channels.everyDay')
|
||||
: t(`admin.channels.weekdays.${slot.weekday}`)}
|
||||
</span>
|
||||
<span className="w-12 shrink-0 tabular-nums">{slot.start}</span>
|
||||
<span className="w-20 shrink-0 tabular-nums text-muted-foreground">
|
||||
<DurationLabel seconds={slot.durationMinutes * 60} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{slot.title}</span>
|
||||
<Badge variant="muted">{slot.block}</Badge>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Plus, Wand2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
updateSlot,
|
||||
} from '../api'
|
||||
import { toTime } from '../lib/format'
|
||||
import { GenerateGridDialog } from './GenerateGridDialog'
|
||||
import { LayerApplicabilityDialog } from './LayerApplicabilityDialog'
|
||||
import { LayerList, ScheduleGrid } from './ScheduleGrid'
|
||||
import { SlotInspector, type SlotDraft } from './SlotInspector'
|
||||
@@ -50,6 +51,7 @@ export function GridTab({
|
||||
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
|
||||
const [viewDate, setViewDate] = useState<string>('')
|
||||
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
// День, который копируем, и отмеченные дни-приёмники.
|
||||
const [copySource, setCopySource] = useState<number | null>(null)
|
||||
const [copyTargets, setCopyTargets] = useState<number[]>([])
|
||||
@@ -302,6 +304,10 @@ export function GridTab({
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('admin.channels.newSlot')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setGenerating(true)}>
|
||||
<Wand2 className="h-4 w-4" />
|
||||
{t('admin.channels.generate.action')}
|
||||
</Button>
|
||||
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
|
||||
<Input
|
||||
type="date"
|
||||
@@ -377,6 +383,15 @@ export function GridTab({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{generating && (
|
||||
<GenerateGridDialog
|
||||
channelId={channelId}
|
||||
onClose={() => setGenerating(false)}
|
||||
onGenerated={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{applicabilityLayer && (
|
||||
<LayerApplicabilityDialog
|
||||
layer={applicabilityLayer}
|
||||
|
||||
Reference in New Issue
Block a user