From 7c4430d240f75b570f1f850daa05e7793b79b63e Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 18 Oct 2026 01:58:31 +0700 Subject: [PATCH] =?UTF-8?q?chore(web):=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D1=8B=20ReUI=20=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B9=D1=82=D0=B8=20=D0=BD=D0=B0=20TanStack=20Table=20v9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обновлены из реестра data-grid, filters, badge, frame, alert, icon-tile, icon-stack, timeline и зависимые примитивы @authportal/ui. @tanstack/react-table 8.21 → 9.3, добавлены dnd-kit, react-virtual, date-fns. Фильтры AdminUsersGrid переведены на дерево FilterQuery, таблицы — на useTable + dataGridFeatures. Русские подписи вынесены в lib/reui-ru.ts. Импорты cn из реестра заменены на @authportal/ui/lib/utils. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 1 + apps/web/package.json | 8 +- .../components/reui-kit/admin-users-grid.tsx | 292 +- .../reui/cascader/cascader-async.tsx | 926 +++++ .../reui/cascader/cascader-columns.tsx | 274 ++ .../reui/cascader/cascader-context.tsx | 326 ++ .../reui/cascader/cascader-footer.tsx | 731 ++++ .../reui/cascader/cascader-i18n.tsx | 92 + .../reui/cascader/cascader-item.tsx | 1082 ++++++ .../components/reui/cascader/cascader-lib.tsx | 1012 ++++++ .../components/reui/cascader/cascader-nav.tsx | 649 ++++ .../reui/cascader/cascader-types.tsx | 220 ++ .../reui/cascader/cascader-virtual.tsx | 470 +++ .../src/components/reui/cascader/cascader.tsx | 3066 +++++++++++++++++ .../data-grid/data-grid-cell-selection.tsx | 2610 ++++++++++++++ .../data-grid/data-grid-column-filter.tsx | 21 +- .../data-grid/data-grid-column-header.tsx | 262 +- .../data-grid/data-grid-column-visibility.tsx | 28 +- .../reui/data-grid/data-grid-i18n.tsx | 99 + .../reui/data-grid/data-grid-pagination.tsx | 266 +- .../reui/data-grid/data-grid-scroll-area.tsx | 332 +- .../data-grid/data-grid-table-dnd-rows.tsx | 823 +++++ .../reui/data-grid/data-grid-table-dnd.tsx | 551 +++ .../data-grid/data-grid-table-virtual.tsx | 1220 +++++++ .../reui/data-grid/data-grid-table.tsx | 2480 ++++++++++--- .../components/reui/data-grid/data-grid.tsx | 1206 ++++++- apps/web/src/components/reui/filters.tsx | 1927 ----------- .../reui/filters/filters-advanced.tsx | 2081 +++++++++++ .../reui/filters/filters-builder.tsx | 303 ++ .../components/reui/filters/filters-chip.tsx | 1069 ++++++ .../reui/filters/filters-context.tsx | 662 ++++ .../components/reui/filters/filters-date.tsx | 213 ++ .../components/reui/filters/filters-dnd.tsx | 700 ++++ .../components/reui/filters/filters-draft.tsx | 187 + .../reui/filters/filters-editors.tsx | 1341 +++++++ .../components/reui/filters/filters-i18n.tsx | 151 + .../components/reui/filters/filters-lib.tsx | 539 +++ .../reui/filters/filters-operators.tsx | 227 ++ .../components/reui/filters/filters-query.tsx | 701 ++++ .../components/reui/filters/filters-types.tsx | 566 +++ .../src/components/reui/filters/filters.tsx | 1228 +++++++ apps/web/src/components/reui/frame.tsx | 61 +- apps/web/src/components/reui/icon-tile.tsx | 15 +- apps/web/src/design-preview/section-grid.tsx | 24 +- apps/web/src/lib/reui-ru.ts | 79 + apps/web/src/routes/_auth.admin.logins.tsx | 27 +- apps/web/tsconfig.app.json | 6 +- packages/ui/src/components/button-group.tsx | 2 +- packages/ui/src/components/button.tsx | 1 - packages/ui/src/components/checkbox.tsx | 5 +- packages/ui/src/components/dropdown-menu.tsx | 1 - packages/ui/src/components/input.tsx | 1 - packages/ui/src/components/popover.tsx | 1 - packages/ui/src/components/scroll-area.tsx | 4 +- packages/ui/src/components/select.tsx | 3 - packages/ui/src/components/separator.tsx | 3 +- packages/ui/src/components/tooltip.tsx | 3 +- pnpm-lock.yaml | 207 +- 58 files changed, 28252 insertions(+), 3133 deletions(-) create mode 100644 apps/web/src/components/reui/cascader/cascader-async.tsx create mode 100644 apps/web/src/components/reui/cascader/cascader-columns.tsx create mode 100644 apps/web/src/components/reui/cascader/cascader-context.tsx create mode 100644 apps/web/src/components/reui/cascader/cascader-footer.tsx create mode 100644 apps/web/src/components/reui/cascader/cascader-i18n.tsx create mode 100644 apps/web/src/components/reui/cascader/cascader-item.tsx create mode 100644 apps/web/src/components/reui/cascader/cascader-lib.tsx create mode 100644 apps/web/src/components/reui/cascader/cascader-nav.tsx create mode 100644 apps/web/src/components/reui/cascader/cascader-types.tsx create mode 100644 apps/web/src/components/reui/cascader/cascader-virtual.tsx create mode 100644 apps/web/src/components/reui/cascader/cascader.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-cell-selection.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-i18n.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx delete mode 100644 apps/web/src/components/reui/filters.tsx create mode 100644 apps/web/src/components/reui/filters/filters-advanced.tsx create mode 100644 apps/web/src/components/reui/filters/filters-builder.tsx create mode 100644 apps/web/src/components/reui/filters/filters-chip.tsx create mode 100644 apps/web/src/components/reui/filters/filters-context.tsx create mode 100644 apps/web/src/components/reui/filters/filters-date.tsx create mode 100644 apps/web/src/components/reui/filters/filters-dnd.tsx create mode 100644 apps/web/src/components/reui/filters/filters-draft.tsx create mode 100644 apps/web/src/components/reui/filters/filters-editors.tsx create mode 100644 apps/web/src/components/reui/filters/filters-i18n.tsx create mode 100644 apps/web/src/components/reui/filters/filters-lib.tsx create mode 100644 apps/web/src/components/reui/filters/filters-operators.tsx create mode 100644 apps/web/src/components/reui/filters/filters-query.tsx create mode 100644 apps/web/src/components/reui/filters/filters-types.tsx create mode 100644 apps/web/src/components/reui/filters/filters.tsx create mode 100644 apps/web/src/lib/reui-ru.ts diff --git a/.gitignore b/.gitignore index 1bd84a8..b45eefc 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ version.txt .codegraph/daemon.pid .release-version CHANGELOG.md +.claude/settings.local.json diff --git a/apps/web/package.json b/apps/web/package.json index 3408846..764720c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,10 @@ "@authportal/shared": "workspace:*", "@authportal/ui": "workspace:*", "@base-ui/react": "^1.5.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", "@hookform/resolvers": "^5.4.0", @@ -23,9 +27,11 @@ "@tailwindcss/vite": "^4.3.1", "@tanstack/react-query": "^5.101.0", "@tanstack/react-router": "^1.170.15", - "@tanstack/react-table": "^8.21.3", + "@tanstack/react-table": "^9.3.1", + "@tanstack/react-virtual": "^3.14.21", "@tanstack/router-vite-plugin": "^1.167.18", "class-variance-authority": "^0.7.1", + "date-fns": "^4.4.0", "lucide-react": "catalog:", "next-themes": "^0.4.6", "react": "^19.2.6", diff --git a/apps/web/src/components/reui-kit/admin-users-grid.tsx b/apps/web/src/components/reui-kit/admin-users-grid.tsx index 72a3072..c127834 100644 --- a/apps/web/src/components/reui-kit/admin-users-grid.tsx +++ b/apps/web/src/components/reui-kit/admin-users-grid.tsx @@ -3,9 +3,8 @@ * Preview: https://reui.io/preview/base/solution-users-1 * Docs: https://reui.io/docs/components/base/filters · https://reui.io/docs/components/base/data-grid */ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useMemo, useRef, useState } from 'react' import type { AdminUser, AppId } from '@authportal/shared' -import { APPS } from '@authportal/shared' import { CircleDotIcon, FilterIcon, @@ -25,20 +24,16 @@ import { UserPlusIcon, } from 'lucide-react' import { - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, type ColumnDef, type PaginationState, type Row, type RowSelectionState, type SortingState, - type VisibilityState, - useReactTable, + type ColumnVisibilityState, + useTable, } from '@tanstack/react-table' import { Badge } from '@/components/reui/badge' -import { DataGrid } from '@/components/reui/data-grid/data-grid' +import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility' import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' @@ -48,12 +43,17 @@ import { DataGridTableRowSelect, DataGridTableRowSelectAll, } from '@/components/reui/data-grid/data-grid-table' +import { DATA_GRID_RU, FILTERS_RU, FILTER_OPERATORS_RU } from '@/lib/reui-ru' +import { Filters } from '@/components/reui/filters/filters' import { - createFilter, - Filters, - type Filter, - type FilterFieldConfig, -} from '@/components/reui/filters' + createFilterQuery, + flattenFilterConditions, + type FilterCondition, +} from '@/components/reui/filters/filters-query' +import type { + FilterField, + FilterQuery, +} from '@/components/reui/filters/filters-types' import { Frame, FrameDescription, @@ -97,10 +97,6 @@ import { Separator } from '@authportal/ui/components/separator' import { Skeleton } from '@authportal/ui/components/skeleton' import { TooltipProvider } from '@authportal/ui/components/tooltip' -const APP_TITLE: Record = Object.fromEntries( - APPS.map((app) => [app.id, app.title]), -) as Record - const BULK_ROLE_OPTIONS = [ { value: 'admin', label: 'Админ' }, { value: 'user', label: 'Пользователь' }, @@ -121,28 +117,13 @@ export interface AdminUsersGridProps { onBulkDeactivate: (userIds: string[]) => void } -function getActiveFilters(filters: Filter[]) { - return filters.filter((filter) => { - const { values } = filter - if (!values || values.length === 0) return false - if ( - values.every((value) => typeof value === 'string' && value.trim() === '') - ) - return false - if (values.every((value) => value === null || value === undefined)) - return false - if (values.every((value) => Array.isArray(value) && value.length === 0)) - return false - return true - }) -} - -function serializeActiveFiltersKey(active: Filter[]) { +function serializeConditionsKey(conditions: FilterCondition[]) { return JSON.stringify( - active.map((f) => ({ - field: f.field, - operator: f.operator, - values: f.values, + conditions.map((c) => ({ + field: c.field, + operator: c.operator, + values: c.values, + negated: c.negated, })), ) } @@ -156,84 +137,47 @@ function filterFieldValue(item: AdminUser, field: string): unknown { return undefined } -function applyFiltersToUsers(data: AdminUser[], filters: Filter[]): AdminUser[] { - const active = getActiveFilters(filters) - let result = [...data] - for (const filter of active) { - const { field, operator, values } = filter - result = result.filter((item) => { - if (field === 'apps') { - const selected = values.map(String) - switch (operator) { - case 'is': - return ( - selected.length > 0 && item.apps.includes(selected[0] as AppId) - ) - case 'is_any_of': - return selected.some((v) => item.apps.includes(v as AppId)) - case 'is_not_any_of': - return !selected.some((v) => item.apps.includes(v as AppId)) - case 'contains': { - const tokens = values.map((v) => String(v).trim()).filter(Boolean) - if (tokens.length === 0) return true - return tokens.some((token) => - item.apps.some((appId) => - APP_TITLE[appId].toLowerCase().includes(token.toLowerCase()), - ), - ) - } - default: - return true - } - } +function matchesCondition(item: AdminUser, condition: FilterCondition): boolean { + const { field, operator, values } = condition + const raw = filterFieldValue(item, field) + const fieldValue = raw != null ? String(raw) : '' + const lower = fieldValue.toLowerCase() + const tokens = values.map((v) => String(v).trim()).filter(Boolean) - const raw = filterFieldValue(item, field) - const fieldValue = raw != null ? raw : '' - - switch (operator) { - case 'is': - return values.includes(fieldValue) - case 'is_not': - return !values.includes(fieldValue) - case 'is_any_of': - return values.some((v) => fieldValue === v) - case 'is_not_any_of': - return !values.some((v) => fieldValue === v) - case 'contains': { - const tokens = values.map((v) => String(v).trim()).filter(Boolean) - if (tokens.length === 0) return true - return tokens.some((token) => - String(fieldValue).toLowerCase().includes(token.toLowerCase()), - ) - } - case 'not_contains': - return !values.some((v) => - String(fieldValue).toLowerCase().includes(String(v).toLowerCase()), - ) - case 'starts_with': - return values.some((v) => - String(fieldValue) - .toLowerCase() - .startsWith(String(v).toLowerCase()), - ) - case 'ends_with': - return values.some((v) => - String(fieldValue).toLowerCase().endsWith(String(v).toLowerCase()), - ) - case 'empty': - return fieldValue === '' || fieldValue == null - case 'not_empty': - return fieldValue !== '' && fieldValue != null - default: - return true - } - }) + switch (operator) { + case 'is': + return values.map(String).includes(fieldValue) + case 'is_not': + return !values.map(String).includes(fieldValue) + case 'is_any_of': + return values.map(String).includes(fieldValue) + case 'is_none_of': + return !values.map(String).includes(fieldValue) + case 'contains': + return tokens.length === 0 || tokens.some((t) => lower.includes(t.toLowerCase())) + case 'not_contains': + return !tokens.some((t) => lower.includes(t.toLowerCase())) + case 'starts_with': + return tokens.some((t) => lower.startsWith(t.toLowerCase())) + case 'ends_with': + return tokens.some((t) => lower.endsWith(t.toLowerCase())) + case 'empty': + return fieldValue === '' + case 'not_empty': + return fieldValue !== '' + default: + return true } - return result } -function createDefaultFilters(): Filter[] { - return [createFilter('name', 'contains', [''])] +function applyConditionsToUsers(data: AdminUser[], conditions: FilterCondition[]): AdminUser[] { + if (conditions.length === 0) return data + return data.filter((item) => + conditions.every((c) => { + const hit = matchesCondition(item, c) + return c.negated ? !hit : hit + }), + ) } function userInitials(name: string, email: string) { @@ -257,12 +201,6 @@ function formatLastActive(iso: string | null) { }).format(date) } -function renderSelectedCount(values: unknown[]) { - if (values.length === 0) return 'Выберите…' - if (values.length > 1) return `${values.length} выбрано` - return null -} - function DotSeparator() { return ( }) { +const MemberCell = memo(function MemberCell({ row }: { row: Row }) { const user = row.original return (
@@ -407,7 +345,7 @@ function ActionsCell({ onDelete, onResetPasskeys, }: { - row: Row + row: Row onOpenAudit: (user: AdminUser) => void onOpenAccess: (user: AdminUser) => void onDeactivate: (user: AdminUser) => void @@ -526,7 +464,7 @@ function createAdminUserColumns(handlers: { onDeactivate: (user: AdminUser) => void onDelete: (user: AdminUser) => void onResetPasskeys: (user: AdminUser) => void -}): ColumnDef[] { +}): ColumnDef[] { return [ { id: 'select', @@ -766,15 +704,11 @@ export function AdminUsersGrid({ const [sorting, setSorting] = useState([ { id: 'name', desc: false }, ]) - const [columnVisibility, setColumnVisibility] = useState({}) + const [columnVisibility, setColumnVisibility] = useState({}) const [rowSelection, setRowSelection] = useState({}) - const [filters, setFilters] = useState(createDefaultFilters) + const [query, setQuery] = useState(() => createFilterQuery()) const [bulkRole, setBulkRole] = useState('user') - const [filteredData, setFilteredData] = useState(users) - const isInitialLoad = useRef(true) - const lastAppliedActiveKey = useRef( - serializeActiveFiltersKey(getActiveFilters(createDefaultFilters())), - ) + const lastAppliedKey = useRef(serializeConditionsKey([])) const stats = useMemo( () => ({ @@ -785,95 +719,56 @@ export function AdminUsersGrid({ [users], ) - const filterFields: FilterFieldConfig[] = useMemo( + const filterFields: FilterField[] = useMemo( () => [ { - key: 'name', + id: 'name', label: 'Участник', icon: , type: 'text', - className: 'w-44', placeholder: 'Поиск…', }, { - key: 'email', + id: 'email', label: 'Email', icon: , type: 'text', - className: 'w-48', placeholder: 'Поиск…', }, { - key: 'role', + id: 'role', label: 'Роль', icon: , type: 'select', searchable: false, - className: 'w-[160px]', - options: BULK_ROLE_OPTIONS.map((o) => ({ - value: o.value, - label: o.label, - })), - customValueRenderer: (values) => { - const state = renderSelectedCount(values) - if (state) return state - return ( - - ) - }, + options: BULK_ROLE_OPTIONS.map((o) => ({ value: o.value, label: o.label })), }, { - key: 'status', + id: 'status', label: 'Статус', icon: , type: 'select', searchable: false, - className: 'w-[150px]', options: [ { value: 'active', label: 'Активен' }, { value: 'disabled', label: 'Отключён' }, ], - customValueRenderer: (values) => { - const state = renderSelectedCount(values) - if (state) return state - return ( - - ) - }, }, ], [], ) - const applyFilters = useCallback( - (newFilters: Filter[]) => applyFiltersToUsers(users, newFilters), - [users], - ) + const conditions = useMemo(() => flattenFilterConditions(query), [query]) + const filteredData = useMemo(() => applyConditionsToUsers(users, conditions), [users, conditions]) - const handleFiltersChange = useCallback( - (newFilters: Filter[]) => { - setFilters(newFilters) - const newActive = getActiveFilters(newFilters) - const nextKey = serializeActiveFiltersKey(newActive) - if (nextKey === lastAppliedActiveKey.current) return - lastAppliedActiveKey.current = nextKey - setPagination((prev) => ({ ...prev, pageIndex: 0 })) - setRowSelection({}) - }, - [], - ) - - useEffect(() => { - if (isInitialLoad.current) { - setFilteredData(applyFilters(filters)) - isInitialLoad.current = false - return - } - setFilteredData(applyFilters(filters)) - lastAppliedActiveKey.current = serializeActiveFiltersKey( - getActiveFilters(filters), - ) - }, [users, filters, applyFilters]) + const handleQueryChange = useCallback((next: FilterQuery) => { + setQuery(next) + const nextKey = serializeConditionsKey(flattenFilterConditions(next)) + if (nextKey === lastAppliedKey.current) return + lastAppliedKey.current = nextKey + setPagination((prev) => ({ ...prev, pageIndex: 0 })) + setRowSelection({}) + }, []) const columns = useMemo( () => @@ -891,7 +786,8 @@ export function AdminUsersGrid({ () => columns.map((c) => c.id as string), ) - const table = useReactTable({ + const table = useTable({ + features: dataGridFeatures, columns, data: filteredData, pageCount: Math.ceil(filteredData.length / pagination.pageSize), @@ -910,17 +806,13 @@ export function AdminUsersGrid({ onPaginationChange: setPagination, onSortingChange: setSorting, onRowSelectionChange: setRowSelection, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), }) const selectedCount = table.getSelectedRowModel().rows.length const selectedIds = table .getSelectedRowModel() .rows.map((row) => row.original.id) - const showClearButton = filters.length > 0 + const showClearButton = conditions.length > 0 const gridLoading = isLoading const handleClearSelection = useCallback(() => setRowSelection({}), []) @@ -938,11 +830,8 @@ export function AdminUsersGrid({ }, [onBulkDeactivate, selectedCount, selectedIds]) const handleClearFilters = useCallback(() => { - const next = createDefaultFilters() - lastAppliedActiveKey.current = serializeActiveFiltersKey( - getActiveFilters(next), - ) - setFilters(next) + lastAppliedKey.current = serializeConditionsKey([]) + setQuery(createFilterQuery()) setRowSelection({}) }, []) @@ -950,6 +839,7 @@ export function AdminUsersGrid({
- Filters + Фильтры } /> @@ -1020,7 +912,7 @@ export function AdminUsersGrid({ disabled={gridLoading} > - Clear + Сбросить ) : null}