feat(web): upgrade lists to ReUI Table v9 and EventCalendar
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 39s
quality / web (push) Successful in 1m29s
quality / go (push) Successful in 1m3s
quality / bird2 (push) Successful in 15s
CD / quality (push) Successful in 3m43s
CD / publish (push) Failing after 8m29s

Единый kitDataGridTableLayout и ResourcePage/FrameDataGrid на всех списках.
Календарь задач переведён на EventCalendar, lookup — на cascader, у операций появился вид доски.
Удалены settings-7 и самописные DataGridSection/toolbar.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-23 01:38:56 +07:00
co-authored by Cursor
parent 927e27640a
commit d56405af7b
157 changed files with 39067 additions and 6110 deletions
@@ -1,34 +1,18 @@
import { useMemo, useState } from 'react'
import { useState } from 'react'
import { LayoutGrid, Table2 } from 'lucide-react'
import { FrameDataGrid } from '@/components/reui-kit'
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
import { OperationsJobsKanban } from '@/components/operations/operations-jobs-kanban'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { ToggleGroup, ToggleGroupItem } from '@evobgp/ui/components/toggle-group'
import type { JobRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
type JobTab = 'all' | 'active' | 'failed' | 'succeeded'
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
if (tab === 'all') return items
if (tab === 'active') return items.filter((j) => j.status === 'running' || j.status === 'queued')
if (tab === 'failed')
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
return items.filter((j) => j.status === 'succeeded')
}
function tabCounts(items: JobRow[]) {
return {
all: items.length,
active: items.filter((j) => j.status === 'running' || j.status === 'queued').length,
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
.length,
succeeded: items.filter((j) => j.status === 'succeeded').length,
}
}
/** data-grid-filtering-1 style jobs card with status tabs. */
/**
* Jobs ResourcePage + optional Kanban board (не замена грида).
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see https://reui.io/docs/components/base/kanban
*/
export function OperationsJobsCard({
jobs,
nameById,
@@ -46,41 +30,54 @@ export function OperationsJobsCard({
error: unknown
onRetry: () => void
}) {
const [tab, setTab] = useState<JobTab>('all')
const counts = useMemo(() => tabCounts(jobs), [jobs])
const filtered = useMemo(() => filterJobs(jobs, tab), [jobs, tab])
const [view, setView] = useState<'table' | 'board'>('table')
return (
<FrameDataGrid title="Задачи" description="Фильтр по статусу · data-grid-filtering pattern">
<div className="px-5 pt-3">
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)} className="w-full">
<TabsList variant="line" className="w-full justify-start gap-6">
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
<TabsTrigger value="active">Активные ({counts.active})</TabsTrigger>
<TabsTrigger value="succeeded">Успешные ({counts.succeeded})</TabsTrigger>
<TabsTrigger value="failed">Ошибки ({counts.failed})</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex flex-col gap-3">
<div className="flex justify-end">
<ToggleGroup
multiple={false}
value={[view]}
onValueChange={(values) => {
const next = values[0]
if (next === 'table' || next === 'board') setView(next)
}}
variant="outline"
size="sm"
aria-label="Вид задач"
className="w-fit"
>
<ToggleGroupItem value="table" aria-label="Таблица">
<Table2 className="size-4" />
Таблица
</ToggleGroupItem>
<ToggleGroupItem value="board" aria-label="Доска">
<LayoutGrid className="size-4" />
Доска
</ToggleGroupItem>
</ToggleGroup>
</div>
<QueryState
data={filtered}
data={jobs}
isLoading={isLoading}
isError={isError}
error={error}
empty={filtered.length === 0}
emptyTitle="Нет задач в выборке"
skeleton={<TableSkeleton rows={6} cols={5} />}
empty={false}
onRetry={onRetry}
>
{(items) => (
<OperationsJobsGrid
items={items}
nameById={nameById}
qc={qc}
isLoading={isLoading && items.length > 0}
/>
)}
{(items) =>
view === 'board' ? (
<OperationsJobsKanban items={items} nameById={nameById} />
) : (
<OperationsJobsGrid
items={items}
nameById={nameById}
qc={qc}
isLoading={isLoading && items.length > 0}
/>
)
}
</QueryState>
</FrameDataGrid>
</div>
)
}
@@ -1,20 +1,48 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useMemo } from 'react'
import { ListTodo, SearchIcon } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
ResourcePage,
createTextFilterQuery,
type DataGridColumnDef,
} from '@/components/reui-kit'
import { apiMutate } from '@/lib/api-client'
import { jobKindRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
const JOB_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'active', label: 'Активные' },
{ id: 'succeeded', label: 'Успешные' },
{ id: 'failed', label: 'Ошибки' },
]
const filterFields: FilterField[] = [
{
id: 'search',
label: 'Поиск',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
placeholder: 'Поиск задач…',
},
]
function tabFilter(item: JobRow, tabId: string): boolean {
if (tabId === 'active') return item.status === 'running' || item.status === 'queued'
if (tabId === 'failed')
return ['failed', 'error', 'cancelled'].includes(item.status.toLowerCase())
if (tabId === 'succeeded') return item.status === 'succeeded'
return true
}
export function OperationsJobsGrid({
items,
nameById,
@@ -26,6 +54,9 @@ export function OperationsJobsGrid({
qc: QueryClient
isLoading?: boolean
}) {
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
createTextFilterQuery('search'),
)
const cancelMutation = useMutation({
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
onSuccess: () => {
@@ -35,13 +66,14 @@ export function OperationsJobsGrid({
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
})
const columns = useMemo<ColumnDef<JobRow>[]>(
const columns = useMemo<DataGridColumnDef<JobRow>[]>(
() => [
{
accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => (
<DataGridPrimaryCell
<DataGridNameCell
icon={ListTodo}
title={jobKindRu(row.original.kind)}
subtitle={
row.original.meta?.module_id
@@ -112,27 +144,30 @@ export function OperationsJobsGrid({
[cancelMutation, nameById],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => {
const moduleName = row.meta?.module_id
? (nameById.get(String(row.meta.module_id)) ?? String(row.meta.module_id))
: ''
return `${jobKindRu(row.kind)} ${row.status} ${row.job_id} ${moduleName}`
},
getRowId: (row) => row.job_id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
<ResourcePage
title="Задачи"
description="Фильтр по статусу"
tabs={JOB_TABS}
tabFilter={tabFilter}
filterFields={filterFields}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
getFilterFieldValue={(item, field) => {
if (field !== 'search') return undefined
const moduleName = item.meta?.module_id
? (nameById.get(String(item.meta.module_id)) ?? String(item.meta.module_id))
: ''
return `${jobKindRu(item.kind)} ${item.status} ${item.job_id} ${moduleName}`
}}
columns={columns}
data={items}
getRowId={(row) => row.job_id}
isLoading={isLoading}
emptyMessage="Нет задач"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
pinLastColumn
virtualization={items.length > 80}
emptyState={{ title: 'Нет задач' }}
/>
)
}
@@ -0,0 +1,113 @@
import { useMemo } from 'react'
import { DataGridMutedCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import {
Kanban,
KanbanBoard,
KanbanColumn,
KanbanColumnContent,
KanbanItem,
} from '@/components/reui/kanban'
import { Frame, FramePanel } from '@/components/reui/frame'
import { jobKindRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
/**
* Operations jobs board view — ReUI Kanban (read-only columns).
* @see https://reui.io/docs/components/base/kanban
* @see https://reui.io/docs/components/base/frame
*/
const COLUMN_ORDER = ['queued', 'running', 'succeeded', 'failed'] as const
const COLUMN_LABELS: Record<(typeof COLUMN_ORDER)[number], string> = {
queued: 'Очередь',
running: 'Выполняются',
succeeded: 'Успешные',
failed: 'Ошибки',
}
function columnForStatus(status: string): (typeof COLUMN_ORDER)[number] {
const s = status.toLowerCase()
if (s === 'queued') return 'queued'
if (s === 'running') return 'running'
if (s === 'succeeded') return 'succeeded'
return 'failed'
}
function emptyColumns(): Record<string, JobRow[]> {
return {
queued: [],
running: [],
succeeded: [],
failed: [],
}
}
export function OperationsJobsKanban({
items,
nameById,
}: {
items: JobRow[]
nameById: Map<string, string>
}) {
const columns = useMemo(() => {
const next = emptyColumns()
for (const job of items) {
next[columnForStatus(job.status)].push(job)
}
return next
}, [items])
return (
<Frame dense spacing="sm" className="w-full min-w-0">
<FramePanel className="p-4">
<Kanban
value={columns}
onValueChange={() => undefined}
getItemValue={(job) => job.job_id}
>
<KanbanBoard className="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{COLUMN_ORDER.map((columnId) => (
<KanbanColumn
key={columnId}
value={columnId}
disabled
className="bg-muted/30 flex min-h-40 flex-col gap-3 rounded-lg border p-3"
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">{COLUMN_LABELS[columnId]}</p>
<span className="text-muted-foreground text-xs tabular-nums">
{columns[columnId]?.length ?? 0}
</span>
</div>
<KanbanColumnContent value={columnId}>
{(columns[columnId] ?? []).map((job) => (
<KanbanItem
key={job.job_id}
value={job.job_id}
disabled
className="bg-background flex flex-col gap-1 rounded-md border p-3"
>
<p className="text-sm leading-tight font-medium">
{jobKindRu(job.kind)}
</p>
<StatusBadge status={job.status} />
{job.meta?.module_id ? (
<DataGridMutedCell>
{nameById.get(String(job.meta.module_id)) ??
String(job.meta.module_id)}
</DataGridMutedCell>
) : null}
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
</Kanban>
</FramePanel>
</Frame>
)
}
@@ -1,20 +1,32 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react'
import { useMemo } from 'react'
import { GitCommitHorizontal, RefreshCw, SearchIcon } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
ResourcePage,
createTextFilterQuery,
type DataGridColumnDef,
} from '@/components/reui-kit'
import { apiMutate } from '@/lib/api-client'
import type { RevisionRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
const filterFields: FilterField[] = [
{
id: 'search',
label: 'Поиск',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
placeholder: 'Поиск ревизий…',
},
]
export function OperationsRevisionsGrid({
items,
qc,
@@ -24,6 +36,9 @@ export function OperationsRevisionsGrid({
qc: QueryClient
isLoading?: boolean
}) {
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
createTextFilterQuery('search'),
)
const rollbackMutation = useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
@@ -34,14 +49,14 @@ export function OperationsRevisionsGrid({
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
})
const columns = useMemo<ColumnDef<RevisionRow>[]>(
const columns = useMemo<DataGridColumnDef<RevisionRow>[]>(
() => [
{
id: 'id',
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={`${row.original.id.slice(0, 12)}…`} accent="mono" />
<DataGridNameCell icon={GitCommitHorizontal} title={`${row.original.id.slice(0, 12)}…`} />
),
meta: { headerTitle: 'ID' },
},
@@ -88,22 +103,21 @@ export function OperationsRevisionsGrid({
[rollbackMutation],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => `${row.id} ${row.materialized_prefix_count}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
<ResourcePage
title="История ревизий"
filterFields={filterFields}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
getFilterFieldValue={(item) => `${item.id} ${item.materialized_prefix_count}`}
columns={columns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading}
emptyMessage="Нет ревизий"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск ревизий…"
pinLastColumn
virtualization={items.length > 80}
emptyState={{ title: 'Нет ревизий' }}
/>
)
}