refactor(web): унифицировать UI по паттернам ReUI на всех 12 страницах
Docker / build (push) Has been cancelled

Ввести CrudListPage, AnalyticsPage, RowActions и domain edit sheets с FormSheetRhf; убрать устаревшие TableCard/DataTableCard.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-28 13:16:00 +07:00
co-authored by Cursor
parent dc0650b61e
commit 0ea92746e4
31 changed files with 2002 additions and 1430 deletions
@@ -0,0 +1,69 @@
import type { ReactNode } from 'react'
import { ServerIcon } from 'lucide-react'
import { PageShell } from './page-shell'
import { PageHeader } from './page-header'
import { QueryState } from './query-state'
import { EmptyState } from './empty-state'
import { SectionCardsSkeleton } from './skeletons'
interface AnalyticsPageProps<T> {
title: string
description?: string
actions?: ReactNode
data: T | undefined
isLoading: boolean
isError: boolean
error?: unknown
onRetry?: () => void
/** Показать empty, если нет данных для аналитики (например, 0 VPS). */
analyticsEmpty?: boolean
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
skeleton?: ReactNode
children: (data: T) => ReactNode
}
export function AnalyticsPage<T>({
title,
description,
actions,
data,
isLoading,
isError,
error,
onRetry,
analyticsEmpty,
emptyTitle = 'Нет данных для аналитики',
emptyDescription = 'Добавьте VPS или дождитесь синхронизации с BILLmanager',
emptyAction,
skeleton,
children,
}: AnalyticsPageProps<T>) {
return (
<PageShell>
<PageHeader title={title} description={description} actions={actions} />
<QueryState
data={data}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={onRetry}
skeleton={skeleton ?? <SectionCardsSkeleton count={3} />}
>
{(snap) =>
analyticsEmpty ? (
<EmptyState
icon={<ServerIcon className="size-8" />}
title={emptyTitle}
description={emptyDescription}
action={emptyAction}
/>
) : (
children(snap)
)
}
</QueryState>
</PageShell>
)
}
@@ -0,0 +1,62 @@
import type { ReactNode } from 'react'
import { PageShell } from './page-shell'
import { PageHeader } from './page-header'
import { QueryState } from './query-state'
import { TableSkeleton } from './skeletons'
interface CrudListPageProps<T> {
title: string
description?: string
actions?: ReactNode
data: T | undefined
isLoading: boolean
isError: boolean
error?: unknown
onRetry?: () => void
empty?: boolean
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
skeleton?: ReactNode
sheet?: ReactNode
children: (data: T) => ReactNode
}
export function CrudListPage<T>({
title,
description,
actions,
data,
isLoading,
isError,
error,
onRetry,
empty,
emptyTitle,
emptyDescription,
emptyAction,
skeleton,
sheet,
children,
}: CrudListPageProps<T>) {
return (
<PageShell>
<PageHeader title={title} description={description} actions={actions} />
<QueryState
data={data}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={onRetry}
skeleton={skeleton ?? <TableSkeleton />}
empty={empty}
emptyTitle={emptyTitle}
emptyDescription={emptyDescription}
emptyAction={emptyAction}
>
{children}
</QueryState>
{sheet}
</PageShell>
)
}
+1 -1
View File
@@ -195,7 +195,7 @@ export function DataGridCard<TData extends object>({
return (
<Card className={cn('ring-0 shadow-none', className)}>
<CardHeader className="flex flex-row items-center justify-between gap-2">
<div className="space-y-1">
<div className="flex flex-col gap-1">
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
@@ -1,2 +0,0 @@
/** @deprecated Используйте DataGridCard. Тип колонок — data-grid-types. */
export type { DataTableColumn } from './data-grid-types'
@@ -0,0 +1,119 @@
import { FormSheetRhf } from '@/components/form-sheet-rhf'
import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { Textarea } from '@cfdm/ui/components/textarea'
import { SelectField } from '@/components/select-field'
import type { ZodType } from 'zod'
import { providerAccountSchema, type ProviderAccountFormValues } from '@/lib/schemas'
import type { BillingMode, Provider } from '@/types/entities'
import { billingModeLabel } from '@/lib/format'
const EMPTY: ProviderAccountFormValues = {
providerId: '',
name: '',
login: '',
apiCredentials: '',
billingMode: 'monthly',
balanceAlertBelow: '',
notes: '',
}
interface ProviderAccountEditSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
defaultValues: ProviderAccountFormValues
providers: Provider[]
onSubmit: (values: ProviderAccountFormValues) => void
submitting?: boolean
}
export function providerAccountFormDefaults(
edit?: Partial<ProviderAccountFormValues> | null,
fallbackProviderId = '',
): ProviderAccountFormValues {
if (!edit) {
return { ...EMPTY, providerId: fallbackProviderId }
}
return {
...EMPTY,
...edit,
balanceAlertBelow: edit.balanceAlertBelow ?? '',
}
}
export function ProviderAccountEditSheet({
open,
onOpenChange,
defaultValues,
providers,
onSubmit,
submitting,
}: ProviderAccountEditSheetProps) {
const isEdit = Boolean(defaultValues.id)
return (
<FormSheetRhf
open={open}
onOpenChange={onOpenChange}
title={isEdit ? 'Редактировать аккаунт' : 'Новый аккаунт'}
description="API-креды хранятся на сервере и используются для синка с BILLmanager"
schema={providerAccountSchema as unknown as ZodType<ProviderAccountFormValues>}
defaultValues={defaultValues}
onSubmit={onSubmit}
submitting={submitting}
>
{(form) => {
const { register, formState: { errors }, watch, setValue } = form
return (
<>
<FormField label="Хостер" htmlFor="acc-provider" error={errors.providerId?.message}>
<SelectField
triggerId="acc-provider"
placeholder="Выберите хостера"
value={watch('providerId')}
onValueChange={(v) => setValue('providerId', v ?? '', { shouldValidate: true })}
options={providers.map((p) => ({ value: p.id, label: p.name }))}
/>
</FormField>
<FormField label="Название" htmlFor="acc-name" error={errors.name?.message} invalid={!!errors.name}>
<Input id="acc-name" aria-invalid={!!errors.name} {...register('name')} />
</FormField>
<FormField label="Логин" htmlFor="acc-login">
<Input id="acc-login" {...register('login')} />
</FormField>
<FormField
label={isEdit ? 'Новый API-пароль (необязательно)' : 'API-пароль (логин:пароль)'}
htmlFor="acc-creds"
description="Оставьте пустым при редактировании, чтобы сохранить существующий"
>
<Input id="acc-creds" type="password" {...register('apiCredentials')} />
</FormField>
<FormField label="Режим биллинга" htmlFor="acc-mode">
<SelectField
triggerId="acc-mode"
value={watch('billingMode')}
onValueChange={(v) => setValue('billingMode', (v ?? 'monthly') as BillingMode)}
options={[
{ value: 'monthly', label: billingModeLabel('monthly') },
{ value: 'daily', label: billingModeLabel('daily') },
]}
/>
</FormField>
<FormField label="Порог низкого баланса" htmlFor="acc-alert" description="Уведомление на дашборде, если баланс API ниже">
<Input
id="acc-alert"
type="number"
min={0}
placeholder="Не задан"
{...register('balanceAlertBelow')}
/>
</FormField>
<FormField label="Заметки" htmlFor="acc-notes">
<Textarea id="acc-notes" {...register('notes')} />
</FormField>
</>
)
}}
</FormSheetRhf>
)
}
@@ -0,0 +1,103 @@
import { FormSheetRhf } from '@/components/form-sheet-rhf'
import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { Textarea } from '@cfdm/ui/components/textarea'
import { SelectField } from '@/components/select-field'
import type { ZodType } from 'zod'
import { balanceLedgerSchema, type BalanceLedgerFormValues } from '@/lib/schemas'
import type { LedgerDirection, Provider, ProviderAccount } from '@/types/entities'
import { accountSelectLabel, providerByIdMap } from '@/lib/billmanager'
const TODAY = new Date().toISOString().slice(0, 10)
const EMPTY: BalanceLedgerFormValues = {
providerAccountId: '',
direction: 'credit',
amount: 0,
currency: 'RUB',
date: TODAY,
note: '',
}
interface BalanceEntrySheetProps {
open: boolean
onOpenChange: (open: boolean) => void
defaultValues: BalanceLedgerFormValues
providerAccounts: ProviderAccount[]
providers: Provider[]
onSubmit: (values: BalanceLedgerFormValues) => void
submitting?: boolean
}
export function balanceEntryFormDefaults(fallbackAccountId = ''): BalanceLedgerFormValues {
return { ...EMPTY, providerAccountId: fallbackAccountId }
}
export function BalanceEntrySheet({
open,
onOpenChange,
defaultValues,
providerAccounts,
providers,
onSubmit,
submitting,
}: BalanceEntrySheetProps) {
const providerById = providerByIdMap(providers)
return (
<FormSheetRhf
open={open}
onOpenChange={onOpenChange}
title="Новая запись"
schema={balanceLedgerSchema as unknown as ZodType<BalanceLedgerFormValues>}
defaultValues={defaultValues}
onSubmit={onSubmit}
submitting={submitting}
>
{(form) => {
const { register, formState: { errors }, watch, setValue } = form
return (
<>
<FormField label="Аккаунт" htmlFor="bl-acc" error={errors.providerAccountId?.message}>
<SelectField
triggerId="bl-acc"
placeholder="Выберите аккаунт"
value={watch('providerAccountId')}
onValueChange={(v) => setValue('providerAccountId', v ?? '', { shouldValidate: true })}
options={providerAccounts.map((a) => ({
value: a.id,
label: accountSelectLabel(a, providerById),
}))}
/>
</FormField>
<FormField label="Движение" htmlFor="bl-dir">
<SelectField
triggerId="bl-dir"
value={watch('direction')}
onValueChange={(v) => setValue('direction', (v ?? 'credit') as LedgerDirection)}
options={[
{ value: 'credit', label: 'Приход' },
{ value: 'debit', label: 'Списание' },
]}
/>
</FormField>
<div className="grid grid-cols-3 gap-3">
<FormField label="Дата" htmlFor="bl-date" error={errors.date?.message}>
<Input id="bl-date" type="date" {...register('date')} />
</FormField>
<FormField label="Сумма" htmlFor="bl-amount" error={errors.amount?.message}>
<Input id="bl-amount" type="number" step="0.01" {...register('amount')} />
</FormField>
<FormField label="Валюта" htmlFor="bl-cur" error={errors.currency?.message}>
<Input id="bl-cur" {...register('currency')} />
</FormField>
</div>
<FormField label="Заметка" htmlFor="bl-note">
<Textarea id="bl-note" {...register('note')} />
</FormField>
</>
)
}}
</FormSheetRhf>
)
}
@@ -0,0 +1,112 @@
import { FormSheetRhf } from '@/components/form-sheet-rhf'
import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { Textarea } from '@cfdm/ui/components/textarea'
import { SelectField } from '@/components/select-field'
import type { ZodType } from 'zod'
import { paymentSchema, type PaymentFormValues } from '@/lib/schemas'
import type { PaymentType, Provider, ProviderAccount } from '@/types/entities'
import { paymentTypeLabel } from '@/lib/format'
import { accountSelectLabel, providerByIdMap } from '@/lib/billmanager'
const TODAY = new Date().toISOString().slice(0, 10)
const EMPTY: PaymentFormValues = {
type: 'provider_balance_topup',
date: TODAY,
amount: 0,
currency: 'RUB',
providerAccountId: '',
note: '',
}
interface PaymentEditSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
defaultValues: PaymentFormValues
providerAccounts: ProviderAccount[]
providers: Provider[]
onSubmit: (values: PaymentFormValues) => void
submitting?: boolean
}
export function paymentFormDefaults(
edit?: Partial<PaymentFormValues> | null,
fallbackAccountId = '',
): PaymentFormValues {
if (!edit) {
return { ...EMPTY, providerAccountId: fallbackAccountId }
}
return { ...EMPTY, ...edit }
}
export function PaymentEditSheet({
open,
onOpenChange,
defaultValues,
providerAccounts,
providers,
onSubmit,
submitting,
}: PaymentEditSheetProps) {
const providerById = providerByIdMap(providers)
return (
<FormSheetRhf
open={open}
onOpenChange={onOpenChange}
title={defaultValues.id ? 'Редактировать платёж' : 'Новый платёж'}
schema={paymentSchema as unknown as ZodType<PaymentFormValues>}
defaultValues={defaultValues}
onSubmit={onSubmit}
submitting={submitting}
>
{(form) => {
const { register, formState: { errors }, watch, setValue } = form
return (
<>
<FormField label="Тип" htmlFor="pay-type" error={errors.type?.message}>
<SelectField
triggerId="pay-type"
value={watch('type')}
onValueChange={(v) => setValue('type', (v ?? 'provider_balance_topup') as PaymentType, { shouldValidate: true })}
options={[
{ value: 'provider_balance_topup', label: paymentTypeLabel('provider_balance_topup') },
{ value: 'direct_vps_payment', label: paymentTypeLabel('direct_vps_payment') },
{ value: 'daily_debit', label: paymentTypeLabel('daily_debit') },
{ value: 'monthly_debit', label: paymentTypeLabel('monthly_debit') },
]}
/>
</FormField>
<FormField label="Аккаунт" htmlFor="pay-acc" error={errors.providerAccountId?.message}>
<SelectField
triggerId="pay-acc"
placeholder="Выберите аккаунт"
value={watch('providerAccountId')}
onValueChange={(v) => setValue('providerAccountId', v ?? '', { shouldValidate: true })}
options={providerAccounts.map((a) => ({
value: a.id,
label: accountSelectLabel(a, providerById),
}))}
/>
</FormField>
<div className="grid grid-cols-3 gap-3">
<FormField label="Дата" htmlFor="pay-date" error={errors.date?.message}>
<Input id="pay-date" type="date" {...register('date')} />
</FormField>
<FormField label="Сумма" htmlFor="pay-amount" error={errors.amount?.message}>
<Input id="pay-amount" type="number" step="0.01" {...register('amount')} />
</FormField>
<FormField label="Валюта" htmlFor="pay-cur" error={errors.currency?.message}>
<Input id="pay-cur" {...register('currency')} />
</FormField>
</div>
<FormField label="Заметка" htmlFor="pay-note">
<Textarea id="pay-note" {...register('note')} />
</FormField>
</>
)
}}
</FormSheetRhf>
)
}
@@ -0,0 +1,37 @@
import { FormSheetRhf } from '@/components/form-sheet-rhf'
import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { projectSchema, type ProjectFormValues } from '@/lib/schemas'
const EMPTY: ProjectFormValues = { name: '' }
interface ProjectEditSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: ProjectFormValues) => void
submitting?: boolean
}
export function ProjectEditSheet({ open, onOpenChange, onSubmit, submitting }: ProjectEditSheetProps) {
return (
<FormSheetRhf
open={open}
onOpenChange={onOpenChange}
title="Новый проект"
description="Имя будет доступно в автодополнении на форме VPS"
schema={projectSchema}
defaultValues={EMPTY}
onSubmit={onSubmit}
submitting={submitting}
>
{(form) => {
const { register, formState: { errors } } = form
return (
<FormField label="Название" htmlFor="project-name" error={errors.name?.message} invalid={!!errors.name}>
<Input id="project-name" aria-invalid={!!errors.name} {...register('name')} />
</FormField>
)
}}
</FormSheetRhf>
)
}
@@ -0,0 +1,97 @@
import { FormSheetRhf } from '@/components/form-sheet-rhf'
import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { Textarea } from '@cfdm/ui/components/textarea'
import { SelectField } from '@/components/select-field'
import type { ZodType } from 'zod'
import { providerSchema, type ProviderFormValues } from '@/lib/schemas'
import type { ApiType } from '@/types/entities'
const EMPTY: ProviderFormValues = {
name: '',
website: '',
apiType: 'billmanager',
apiBaseUrl: '',
baseCurrency: 'RUB',
usdRate: '',
eurRate: '',
supportPhone: '',
supportUrl: '',
notes: '',
}
interface ProviderEditSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
defaultValues?: ProviderFormValues
onSubmit: (values: ProviderFormValues) => void
submitting?: boolean
}
export function providerFormDefaults(edit?: ProviderFormValues | null): ProviderFormValues {
return edit ? { ...EMPTY, ...edit } : EMPTY
}
export { EMPTY as providerFormEmpty }
export function ProviderEditSheet({
open,
onOpenChange,
defaultValues = EMPTY,
onSubmit,
submitting,
}: ProviderEditSheetProps) {
return (
<FormSheetRhf
open={open}
onOpenChange={onOpenChange}
title={defaultValues.id ? 'Редактировать хостера' : 'Новый хостер'}
schema={providerSchema as unknown as ZodType<ProviderFormValues>}
defaultValues={defaultValues}
onSubmit={onSubmit}
submitting={submitting}
>
{(form) => {
const { register, formState: { errors }, watch, setValue } = form
return (
<>
<FormField label="Название" htmlFor="pr-name" error={errors.name?.message} invalid={!!errors.name}>
<Input id="pr-name" aria-invalid={!!errors.name} {...register('name')} />
</FormField>
<FormField label="Сайт" htmlFor="pr-site" error={errors.website?.message}>
<Input id="pr-site" {...register('website')} />
</FormField>
<FormField label="Тип API" htmlFor="pr-api">
<SelectField
triggerId="pr-api"
value={watch('apiType')}
onValueChange={(v) => setValue('apiType', (v ?? 'none') as ApiType, { shouldValidate: true })}
options={[
{ value: 'billmanager', label: 'BILLmanager' },
{ value: 'none', label: 'Нет' },
]}
/>
</FormField>
<FormField label="API URL" htmlFor="pr-apiurl" description="Один URL на хостера для BILLmanager">
<Input id="pr-apiurl" {...register('apiBaseUrl')} />
</FormField>
<div className="grid grid-cols-3 gap-3">
<FormField label="Валюта" htmlFor="pr-cur">
<Input id="pr-cur" {...register('baseCurrency')} />
</FormField>
<FormField label="Курс USD" htmlFor="pr-usd">
<Input id="pr-usd" {...register('usdRate')} />
</FormField>
<FormField label="Курс EUR" htmlFor="pr-eur">
<Input id="pr-eur" {...register('eurRate')} />
</FormField>
</div>
<FormField label="Заметки" htmlFor="pr-notes">
<Textarea id="pr-notes" {...register('notes')} />
</FormField>
</>
)
}}
</FormSheetRhf>
)
}
@@ -0,0 +1,334 @@
import { Controller } from 'react-hook-form'
import { FormSheetRhf } from '@/components/form-sheet-rhf'
import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { SelectField } from '@/components/select-field'
import { AutoCompleteInput } from '@/components/auto-complete-input'
import { Textarea } from '@cfdm/ui/components/textarea'
import {
NumberField,
NumberFieldGroup,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
} from '@/components/reui/number-field'
import { FormDatePicker } from '@/components/form-date-picker'
import { vpsSchema, type VpsFormValues } from '@/lib/schemas'
import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
import { buildCityOptions, cityMatchesCountry, resolveCountryForCityFromRows } from '@cfdm/shared/geo'
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
import type { ZodType } from 'zod'
export const VPS_FORM_EMPTY: VpsFormValues = {
ip: '',
dns: '',
providerId: '',
providerAccountId: '',
country: '',
city: '',
datacenter: '',
vcpu: 1,
ramGb: 1,
diskGb: 10,
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
monthlyRate: 0,
dailyRate: 0,
paidUntil: '',
project: '',
notes: '',
}
export function vpsFormFromRow(v: Vps): VpsFormValues {
return {
id: v.id,
ip: v.ip,
dns: v.dns ?? '',
providerId: v.providerId,
providerAccountId: v.providerAccountId,
country: v.country ?? '',
city: v.city ?? '',
datacenter: v.datacenter ?? '',
vcpu: v.vcpu,
ramGb: v.ramGb,
diskGb: v.diskGb,
status: v.status,
tariffType: v.tariffType,
currency: v.currency,
monthlyRate: Number(v.monthlyRate ?? 0),
dailyRate: Number(v.dailyRate ?? 0),
paidUntil: v.paidUntil ?? '',
project: v.project ?? '',
notes: v.notes ?? '',
}
}
interface VpsEditSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
editingId: string | null
defaultValues: VpsFormValues
providers: Provider[]
providerAccounts: ProviderAccount[]
vpsRows: Vps[]
formCountryOptions: Array<{ value: string; label: string }>
onSubmit: (values: VpsFormValues) => void
submitting?: boolean
}
export function VpsEditSheet({
open,
onOpenChange,
editingId,
defaultValues,
providers,
providerAccounts,
vpsRows,
formCountryOptions,
onSubmit,
submitting,
}: VpsEditSheetProps) {
return (
<FormSheetRhf
open={open}
onOpenChange={onOpenChange}
title={editingId ? 'Редактировать VPS' : 'Новый VPS'}
description="Заполните параметры сервера"
schema={vpsSchema as unknown as ZodType<VpsFormValues>}
defaultValues={defaultValues}
onSubmit={onSubmit}
submitting={submitting}
>
{(form) => {
const { register, formState: { errors }, watch, setValue, control } = form
const providerId = watch('providerId')
const formCountry = watch('country') ?? ''
const formCity = watch('city') ?? ''
const formCityOptions = buildCityOptions(vpsRows, formCountry.trim() || undefined)
return (
<>
<FormField label="IP" htmlFor="vps-ip" error={errors.ip?.message} invalid={!!errors.ip}>
<Input id="vps-ip" aria-invalid={!!errors.ip} {...register('ip')} />
</FormField>
<FormField label="DNS" htmlFor="vps-dns">
<Input id="vps-dns" {...register('dns')} />
</FormField>
<FormField label="Хостер" htmlFor="vps-provider" error={errors.providerId?.message}>
<SelectField
triggerId="vps-provider"
placeholder="Выберите хостера"
value={providerId}
onValueChange={(v) => setValue('providerId', v ?? '', { shouldValidate: true })}
options={providers.map((p) => ({ value: p.id, label: p.name }))}
/>
</FormField>
<FormField label="Аккаунт" htmlFor="vps-account" error={errors.providerAccountId?.message}>
<SelectField
triggerId="vps-account"
placeholder="Выберите аккаунт"
value={watch('providerAccountId')}
onValueChange={(v) => setValue('providerAccountId', v ?? '', { shouldValidate: true })}
options={providerAccounts
.filter((a) => !providerId || a.providerId === providerId)
.map((a) => ({ value: a.id, label: a.name }))}
/>
</FormField>
<FormField label="Проект" htmlFor="vps-project">
<Input id="vps-project" {...register('project')} />
</FormField>
<FormField label="Страна" htmlFor="vps-country">
<AutoCompleteInput
id="vps-country"
placeholder="Любая"
value={formCountry}
onChange={(v) => {
setValue('country', v)
if (v.trim() && formCity.trim() && !cityMatchesCountry(formCity, v, vpsRows)) {
setValue('city', '')
}
}}
options={formCountryOptions}
searchPlaceholder="Поиск страны…"
emptyText="Нет вариантов"
/>
</FormField>
<FormField label="Город" htmlFor="vps-city">
<AutoCompleteInput
id="vps-city"
placeholder="Любой"
value={formCity}
onChange={(v) => {
setValue('city', v)
const country = resolveCountryForCityFromRows(v, vpsRows)
if (country) setValue('country', country)
}}
options={formCityOptions}
searchPlaceholder="Поиск города…"
emptyText="Нет вариантов"
showLeadingInInput={false}
/>
</FormField>
<FormField label="Дата-центр" htmlFor="vps-dc">
<Input id="vps-dc" {...register('datacenter')} />
</FormField>
<div className="grid grid-cols-3 gap-3">
<FormField label="vCPU" htmlFor="vps-vcpu" error={errors.vcpu?.message}>
<Controller
control={control}
name="vcpu"
render={({ field }) => (
<NumberField
id="vps-vcpu"
min={0}
step={1}
value={Number(field.value ?? 0)}
onValueChange={(val) => field.onChange(val ?? 0)}
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
)}
/>
</FormField>
<FormField label="RAM (GB)" htmlFor="vps-ram" error={errors.ramGb?.message}>
<Controller
control={control}
name="ramGb"
render={({ field }) => (
<NumberField
id="vps-ram"
min={0}
step={1}
value={Number(field.value ?? 0)}
onValueChange={(val) => field.onChange(val ?? 0)}
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
)}
/>
</FormField>
<FormField label="Disk (GB)" htmlFor="vps-disk" error={errors.diskGb?.message}>
<Controller
control={control}
name="diskGb"
render={({ field }) => (
<NumberField
id="vps-disk"
min={0}
step={1}
value={Number(field.value ?? 0)}
onValueChange={(val) => field.onChange(val ?? 0)}
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
)}
/>
</FormField>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField label="Статус" htmlFor="vps-status">
<SelectField
triggerId="vps-status"
value={watch('status')}
onValueChange={(v) => setValue('status', (v ?? 'active') as VpsFormValues['status'])}
options={[
{ value: 'active', label: vpsStatusLabel('active') },
{ value: 'paused', label: vpsStatusLabel('paused') },
{ value: 'archived', label: vpsStatusLabel('archived') },
]}
/>
</FormField>
<FormField label="Тип тарифа" htmlFor="vps-tariff">
<SelectField
triggerId="vps-tariff"
value={watch('tariffType')}
onValueChange={(v) => setValue('tariffType', (v ?? 'monthly') as VpsFormValues['tariffType'])}
options={[
{ value: 'monthly', label: tariffTypeLabel('monthly') },
{ value: 'daily', label: tariffTypeLabel('daily') },
]}
/>
</FormField>
</div>
<div className="grid grid-cols-3 gap-3">
<FormField label="Валюта" htmlFor="vps-cur" error={errors.currency?.message}>
<Input id="vps-cur" {...register('currency')} />
</FormField>
<FormField label="Ставка/мес" htmlFor="vps-monthly">
<Controller
control={control}
name="monthlyRate"
render={({ field }) => (
<NumberField
id="vps-monthly"
min={0}
step={0.01}
value={Number(field.value ?? 0)}
onValueChange={(val) => field.onChange(val ?? 0)}
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
)}
/>
</FormField>
<FormField label="Ставка/день" htmlFor="vps-daily">
<Controller
control={control}
name="dailyRate"
render={({ field }) => (
<NumberField
id="vps-daily"
min={0}
step={0.01}
value={Number(field.value ?? 0)}
onValueChange={(val) => field.onChange(val ?? 0)}
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
)}
/>
</FormField>
</div>
<FormField label="Оплачено до" htmlFor="vps-paid">
<Controller
control={control}
name="paidUntil"
render={({ field }) => (
<FormDatePicker
id="vps-paid"
value={(field.value as string | undefined) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
<FormField label="Заметки" htmlFor="vps-notes">
<Textarea id="vps-notes" {...register('notes')} />
</FormField>
</>
)
}}
</FormSheetRhf>
)
}
+53
View File
@@ -0,0 +1,53 @@
import type { ReactNode } from 'react'
import { PencilIcon, Trash2Icon } from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import { ConfirmDialog } from './confirm-dialog'
interface RowActionsProps {
onEdit?: () => void
onDelete?: () => void
editLabel?: string
deleteTitle?: string
deleteDescription?: ReactNode
deleteLabel?: string
extra?: ReactNode
className?: string
}
export function RowActions({
onEdit,
onDelete,
editLabel = 'Редактировать',
deleteTitle = 'Удалить запись?',
deleteDescription,
deleteLabel = 'Удалить',
extra,
className,
}: RowActionsProps) {
if (!onEdit && !onDelete && !extra) return null
return (
<div className={`flex justify-end gap-1 ${className ?? ''}`}>
{extra}
{onEdit ? (
<Button variant="ghost" size="icon-sm" onClick={onEdit} aria-label={editLabel}>
<PencilIcon />
</Button>
) : null}
{onDelete ? (
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" aria-label="Удалить">
<Trash2Icon />
</Button>
}
title={deleteTitle}
description={deleteDescription}
destructive
confirmLabel={deleteLabel}
onConfirm={onDelete}
/>
) : null}
</div>
)
}
+10 -1
View File
@@ -17,9 +17,18 @@ const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
destructive: 'border-destructive/50',
}
function sectionGridClass(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'sm:grid-cols-2'
if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3'
if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4'
if (count === 5) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5'
return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6'
}
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
return (
<div className={cn('grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6', className)}>
<div className={cn('grid gap-4', sectionGridClass(items.length), className)}>
{items.map((item, idx) => {
const clickable = Boolean(item.onClick)
const content = (
-29
View File
@@ -1,29 +0,0 @@
import type { ReactNode } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { cn } from '@cfdm/ui/lib/utils'
interface TableCardProps {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
children: ReactNode
className?: string
contentClassName?: string
}
export function TableCard({ title, description, actions, children, className, contentClassName }: TableCardProps) {
return (
<Card className={cn('gap-0', className)}>
{(title || actions) && (
<CardHeader className="flex flex-row items-center justify-between gap-2">
<div className="space-y-1">
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</CardHeader>
)}
<CardContent className={cn('p-0', contentClassName)}>{children}</CardContent>
</Card>
)
}