From 41e4ca1410ffaa5e5dba1185babc23c91ae09cba Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 21 Jul 2026 02:36:09 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20Lists/Rules=20UX=20parity=20=D1=81?= =?UTF-8?q?=20=D0=90=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=D0=BC=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=BE=20kpi-hybrid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hybrid row icons, row click, icon actions; DetailPanel header; ToggleGroup режима фильтра (settings-3). Co-authored-by: Cursor --- .cursor/rules/kpi-hybrid.mdc | 29 ++++ .cursor/rules/reui-mcp.mdc | 2 +- .../src/components/lists/list-type-icon.tsx | 51 +++++++ .../src/components/lists/lists-columns.tsx | 26 +--- .../components/rules/policy-mode-toggle.tsx | 69 ++++++++++ .../src/components/rules/policy-set-icon.tsx | 30 +++++ apps/web/src/routes/_auth/lists/$id.tsx | 125 ++++++++---------- apps/web/src/routes/_auth/lists/index.tsx | 4 +- apps/web/src/routes/_auth/rules/$setId.tsx | 66 +++------ apps/web/src/routes/_auth/rules/index.tsx | 69 +++++----- packages/ui/src/components/toggle-group.tsx | 89 +++++++++++++ packages/ui/src/components/toggle.tsx | 43 ++++++ 12 files changed, 432 insertions(+), 171 deletions(-) create mode 100644 .cursor/rules/kpi-hybrid.mdc create mode 100644 apps/web/src/components/lists/list-type-icon.tsx create mode 100644 apps/web/src/components/rules/policy-mode-toggle.tsx create mode 100644 apps/web/src/components/rules/policy-set-icon.tsx create mode 100644 packages/ui/src/components/toggle-group.tsx create mode 100644 packages/ui/src/components/toggle.tsx diff --git a/.cursor/rules/kpi-hybrid.mdc b/.cursor/rules/kpi-hybrid.mdc new file mode 100644 index 0000000..9d746bf --- /dev/null +++ b/.cursor/rules/kpi-hybrid.mdc @@ -0,0 +1,29 @@ +--- +description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll. +alwaysApply: true +--- + +# KPI hybrid — только kit (stats-12 DNA) + +Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`. + +Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc). + +## MUST + +| Зона | Компонент / DNA | +|------|-----------------| +| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) | +| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` | +| Row icon tiles (data-grid) | та же DNA (`AgentPlatformIcon` и клоны) — semantic `text-*` на `bg-muted` | +| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) | + +Импорты UI: `@evofw/ui/components/*`. + +## NEVER + +- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI +- Другой size / radius / solid brand fill вместо `bg-muted` +- `card-35` как замена stats-12 hybrid KPI +- Копипаст ReUI block в route — adapt через `reui-kit/` +- Голый lucide `size-4` в name-cell без hybrid tile diff --git a/.cursor/rules/reui-mcp.mdc b/.cursor/rules/reui-mcp.mdc index bd4355b..677faf9 100644 --- a/.cursor/rules/reui-mcp.mdc +++ b/.cursor/rules/reui-mcp.mdc @@ -36,7 +36,7 @@ alwaysApply: true | Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` | | Filters | `@reui` | `@/components/reui/filters` | | Frame surface | `@reui` | `@/components/reui/frame` | -| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` (EvoBGP hybrid SoT) | +| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` — см. [`kpi-hybrid.mdc`](kpi-hybrid.mdc) | | Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` | | Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` | | Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` | diff --git a/apps/web/src/components/lists/list-type-icon.tsx b/apps/web/src/components/lists/list-type-icon.tsx new file mode 100644 index 0000000..fdeb5e5 --- /dev/null +++ b/apps/web/src/components/lists/list-type-icon.tsx @@ -0,0 +1,51 @@ +import { Globe, List, Network } from 'lucide-react' +import { cn } from '@evofw/ui/lib/utils' +import { Item, ItemMedia } from '@evofw/ui/components/item' +import { isManualListType } from '@evofw/shared' + +type ListTypeIconProps = { + type: string + className?: string +} + +/** + * KPI-style type tile for list rows. + * Preview DNA: https://reui.io/preview/base/stats-12 + */ +export function ListTypeIcon({ type, className }: ListTypeIconProps) { + const kind = isManualListType(type) + ? 'static' + : type === 'evobgp_community' + ? 'evobgp_community' + : 'json_url' + + const Icon = + kind === 'evobgp_community' ? Network : kind === 'json_url' ? Globe : List + const color = + kind === 'evobgp_community' + ? 'text-info' + : kind === 'json_url' + ? 'text-warning' + : 'text-muted-foreground' + + return ( + + + + + + ) +} diff --git a/apps/web/src/components/lists/lists-columns.tsx b/apps/web/src/components/lists/lists-columns.tsx index 3c99d98..f74edc3 100644 --- a/apps/web/src/components/lists/lists-columns.tsx +++ b/apps/web/src/components/lists/lists-columns.tsx @@ -1,6 +1,5 @@ -import { Link } from '@tanstack/react-router' import type { ColumnDef } from '@tanstack/react-table' -import { ListIcon, RefreshCwIcon, Trash2 } from 'lucide-react' +import { RefreshCwIcon, Trash2 } from 'lucide-react' import type { FilterFieldConfig } from '@/components/reui/filters' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { @@ -8,6 +7,7 @@ import { DataGridPrimaryCell, } from '@/components/data-grid-cell' import { StatusBadge } from '@/components/status-badge' +import { ListTypeIcon } from '@/components/lists/list-type-icon' import { Button } from '@evofw/ui/components/button' import { isManualListType, type IpList } from '@evofw/shared' @@ -58,15 +58,8 @@ export function createListColumns(opts: { ), cell: ({ row }) => ( - - +
+ - +
), meta: { headerTitle: 'Список' }, }, @@ -137,15 +130,6 @@ export function createListColumns(opts: { } /> - - } - /> + + } + > + К спискам + + } + /> + ) } return ( - - - - {manual ? ( - - ) : null} - - - } - /> - + + + {manual ? ( + + ) : null} + + } - actions={} /> @@ -185,6 +186,7 @@ function ListsPage() { description: 'Создайте первый список для политики firewall.', action: ( ), diff --git a/apps/web/src/routes/_auth/rules/$setId.tsx b/apps/web/src/routes/_auth/rules/$setId.tsx index 176a526..a5f4798 100644 --- a/apps/web/src/routes/_auth/rules/$setId.tsx +++ b/apps/web/src/routes/_auth/rules/$setId.tsx @@ -14,7 +14,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { StatusBadge } from '@/components/status-badge' import { ConfirmDialog } from '@/components/confirm-dialog' import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable' -import { Badge } from '@/components/reui/badge' +import { PolicyModeToggle } from '@/components/rules/policy-mode-toggle' import { agentsQueryOptions, listsQueryOptions, @@ -56,6 +56,12 @@ import { } from '@/components/reui/frame' export const Route = createFileRoute('/_auth/rules/$setId')({ + loader: async ({ context: { queryClient }, params }) => { + const set = await queryClient.ensureQueryData( + policySetQueryOptions(params.setId), + ) + return { breadcrumb: set.name } + }, component: PolicySetDetailPage, }) @@ -286,10 +292,12 @@ function PolicySetDetailPage() { patchSet.mutate({ enabled: v })} + disabled={patchSet.isPending} + aria-label={set.enabled ? 'Выключить набор' : 'Включить набор'} /> - + + {set.enabled ? 'Включён' : 'Выключен'} + - - - {policyMode} - - - - + patchSet.mutate({ policy_mode: mode })} + /> ), cell: ({ row }) => ( - +
+ - +
), }, { @@ -190,16 +188,18 @@ function PolicySetsPage() { cell: ({ row }) => (
{row.original.id !== 'set-shared-default' ? ( @@ -216,7 +219,14 @@ function PolicySetsPage() { ), }, ], - [], + [navigate], + ) + + const addButton = ( + ) return ( @@ -224,11 +234,7 @@ function PolicySetsPage() { setSheetOpen(true)}> - Новый набор - - } + actions={addButton} /> void setsQ.refetch()} + onRowClick={(row) => + void navigate({ to: '/rules/$setId', params: { setId: row.id } }) + } emptyState={{ title: 'Нет наборов', description: 'Создайте первый набор правил политики.', - action: ( - - ), + action: addButton, }} /> diff --git a/packages/ui/src/components/toggle-group.tsx b/packages/ui/src/components/toggle-group.tsx new file mode 100644 index 0000000..caf1775 --- /dev/null +++ b/packages/ui/src/components/toggle-group.tsx @@ -0,0 +1,89 @@ +"use client" + +import * as React from "react" +import { Toggle as TogglePrimitive } from "@base-ui/react/toggle" +import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group" +import { type VariantProps } from "class-variance-authority" + +import { cn } from "@evofw/ui/lib/utils" +import { toggleVariants } from "@evofw/ui/components/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps & { + spacing?: number + orientation?: "horizontal" | "vertical" + } +>({ + size: "default", + variant: "default", + spacing: 2, + orientation: "horizontal", +}) + +function ToggleGroup({ + className, + variant, + size, + spacing = 2, + orientation = "horizontal", + children, + ...props +}: ToggleGroupPrimitive.Props & + VariantProps & { + spacing?: number + orientation?: "horizontal" | "vertical" + }) { + return ( + + + {children} + + + ) +} + +function ToggleGroupItem({ + className, + children, + variant = "default", + size = "default", + ...props +}: TogglePrimitive.Props & VariantProps) { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +} + +export { ToggleGroup, ToggleGroupItem } diff --git a/packages/ui/src/components/toggle.tsx b/packages/ui/src/components/toggle.tsx new file mode 100644 index 0000000..463ea98 --- /dev/null +++ b/packages/ui/src/components/toggle.tsx @@ -0,0 +1,43 @@ +import { Toggle as TogglePrimitive } from "@base-ui/react/toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@evofw/ui/lib/utils" + +const toggleVariants = cva( + "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-transparent", + outline: "border border-input bg-transparent hover:bg-muted", + }, + size: { + default: + "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Toggle({ + className, + variant = "default", + size = "default", + ...props +}: TogglePrimitive.Props & VariantProps) { + return ( + + ) +} + +export { Toggle, toggleVariants }