refactor(web): унифицировать UI по паттернам ReUI на всех 12 страницах
Docker / build (push) Has been cancelled
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user