Refactor GridTab and SlotInspector components for improved UI and functionality
ci / build-backend (push) Successful in 1m16s
ci / build-frontend (push) Successful in 42s
ci / tests (push) Successful in 1m19s
ci / sonar (push) Successful in 5m13s

Updated the GridTab component to reorganize the layout, integrating the TemplateIssues component into a more user-friendly format. Enhanced the SlotInspector component by transitioning it from a panel to a dialog for better visibility and usability, especially for lengthy forms. Added new button functionality for creating slots and improved the handling of template issues with collapsible sections. These changes enhance the overall user experience in managing channels and slots.
This commit is contained in:
Leonid Pershin
2026-07-27 06:57:27 +03:00
parent 212899bc2b
commit 6fe6404e61
3 changed files with 379 additions and 327 deletions
@@ -281,6 +281,11 @@ export function GridTab({
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<TemplatePreview channelId={channelId} />
{/* Один ряд служебных элементов: проверки, дата просмотра и добавление слота. Всё,
что не сама сетка, живёт в одной строке — вертикаль экрана нужна сетке. */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<TemplateIssues <TemplateIssues
channelId={channelId} channelId={channelId}
slotsById={ slotsById={
@@ -288,10 +293,15 @@ export function GridTab({
} }
onGoToSlot={openSlot} onGoToSlot={openSlot}
/> />
<TemplatePreview channelId={channelId} /> <Button
size="sm"
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */} variant="outline"
<div className="flex flex-wrap items-center gap-2 text-sm"> disabled={!layerForNewSlot}
onClick={() => openNewSlot(1, 20 * 60)}
>
<Plus className="h-4 w-4" />
{t('admin.channels.newSlot')}
</Button>
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span> <span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
<Input <Input
type="date" type="date"
@@ -14,6 +14,7 @@ import type {
import { qk } from '@/shared/api/query-keys' import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error' import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label' import { Label } from '@/shared/ui/label'
import { import {
@@ -116,16 +117,17 @@ export function SlotInspector({
const isContent = body.slotKind === 'Content' const isContent = body.slotKind === 'Content'
return ( return (
<div className="crt-panel flex flex-col gap-3 rounded-md p-4 text-sm"> // Отдельным окном, а не панелью под сеткой: форма длинная, и раньше она уезжала за нижний край,
<div className="flex items-center justify-between gap-2"> // а сетка — то, с чем её сверяют, — уходила из виду.
<h3 className="text-sm font-semibold uppercase tracking-wide"> <Dialog open onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{draft.slot ? t('admin.channels.editSlot') : t('admin.channels.newSlot')} {draft.slot ? t('admin.channels.editSlot') : t('admin.channels.newSlot')}
</h3> </DialogTitle>
<Button size="sm" variant="ghost" onClick={onClose}> </DialogHeader>
×
</Button>
</div>
<div className="flex max-h-[65vh] flex-col gap-3 overflow-y-auto pr-1 text-sm">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.slotTitle')}</Label> <Label>{t('admin.channels.slotTitle')}</Label>
<Input value={body.title} onChange={(e) => patch({ title: e.target.value })} /> <Input value={body.title} onChange={(e) => patch({ title: e.target.value })} />
@@ -196,7 +198,11 @@ export function SlotInspector({
// Повтору нужен источник, конец вещания не берёт контент вовсе. // Повтору нужен источник, конец вещания не берёт контент вовсе.
repeatSource: repeatSource:
slotKind === 'Repeat' slotKind === 'Repeat'
? (body.repeatSource ?? { daysAgo: 1, time: '20:00:00', durationMinutes: 90 }) ? (body.repeatSource ?? {
daysAgo: 1,
time: '20:00:00',
durationMinutes: 90,
})
: null, : null,
}) })
}} }}
@@ -268,7 +274,9 @@ export function SlotInspector({
}) })
} }
/> />
<p className="text-xs text-muted-foreground">{t('admin.channels.cooldownHint')}</p> <p className="text-xs text-muted-foreground">
{t('admin.channels.cooldownHint')}
</p>
</div> </div>
)} )}
@@ -387,7 +395,9 @@ export function SlotInspector({
className="h-9 rounded-md border border-border bg-transparent px-2" className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.snapToMinutes ?? 0} value={body.snapToMinutes ?? 0}
onChange={(e) => onChange={(e) =>
patch({ snapToMinutes: Number(e.target.value) === 0 ? null : Number(e.target.value) }) patch({
snapToMinutes: Number(e.target.value) === 0 ? null : Number(e.target.value),
})
} }
> >
{SNAP_OPTIONS.map((value) => ( {SNAP_OPTIONS.map((value) => (
@@ -433,19 +443,27 @@ export function SlotInspector({
</select> </select>
</div> </div>
</div> </div>
</div>
<div className="flex items-center justify-between gap-2"> <DialogFooter className="items-center justify-between">
{draft.slot ? ( {draft.slot ? (
<Button size="sm" variant="destructive" onClick={() => remove.mutate()}> <Button size="sm" variant="destructive" onClick={() => remove.mutate()}>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
{t('common.delete')}
</Button> </Button>
) : ( ) : (
<span /> <span />
)} )}
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}> <Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')} {t('common.save')}
</Button> </Button>
</div> </div>
</div> </DialogFooter>
</DialogContent>
</Dialog>
) )
} }
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, CircleAlert } from 'lucide-react' import { AlertTriangle, ChevronDown, ChevronRight, CircleAlert } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys' import { qk } from '@/shared/api/query-keys'
import type { SlotDto, TemplateIssueDto } from '@/shared/api/types' import type { SlotDto, TemplateIssueDto } from '@/shared/api/types'
@@ -9,6 +10,10 @@ import { getTemplateIssues } from '../api'
/** /**
* Проверки по правилам (см. 5.1). Считаются на сервере по шаблону, без прогона генератора, поэтому * Проверки по правилам (см. 5.1). Считаются на сервере по шаблону, без прогона генератора, поэтому
* показываются прямо в редакторе и обновляются вместе с сеткой. * показываются прямо в редакторе и обновляются вместе с сеткой.
*
* По умолчанию свёрнуты в одну строку со счётчиками: семь дыр в сетке — обычное состояние
* недособранного канала, и занимать ими пол-экрана над самой сеткой значит прятать то, ради чего
* экран открывали. Разворачиваются по клику и скроллятся, не растягивая страницу.
*/ */
export function TemplateIssues({ export function TemplateIssues({
channelId, channelId,
@@ -20,6 +25,7 @@ export function TemplateIssues({
onGoToSlot: (slot: SlotDto) => void onGoToSlot: (slot: SlotDto) => void
}>) { }>) {
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = useState(false)
const { data: issues } = useQuery({ const { data: issues } = useQuery({
queryKey: qk.channels.issues(channelId), queryKey: qk.channels.issues(channelId),
queryFn: () => getTemplateIssues(channelId), queryFn: () => getTemplateIssues(channelId),
@@ -31,11 +37,28 @@ export function TemplateIssues({
const warnings = issues.filter((i) => i.severity === 'Warning') const warnings = issues.filter((i) => i.severity === 'Warning')
return ( return (
<div className="flex flex-col gap-1 rounded-md border border-border px-3 py-2 text-xs"> <div className="flex min-w-0 flex-col gap-1 text-xs">
<span className="font-semibold uppercase tracking-wide text-muted-foreground"> <button
type="button"
className={cn(
'flex items-center gap-1.5 self-start rounded-md border px-2 py-1 font-semibold uppercase tracking-wide',
errors.length > 0
? 'border-red-700/50 text-red-500'
: 'border-amber-600/50 text-amber-500',
)}
onClick={() => setOpen((v) => !v)}
>
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
{errors.length > 0 ? (
<CircleAlert className="h-3 w-3" />
) : (
<AlertTriangle className="h-3 w-3" />
)}
{t('admin.channels.issues', { errors: errors.length, warnings: warnings.length })} {t('admin.channels.issues', { errors: errors.length, warnings: warnings.length })}
</span> </button>
<ul className="flex flex-col gap-0.5">
{open && (
<ul className="crt-panel flex max-h-48 flex-col gap-0.5 overflow-y-auto rounded-md px-3 py-2">
{[...errors, ...warnings].map((issue, index) => ( {[...errors, ...warnings].map((issue, index) => (
<IssueRow <IssueRow
key={`${issue.kind}-${index}`} key={`${issue.kind}-${index}`}
@@ -45,6 +68,7 @@ export function TemplateIssues({
/> />
))} ))}
</ul> </ul>
)}
</div> </div>
) )
} }