diff --git a/apps/web/src/components/data-grid-cells.tsx b/apps/web/src/components/data-grid-cells.tsx index 8870f85..1c7aa16 100644 --- a/apps/web/src/components/data-grid-cells.tsx +++ b/apps/web/src/components/data-grid-cells.tsx @@ -1,7 +1,9 @@ +import type { LucideIcon } from 'lucide-react' import type { ReactNode } from 'react' -import { cn } from '@cfdm/ui/lib/utils' +import { IconTile } from '@/components/reui/icon-tile' import { TruncatedText } from '@/components/truncated-text' +import { cn } from '@cfdm/ui/lib/utils' export function dataGridCellStack( primary: ReactNode, @@ -26,14 +28,49 @@ export function dataGridCellStack( ) } +/** + * Name cell DNA — IconTile elevated size-10.5 + truncate. + * Preview: https://reui.io/preview/base/stats-12 + * Docs: https://reui.io/docs/components/base/icon-tile + */ +export function DataGridNameCell({ + icon: Icon, + title, + subtitle, + iconClassName = 'text-muted-foreground', + className, +}: { + icon: LucideIcon + title: ReactNode + subtitle?: ReactNode + iconClassName?: string + className?: string +}) { + return ( +
+ + + +
+ {title} + {subtitle ? ( + {subtitle} + ) : null} +
+
+ ) +} + export function dataGridCellWithIcon( icon: ReactNode, children: ReactNode, className?: string, ) { return ( -
- {icon} +
+ + {icon} + {children}
) diff --git a/apps/web/src/components/row-actions.tsx b/apps/web/src/components/row-actions.tsx index 7efca79..800c0cd 100644 --- a/apps/web/src/components/row-actions.tsx +++ b/apps/web/src/components/row-actions.tsx @@ -1,8 +1,25 @@ -import type { ReactNode } from 'react' -import { PencilIcon, Trash2Icon } from 'lucide-react' +import { useState, type ReactNode } from 'react' +import type { LucideIcon } from 'lucide-react' +import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react' + import { Button } from '@cfdm/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@cfdm/ui/components/dropdown-menu' +import { cn } from '@cfdm/ui/lib/utils' + import { ConfirmDialog } from './confirm-dialog' +export interface RowActionExtra { + label: string + icon?: LucideIcon + onSelect: () => void + disabled?: boolean +} + interface RowActionsProps { onEdit?: () => void onDelete?: () => void @@ -10,10 +27,15 @@ interface RowActionsProps { deleteTitle?: string deleteDescription?: ReactNode deleteLabel?: string - extra?: ReactNode + extra?: RowActionExtra[] className?: string } +/** + * Data-grid row actions — outline ⋯ menu. + * Preview: https://reui.io/preview/base/components/c-dropdown-menu-12 + * Docs: https://reui.io/docs/components/base/dropdown-menu + */ export function RowActions({ onEdit, onDelete, @@ -24,23 +46,52 @@ export function RowActions({ extra, className, }: RowActionsProps) { - if (!onEdit && !onDelete && !extra) return null + const [deleteOpen, setDeleteOpen] = useState(false) + const extras = extra ?? [] + if (!onEdit && !onDelete && extras.length === 0) return null return ( -
- {extra} - {onEdit ? ( - - ) : null} +
+ + + } + > + + + + {extras.map((item) => { + const Icon = item.icon + return ( + + {Icon ? : null} + {item.label} + + ) + })} + {onEdit ? ( + + + {editLabel} + + ) : null} + {onDelete ? ( + setDeleteOpen(true)}> + + {deleteLabel} + + ) : null} + + {onDelete ? ( - - - } + open={deleteOpen} + onOpenChange={setDeleteOpen} title={deleteTitle} description={deleteDescription} destructive diff --git a/apps/web/src/components/status-badge.tsx b/apps/web/src/components/status-badge.tsx index 96e7172..969e768 100644 --- a/apps/web/src/components/status-badge.tsx +++ b/apps/web/src/components/status-badge.tsx @@ -1,40 +1,62 @@ import type { ComponentProps } from 'react' +import { cn } from '@cfdm/ui/lib/utils' + import { Badge } from '@/components/reui/badge' type BadgeVariant = NonNullable['variant']> const STATUS_VARIANT: Record = { - active: 'success', - ok: 'success', - paid: 'success', - available: 'success', - complete: 'success', - paused: 'secondary', + active: 'success-light', + ok: 'success-light', + up: 'success-light', + paid: 'success-light', + available: 'success-light', + complete: 'success-light', + paused: 'invert-light', archived: 'outline', - error: 'destructive', - denied: 'destructive', - blocked: 'destructive', - running: 'info', - overdue: 'warning', - stale: 'warning', - timeout: 'warning', - redirected: 'warning', - partial: 'warning', + error: 'destructive-light', + denied: 'destructive-light', + blocked: 'destructive-light', + down: 'destructive-light', + running: 'info-light', + overdue: 'warning-light', + stale: 'warning-light', + timeout: 'warning-light', + redirected: 'warning-light', + partial: 'warning-light', +} + +const DOT_COLOR: Record = { + 'success-light': 'bg-success', + success: 'bg-success', + 'info-light': 'bg-info', + info: 'bg-info', + 'warning-light': 'bg-warning', + warning: 'bg-warning', + 'destructive-light': 'bg-destructive', + destructive: 'bg-destructive', + 'invert-light': 'bg-muted-foreground', + secondary: 'bg-muted-foreground', + outline: 'bg-muted-foreground', } export function StatusBadge({ status, label, - size = 'default', + size = 'sm', + className, }: { status: string label?: string size?: NonNullable['size']> + className?: string }) { const variant = STATUS_VARIANT[status] ?? 'outline' + const dotColor = DOT_COLOR[variant] ?? 'bg-muted-foreground' return ( - + + {label ?? status} ) diff --git a/apps/web/src/lib/custom-fields.tsx b/apps/web/src/lib/custom-fields.tsx index ca395c2..0987352 100644 --- a/apps/web/src/lib/custom-fields.tsx +++ b/apps/web/src/lib/custom-fields.tsx @@ -8,7 +8,7 @@ export { } from '@cfdm/shared/contracts/custom-fields' import type { ReactNode } from 'react' -import { Badge } from '@cfdm/ui/components/badge' +import { Badge } from '@/components/reui/badge' import { type CustomFieldDef, formatCustomFieldValue, @@ -39,7 +39,7 @@ export function buildCustomFieldColumns( } if (def.type === 'bool') { return ( - + {formatCustomFieldValue(def, val)} ) diff --git a/apps/web/src/routes/_auth/accounts.tsx b/apps/web/src/routes/_auth/accounts.tsx index 26e3cef..5ff5707 100644 --- a/apps/web/src/routes/_auth/accounts.tsx +++ b/apps/web/src/routes/_auth/accounts.tsx @@ -20,10 +20,10 @@ import { buildApiCredentials } from '@cfdm/shared/utils/api-credentials' import { snapshotQueryOptions } from '@/queries/snapshot' import { api, ApiError } from '@/lib/api-client' import { Button } from '@cfdm/ui/components/button' -import { Badge } from '@cfdm/ui/components/badge' +import { Badge } from '@/components/reui/badge' import { ResourcePage, columnDefFromDataGrid, KpiStatGrid } from '@/components/reui-kit' import type { DataGridColumn } from '@/components/data-grid-types' -import { dataGridCellStack } from '@/components/data-grid-cells' +import { dataGridCellStack, DataGridNameCell } from '@/components/data-grid-cells' import { CrudListPage } from '@/components/crud-list-page' import { RowActions } from '@/components/row-actions' import { HealthModeBanner } from '@/components/health-mode-banner' @@ -62,10 +62,13 @@ const accountsSearchSchema = z.object({ health: z.string().optional(), }) -const HEALTH_BADGE_VARIANT: Record = { - 'stale-sync': 'secondary', - 'low-balance': 'destructive', - 'balance-mismatch': 'outline', +const HEALTH_BADGE_VARIANT: Record< + AccountHealthFlag, + 'warning-light' | 'destructive-light' | 'outline' +> = { + 'stale-sync': 'warning-light', + 'low-balance': 'destructive-light', + 'balance-mismatch': 'warning-light', 'no-creds': 'outline', } @@ -267,7 +270,13 @@ function AccountsPage() { key: 'name', header: 'Аккаунт', icon: UserRoundIcon, - cell: (a) => dataGridCellStack(a.name, providerById.get(a.providerId)?.name ?? '—'), + cell: (a) => ( + + ), }, { key: 'login', @@ -285,12 +294,12 @@ function AccountsPage() { cell: (a) => { const flags = getAccountHealthFlags(a, healthCtx) if (!flags.length) { - return OK + return OK } return (
{flags.map((flag) => ( - + {ACCOUNT_HEALTH_LABELS[flag]} ))} @@ -303,7 +312,7 @@ function AccountsPage() { header: 'API-доступ', icon: PlugIcon, cell: (a) => ( - + {a.apiCredentialsSet ? 'установлены' : 'нет'} ), @@ -323,7 +332,7 @@ function AccountsPage() { sortValue: (a) => vpsCountByAccount.get(a.id) ?? 0, cell: (a) => { const count = vpsCountByAccount.get(a.id) ?? 0 - return count ? {count} : 0 + return count ? {count} : 0 }, }, { @@ -358,7 +367,7 @@ function AccountsPage() { key: 'actions', header: '', sortable: false, - className: 'w-32 text-right', + className: 'w-12 text-right', cell: (a) => { const provider = providerById.get(a.providerId) const canSync = accountBillmanagerUiReady(a, provider) @@ -369,17 +378,16 @@ function AccountsPage() { deleteTitle="Удалить аккаунт?" deleteDescription={`«${a.name}» будет удалён.`} extra={ - canSync ? ( - - ) : null + canSync + ? [ + { + label: 'Синхронизировать', + icon: RefreshCwIcon, + disabled: syncMut.isPending && syncMut.variables === a.id, + onSelect: () => syncMut.mutate(a.id), + }, + ] + : undefined } /> ) @@ -450,7 +458,6 @@ function AccountsPage() { columns={columnDefFromDataGrid(columns)} data={filteredAccounts} getRowId={(a) => a.id} - pinLastColumn emptyTitle={health || hasActiveAccountFilters(filters) ? 'Нет аккаунтов с этими фильтрами' : 'Нет записей'} emptyDescription={ health || hasActiveAccountFilters(filters) diff --git a/apps/web/src/routes/_auth/balance.tsx b/apps/web/src/routes/_auth/balance.tsx index e11c3fa..14ec304 100644 --- a/apps/web/src/routes/_auth/balance.tsx +++ b/apps/web/src/routes/_auth/balance.tsx @@ -19,7 +19,7 @@ import { toast } from 'sonner' import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot' import { api, ApiError } from '@/lib/api-client' import { Button } from '@cfdm/ui/components/button' -import { Badge } from '@cfdm/ui/components/badge' +import { Badge } from '@/components/reui/badge' import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit' import type { DataGridColumn } from '@/components/data-grid-types' import { dataGridCellStack } from '@/components/data-grid-cells' @@ -100,7 +100,11 @@ function BalancePage() { header: 'Движение', icon: ArrowLeftRightIcon, cell: (r) => ( - + {r.direction === 'credit' ? 'Приход' : 'Списание'} @@ -236,7 +240,6 @@ function BalancePage() { columns={columnDefFromDataGrid(columns)} data={rows} getRowId={(r) => r.id} - pinLastColumn footerContent={
diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx index 404cdfd..44bbdec 100644 --- a/apps/web/src/routes/_auth/dashboard.tsx +++ b/apps/web/src/routes/_auth/dashboard.tsx @@ -277,7 +277,7 @@ function DashboardPage() { iconClassName: 'text-primary', to: '/vps', footer: ( - + {totalCount} всего ), @@ -290,7 +290,7 @@ function DashboardPage() { iconClassName: 'text-info', to: '/reports', footer: ( - + оценка ), @@ -308,7 +308,7 @@ function DashboardPage() { iconClassName: 'text-success', to: '/accounts', footer: ( - + API ), @@ -322,11 +322,11 @@ function DashboardPage() { variant: runwayLow ? 'warning' : 'default', to: '/accounts', footer: runwayLow ? ( - + < 14 дн ) : ( - + запас ), @@ -343,6 +343,7 @@ function DashboardPage() { 0 ? 'warning-light' : 'success-light'} size="sm" + radius="full" > {expiringCount > 0 ? 'скоро' : 'в норме'} @@ -360,6 +361,7 @@ function DashboardPage() { 0 ? 'destructive-light' : 'success-light'} size="sm" + radius="full" > {issuesCount > 0 ? 'требует внимания' : 'в норме'} @@ -406,7 +408,9 @@ function DashboardPage() { Проблемы {issues.length > 0 ? ( - {issues.length} + + {issues.length} + ) : null} @@ -415,7 +419,9 @@ function DashboardPage() { Аккаунты {atRisk.length > 0 ? ( - {atRisk.length} + + {atRisk.length} + ) : null} diff --git a/apps/web/src/routes/_auth/payments.tsx b/apps/web/src/routes/_auth/payments.tsx index 0237022..7a7a477 100644 --- a/apps/web/src/routes/_auth/payments.tsx +++ b/apps/web/src/routes/_auth/payments.tsx @@ -143,7 +143,7 @@ function PaymentsPage() { key: 'actions', header: '', sortable: false, - className: 'w-24 text-right', + className: 'w-12 text-right', cell: (p) => ( openEdit(p)} @@ -250,7 +250,6 @@ function PaymentsPage() { columns={columnDefFromDataGrid(columns)} data={sorted} getRowId={(p) => p.id} - pinLastColumn virtualization={sorted.length > 200} height={560} footerContent={ diff --git a/apps/web/src/routes/_auth/projects.tsx b/apps/web/src/routes/_auth/projects.tsx index ef82363..849d55a 100644 --- a/apps/web/src/routes/_auth/projects.tsx +++ b/apps/web/src/routes/_auth/projects.tsx @@ -25,7 +25,7 @@ import { type ProjectFiltersState, } from '@/components/project-filters' import { Button } from '@cfdm/ui/components/button' -import { Badge } from '@cfdm/ui/components/badge' +import { Badge } from '@/components/reui/badge' import { EmptyState } from '@/components/empty-state' import { ProjectColorDot } from '@/components/project-color-dot' import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet' @@ -153,7 +153,7 @@ function ProjectsPage() { className: 'text-right tabular-nums', sortValue: (row) => row.vpsTotal, cell: (row) => ( - + {row.vpsActive}/{row.vpsTotal} ), @@ -183,7 +183,7 @@ function ProjectsPage() { key: 'actions', header: '', sortable: false, - className: 'w-24 text-right', + className: 'w-12 text-right', cell: (row) => (
e.stopPropagation()}> r.id} - pinLastColumn onRowClick={(row) => void navigate({ to: '/projects/$projectId', diff --git a/apps/web/src/routes/_auth/providers.tsx b/apps/web/src/routes/_auth/providers.tsx index 7636a23..8b51238 100644 --- a/apps/web/src/routes/_auth/providers.tsx +++ b/apps/web/src/routes/_auth/providers.tsx @@ -7,15 +7,14 @@ import { toast } from 'sonner' import { snapshotQueryOptions } from '@/queries/snapshot' import { api, ApiError } from '@/lib/api-client' import { Button } from '@cfdm/ui/components/button' -import { Badge } from '@cfdm/ui/components/badge' +import { Badge } from '@/components/reui/badge' import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit' import type { DataGridColumn } from '@/components/data-grid-types' -import { dataGridCellWithIcon } from '@/components/data-grid-cells' +import { DataGridNameCell } from '@/components/data-grid-cells' import { CrudListPage } from '@/components/crud-list-page' import { RowActions } from '@/components/row-actions' import { ProviderEditSheet, providerFormDefaults } from '@/components/domain/provider-edit-sheet' import type { ProviderFormValues } from '@/lib/schemas' -import { faviconUrlFromWebsite } from '@/lib/format' import type { Provider } from '@/types/entities' export const Route = createFileRoute('/_auth/providers')({ @@ -79,20 +78,19 @@ function ProvidersPage() { key: 'name', header: 'Хостер', icon: BuildingIcon, - cell: (p) => { - const icon = p.website ? ( - - ) : ( - - ) - return dataGridCellWithIcon(icon, {p.name}) - }, + cell: (p) => ( + + ), }, { key: 'api', header: 'API', icon: PlugIcon, - cell: (p) => {p.apiType}, + cell: (p) => {p.apiType}, }, { key: 'cur', @@ -104,7 +102,7 @@ function ProvidersPage() { key: 'actions', header: '', sortable: false, - className: 'w-24 text-right', + className: 'w-12 text-right', cell: (p) => ( openEdit(p)} diff --git a/apps/web/src/routes/_auth/renewals.tsx b/apps/web/src/routes/_auth/renewals.tsx index 23728f5..5697aaf 100644 --- a/apps/web/src/routes/_auth/renewals.tsx +++ b/apps/web/src/routes/_auth/renewals.tsx @@ -16,7 +16,7 @@ import { FramePanel, FrameTitle, } from '@/components/reui/frame' -import { Badge } from '@cfdm/ui/components/badge' +import { Badge } from '@/components/reui/badge' import { Button } from '@cfdm/ui/components/button' import { SelectField } from '@/components/select-field' import { getPaidUntilDate } from '@/lib/paid-until' @@ -197,7 +197,11 @@ function RenewalsPage() { {item.sublabel}
- {item.overdue ? Просрочено : null} + {item.overdue ? ( + + Просрочено + + ) : null} {item.date.toLocaleDateString('ru-RU')}
diff --git a/apps/web/src/routes/_auth/tariffs.tsx b/apps/web/src/routes/_auth/tariffs.tsx index 66a512f..bc89acf 100644 --- a/apps/web/src/routes/_auth/tariffs.tsx +++ b/apps/web/src/routes/_auth/tariffs.tsx @@ -6,7 +6,7 @@ import { toast } from 'sonner' import { snapshotQueryOptions } from '@/queries/snapshot' import { api, ApiError } from '@/lib/api-client' import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert' -import { Badge } from '@cfdm/ui/components/badge' +import { Badge } from '@/components/reui/badge' import { ResourcePage, columnDefFromDataGrid, loadStoredColumnVisibility, dataGridColumnVisibilityOptions } from '@/components/reui-kit' import type { ColumnVisibilityState } from '@tanstack/react-table' import type { DataGridColumn } from '@/components/data-grid-types' @@ -224,7 +224,7 @@ function TariffsPage() { header: 'Диск', icon: HardDriveIcon, sortValue: (t) => t.diskType ?? '', - cell: (t) => {t.diskType ?? '—'}, + cell: (t) => {t.diskType ?? '—'}, }, { key: 'location', diff --git a/apps/web/src/routes/_auth/vps.$vpsId.tsx b/apps/web/src/routes/_auth/vps.$vpsId.tsx index bfbb6e4..b6b9f27 100644 --- a/apps/web/src/routes/_auth/vps.$vpsId.tsx +++ b/apps/web/src/routes/_auth/vps.$vpsId.tsx @@ -17,7 +17,7 @@ import { QueryState } from '@/components/query-state' import { StatusBadge } from '@/components/status-badge' import { Skeleton } from '@cfdm/ui/components/skeleton' import { Button } from '@cfdm/ui/components/button' -import { Badge } from '@cfdm/ui/components/badge' +import { Badge } from '@/components/reui/badge' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs' import { Frame, @@ -200,8 +200,16 @@ function VpsDetailPage() {
- {row.project ? {row.project} : null} - {row.environment ? {row.environment} : null} + {row.project ? ( + + {row.project} + + ) : null} + {row.environment ? ( + + {row.environment} + + ) : null}
}> diff --git a/apps/web/src/routes/_auth/vps.tsx b/apps/web/src/routes/_auth/vps.tsx index 68f2156..1ef1f66 100644 --- a/apps/web/src/routes/_auth/vps.tsx +++ b/apps/web/src/routes/_auth/vps.tsx @@ -10,11 +10,12 @@ import { normalizeRatesPayload, effectiveVpsTariffCurrency, formatInProviderCurr import { PageShell } from '@/components/page-shell' import { PageHeader } from '@/components/page-header' import { Button } from '@cfdm/ui/components/button' -import { Badge } from '@cfdm/ui/components/badge' +import { Badge } from '@/components/reui/badge' import { ResourcePage, columnDefFromDataGrid, loadStoredColumnVisibility, dataGridColumnVisibilityOptions } from '@/components/reui-kit' import type { ColumnVisibilityState } from '@tanstack/react-table' import type { DataGridColumn } from '@/components/data-grid-types' -import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells' +import { DataGridNameCell, dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells' +import { StatusBadge } from '@/components/status-badge' import { CountryFlag } from '@/components/country-flag' import { QueryState } from '@/components/query-state' import { EmptyState } from '@/components/empty-state' @@ -344,30 +345,35 @@ function VpsPage() { header: 'IP / DNS', icon: GlobeIcon, sortValue: (v) => v.ip || v.dns || '', - cell: (v) => - dataGridCellStack( - v.ip ? ( - - ) : ( - - ), - v.dns ? ( - - ) : undefined, - ), + cell: (v) => ( + void copyText(v.ip, 'IP скопирован')} + > + {v.ip} + + ) : ( + + ) + } + subtitle={ + v.dns ? ( + + {v.dns} + + ) : undefined + } + /> + ), }, { key: 'domains', @@ -439,11 +445,11 @@ function VpsPage() { cell: (v) => (
{v.access === 'shared' ? ( - Общий + + Общий + ) : null} - - {vpsStatusLabel(v.status)} - +
), }, @@ -484,9 +490,9 @@ function VpsPage() { cell: (v) => { const ext = v as Vps & { lastHealthStatus?: string; monitoringEnabled?: boolean } if (!ext.monitoringEnabled) return - if (ext.lastHealthStatus === 'up') return up - if (ext.lastHealthStatus === 'down') return down - return + if (ext.lastHealthStatus === 'up') return + if (ext.lastHealthStatus === 'down') return + return }, }, { @@ -529,7 +535,7 @@ function VpsPage() { header: '', sortable: false, enableHiding: false, - className: 'w-24 text-right', + className: 'w-12 text-right', cell: (v) => ( openEdit(v)} @@ -537,19 +543,18 @@ function VpsPage() { deleteTitle="Удалить VPS?" deleteDescription={`IP ${v.ip} будет удалён безвозвратно.`} extra={ - v.access !== 'shared' ? ( - - ) : null + v.access !== 'shared' + ? [ + { + label: 'Доступ', + icon: Share2Icon, + onSelect: () => { + setAccessVps(v) + setAccessOpen(true) + }, + }, + ] + : undefined } /> ), @@ -683,7 +688,6 @@ function VpsPage() { data={section.items} getRowId={(v) => v.id} emptyTitle="VPS не найдены" - pinLastColumn dense={filters.tableCompact} enableRowSelection onRowSelectionChange={setSelectedIds}