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,14 +1,10 @@
"use client"
import {
CSSProperties,
memo,
ReactNode,
useCallback,
useEffect,
useState,
} from "react"
import { memo, useCallback, useEffect, useRef, useState } from "react"
import type { CSSProperties, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import type {
DataGridFeatures,
DataGridTableInstance,
} from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
@@ -23,14 +19,16 @@ import {
DataGridTableRenderedRow,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridScrollAreaViewport,
getDataGridTableMergedHeaderGroups,
getDataGridTableRowSections,
getPinningStyles,
hasDataGridTableRightPinnedColumns,
} from "@/components/reui/data-grid/data-grid-table"
import { Column, flexRender, Row, Table } from "@tanstack/react-table"
import {
useVirtualizer,
import { flexRender } from "@tanstack/react-table"
import type { Column, Row, Table } from "@tanstack/react-table"
import { useVirtualizer } from "@tanstack/react-virtual"
import type {
VirtualItem,
Virtualizer,
VirtualizerOptions,
@@ -49,21 +47,226 @@ type DataGridTableVirtualizerInstance = Virtualizer<
HTMLTableRowElement
>
type DataGridTableVirtualizerOptions<TData> = Omit<
type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end"
type DataGridTableVirtualScrollRequest = {
align: DataGridTableVirtualScrollAlignment
behavior: ScrollBehavior
containerElement: HTMLDivElement
headerSticky: boolean
isVirtualizationEnabled: boolean
rowId: string | undefined
rowIndex: number
scrollElement: HTMLElement
}
function isSameDataGridTableScrollRequest(
previous: DataGridTableVirtualScrollRequest | null,
next: DataGridTableVirtualScrollRequest
) {
return (
previous?.align === next.align &&
previous.behavior === next.behavior &&
previous.containerElement === next.containerElement &&
previous.headerSticky === next.headerSticky &&
previous.isVirtualizationEnabled === next.isVirtualizationEnabled &&
previous.rowId === next.rowId &&
previous.rowIndex === next.rowIndex &&
previous.scrollElement === next.scrollElement
)
}
function getDataGridTableScrollTarget({
align,
clientHeight,
rowBottom,
rowHeight,
rowTop,
scrollHeight,
scrollTop,
viewportTopOffset = 0,
}: {
align: DataGridTableVirtualScrollAlignment
clientHeight: number
rowBottom: number
rowHeight: number
rowTop: number
scrollHeight: number
scrollTop: number
viewportTopOffset?: number
}) {
const visibleHeight = Math.max(0, clientHeight - viewportTopOffset)
const viewportTop = scrollTop + viewportTopOffset
const viewportBottom = scrollTop + clientHeight
const targetTop =
align === "auto"
? rowTop < viewportTop
? rowTop - viewportTopOffset
: rowBottom > viewportBottom
? rowBottom - clientHeight
: null
: align === "start"
? rowTop - viewportTopOffset
: align === "end"
? rowBottom - clientHeight
: rowTop -
viewportTopOffset -
Math.max(0, (visibleHeight - rowHeight) / 2)
if (targetTop === null) return null
return Math.min(
Math.max(0, targetTop),
Math.max(0, scrollHeight - clientHeight)
)
}
function getDataGridTableHeaderOffset({
containerElement,
headerSticky,
scrollElement,
}: {
containerElement: HTMLDivElement
headerSticky: boolean
scrollElement: HTMLElement
}) {
if (!headerSticky) return 0
const headerElement = containerElement.querySelector<HTMLElement>(
':scope > [data-slot="data-grid-table"] > thead'
)
if (!headerElement) return 0
const scrollRect = scrollElement.getBoundingClientRect()
const headerRect = headerElement.getBoundingClientRect()
const headerBottomOffset = headerRect.bottom - scrollRect.top
const overlapsViewportTop =
headerRect.top <= scrollRect.top + 0.5 && headerBottomOffset > 0
if (!overlapsViewportTop) return 0
return Math.min(scrollElement.clientHeight, Math.max(0, headerBottomOffset))
}
function scrollDataGridTableToOffset({
behavior,
scrollElement,
targetTop,
virtualizer,
}: {
behavior: ScrollBehavior
scrollElement: HTMLElement
targetTop: number
virtualizer?: DataGridTableVirtualizerInstance
}) {
if (virtualizer) {
virtualizer.scrollToOffset(targetTop, { align: "start", behavior })
} else if (typeof scrollElement.scrollTo === "function") {
scrollElement.scrollTo({ behavior, top: targetTop })
} else {
scrollElement.scrollTop = targetTop
}
}
function scrollDataGridTableRowIntoView({
align,
behavior,
cancelPendingScroll = false,
containerElement,
headerSticky,
rowIndex,
scrollElement,
virtualizer,
}: {
align: DataGridTableVirtualScrollAlignment
behavior: ScrollBehavior
cancelPendingScroll?: boolean
containerElement: HTMLDivElement | null
headerSticky: boolean
rowIndex: number
scrollElement: HTMLElement | null
virtualizer?: DataGridTableVirtualizerInstance
}) {
if (!containerElement || !scrollElement) return false
const rowElement = containerElement.querySelector<HTMLTableRowElement>(
`:scope > [data-slot="data-grid-table"] > tbody > tr[data-index="${rowIndex}"]`
)
if (!rowElement) return false
const scrollRect = scrollElement.getBoundingClientRect()
const rowRect = rowElement.getBoundingClientRect()
const viewportTopOffset = getDataGridTableHeaderOffset({
containerElement,
headerSticky,
scrollElement,
})
const rowTop = scrollElement.scrollTop + rowRect.top - scrollRect.top
const rowBottom = scrollElement.scrollTop + rowRect.bottom - scrollRect.top
const targetTop = getDataGridTableScrollTarget({
align,
clientHeight: scrollElement.clientHeight,
rowBottom,
rowHeight: rowRect.height || rowElement.offsetHeight,
rowTop,
scrollHeight: scrollElement.scrollHeight,
scrollTop: scrollElement.scrollTop,
viewportTopOffset,
})
if (
targetTop === null ||
Math.abs(targetTop - scrollElement.scrollTop) < 0.5
) {
if (cancelPendingScroll) {
scrollDataGridTableToOffset({
behavior: "auto",
scrollElement,
targetTop: scrollElement.scrollTop,
virtualizer,
})
}
return true
}
scrollDataGridTableToOffset({
behavior,
scrollElement,
targetTop,
virtualizer,
})
return true
}
type DataGridTableVirtualizerOptions<TData extends object> = Omit<
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
> & {
estimateSize?: (index: number, row: Row<TData>) => number
getItemKey?: (index: number, row: Row<TData>) => string | number
estimateSize?: (index: number, row: Row<DataGridFeatures, TData>) => number
getItemKey?: (
index: number,
row: Row<DataGridFeatures, TData>
) => string | number
getScrollElement?: (
elements: DataGridTableVirtualScrollElements
) => HTMLElement | null
}
interface DataGridTableVirtualProps<TData> {
interface DataGridTableVirtualProps<TData extends object> {
height?: number | string
estimateSize?: number
overscan?: number
/** Scroll animation used when revealing a controlled target row. */
scrollBehavior?: ScrollBehavior
/** Alignment used when revealing a controlled target row. Defaults to auto. */
scrollToRowAlign?: DataGridTableVirtualScrollAlignment
/** Index within the center (non-pinned) row section to reveal. */
scrollToRowIndex?: number
footerContent?: ReactNode
renderHeader?: boolean
onFetchMore?: () => void
@@ -73,11 +276,11 @@ interface DataGridTableVirtualProps<TData> {
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
}
interface VirtualBodyProps<TData> {
table: Table<TData>
topRows: Row<TData>[]
centerRows: Row<TData>[]
bottomRows: Row<TData>[]
interface VirtualBodyProps<TData extends object> {
table: DataGridTableInstance<TData>
topRows: Row<DataGridFeatures, TData>[]
centerRows: Row<DataGridFeatures, TData>[]
bottomRows: Row<DataGridFeatures, TData>[]
virtualItems: VirtualItem[]
totalSize: number
isVirtualizationEnabled: boolean
@@ -89,16 +292,16 @@ interface VirtualBodyProps<TData> {
measureRowRef?: (element: HTMLTableRowElement | null) => void
}
function DataGridTableVirtualPinnedPlaceholderCell<TData>({
function DataGridTableVirtualPinnedPlaceholderCell<TData extends object>({
column,
}: {
column: Column<TData>
column: Column<DataGridFeatures, TData, unknown>
}) {
const { props } = useDataGrid()
const isPinned = column.getIsPinned()
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
const isFirstRightPinned =
isPinned === "right" && column.getIsFirstColumn("right")
const isLastStartPinned =
isPinned === "start" && column.getIsLastColumn("start")
const isFirstEndPinned = isPinned === "end" && column.getIsFirstColumn("end")
return (
<td
@@ -113,20 +316,20 @@ function DataGridTableVirtualPinnedPlaceholderCell<TData>({
}}
data-pinned={isPinned || undefined}
data-last-col={
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
isLastStartPinned ? "start" : isFirstEndPinned ? "end" : undefined
}
className={cn(
"p-0",
props.tableLayout?.cellBorder && "border-e",
props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=end][data-last-col=end]]:shadow-[inset_1px_0_0_0_var(--border)] [&[data-pinned=start][data-last-col=start]]:shadow-[inset_-1px_0_0_0_var(--border)]"
)}
/>
)
}
function DataGridTableVirtualUtilityRow<TData>({
function DataGridTableVirtualUtilityRow<TData extends object>({
table,
children,
centerCellClassName,
@@ -134,7 +337,7 @@ function DataGridTableVirtualUtilityRow<TData>({
rowClassName,
ariaHidden,
}: {
table: Table<TData>
table: DataGridTableInstance<TData>
children: ReactNode
centerCellClassName?: string
centerCellStyle?: CSSProperties
@@ -142,9 +345,9 @@ function DataGridTableVirtualUtilityRow<TData>({
ariaHidden?: boolean
}) {
const { props } = useDataGrid()
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
const leftVisibleColumns = table.getStartVisibleLeafColumns()
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
const rightVisibleColumns = table.getRightVisibleLeafColumns()
const rightVisibleColumns = table.getEndVisibleLeafColumns()
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
@@ -178,11 +381,11 @@ function DataGridTableVirtualUtilityRow<TData>({
)
}
function DataGridTableVirtualSpacer<TData>({
function DataGridTableVirtualSpacer<TData extends object>({
table,
height,
}: {
table: Table<TData>
table: DataGridTableInstance<TData>
height: number
}) {
if (height <= 0) return null
@@ -199,12 +402,12 @@ function DataGridTableVirtualSpacer<TData>({
)
}
function DataGridTableVirtualStatusRow<TData>({
function DataGridTableVirtualStatusRow<TData extends object>({
table,
children,
className,
}: {
table: Table<TData>
table: DataGridTableInstance<TData>
children: ReactNode
className?: string
}) {
@@ -221,7 +424,7 @@ function DataGridTableVirtualStatusRow<TData>({
)
}
function DataGridTableVirtualBody<TData>({
function DataGridTableVirtualBody<TData extends object>({
table,
topRows,
centerRows,
@@ -236,9 +439,25 @@ function DataGridTableVirtualBody<TData>({
allRowsLoadedMessage,
measureRowRef,
}: VirtualBodyProps<TData>) {
const { isLoading } = useDataGrid()
const totalRows = topRows.length + centerRows.length + bottomRows.length
if (!totalRows) return <DataGridTableEmpty />
if (!totalRows) {
// Initial load must not flash the empty state as if the query returned
// nothing.
if (isLoading) {
return (
<DataGridTableVirtualStatusRow table={table}>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
return <DataGridTableEmpty />
}
const hasCenterRows = centerRows.length > 0
const showFetchingRow = isInfiniteMode && isFetchingMore
@@ -291,6 +510,7 @@ function DataGridTableVirtualBody<TData>({
key={row.id}
row={row}
rowRef={measureRowRef}
rowIndex={virtualRow.index}
/>
)
})
@@ -305,8 +525,10 @@ function DataGridTableVirtualBody<TData>({
)
}
} else {
centerRows.forEach((row) => {
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
centerRows.forEach((row, rowIndex) => {
renderedRows.push(
<DataGridTableRenderedRow key={row.id} row={row} rowIndex={rowIndex} />
)
})
}
@@ -357,13 +579,16 @@ function DataGridTableVirtualBody<TData>({
*/
const MemoizedVirtualBody = memo(
DataGridTableVirtualBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
) as typeof DataGridTableVirtualBody
function DataGridTableVirtual<TData>({
function DataGridTableVirtual<TData extends object>({
height,
estimateSize = 48,
overscan = 10,
scrollBehavior = "auto",
scrollToRowAlign = "auto",
scrollToRowIndex,
footerContent,
renderHeader = true,
onFetchMore,
@@ -372,7 +597,7 @@ function DataGridTableVirtual<TData>({
fetchMoreOffset = 0,
virtualizerOptions,
}: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid()
const { table, props } = useDataGrid<TData>()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
@@ -397,17 +622,16 @@ function DataGridTableVirtual<TData>({
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Загрузка…"
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
const allRowsLoadedMessage =
props.allRowsLoadedMessage || "Все записи загружены"
props.allRowsLoadedMessage || "All records loaded"
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({
containerElement: node,
scrollElement:
(node?.closest(
'[data-slot="scroll-area-viewport"]'
) as HTMLElement | null) ?? node,
scrollElement: node
? (getDataGridScrollAreaViewport(node) ?? node)
: null,
})
}, [])
@@ -464,6 +688,134 @@ function DataGridTableVirtual<TData>({
? virtualizer.measureElement
: undefined
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
const scrollToRowId =
scrollToRowIndex !== undefined
? centerRows[scrollToRowIndex]?.id
: undefined
const scrollToRowVirtualItem =
isVirtualizationEnabled && scrollToRowIndex !== undefined
? virtualItems.find((item) => item.index === scrollToRowIndex)
: undefined
const pendingScrollToRowIndexRef = useRef<number | null>(null)
const lastScrollRequestRef = useRef<DataGridTableVirtualScrollRequest | null>(
null
)
// Latch onFetchMore per row count: virtualItems gets a new identity every
// scroll frame, so without it the effect fires duplicate page requests
// before the consumer flips isFetchingMore, and loops at end-of-data when
// hasMore is never set.
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
// Resolve after every commit so a stable getter can expose a replaced ref;
// the request signature prevents duplicate scrolling on ordinary renders.
useEffect(() => {
const previousRequest = lastScrollRequestRef.current
if (
scrollToRowIndex === undefined ||
scrollToRowIndex < 0 ||
scrollToRowIndex >= centerRows.length
) {
pendingScrollToRowIndexRef.current = null
lastScrollRequestRef.current = null
if (previousRequest) {
const scrollElement = resolveScrollElement()
if (scrollElement) {
scrollDataGridTableToOffset({
behavior: "auto",
scrollElement,
targetTop: scrollElement.scrollTop,
virtualizer: isVirtualizationEnabled ? virtualizer : undefined,
})
}
}
return
}
const scrollElement = resolveScrollElement()
const containerElement = viewportElements.containerElement
if (!containerElement || !scrollElement) return
const headerSticky = renderHeader && !!props.tableLayout?.headerSticky
const nextRequest: DataGridTableVirtualScrollRequest = {
align: scrollToRowAlign,
behavior: scrollBehavior,
containerElement,
headerSticky,
isVirtualizationEnabled,
rowId: scrollToRowId,
rowIndex: scrollToRowIndex,
scrollElement,
}
if (isSameDataGridTableScrollRequest(previousRequest, nextRequest)) return
pendingScrollToRowIndexRef.current = null
const rowWasHandled = scrollDataGridTableRowIntoView({
align: scrollToRowAlign,
behavior: scrollBehavior,
cancelPendingScroll: previousRequest !== null,
containerElement,
headerSticky,
rowIndex: scrollToRowIndex,
scrollElement,
virtualizer: isVirtualizationEnabled ? virtualizer : undefined,
})
if (rowWasHandled) {
lastScrollRequestRef.current = nextRequest
return
}
if (!isVirtualizationEnabled) return
pendingScrollToRowIndexRef.current = scrollToRowIndex
lastScrollRequestRef.current = nextRequest
virtualizer.scrollToIndex(scrollToRowIndex, {
align: scrollToRowAlign,
behavior: scrollBehavior,
})
})
useEffect(() => {
if (
!isVirtualizationEnabled ||
scrollToRowIndex === undefined ||
pendingScrollToRowIndexRef.current !== scrollToRowIndex ||
!scrollToRowVirtualItem
) {
return
}
const rowWasHandled = scrollDataGridTableRowIntoView({
align: scrollToRowAlign,
behavior: "auto",
cancelPendingScroll: true,
containerElement: viewportElements.containerElement,
headerSticky: renderHeader && !!props.tableLayout?.headerSticky,
rowIndex: scrollToRowIndex,
scrollElement: resolveScrollElement(),
virtualizer,
})
if (rowWasHandled) {
pendingScrollToRowIndexRef.current = null
}
}, [
isVirtualizationEnabled,
props.tableLayout?.headerSticky,
renderHeader,
resolveScrollElement,
scrollToRowAlign,
scrollToRowIndex,
scrollToRowVirtualItem,
virtualizer,
viewportElements.containerElement,
])
useEffect(() => {
if (
@@ -478,7 +830,10 @@ function DataGridTableVirtual<TData>({
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (fetchMoreFiredAtCountRef.current === centerRows.length) return
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
fetchMoreFiredAtCountRef.current = centerRows.length
onFetchMore?.()
}
}, [
@@ -499,7 +854,15 @@ function DataGridTableVirtual<TData>({
style={
usesExternalScrollArea
? undefined
: { height, overflow: "auto", position: "relative" }
: {
height,
overflow: "auto",
position: "relative",
// Standalone mode: this node IS the scroll container, so it
// must stay at its parent's width (not the resizable table
// width) or horizontal scrolling becomes impossible.
width: "auto",
}
}
>
<DataGridTableBase>
@@ -508,7 +871,7 @@ function DataGridTableVirtual<TData>({
{mergedHeaderGroups.map((headerGroup) => (
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
{headerGroup.headers
.filter((header) => header.column.getIsPinned() !== "right")
.filter((header) => header.column.getIsPinned() !== "end")
.map((header) => {
const { column } = header
@@ -532,7 +895,7 @@ function DataGridTableVirtual<TData>({
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "right")
.filter((header) => header.column.getIsPinned() === "end")
.map((header) => {
const { column } = header
@@ -593,6 +956,7 @@ function DataGridTableVirtual<TData>({
export { DataGridTableVirtual }
export type {
DataGridTableVirtualScrollAlignment,
DataGridTableVirtualProps,
DataGridTableVirtualScrollElements,
DataGridTableVirtualizerOptions,