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:
@@ -41,6 +41,7 @@ pnpm dlx shadcn@latest add @reui/filters
|
|||||||
| filters | `VpsFiltersToolbar` |
|
| filters | `VpsFiltersToolbar` |
|
||||||
| autocomplete | `AutoCompleteInput` |
|
| autocomplete | `AutoCompleteInput` |
|
||||||
| date-selector | `lib/date-selector-i18n.ts` |
|
| date-selector | `lib/date-selector-i18n.ts` |
|
||||||
|
| color-picker | напрямую в domain forms (`project-edit-sheet`) |
|
||||||
|
|
||||||
## Матрица выбора
|
## Матрица выбора
|
||||||
|
|
||||||
@@ -61,3 +62,4 @@ pnpm dlx shadcn@latest add @reui/filters
|
|||||||
- `@/components/ui/*` → `@cfdm/ui/components/*`
|
- `@/components/ui/*` → `@cfdm/ui/components/*`
|
||||||
- Не класть ReUI в `packages/ui`
|
- Не класть ReUI в `packages/ui`
|
||||||
- Semantic colors: `variant="success"` — не `bg-emerald-*`
|
- Semantic colors: `variant="success"` — не `bg-emerald-*`
|
||||||
|
- `color-picker`: hex только в preset data, не в Tailwind `className`
|
||||||
|
|||||||
@@ -51,12 +51,11 @@ Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workfl
|
|||||||
| `EmptyState` | `empty-state.tsx` |
|
| `EmptyState` | `empty-state.tsx` |
|
||||||
| `QueryState` | `query-state.tsx` |
|
| `QueryState` | `query-state.tsx` |
|
||||||
| `ConfirmDialog` | `confirm-dialog.tsx` |
|
| `ConfirmDialog` | `confirm-dialog.tsx` |
|
||||||
| `DataTableCard` | `data-table-card.tsx` |
|
| `DataGridCard` | `data-grid-card.tsx` |
|
||||||
| `SectionCards` | `section-cards.tsx` |
|
| `SectionCards` | `section-cards.tsx` |
|
||||||
| `StatusBadge` | `status-badge.tsx` |
|
| `StatusBadge` | `status-badge.tsx` |
|
||||||
| `FormSheet` | `form-sheet.tsx` |
|
| `FormSheet` | `form-sheet.tsx` |
|
||||||
| `FormField` | `form-field.tsx` |
|
| `FormField` | `form-field.tsx` |
|
||||||
| `TableCard` | `table-card.tsx` |
|
|
||||||
| `LoadingButton` | `loading-button.tsx` |
|
| `LoadingButton` | `loading-button.tsx` |
|
||||||
| `SectionCardsSkeleton` | `section-cards-skeleton.tsx` |
|
| `SectionCardsSkeleton` | `section-cards-skeleton.tsx` |
|
||||||
| `TableSkeleton` | `table-skeleton.tsx` |
|
| `TableSkeleton` | `table-skeleton.tsx` |
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ ReUI — first-class shadcn registry с enterprise-компонентами (Dat
|
|||||||
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
|
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
|
||||||
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
|
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
|
||||||
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
|
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
|
||||||
|
| Color picker (формы) | `@reui` | `@/components/reui/color-picker` — hex только в swatch data |
|
||||||
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
|
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
|
||||||
|
|
||||||
**Простые списки** — shadcn `Table`. **Сложные data-списки** — `DataGridCard` (ReUI data-grid), не shadcn Data Table.
|
**Простые списки** — shadcn `Table`. **Сложные data-списки** — `DataGridCard` (ReUI data-grid), не shadcn Data Table.
|
||||||
|
|||||||
@@ -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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
|
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
|
||||||
import { EmptyState } from './empty-state'
|
import { EmptyState } from './empty-state'
|
||||||
import type { DataTableColumn } from './data-grid-types'
|
import type { DataGridColumn } from './data-grid-types'
|
||||||
|
|
||||||
const PAGINATION_LABELS = {
|
const PAGINATION_LABELS = {
|
||||||
rowsPerPageLabel: 'Строк на странице',
|
rowsPerPageLabel: 'Строк на странице',
|
||||||
@@ -59,8 +59,8 @@ export interface DataGridColumnVisibilityOption {
|
|||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dataTableColumnVisibilityOptions<T>(
|
export function dataGridColumnVisibilityOptions<T>(
|
||||||
cols: DataTableColumn<T>[],
|
cols: DataGridColumn<T>[],
|
||||||
): DataGridColumnVisibilityOption[] {
|
): DataGridColumnVisibilityOption[] {
|
||||||
return cols
|
return cols
|
||||||
.filter((c) => c.enableHiding !== false)
|
.filter((c) => c.enableHiding !== false)
|
||||||
@@ -390,9 +390,9 @@ export function DataGridCard<TData extends object>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Хелпер для конвертации DataTableColumn<T> → ColumnDef<T> с DataGridColumnHeader. */
|
/** Хелпер для конвертации DataGridColumn<T> → ColumnDef<T> с DataGridColumnHeader. */
|
||||||
export function columnDefFromDataTable<T>(
|
export function columnDefFromDataGrid<T>(
|
||||||
cols: DataTableColumn<T>[],
|
cols: DataGridColumn<T>[],
|
||||||
): ColumnDef<T, unknown>[] {
|
): ColumnDef<T, unknown>[] {
|
||||||
return cols.map((c) => {
|
return cols.map((c) => {
|
||||||
const title = resolveHeaderTitle(c.header, c.headerTitle)
|
const title = resolveHeaderTitle(c.header, c.headerTitle)
|
||||||
@@ -429,5 +429,8 @@ export function columnDefFromDataTable<T>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Используйте columnDefFromDataGrid */
|
||||||
|
export const columnDefFromDataTable = columnDefFromDataGrid
|
||||||
|
|
||||||
/** re-export flexRender для удобства использования в колонках. */
|
/** re-export flexRender для удобства использования в колонках. */
|
||||||
export { flexRender }
|
export { flexRender }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
export interface DataTableColumn<T> {
|
export interface DataGridColumn<T> {
|
||||||
key: string
|
key: string
|
||||||
header: ReactNode
|
header: ReactNode
|
||||||
cell: (row: T, index: number) => ReactNode
|
cell: (row: T, index: number) => ReactNode
|
||||||
@@ -14,6 +14,9 @@ export interface DataTableColumn<T> {
|
|||||||
enableHiding?: boolean
|
enableHiding?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Используйте DataGridColumn */
|
||||||
|
export type DataTableColumn<T> = DataGridColumn<T>
|
||||||
|
|
||||||
/** Унифицированные классы колонок для DataGridCard. */
|
/** Унифицированные классы колонок для DataGridCard. */
|
||||||
export const COL = {
|
export const COL = {
|
||||||
num: 'w-28 text-right tabular-nums',
|
num: 'w-28 text-right tabular-nums',
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import { DayButton } from "react-day-picker"
|
import { DayButton } from "react-day-picker"
|
||||||
import type { DateRange } 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 { cn } from "@cfdm/ui/lib/utils"
|
||||||
import { Button } from "@cfdm/ui/components/button"
|
import { Button } from "@cfdm/ui/components/button"
|
||||||
import { Calendar, CalendarDayButton } from "@cfdm/ui/components/calendar"
|
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 type { ComponentProps } from 'react'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
|
||||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||||
active: 'default',
|
active: 'success',
|
||||||
ok: 'default',
|
ok: 'success',
|
||||||
paid: 'default',
|
paid: 'success',
|
||||||
paused: 'secondary',
|
paused: 'secondary',
|
||||||
archived: 'outline',
|
archived: 'outline',
|
||||||
error: 'destructive',
|
error: 'destructive',
|
||||||
running: 'secondary',
|
running: 'info',
|
||||||
overdue: 'destructive',
|
overdue: 'warning',
|
||||||
stale: 'destructive',
|
stale: 'warning',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||||
|
|||||||
@@ -10,13 +10,24 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@cfdm/ui/components/popover'
|
} from '@cfdm/ui/components/popover'
|
||||||
import { Slider } from '@cfdm/ui/components/slider'
|
|
||||||
import { PlusIcon } from 'lucide-react'
|
import { PlusIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { ListFiltersBar, type FilterChip } from '@/components/list-filters-bar'
|
import { ListFiltersBar, type FilterChip } from '@/components/list-filters-bar'
|
||||||
import type { DataGridColumnVisibilityOption } from '@/components/data-grid-card'
|
import type { DataGridColumnVisibilityOption } from '@/components/data-grid-card'
|
||||||
import type { VisibilityState } from '@tanstack/react-table'
|
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 {
|
import {
|
||||||
Filters,
|
Filters,
|
||||||
createFilter,
|
createFilter,
|
||||||
@@ -24,6 +35,7 @@ import {
|
|||||||
type FilterFieldConfig,
|
type FilterFieldConfig,
|
||||||
type FilterI18nConfig,
|
type FilterI18nConfig,
|
||||||
type FilterOption,
|
type FilterOption,
|
||||||
|
type CustomRendererProps,
|
||||||
} from '@/components/reui/filters'
|
} from '@/components/reui/filters'
|
||||||
import {
|
import {
|
||||||
type VpsFiltersState,
|
type VpsFiltersState,
|
||||||
@@ -34,6 +46,7 @@ import {
|
|||||||
saveFilterPresets,
|
saveFilterPresets,
|
||||||
type VpsFilterPreset,
|
type VpsFilterPreset,
|
||||||
} from '@/components/vps-filters'
|
} from '@/components/vps-filters'
|
||||||
|
import { RU_DATE_SELECTOR_I18N } from '@/lib/date-selector-i18n'
|
||||||
import { vpsStatusLabel, tariffTypeLabel, environmentLabel } from '@/lib/format'
|
import { vpsStatusLabel, tariffTypeLabel, environmentLabel } from '@/lib/format'
|
||||||
import { CountryFlag } from '@/components/country-flag'
|
import { CountryFlag } from '@/components/country-flag'
|
||||||
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
|
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
|
||||||
@@ -54,6 +67,47 @@ interface VpsFiltersToolbarProps {
|
|||||||
onColumnVisibilityChange?: (columnId: string, visible: boolean) => void
|
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 = {
|
const RU_I18N: FilterI18nConfig = {
|
||||||
addFilter: 'Фильтр',
|
addFilter: 'Фильтр',
|
||||||
searchFields: 'Поиск поля…',
|
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.minVcpu != null) out.push(createFilter<number>('minVcpu', 'is', [state.minVcpu]))
|
||||||
if (state.minRamGb != null) out.push(createFilter<number>('minRamGb', 'is', [state.minRamGb]))
|
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.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
|
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 'minVcpu': next.minVcpu = (f.values[0] as number) ?? null; break
|
||||||
case 'minRamGb': next.minRamGb = (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 '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
|
return next
|
||||||
@@ -255,25 +322,39 @@ export function VpsFiltersToolbar({
|
|||||||
{ key: 'monitoring', label: 'Мониторинг', type: 'multiselect' as const, options: onOffOpts, defaultOperator: 'is_any_of' },
|
{ 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: '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: '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',
|
key: 'minVcpu',
|
||||||
label: 'vCPU ≥',
|
label: 'vCPU ≥',
|
||||||
type: 'custom' as const,
|
type: 'custom' as const,
|
||||||
defaultOperator: 'is',
|
defaultOperator: 'is',
|
||||||
operators: [{ value: 'is', label: '≥' }],
|
operators: [{ value: 'is', label: '≥' }],
|
||||||
customRenderer: ({ values, onChange: onCh }: { values: number[]; onChange: (v: number[]) => void; operator: string }) => (
|
customRenderer: ({ values, onChange: onCh }: CustomRendererProps<number>) =>
|
||||||
<div className="flex items-center gap-2 px-2 py-1 w-44">
|
renderMinNumberField(0, 32, values, onCh),
|
||||||
<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>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'minRamGb',
|
key: 'minRamGb',
|
||||||
@@ -281,19 +362,8 @@ export function VpsFiltersToolbar({
|
|||||||
type: 'custom' as const,
|
type: 'custom' as const,
|
||||||
defaultOperator: 'is',
|
defaultOperator: 'is',
|
||||||
operators: [{ value: 'is', label: '≥' }],
|
operators: [{ value: 'is', label: '≥' }],
|
||||||
customRenderer: ({ values, onChange: onCh }: { values: number[]; onChange: (v: number[]) => void; operator: string }) => (
|
customRenderer: ({ values, onChange: onCh }: CustomRendererProps<number>) =>
|
||||||
<div className="flex items-center gap-2 px-2 py-1 w-44">
|
renderMinNumberField(0, 256, values, onCh),
|
||||||
<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>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'minDiskGb',
|
key: 'minDiskGb',
|
||||||
@@ -301,19 +371,8 @@ export function VpsFiltersToolbar({
|
|||||||
type: 'custom' as const,
|
type: 'custom' as const,
|
||||||
defaultOperator: 'is',
|
defaultOperator: 'is',
|
||||||
operators: [{ value: 'is', label: '≥' }],
|
operators: [{ value: 'is', label: '≥' }],
|
||||||
customRenderer: ({ values, onChange: onCh }: { values: number[]; onChange: (v: number[]) => void; operator: string }) => (
|
customRenderer: ({ values, onChange: onCh }: CustomRendererProps<number>) =>
|
||||||
<div className="flex items-center gap-2 px-2 py-1 w-44">
|
renderMinNumberField(0, 2000, values, onCh),
|
||||||
<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>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}, [providers, providerAccounts, vps, countryOptions, cityOptions, projectNameOptions])
|
}, [providers, providerAccounts, vps, countryOptions, cityOptions, projectNameOptions])
|
||||||
@@ -434,6 +493,28 @@ export function VpsFiltersToolbar({
|
|||||||
onRemove: () => onChange({ ...filters, minDiskGb: null }),
|
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) {
|
if (filters.groupByProject) {
|
||||||
out.push({
|
out.push({
|
||||||
id: 'groupByProject',
|
id: 'groupByProject',
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { Vps } from '@/types/entities'
|
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.
|
* Состояние фильтров VPS.
|
||||||
@@ -21,6 +25,9 @@ export interface VpsFiltersState {
|
|||||||
minVcpu: number | null
|
minVcpu: number | null
|
||||||
minRamGb: number | null
|
minRamGb: number | null
|
||||||
minDiskGb: number | null
|
minDiskGb: number | null
|
||||||
|
paidUntilOperator: PaidUntilFilterOperator | null
|
||||||
|
paidUntilStart: string | null
|
||||||
|
paidUntilEnd: string | null
|
||||||
project: string[]
|
project: string[]
|
||||||
groupByProject: boolean
|
groupByProject: boolean
|
||||||
tableCompact: boolean
|
tableCompact: boolean
|
||||||
@@ -42,6 +49,9 @@ export function buildDefaultVpsFilters(): VpsFiltersState {
|
|||||||
minVcpu: null,
|
minVcpu: null,
|
||||||
minRamGb: null,
|
minRamGb: null,
|
||||||
minDiskGb: null,
|
minDiskGb: null,
|
||||||
|
paidUntilOperator: null,
|
||||||
|
paidUntilStart: null,
|
||||||
|
paidUntilEnd: null,
|
||||||
project: [],
|
project: [],
|
||||||
groupByProject: false,
|
groupByProject: false,
|
||||||
tableCompact: false,
|
tableCompact: false,
|
||||||
@@ -91,6 +101,41 @@ const matchesNumberGte = (
|
|||||||
return Number(item ?? 0) >= threshold
|
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 {
|
interface ActiveFilter {
|
||||||
field: string
|
field: string
|
||||||
operator: string
|
operator: string
|
||||||
@@ -105,6 +150,7 @@ interface ActiveFilter {
|
|||||||
export function applyVpsFilters(
|
export function applyVpsFilters(
|
||||||
items: Vps[],
|
items: Vps[],
|
||||||
filters: VpsFiltersState | ActiveFilter[],
|
filters: VpsFiltersState | ActiveFilter[],
|
||||||
|
paidUntilCtx?: PaidUntilContext,
|
||||||
): Vps[] {
|
): Vps[] {
|
||||||
// Если передан state — конвертируем в active filters
|
// Если передан state — конвертируем в active filters
|
||||||
const activeFilters: ActiveFilter[] = Array.isArray(filters)
|
const activeFilters: ActiveFilter[] = Array.isArray(filters)
|
||||||
@@ -189,6 +235,15 @@ export function applyVpsFilters(
|
|||||||
case 'minDiskGb':
|
case 'minDiskGb':
|
||||||
if (!matchesNumberGte(item.diskGb, f.values as number[])) return false
|
if (!matchesNumberGte(item.diskGb, f.values as number[])) return false
|
||||||
break
|
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
|
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.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.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.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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +297,7 @@ export function countActiveFilters(filters: VpsFiltersState): number {
|
|||||||
if (filters.minVcpu != null) n++
|
if (filters.minVcpu != null) n++
|
||||||
if (filters.minRamGb != null) n++
|
if (filters.minRamGb != null) n++
|
||||||
if (filters.minDiskGb != null) n++
|
if (filters.minDiskGb != null) n++
|
||||||
|
if (filters.paidUntilStart) n++
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768
|
|
||||||
|
|
||||||
export function useIsMobile() {
|
|
||||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
|
||||||
const onChange = () => {
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
||||||
}
|
|
||||||
mql.addEventListener("change", onChange)
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
||||||
return () => mql.removeEventListener("change", onChange)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return !!isMobile
|
|
||||||
}
|
|
||||||
@@ -14,11 +14,11 @@ import {
|
|||||||
formatCustomFieldValue,
|
formatCustomFieldValue,
|
||||||
parseCustomData,
|
parseCustomData,
|
||||||
} from '@cfdm/shared/contracts/custom-fields'
|
} from '@cfdm/shared/contracts/custom-fields'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
|
|
||||||
export function buildCustomFieldColumns<T extends { customData?: unknown }>(
|
export function buildCustomFieldColumns<T extends { customData?: unknown }>(
|
||||||
defs: CustomFieldDef[],
|
defs: CustomFieldDef[],
|
||||||
): DataTableColumn<T>[] {
|
): DataGridColumn<T>[] {
|
||||||
return defs.map((def) => ({
|
return defs.map((def) => ({
|
||||||
key: `custom_${def.key}`,
|
key: `custom_${def.key}`,
|
||||||
header: def.label,
|
header: def.label,
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import { snapshotQueryOptions } from '@/queries/snapshot'
|
|||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||||
import { CrudListPage } from '@/components/crud-list-page'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { RowActions } from '@/components/row-actions'
|
import { RowActions } from '@/components/row-actions'
|
||||||
@@ -251,7 +251,7 @@ function AccountsPage() {
|
|||||||
]
|
]
|
||||||
}, [snapshot, syncableCount, healthCtx, filters, health])
|
}, [snapshot, syncableCount, healthCtx, filters, health])
|
||||||
|
|
||||||
const columns: DataTableColumn<ProviderAccount>[] = [
|
const columns: DataGridColumn<ProviderAccount>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
header: 'Аккаунт',
|
header: 'Аккаунт',
|
||||||
@@ -436,7 +436,7 @@ function AccountsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{health ? <HealthModeBanner health={health} exitTo="/accounts" /> : null}
|
{health ? <HealthModeBanner health={health} exitTo="/accounts" /> : null}
|
||||||
<DataGridCard
|
<DataGridCard
|
||||||
columns={columnDefFromDataTable(columns)}
|
columns={columnDefFromDataGrid(columns)}
|
||||||
data={filteredAccounts}
|
data={filteredAccounts}
|
||||||
rowId={(a) => a.id}
|
rowId={(a) => a.id}
|
||||||
pinLastColumn
|
pinLastColumn
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { api } from '@/lib/api-client'
|
|||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ function AuditPage() {
|
|||||||
queryFn: () => api.fetchAuditLog(200),
|
queryFn: () => api.fetchAuditLog(200),
|
||||||
})
|
})
|
||||||
|
|
||||||
const columns: DataTableColumn<AuditRow>[] = [
|
const columns: DataGridColumn<AuditRow>[] = [
|
||||||
{
|
{
|
||||||
key: 'createdAt',
|
key: 'createdAt',
|
||||||
header: 'Время',
|
header: 'Время',
|
||||||
@@ -97,7 +97,7 @@ function AuditPage() {
|
|||||||
>
|
>
|
||||||
{(rows) => (
|
{(rows) => (
|
||||||
<DataGridCard
|
<DataGridCard
|
||||||
columns={columnDefFromDataTable(columns)}
|
columns={columnDefFromDataGrid(columns)}
|
||||||
data={rows as AuditRow[]}
|
data={rows as AuditRow[]}
|
||||||
rowId={(r) => r.id}
|
rowId={(r) => r.id}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
|||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||||
import { CrudListPage } from '@/components/crud-list-page'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { SectionCards } from '@/components/section-cards'
|
import { SectionCards } from '@/components/section-cards'
|
||||||
@@ -73,7 +73,7 @@ function BalancePage() {
|
|||||||
|
|
||||||
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
||||||
|
|
||||||
const columns: DataTableColumn<BalanceLedgerRow>[] = [
|
const columns: DataGridColumn<BalanceLedgerRow>[] = [
|
||||||
{
|
{
|
||||||
key: 'date',
|
key: 'date',
|
||||||
header: 'Дата',
|
header: 'Дата',
|
||||||
@@ -233,7 +233,7 @@ function BalancePage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataGridCard
|
<DataGridCard
|
||||||
columns={columnDefFromDataTable(columns)}
|
columns={columnDefFromDataGrid(columns)}
|
||||||
data={rows}
|
data={rows}
|
||||||
rowId={(r) => r.id}
|
rowId={(r) => r.id}
|
||||||
pinLastColumn
|
pinLastColumn
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import { PageShell } from '@/components/page-shell'
|
|||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { SectionCards } from '@/components/section-cards'
|
import { SectionCards } from '@/components/section-cards'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||||
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
|
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
@@ -101,7 +101,7 @@ function DashboardPage() {
|
|||||||
const atRisk = buildAtRiskAccounts(snap.providerAccounts, snap.providers, snap.syncLog ?? [])
|
const atRisk = buildAtRiskAccounts(snap.providerAccounts, snap.providers, snap.syncLog ?? [])
|
||||||
const baseCur = snap.settings[0]?.baseCurrency ?? 'RUB'
|
const baseCur = snap.settings[0]?.baseCurrency ?? 'RUB'
|
||||||
|
|
||||||
const issueColumns: DataTableColumn<InventoryIssue>[] = [
|
const issueColumns: DataGridColumn<InventoryIssue>[] = [
|
||||||
{
|
{
|
||||||
key: 'title',
|
key: 'title',
|
||||||
header: 'Проблема',
|
header: 'Проблема',
|
||||||
@@ -134,7 +134,7 @@ function DashboardPage() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const vpsColumns: DataTableColumn<Vps>[] = [
|
const vpsColumns: DataGridColumn<Vps>[] = [
|
||||||
{
|
{
|
||||||
key: 'ip',
|
key: 'ip',
|
||||||
header: 'IP / DNS',
|
header: 'IP / DNS',
|
||||||
@@ -174,7 +174,7 @@ function DashboardPage() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const riskColumns: DataTableColumn<AtRiskAccount>[] = [
|
const riskColumns: DataGridColumn<AtRiskAccount>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
header: 'Аккаунт',
|
header: 'Аккаунт',
|
||||||
@@ -319,7 +319,7 @@ function DashboardPage() {
|
|||||||
<DataGridCard
|
<DataGridCard
|
||||||
title="Здоровье инвентаря"
|
title="Здоровье инвентаря"
|
||||||
description="Ставка, просрочка оплаты, устаревший синк, расхождения баланса"
|
description="Ставка, просрочка оплаты, устаревший синк, расхождения баланса"
|
||||||
columns={columnDefFromDataTable(issueColumns)}
|
columns={columnDefFromDataGrid(issueColumns)}
|
||||||
data={issues}
|
data={issues}
|
||||||
rowId={(i) => i.key}
|
rowId={(i) => i.key}
|
||||||
emptyTitle="Проблем не найдено"
|
emptyTitle="Проблем не найдено"
|
||||||
@@ -336,7 +336,7 @@ function DashboardPage() {
|
|||||||
Все VPS
|
Все VPS
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
columns={columnDefFromDataTable(vpsColumns)}
|
columns={columnDefFromDataGrid(vpsColumns)}
|
||||||
data={activeVps.slice(0, 8)}
|
data={activeVps.slice(0, 8)}
|
||||||
rowId={(v) => v.id}
|
rowId={(v) => v.id}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
@@ -347,7 +347,7 @@ function DashboardPage() {
|
|||||||
<DataGridCard
|
<DataGridCard
|
||||||
title="Аккаунты под риском"
|
title="Аккаунты под риском"
|
||||||
description="Низкий баланс или устаревший синк BILLmanager"
|
description="Низкий баланс или устаревший синк BILLmanager"
|
||||||
columns={columnDefFromDataTable(riskColumns)}
|
columns={columnDefFromDataGrid(riskColumns)}
|
||||||
data={atRisk}
|
data={atRisk}
|
||||||
rowId={(r) => r.id}
|
rowId={(r) => r.id}
|
||||||
emptyTitle="Рисков нет"
|
emptyTitle="Рисков нет"
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import { toast } from 'sonner'
|
|||||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||||
import { CrudListPage } from '@/components/crud-list-page'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { SectionCards } from '@/components/section-cards'
|
import { SectionCards } from '@/components/section-cards'
|
||||||
@@ -93,7 +93,7 @@ function PaymentsPage() {
|
|||||||
|
|
||||||
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
||||||
|
|
||||||
const columns: DataTableColumn<Payment>[] = [
|
const columns: DataGridColumn<Payment>[] = [
|
||||||
{
|
{
|
||||||
key: 'date',
|
key: 'date',
|
||||||
header: 'Дата',
|
header: 'Дата',
|
||||||
@@ -247,7 +247,7 @@ function PaymentsPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataGridCard
|
<DataGridCard
|
||||||
columns={columnDefFromDataTable(columns)}
|
columns={columnDefFromDataGrid(columns)}
|
||||||
data={sorted}
|
data={sorted}
|
||||||
rowId={(p) => p.id}
|
rowId={(p) => p.id}
|
||||||
pinLastColumn
|
pinLastColumn
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { toast } from 'sonner'
|
|||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
import { CrudListPage } from '@/components/crud-list-page'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { RowActions } from '@/components/row-actions'
|
import { RowActions } from '@/components/row-actions'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
@@ -79,7 +79,7 @@ function ProjectsPage() {
|
|||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: DataTableColumn<ProjectRow>[] = [
|
const columns: DataGridColumn<ProjectRow>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
header: 'Проект',
|
header: 'Проект',
|
||||||
@@ -167,7 +167,7 @@ function ProjectsPage() {
|
|||||||
>
|
>
|
||||||
{() => (
|
{() => (
|
||||||
<DataGridCard
|
<DataGridCard
|
||||||
columns={columnDefFromDataTable(columns)}
|
columns={columnDefFromDataGrid(columns)}
|
||||||
data={rows}
|
data={rows}
|
||||||
rowId={(r) => r.id}
|
rowId={(r) => r.id}
|
||||||
pinLastColumn
|
pinLastColumn
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { snapshotQueryOptions } from '@/queries/snapshot'
|
|||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
import { dataGridCellWithIcon } from '@/components/data-grid-cells'
|
import { dataGridCellWithIcon } from '@/components/data-grid-cells'
|
||||||
import { CrudListPage } from '@/components/crud-list-page'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { RowActions } from '@/components/row-actions'
|
import { RowActions } from '@/components/row-actions'
|
||||||
@@ -74,7 +74,7 @@ function ProvidersPage() {
|
|||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: DataTableColumn<Provider>[] = [
|
const columns: DataGridColumn<Provider>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
header: 'Хостер',
|
header: 'Хостер',
|
||||||
@@ -152,7 +152,7 @@ function ProvidersPage() {
|
|||||||
>
|
>
|
||||||
{(snap) => (
|
{(snap) => (
|
||||||
<DataGridCard
|
<DataGridCard
|
||||||
columns={columnDefFromDataTable(columns)}
|
columns={columnDefFromDataGrid(columns)}
|
||||||
data={snap.providers}
|
data={snap.providers}
|
||||||
rowId={(p) => p.id}
|
rowId={(p) => p.id}
|
||||||
pinLastColumn
|
pinLastColumn
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { HistoryIcon, UserRoundIcon, CheckCircle2Icon, XCircleIcon, LoaderIcon }
|
|||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { CrudListPage } from '@/components/crud-list-page'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
@@ -29,7 +29,7 @@ function statusIcon(status: SyncLogRow['status']) {
|
|||||||
function SyncJournalPage() {
|
function SyncJournalPage() {
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
|
|
||||||
const columns: DataTableColumn<SyncLogRow>[] = [
|
const columns: DataGridColumn<SyncLogRow>[] = [
|
||||||
{
|
{
|
||||||
key: 'started',
|
key: 'started',
|
||||||
header: 'Запуск',
|
header: 'Запуск',
|
||||||
@@ -99,7 +99,7 @@ function SyncJournalPage() {
|
|||||||
>
|
>
|
||||||
{(snap) => (
|
{(snap) => (
|
||||||
<DataGridCard
|
<DataGridCard
|
||||||
columns={columnDefFromDataTable(columns)}
|
columns={columnDefFromDataGrid(columns)}
|
||||||
data={snap.syncLog ?? []}
|
data={snap.syncLog ?? []}
|
||||||
rowId={(r) => r.id}
|
rowId={(r) => r.id}
|
||||||
dense
|
dense
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { snapshotQueryOptions } from '@/queries/snapshot'
|
|||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||||
import { CrudListPage } from '@/components/crud-list-page'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
@@ -62,7 +62,7 @@ function TariffsPage() {
|
|||||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка загрузки тарифов'),
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка загрузки тарифов'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const columns: DataTableColumn<ActiveTariff>[] = [
|
const columns: DataGridColumn<ActiveTariff>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
header: 'Тариф',
|
header: 'Тариф',
|
||||||
@@ -169,7 +169,7 @@ function TariffsPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
<DataGridCard
|
<DataGridCard
|
||||||
columns={columnDefFromDataTable(columns)}
|
columns={columnDefFromDataGrid(columns)}
|
||||||
data={snap.activeTariffs}
|
data={snap.activeTariffs}
|
||||||
rowId={(t) => t.id}
|
rowId={(t) => t.id}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import { Button } from '@cfdm/ui/components/button'
|
|||||||
import { Badge } from '@cfdm/ui/components/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
import { getPaidUntilDate } from '@/lib/paid-until'
|
import { getPaidUntilDate } from '@/lib/paid-until'
|
||||||
import {
|
import {
|
||||||
effectiveVpsTariffCurrency,
|
effectiveVpsTariffCurrency,
|
||||||
@@ -93,7 +93,7 @@ function VpsDetailPage() {
|
|||||||
.filter(({ value }) => value !== undefined && value !== null && value !== '')
|
.filter(({ value }) => value !== undefined && value !== null && value !== '')
|
||||||
}, [vps, customFieldDefs])
|
}, [vps, customFieldDefs])
|
||||||
|
|
||||||
const paymentColumns: DataTableColumn<Payment>[] = [
|
const paymentColumns: DataGridColumn<Payment>[] = [
|
||||||
{ key: 'date', header: 'Дата', cell: (p) => <span className="tabular-nums">{p.date}</span> },
|
{ key: 'date', header: 'Дата', cell: (p) => <span className="tabular-nums">{p.date}</span> },
|
||||||
{ key: 'type', header: 'Тип', cell: (p) => paymentTypeLabel(p.type) },
|
{ key: 'type', header: 'Тип', cell: (p) => paymentTypeLabel(p.type) },
|
||||||
{
|
{
|
||||||
@@ -231,7 +231,7 @@ function VpsDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
<DataGridCard
|
<DataGridCard
|
||||||
title="Связанные платежи"
|
title="Связанные платежи"
|
||||||
columns={columnDefFromDataTable(paymentColumns)}
|
columns={columnDefFromDataGrid(paymentColumns)}
|
||||||
data={relatedPayments}
|
data={relatedPayments}
|
||||||
rowId={(p) => p.id}
|
rowId={(p) => p.id}
|
||||||
emptyTitle="Платежей нет"
|
emptyTitle="Платежей нет"
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import { PageShell } from '@/components/page-shell'
|
|||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
import { DataGridCard, columnDefFromDataTable, loadStoredColumnVisibility, dataTableColumnVisibilityOptions } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataGrid, loadStoredColumnVisibility, dataGridColumnVisibilityOptions } from '@/components/data-grid-card'
|
||||||
import type { VisibilityState } from '@tanstack/react-table'
|
import type { VisibilityState } from '@tanstack/react-table'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
|
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
|
||||||
import { CountryFlag } from '@/components/country-flag'
|
import { CountryFlag } from '@/components/country-flag'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
@@ -196,17 +196,20 @@ function VpsPage() {
|
|||||||
}, [snapshot?.serverProjects, projectNameOptions])
|
}, [snapshot?.serverProjects, projectNameOptions])
|
||||||
|
|
||||||
const filteredVps = useMemo(() => {
|
const filteredVps = useMemo(() => {
|
||||||
let rows = applyVpsFilters(snapshot?.vps ?? [], filters)
|
|
||||||
if (!health || !snapshot) return rows
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
const paidUntilCtx = snapshot
|
||||||
|
? {
|
||||||
|
vps: snapshot.vps,
|
||||||
|
providerAccounts: snapshot.providerAccounts,
|
||||||
|
payments: snapshot.payments,
|
||||||
|
balanceLedger: snapshot.balanceLedger,
|
||||||
|
now,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
let rows = applyVpsFilters(snapshot?.vps ?? [], filters, paidUntilCtx)
|
||||||
|
if (!health || !snapshot) return rows
|
||||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
const ctx = {
|
const ctx = paidUntilCtx!
|
||||||
vps: snapshot.vps,
|
|
||||||
providerAccounts: snapshot.providerAccounts,
|
|
||||||
payments: snapshot.payments,
|
|
||||||
balanceLedger: snapshot.balanceLedger,
|
|
||||||
now,
|
|
||||||
}
|
|
||||||
if (health === 'no-rate') {
|
if (health === 'no-rate') {
|
||||||
rows = rows.filter((v) => {
|
rows = rows.filter((v) => {
|
||||||
if (v.status !== 'active') return false
|
if (v.status !== 'active') return false
|
||||||
@@ -319,8 +322,8 @@ function VpsPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: DataTableColumn<Vps>[] = useMemo(() => {
|
const columns: DataGridColumn<Vps>[] = useMemo(() => {
|
||||||
const base: DataTableColumn<Vps>[] = [
|
const base: DataGridColumn<Vps>[] = [
|
||||||
{
|
{
|
||||||
key: 'ip',
|
key: 'ip',
|
||||||
header: 'IP / DNS',
|
header: 'IP / DNS',
|
||||||
@@ -483,7 +486,7 @@ function VpsPage() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const columnVisibilityOptions = useMemo(
|
const columnVisibilityOptions = useMemo(
|
||||||
() => dataTableColumnVisibilityOptions(columns),
|
() => dataGridColumnVisibilityOptions(columns),
|
||||||
[columns],
|
[columns],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -582,7 +585,7 @@ function VpsPage() {
|
|||||||
<DataGridCard
|
<DataGridCard
|
||||||
key={section.key}
|
key={section.key}
|
||||||
title={section.label ?? undefined}
|
title={section.label ?? undefined}
|
||||||
columns={columnDefFromDataTable(columns)}
|
columns={columnDefFromDataGrid(columns)}
|
||||||
data={section.items}
|
data={section.items}
|
||||||
rowId={(v) => v.id}
|
rowId={(v) => v.id}
|
||||||
emptyTitle="VPS не найдены"
|
emptyTitle="VPS не найдены"
|
||||||
|
|||||||
@@ -48,6 +48,8 @@
|
|||||||
--warning-foreground: var(--color-yellow-900);
|
--warning-foreground: var(--color-yellow-900);
|
||||||
--invert: var(--color-zinc-900);
|
--invert: var(--color-zinc-900);
|
||||||
--invert-foreground: var(--color-zinc-50);
|
--invert-foreground: var(--color-zinc-50);
|
||||||
|
--focus: var(--color-blue-500);
|
||||||
|
--focus-foreground: var(--color-blue-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -91,6 +93,8 @@
|
|||||||
--warning-foreground: var(--color-yellow-600);
|
--warning-foreground: var(--color-yellow-600);
|
||||||
--invert: var(--color-zinc-700);
|
--invert: var(--color-zinc-700);
|
||||||
--invert-foreground: var(--color-zinc-50);
|
--invert-foreground: var(--color-zinc-50);
|
||||||
|
--focus: var(--color-blue-500);
|
||||||
|
--focus-foreground: var(--color-blue-600);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -138,6 +142,8 @@
|
|||||||
--color-success-foreground: var(--success-foreground);
|
--color-success-foreground: var(--success-foreground);
|
||||||
--color-success: var(--success);
|
--color-success: var(--success);
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-focus: var(--focus);
|
||||||
|
--color-focus-foreground: var(--focus-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
Reference in New Issue
Block a user