fix(web): привести UI к правилам ReUI — фильтры, badge, токены
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Подключены DateSelector и number-field в VPS-фильтры, semantic StatusBadge, --focus токены и переименование DataGridColumn. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -27,7 +27,7 @@ import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagina
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
|
||||
import { EmptyState } from './empty-state'
|
||||
import type { DataTableColumn } from './data-grid-types'
|
||||
import type { DataGridColumn } from './data-grid-types'
|
||||
|
||||
const PAGINATION_LABELS = {
|
||||
rowsPerPageLabel: 'Строк на странице',
|
||||
@@ -59,8 +59,8 @@ export interface DataGridColumnVisibilityOption {
|
||||
label: string
|
||||
}
|
||||
|
||||
export function dataTableColumnVisibilityOptions<T>(
|
||||
cols: DataTableColumn<T>[],
|
||||
export function dataGridColumnVisibilityOptions<T>(
|
||||
cols: DataGridColumn<T>[],
|
||||
): DataGridColumnVisibilityOption[] {
|
||||
return cols
|
||||
.filter((c) => c.enableHiding !== false)
|
||||
@@ -390,9 +390,9 @@ export function DataGridCard<TData extends object>({
|
||||
)
|
||||
}
|
||||
|
||||
/** Хелпер для конвертации DataTableColumn<T> → ColumnDef<T> с DataGridColumnHeader. */
|
||||
export function columnDefFromDataTable<T>(
|
||||
cols: DataTableColumn<T>[],
|
||||
/** Хелпер для конвертации DataGridColumn<T> → ColumnDef<T> с DataGridColumnHeader. */
|
||||
export function columnDefFromDataGrid<T>(
|
||||
cols: DataGridColumn<T>[],
|
||||
): ColumnDef<T, unknown>[] {
|
||||
return cols.map((c) => {
|
||||
const title = resolveHeaderTitle(c.header, c.headerTitle)
|
||||
@@ -429,5 +429,8 @@ export function columnDefFromDataTable<T>(
|
||||
})
|
||||
}
|
||||
|
||||
/** @deprecated Используйте columnDefFromDataGrid */
|
||||
export const columnDefFromDataTable = columnDefFromDataGrid
|
||||
|
||||
/** re-export flexRender для удобства использования в колонках. */
|
||||
export { flexRender }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface DataTableColumn<T> {
|
||||
export interface DataGridColumn<T> {
|
||||
key: string
|
||||
header: ReactNode
|
||||
cell: (row: T, index: number) => ReactNode
|
||||
@@ -14,6 +14,9 @@ export interface DataTableColumn<T> {
|
||||
enableHiding?: boolean
|
||||
}
|
||||
|
||||
/** @deprecated Используйте DataGridColumn */
|
||||
export type DataTableColumn<T> = DataGridColumn<T>
|
||||
|
||||
/** Унифицированные классы колонок для DataGridCard. */
|
||||
export const COL = {
|
||||
num: 'w-28 text-right tabular-nums',
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { DayButton } from "react-day-picker"
|
||||
import type { DateRange } from "react-day-picker"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { useIsMobile } from "@cfdm/ui/hooks/use-mobile"
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { Calendar, CalendarDayButton } from "@cfdm/ui/components/calendar"
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||
|
||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||
active: 'default',
|
||||
ok: 'default',
|
||||
paid: 'default',
|
||||
active: 'success',
|
||||
ok: 'success',
|
||||
paid: 'success',
|
||||
paused: 'secondary',
|
||||
archived: 'outline',
|
||||
error: 'destructive',
|
||||
running: 'secondary',
|
||||
overdue: 'destructive',
|
||||
stale: 'destructive',
|
||||
running: 'info',
|
||||
overdue: 'warning',
|
||||
stale: 'warning',
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||
|
||||
@@ -10,13 +10,24 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@cfdm/ui/components/popover'
|
||||
import { Slider } from '@cfdm/ui/components/slider'
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
|
||||
import { ListFiltersBar, type FilterChip } from '@/components/list-filters-bar'
|
||||
import type { DataGridColumnVisibilityOption } from '@/components/data-grid-card'
|
||||
import type { VisibilityState } from '@tanstack/react-table'
|
||||
|
||||
import {
|
||||
DateSelector,
|
||||
type DateSelectorFilterType,
|
||||
type DateSelectorValue,
|
||||
} from '@/components/reui/date-selector'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/components/reui/number-field'
|
||||
import {
|
||||
Filters,
|
||||
createFilter,
|
||||
@@ -24,6 +35,7 @@ import {
|
||||
type FilterFieldConfig,
|
||||
type FilterI18nConfig,
|
||||
type FilterOption,
|
||||
type CustomRendererProps,
|
||||
} from '@/components/reui/filters'
|
||||
import {
|
||||
type VpsFiltersState,
|
||||
@@ -34,6 +46,7 @@ import {
|
||||
saveFilterPresets,
|
||||
type VpsFilterPreset,
|
||||
} from '@/components/vps-filters'
|
||||
import { RU_DATE_SELECTOR_I18N } from '@/lib/date-selector-i18n'
|
||||
import { vpsStatusLabel, tariffTypeLabel, environmentLabel } from '@/lib/format'
|
||||
import { CountryFlag } from '@/components/country-flag'
|
||||
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
|
||||
@@ -54,6 +67,47 @@ interface VpsFiltersToolbarProps {
|
||||
onColumnVisibilityChange?: (columnId: string, visible: boolean) => void
|
||||
}
|
||||
|
||||
function toDateSelectorValue(values: string[], operator: string): DateSelectorValue {
|
||||
return {
|
||||
period: 'day',
|
||||
operator: (operator as DateSelectorFilterType) || 'before',
|
||||
startDate: values[0] ? new Date(values[0]) : undefined,
|
||||
endDate: values[1] ? new Date(values[1]) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function fromDateSelectorValue(value: DateSelectorValue): string[] {
|
||||
const out: string[] = []
|
||||
if (value.startDate) out.push(value.startDate.toISOString().slice(0, 10))
|
||||
if (value.endDate) out.push(value.endDate.toISOString().slice(0, 10))
|
||||
return out
|
||||
}
|
||||
|
||||
function renderMinNumberField(
|
||||
min: number,
|
||||
max: number,
|
||||
values: number[],
|
||||
onChange: (v: number[]) => void,
|
||||
) {
|
||||
return (
|
||||
<div className="px-2 py-1">
|
||||
<NumberField
|
||||
value={values[0] ?? null}
|
||||
onValueChange={(v) => onChange([v ?? 0])}
|
||||
min={min}
|
||||
max={max}
|
||||
size="sm"
|
||||
>
|
||||
<NumberFieldGroup>
|
||||
<NumberFieldDecrement />
|
||||
<NumberFieldInput />
|
||||
<NumberFieldIncrement />
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RU_I18N: FilterI18nConfig = {
|
||||
addFilter: 'Фильтр',
|
||||
searchFields: 'Поиск поля…',
|
||||
@@ -135,6 +189,13 @@ function stateToFilters(state: VpsFiltersState): (Filter<string> | Filter<number
|
||||
if (state.minVcpu != null) out.push(createFilter<number>('minVcpu', 'is', [state.minVcpu]))
|
||||
if (state.minRamGb != null) out.push(createFilter<number>('minRamGb', 'is', [state.minRamGb]))
|
||||
if (state.minDiskGb != null) out.push(createFilter<number>('minDiskGb', 'is', [state.minDiskGb]))
|
||||
if (state.paidUntilStart) {
|
||||
const values =
|
||||
state.paidUntilEnd && state.paidUntilOperator === 'between'
|
||||
? [state.paidUntilStart, state.paidUntilEnd]
|
||||
: [state.paidUntilStart]
|
||||
out.push(createFilter<string>('paidUntil', state.paidUntilOperator ?? 'before', values))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -160,6 +221,12 @@ function filtersToState(filters: Filter[], base: VpsFiltersState): VpsFiltersSta
|
||||
case 'minVcpu': next.minVcpu = (f.values[0] as number) ?? null; break
|
||||
case 'minRamGb': next.minRamGb = (f.values[0] as number) ?? null; break
|
||||
case 'minDiskGb': next.minDiskGb = (f.values[0] as number) ?? null; break
|
||||
case 'paidUntil': {
|
||||
next.paidUntilOperator = (f.operator as typeof next.paidUntilOperator) ?? 'before'
|
||||
next.paidUntilStart = (f.values[0] as string) ?? null
|
||||
next.paidUntilEnd = (f.values[1] as string) ?? null
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return next
|
||||
@@ -255,25 +322,39 @@ export function VpsFiltersToolbar({
|
||||
{ key: 'monitoring', label: 'Мониторинг', type: 'multiselect' as const, options: onOffOpts, defaultOperator: 'is_any_of' },
|
||||
{ key: 'backup', label: 'Бэкап', type: 'multiselect' as const, options: onOffOpts, defaultOperator: 'is_any_of' },
|
||||
{ key: 'project', label: 'Проект', type: 'multiselect' as const, options: projectOpts, searchable: true, defaultOperator: 'is_any_of' },
|
||||
{
|
||||
key: 'paidUntil',
|
||||
label: 'Оплачено до',
|
||||
type: 'custom' as const,
|
||||
defaultOperator: 'before',
|
||||
operators: [
|
||||
{ value: 'before', label: 'до' },
|
||||
{ value: 'after', label: 'после' },
|
||||
{ value: 'between', label: 'между' },
|
||||
{ value: 'is', label: '=' },
|
||||
],
|
||||
customRenderer: ({ values, onChange: onCh, operator }: CustomRendererProps<string>) => (
|
||||
<div className="p-2">
|
||||
<DateSelector
|
||||
value={toDateSelectorValue(values, operator)}
|
||||
onChange={(v) => onCh(fromDateSelectorValue(v))}
|
||||
allowRange={operator === 'between'}
|
||||
presetMode={operator as DateSelectorFilterType}
|
||||
i18n={RU_DATE_SELECTOR_I18N}
|
||||
showTwoMonths={operator === 'between'}
|
||||
dayDateFormat="dd.MM.yyyy"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'minVcpu',
|
||||
label: 'vCPU ≥',
|
||||
type: 'custom' as const,
|
||||
defaultOperator: 'is',
|
||||
operators: [{ value: 'is', label: '≥' }],
|
||||
customRenderer: ({ values, onChange: onCh }: { values: number[]; onChange: (v: number[]) => void; operator: string }) => (
|
||||
<div className="flex items-center gap-2 px-2 py-1 w-44">
|
||||
<Slider
|
||||
min={0}
|
||||
max={32}
|
||||
step={1}
|
||||
value={values[0] ?? 0}
|
||||
onValueChange={(v) => onCh([typeof v === 'number' ? v : (v[0] ?? 0)])}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-8 text-sm tabular-nums text-end">{values[0] ?? 0}</span>
|
||||
</div>
|
||||
),
|
||||
customRenderer: ({ values, onChange: onCh }: CustomRendererProps<number>) =>
|
||||
renderMinNumberField(0, 32, values, onCh),
|
||||
},
|
||||
{
|
||||
key: 'minRamGb',
|
||||
@@ -281,19 +362,8 @@ export function VpsFiltersToolbar({
|
||||
type: 'custom' as const,
|
||||
defaultOperator: 'is',
|
||||
operators: [{ value: 'is', label: '≥' }],
|
||||
customRenderer: ({ values, onChange: onCh }: { values: number[]; onChange: (v: number[]) => void; operator: string }) => (
|
||||
<div className="flex items-center gap-2 px-2 py-1 w-44">
|
||||
<Slider
|
||||
min={0}
|
||||
max={256}
|
||||
step={1}
|
||||
value={values[0] ?? 0}
|
||||
onValueChange={(v) => onCh([typeof v === 'number' ? v : (v[0] ?? 0)])}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-10 text-sm tabular-nums text-end">{values[0] ?? 0} GB</span>
|
||||
</div>
|
||||
),
|
||||
customRenderer: ({ values, onChange: onCh }: CustomRendererProps<number>) =>
|
||||
renderMinNumberField(0, 256, values, onCh),
|
||||
},
|
||||
{
|
||||
key: 'minDiskGb',
|
||||
@@ -301,19 +371,8 @@ export function VpsFiltersToolbar({
|
||||
type: 'custom' as const,
|
||||
defaultOperator: 'is',
|
||||
operators: [{ value: 'is', label: '≥' }],
|
||||
customRenderer: ({ values, onChange: onCh }: { values: number[]; onChange: (v: number[]) => void; operator: string }) => (
|
||||
<div className="flex items-center gap-2 px-2 py-1 w-44">
|
||||
<Slider
|
||||
min={0}
|
||||
max={2000}
|
||||
step={10}
|
||||
value={values[0] ?? 0}
|
||||
onValueChange={(v) => onCh([typeof v === 'number' ? v : (v[0] ?? 0)])}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-14 text-sm tabular-nums text-end">{values[0] ?? 0} GB</span>
|
||||
</div>
|
||||
),
|
||||
customRenderer: ({ values, onChange: onCh }: CustomRendererProps<number>) =>
|
||||
renderMinNumberField(0, 2000, values, onCh),
|
||||
},
|
||||
]
|
||||
}, [providers, providerAccounts, vps, countryOptions, cityOptions, projectNameOptions])
|
||||
@@ -434,6 +493,28 @@ export function VpsFiltersToolbar({
|
||||
onRemove: () => onChange({ ...filters, minDiskGb: null }),
|
||||
})
|
||||
}
|
||||
if (filters.paidUntilStart) {
|
||||
const op = filters.paidUntilOperator ?? 'before'
|
||||
const start = new Date(filters.paidUntilStart).toLocaleDateString('ru-RU')
|
||||
const end = filters.paidUntilEnd
|
||||
? new Date(filters.paidUntilEnd).toLocaleDateString('ru-RU')
|
||||
: null
|
||||
const label =
|
||||
op === 'between' && end
|
||||
? `Оплачено до: ${start} — ${end}`
|
||||
: `Оплачено до ${op === 'after' ? 'после' : op === 'is' ? '' : ''} ${start}`
|
||||
out.push({
|
||||
id: 'paidUntil',
|
||||
label: label.trim(),
|
||||
onRemove: () =>
|
||||
onChange({
|
||||
...filters,
|
||||
paidUntilOperator: null,
|
||||
paidUntilStart: null,
|
||||
paidUntilEnd: null,
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (filters.groupByProject) {
|
||||
out.push({
|
||||
id: 'groupByProject',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Vps } from '@/types/entities'
|
||||
import type { PaidUntilContext } from '@/lib/paid-until'
|
||||
import { getPaidUntilDate } from '@/lib/paid-until'
|
||||
|
||||
export type PaidUntilFilterOperator = 'is' | 'before' | 'after' | 'between'
|
||||
|
||||
/**
|
||||
* Состояние фильтров VPS.
|
||||
@@ -21,6 +25,9 @@ export interface VpsFiltersState {
|
||||
minVcpu: number | null
|
||||
minRamGb: number | null
|
||||
minDiskGb: number | null
|
||||
paidUntilOperator: PaidUntilFilterOperator | null
|
||||
paidUntilStart: string | null
|
||||
paidUntilEnd: string | null
|
||||
project: string[]
|
||||
groupByProject: boolean
|
||||
tableCompact: boolean
|
||||
@@ -42,6 +49,9 @@ export function buildDefaultVpsFilters(): VpsFiltersState {
|
||||
minVcpu: null,
|
||||
minRamGb: null,
|
||||
minDiskGb: null,
|
||||
paidUntilOperator: null,
|
||||
paidUntilStart: null,
|
||||
paidUntilEnd: null,
|
||||
project: [],
|
||||
groupByProject: false,
|
||||
tableCompact: false,
|
||||
@@ -91,6 +101,41 @@ const matchesNumberGte = (
|
||||
return Number(item ?? 0) >= threshold
|
||||
}
|
||||
|
||||
function startOfDay(d: Date): Date {
|
||||
const next = new Date(d)
|
||||
next.setHours(0, 0, 0, 0)
|
||||
return next
|
||||
}
|
||||
|
||||
function matchesPaidUntil(
|
||||
itemDate: Date | null,
|
||||
operator: string,
|
||||
values: string[],
|
||||
): boolean {
|
||||
if (values.length === 0) return true
|
||||
if (!itemDate) return false
|
||||
const itemDay = startOfDay(itemDate)
|
||||
const start = values[0] ? startOfDay(new Date(values[0])) : null
|
||||
const end = values[1] ? startOfDay(new Date(values[1])) : null
|
||||
|
||||
switch (operator) {
|
||||
case 'is':
|
||||
return start ? itemDay.getTime() === start.getTime() : true
|
||||
case 'before':
|
||||
return start ? itemDay.getTime() < start.getTime() : true
|
||||
case 'after':
|
||||
return start ? itemDay.getTime() > start.getTime() : true
|
||||
case 'between':
|
||||
if (start && end) {
|
||||
return itemDay.getTime() >= start.getTime() && itemDay.getTime() <= end.getTime()
|
||||
}
|
||||
if (start) return itemDay.getTime() >= start.getTime()
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
interface ActiveFilter {
|
||||
field: string
|
||||
operator: string
|
||||
@@ -105,6 +150,7 @@ interface ActiveFilter {
|
||||
export function applyVpsFilters(
|
||||
items: Vps[],
|
||||
filters: VpsFiltersState | ActiveFilter[],
|
||||
paidUntilCtx?: PaidUntilContext,
|
||||
): Vps[] {
|
||||
// Если передан state — конвертируем в active filters
|
||||
const activeFilters: ActiveFilter[] = Array.isArray(filters)
|
||||
@@ -189,6 +235,15 @@ export function applyVpsFilters(
|
||||
case 'minDiskGb':
|
||||
if (!matchesNumberGte(item.diskGb, f.values as number[])) return false
|
||||
break
|
||||
case 'paidUntil': {
|
||||
const itemDate = paidUntilCtx
|
||||
? getPaidUntilDate(item, paidUntilCtx)
|
||||
: item.paidUntil
|
||||
? new Date(item.paidUntil)
|
||||
: null
|
||||
if (!matchesPaidUntil(itemDate, f.operator, f.values as string[])) return false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
@@ -213,6 +268,15 @@ export function stateToActiveFilters(state: VpsFiltersState): ActiveFilter[] {
|
||||
if (state.minVcpu != null) out.push({ field: 'minVcpu', operator: 'gte', values: [state.minVcpu] })
|
||||
if (state.minRamGb != null) out.push({ field: 'minRamGb', operator: 'gte', values: [state.minRamGb] })
|
||||
if (state.minDiskGb != null) out.push({ field: 'minDiskGb', operator: 'gte', values: [state.minDiskGb] })
|
||||
if (state.paidUntilStart) {
|
||||
out.push({
|
||||
field: 'paidUntil',
|
||||
operator: state.paidUntilOperator ?? 'before',
|
||||
values: state.paidUntilEnd
|
||||
? [state.paidUntilStart, state.paidUntilEnd]
|
||||
: [state.paidUntilStart],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -233,6 +297,7 @@ export function countActiveFilters(filters: VpsFiltersState): number {
|
||||
if (filters.minVcpu != null) n++
|
||||
if (filters.minRamGb != null) n++
|
||||
if (filters.minDiskGb != null) n++
|
||||
if (filters.paidUntilStart) n++
|
||||
return n
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user