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:
@@ -29,12 +29,11 @@ apps/web/src/components/ ← shared + domain + layout
|
|||||||
empty-state.tsx
|
empty-state.tsx
|
||||||
query-state.tsx
|
query-state.tsx
|
||||||
confirm-dialog.tsx
|
confirm-dialog.tsx
|
||||||
data-table-card.tsx
|
data-grid-card.tsx
|
||||||
section-cards.tsx
|
section-cards.tsx
|
||||||
status-badge.tsx
|
status-badge.tsx
|
||||||
form-sheet.tsx ← Sheet + RHF FormProvider
|
form-sheet.tsx ← Sheet + RHF FormProvider
|
||||||
form-field.tsx ← Field + Controller + aria-invalid
|
form-field.tsx ← Field + Controller + aria-invalid
|
||||||
table-card.tsx ← Card + Table wrapper
|
|
||||||
loading-button.tsx ← Button + Spinner + label swap
|
loading-button.tsx ← Button + Spinner + label swap
|
||||||
section-cards-skeleton.tsx
|
section-cards-skeleton.tsx
|
||||||
table-skeleton.tsx
|
table-skeleton.tsx
|
||||||
@@ -49,14 +48,14 @@ apps/web/src/components/ ← shared + domain + layout
|
|||||||
| Page wrapper | `PageShell` | — |
|
| Page wrapper | `PageShell` | — |
|
||||||
| Page title | `PageHeader` | — |
|
| Page title | `PageHeader` | — |
|
||||||
| Stat metrics | `SectionCards` | `Card` |
|
| Stat metrics | `SectionCards` | `Card` |
|
||||||
| Data list | `DataTableCard` | `Table`, `InputGroup` |
|
| Data list | `DataGridCard` | `Table`, `InputGroup` |
|
||||||
| Empty | `EmptyState` | `Empty` |
|
| Empty | `EmptyState` | `Empty` |
|
||||||
| Loading / Error | `QueryState` | `Skeleton`, `Alert` |
|
| Loading / Error | `QueryState` | `Skeleton`, `Alert` |
|
||||||
| Status | `StatusBadge` | `Badge` |
|
| Status | `StatusBadge` | `Badge` |
|
||||||
| Create/Edit | `FormSheet` + `*-edit-sheet.tsx` | `Sheet`, `Field` |
|
| Create/Edit | `FormSheet` + `*-edit-sheet.tsx` | `Sheet`, `Field` |
|
||||||
| Form field | `FormField` | `Field`, `Input`, `Select` |
|
| Form field | `FormField` | `Field`, `Input`, `Select` |
|
||||||
| Submit button | `LoadingButton` | `Button`, `Spinner` |
|
| Submit button | `LoadingButton` | `Button`, `Spinner` |
|
||||||
| Table wrapper | `TableCard` | `Table`, `Card` |
|
| Table wrapper | `DataGridCard` | `Table`, `Card` |
|
||||||
| Delete confirm | `ConfirmDialog` | `AlertDialog` |
|
| Delete confirm | `ConfirmDialog` | `AlertDialog` |
|
||||||
| List row | — | `Item variant="outline" size="sm"` |
|
| List row | — | `Item variant="outline" size="sm"` |
|
||||||
| Nav | `AppSidebar` | `Sidebar` |
|
| Nav | `AppSidebar` | `Sidebar` |
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<Card className={cn('ring-0 shadow-none', className)}>
|
<Card className={cn('ring-0 shadow-none', className)}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
<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}
|
{title ? <CardTitle>{title}</CardTitle> : null}
|
||||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||||
</div>
|
</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',
|
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 }) {
|
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
|
||||||
return (
|
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) => {
|
{items.map((item, idx) => {
|
||||||
const clickable = Boolean(item.onClick)
|
const clickable = Boolean(item.onClick)
|
||||||
const content = (
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -144,6 +144,12 @@ export const api = {
|
|||||||
fetchApi<import('@/queries/dashboard').DashboardStats>('/api/dashboard/stats'),
|
fetchApi<import('@/queries/dashboard').DashboardStats>('/api/dashboard/stats'),
|
||||||
|
|
||||||
fetchProjects: () => fetchApi<{ id: string; name: string }[]>('/api/projects'),
|
fetchProjects: () => fetchApi<{ id: string; name: string }[]>('/api/projects'),
|
||||||
|
|
||||||
|
createProject: (name: string) =>
|
||||||
|
fetchApi<{ id: string; name: string }>('/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { toCsv, downloadTextFile } from '@/lib/format'
|
||||||
|
|
||||||
|
export function exportVpsCsv(
|
||||||
|
rows: Record<string, unknown>[],
|
||||||
|
fileName = 'vps-export.csv',
|
||||||
|
): void {
|
||||||
|
downloadTextFile(fileName, toCsv(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportActiveVpsCsv(
|
||||||
|
vps: Array<{
|
||||||
|
ip: string
|
||||||
|
status: string
|
||||||
|
project?: string
|
||||||
|
currency: string
|
||||||
|
monthlyRate?: number | null
|
||||||
|
}>,
|
||||||
|
fileName = 'vps-export.csv',
|
||||||
|
): void {
|
||||||
|
exportVpsCsv(
|
||||||
|
vps.map((v) => ({
|
||||||
|
ip: v.ip,
|
||||||
|
status: v.status,
|
||||||
|
project: v.project ?? '',
|
||||||
|
currency: v.currency,
|
||||||
|
monthlyRate: v.monthlyRate ?? 0,
|
||||||
|
})),
|
||||||
|
fileName,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export const providerAccountSchema = z.object({
|
|||||||
login: z.string().optional().default(''),
|
login: z.string().optional().default(''),
|
||||||
apiCredentials: z.string().optional().default(''),
|
apiCredentials: z.string().optional().default(''),
|
||||||
billingMode: billingModeSchema.default('monthly'),
|
billingMode: billingModeSchema.default('monthly'),
|
||||||
|
balanceAlertBelow: z.union([z.coerce.number().min(0), z.literal('')]).optional(),
|
||||||
notes: z.string().optional().default(''),
|
notes: z.string().optional().default(''),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -83,10 +84,22 @@ export const settingsSchema = z.object({
|
|||||||
ratesUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(),
|
ratesUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(),
|
||||||
autoConvert: z.boolean().default(true),
|
autoConvert: z.boolean().default(true),
|
||||||
syncEnabled: z.boolean().optional().default(true),
|
syncEnabled: z.boolean().optional().default(true),
|
||||||
|
syncIntervalMinutes: z.coerce.number().min(15).optional().default(60),
|
||||||
|
syncTariffsIntervalMinutes: z.coerce.number().min(60).optional().default(1440),
|
||||||
telegramChatId: z.string().optional().default(''),
|
telegramChatId: z.string().optional().default(''),
|
||||||
telegramBotToken: z.string().optional().default(''),
|
telegramBotToken: z.string().optional().default(''),
|
||||||
|
notifyPaymentExpiryEnabled: z.boolean().optional().default(true),
|
||||||
|
notifyNewTariffsEnabled: z.boolean().optional().default(true),
|
||||||
|
notifyLowBalanceEnabled: z.boolean().optional().default(true),
|
||||||
|
notifySyncDigestEnabled: z.boolean().optional().default(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const projectSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Укажите название проекта').max(120),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ProjectFormValues = z.infer<typeof projectSchema>
|
||||||
|
|
||||||
export type ProviderFormValues = z.infer<typeof providerSchema>
|
export type ProviderFormValues = z.infer<typeof providerSchema>
|
||||||
export type ProviderAccountFormValues = z.infer<typeof providerAccountSchema>
|
export type ProviderAccountFormValues = z.infer<typeof providerAccountSchema>
|
||||||
export type VpsFormValues = z.infer<typeof vpsSchema>
|
export type VpsFormValues = z.infer<typeof vpsSchema>
|
||||||
|
|||||||
@@ -1,36 +1,33 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { PlusIcon, PencilIcon, Trash2Icon, RefreshCwIcon, UserRoundIcon, KeyRoundIcon, PlugIcon, ReceiptIcon, WalletIcon, MoreHorizontalIcon } from 'lucide-react'
|
import {
|
||||||
|
PlusIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
UserRoundIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
|
PlugIcon,
|
||||||
|
ReceiptIcon,
|
||||||
|
WalletIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
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 { PageShell } from '@/components/page-shell'
|
|
||||||
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 } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { RowActions } from '@/components/row-actions'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
|
||||||
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 {
|
import {
|
||||||
DropdownMenu,
|
ProviderAccountEditSheet,
|
||||||
DropdownMenuContent,
|
providerAccountFormDefaults,
|
||||||
DropdownMenuItem,
|
} from '@/components/domain/account-edit-sheet'
|
||||||
DropdownMenuSeparator,
|
import type { ProviderAccountFormValues } from '@/lib/schemas'
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@cfdm/ui/components/dropdown-menu'
|
|
||||||
import { accountBalanceApi, accountBalanceCurrency } from '@/lib/account'
|
import { accountBalanceApi, accountBalanceCurrency } from '@/lib/account'
|
||||||
|
import type { ProviderAccount } from '@/types/entities'
|
||||||
import type { ProviderAccount, BillingMode } from '@/types/entities'
|
|
||||||
import { providerByIdMap, accountBillmanagerUiReady, billmanagerSyncableAccounts } from '@/lib/billmanager'
|
import { providerByIdMap, accountBillmanagerUiReady, billmanagerSyncableAccounts } from '@/lib/billmanager'
|
||||||
import { billingModeLabel, formatCurrency } from '@/lib/format'
|
import { billingModeLabel, formatCurrency } from '@/lib/format'
|
||||||
|
|
||||||
@@ -40,37 +37,18 @@ export const Route = createFileRoute('/_auth/accounts')({
|
|||||||
component: AccountsPage,
|
component: AccountsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
interface FormState {
|
|
||||||
id?: string
|
|
||||||
providerId: string
|
|
||||||
name: string
|
|
||||||
login: string
|
|
||||||
apiCredentials: string
|
|
||||||
billingMode: BillingMode
|
|
||||||
balanceAlertBelow: string
|
|
||||||
notes: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY: FormState = {
|
|
||||||
providerId: '',
|
|
||||||
name: '',
|
|
||||||
login: '',
|
|
||||||
apiCredentials: '',
|
|
||||||
billingMode: 'monthly',
|
|
||||||
balanceAlertBelow: '',
|
|
||||||
notes: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccountsPage() {
|
function AccountsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [form, setForm] = useState<FormState>(EMPTY)
|
const [formDefaults, setFormDefaults] = useState<ProviderAccountFormValues>(
|
||||||
|
providerAccountFormDefaults(null, snapshot?.providers[0]?.id ?? ''),
|
||||||
|
)
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: (r: FormState) => {
|
mutationFn: (r: ProviderAccountFormValues) => {
|
||||||
const { apiCredentials, balanceAlertBelow, ...rest } = r
|
const { apiCredentials, balanceAlertBelow, ...rest } = r
|
||||||
const alertRaw = balanceAlertBelow.trim()
|
const alertRaw = balanceAlertBelow === '' || balanceAlertBelow == null ? '' : String(balanceAlertBelow)
|
||||||
const alertNum = alertRaw ? Number(alertRaw) : null
|
const alertNum = alertRaw ? Number(alertRaw) : null
|
||||||
const base = {
|
const base = {
|
||||||
...rest,
|
...rest,
|
||||||
@@ -125,19 +103,24 @@ function AccountsPage() {
|
|||||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка синка'),
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка синка'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const openCreate = () => { setForm({ ...EMPTY, providerId: snapshot?.providers[0]?.id ?? '' }); setOpen(true) }
|
const openCreate = () => {
|
||||||
|
setFormDefaults(providerAccountFormDefaults(null, snapshot?.providers[0]?.id ?? ''))
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
const openEdit = (a: ProviderAccount) => {
|
const openEdit = (a: ProviderAccount) => {
|
||||||
const ext = a as ProviderAccount & { balanceAlertBelow?: number | null }
|
const ext = a as ProviderAccount & { balanceAlertBelow?: number | null }
|
||||||
setForm({
|
setFormDefaults(
|
||||||
id: a.id,
|
providerAccountFormDefaults({
|
||||||
providerId: a.providerId,
|
id: a.id,
|
||||||
name: a.name,
|
providerId: a.providerId,
|
||||||
login: a.login ?? '',
|
name: a.name,
|
||||||
apiCredentials: '',
|
login: a.login ?? '',
|
||||||
billingMode: a.billingMode ?? 'monthly',
|
apiCredentials: '',
|
||||||
balanceAlertBelow: ext.balanceAlertBelow != null ? String(ext.balanceAlertBelow) : '',
|
billingMode: a.billingMode ?? 'monthly',
|
||||||
notes: a.notes ?? '',
|
balanceAlertBelow: ext.balanceAlertBelow != null ? ext.balanceAlertBelow : '',
|
||||||
})
|
notes: a.notes ?? '',
|
||||||
|
}),
|
||||||
|
)
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +146,11 @@ function AccountsPage() {
|
|||||||
key: 'creds',
|
key: 'creds',
|
||||||
header: 'API-доступ',
|
header: 'API-доступ',
|
||||||
icon: PlugIcon,
|
icon: PlugIcon,
|
||||||
cell: (a) => <Badge variant={a.apiCredentialsSet ? 'default' : 'outline'}>{a.apiCredentialsSet ? 'установлены' : 'нет'}</Badge>,
|
cell: (a) => (
|
||||||
|
<Badge variant={a.apiCredentialsSet ? 'default' : 'outline'}>
|
||||||
|
{a.apiCredentialsSet ? 'установлены' : 'нет'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'mode',
|
key: 'mode',
|
||||||
@@ -198,140 +185,83 @@ function AccountsPage() {
|
|||||||
const provider = providerById.get(a.providerId)
|
const provider = providerById.get(a.providerId)
|
||||||
const canSync = accountBillmanagerUiReady(a, provider)
|
const canSync = accountBillmanagerUiReady(a, provider)
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end">
|
<RowActions
|
||||||
<DropdownMenu>
|
onEdit={() => openEdit(a)}
|
||||||
<DropdownMenuTrigger
|
onDelete={() => delMut.mutate(a.id)}
|
||||||
render={
|
deleteTitle="Удалить аккаунт?"
|
||||||
<Button variant="ghost" size="icon-sm" aria-label="Действия">
|
deleteDescription={`«${a.name}» будет удалён.`}
|
||||||
<MoreHorizontalIcon />
|
extra={
|
||||||
</Button>
|
canSync ? (
|
||||||
}
|
<Button
|
||||||
/>
|
variant="ghost"
|
||||||
<DropdownMenuContent align="end" className="w-auto min-w-44">
|
size="icon-sm"
|
||||||
<DropdownMenuItem
|
aria-label="Синхронизировать"
|
||||||
disabled={!canSync || (syncMut.isPending && syncMut.variables === a.id)}
|
disabled={syncMut.isPending && syncMut.variables === a.id}
|
||||||
onClick={() => syncMut.mutate(a.id)}
|
onClick={() => syncMut.mutate(a.id)}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon />
|
<RefreshCwIcon />
|
||||||
Синхронизировать
|
</Button>
|
||||||
</DropdownMenuItem>
|
) : null
|
||||||
<DropdownMenuItem onClick={() => openEdit(a)}>
|
}
|
||||||
<PencilIcon />
|
/>
|
||||||
Редактировать
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<ConfirmDialog
|
|
||||||
trigger={
|
|
||||||
<DropdownMenuItem variant="destructive" onSelect={(e) => e.preventDefault()}>
|
|
||||||
<Trash2Icon />
|
|
||||||
Удалить
|
|
||||||
</DropdownMenuItem>
|
|
||||||
}
|
|
||||||
title="Удалить аккаунт?"
|
|
||||||
description={`«${a.name}» будет удалён.`}
|
|
||||||
destructive
|
|
||||||
confirmLabel="Удалить"
|
|
||||||
onConfirm={() => delMut.mutate(a.id)}
|
|
||||||
/>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<CrudListPage
|
||||||
<PageHeader
|
title="Аккаунты хостеров"
|
||||||
title="Аккаунты хостеров"
|
description="Аккаунты провайдеров с API-доступом"
|
||||||
description="Аккаунты провайдеров с API-доступом"
|
actions={
|
||||||
actions={
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
{syncableCount > 0 ? (
|
||||||
{syncableCount > 0 ? (
|
<Button variant="outline" disabled={syncAllMut.isPending} onClick={() => syncAllMut.mutate()}>
|
||||||
<Button
|
<RefreshCwIcon data-icon="inline-start" />
|
||||||
variant="outline"
|
Синхронизировать все
|
||||||
disabled={syncAllMut.isPending}
|
</Button>
|
||||||
onClick={() => syncAllMut.mutate()}
|
) : null}
|
||||||
>
|
<Button onClick={openCreate}>
|
||||||
<RefreshCwIcon data-icon="inline-start" />
|
<PlusIcon data-icon="inline-start" />
|
||||||
Синхронизировать все
|
Добавить
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
</div>
|
||||||
<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить</Button>
|
}
|
||||||
</div>
|
data={snapshot}
|
||||||
}
|
isLoading={isLoading}
|
||||||
/>
|
isError={isError}
|
||||||
<QueryState
|
error={error}
|
||||||
data={snapshot}
|
onRetry={() => refetch()}
|
||||||
isLoading={isLoading}
|
empty={snapshot?.providerAccounts.length === 0}
|
||||||
isError={isError}
|
emptyTitle="Аккаунты не найдены"
|
||||||
error={error}
|
emptyDescription="Добавьте аккаунт хостера для синхронизации VPS и платежей"
|
||||||
onRetry={() => refetch()}
|
emptyAction={
|
||||||
skeleton={<TableSkeleton />}
|
<Button onClick={openCreate}>
|
||||||
empty={snapshot?.providerAccounts.length === 0}
|
<PlusIcon data-icon="inline-start" />
|
||||||
emptyTitle="Аккаунты не найдены"
|
Добавить аккаунт
|
||||||
emptyAction={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить аккаунт</Button>}
|
</Button>
|
||||||
>
|
}
|
||||||
{(snap) => <DataGridCard columns={columnDefFromDataTable(columns)} data={snap.providerAccounts} rowId={(a) => a.id} pinLastColumn />}
|
sheet={
|
||||||
</QueryState>
|
snapshot ? (
|
||||||
|
<ProviderAccountEditSheet
|
||||||
<FormSheet
|
open={open}
|
||||||
open={open}
|
onOpenChange={setOpen}
|
||||||
onOpenChange={setOpen}
|
defaultValues={formDefaults}
|
||||||
trigger={null}
|
providers={snapshot.providers}
|
||||||
title={form.id ? 'Редактировать аккаунт' : 'Новый аккаунт'}
|
onSubmit={(values) => saveMut.mutate(values)}
|
||||||
description="API-креды хранятся на сервере и используются для синка с BILLmanager"
|
submitting={saveMut.isPending}
|
||||||
onSubmit={() => saveMut.mutate(form)}
|
|
||||||
submitting={saveMut.isPending}
|
|
||||||
>
|
|
||||||
<FormField label="Хостер" htmlFor="acc-provider">
|
|
||||||
<SelectField
|
|
||||||
triggerId="acc-provider"
|
|
||||||
placeholder="Выберите хостера"
|
|
||||||
value={form.providerId}
|
|
||||||
onValueChange={(v) => setForm({ ...form, providerId: v ?? '' })}
|
|
||||||
options={(snapshot?.providers ?? []).map((p) => ({ value: p.id, label: p.name }))}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
) : null
|
||||||
<FormField label="Название" htmlFor="acc-name">
|
}
|
||||||
<Input id="acc-name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
>
|
||||||
</FormField>
|
{(snap) => (
|
||||||
<FormField label="Логин" htmlFor="acc-login">
|
<DataGridCard
|
||||||
<Input id="acc-login" value={form.login} onChange={(e) => setForm({ ...form, login: e.target.value })} />
|
columns={columnDefFromDataTable(columns)}
|
||||||
</FormField>
|
data={snap.providerAccounts}
|
||||||
<FormField
|
rowId={(a) => a.id}
|
||||||
label={form.id ? 'Новый API-пароль (необязательно)' : 'API-пароль (логин:пароль)'}
|
pinLastColumn
|
||||||
htmlFor="acc-creds"
|
/>
|
||||||
description="Оставьте пустым при редактировании, чтобы сохранить существующий"
|
)}
|
||||||
>
|
</CrudListPage>
|
||||||
<Input id="acc-creds" type="password" value={form.apiCredentials} onChange={(e) => setForm({ ...form, apiCredentials: e.target.value })} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Режим биллинга" htmlFor="acc-mode">
|
|
||||||
<SelectField
|
|
||||||
triggerId="acc-mode"
|
|
||||||
value={form.billingMode}
|
|
||||||
onValueChange={(v) => setForm({ ...form, 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}
|
|
||||||
value={form.balanceAlertBelow}
|
|
||||||
onChange={(e) => setForm({ ...form, balanceAlertBelow: e.target.value })}
|
|
||||||
placeholder="Не задан"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Заметки" htmlFor="acc-notes">
|
|
||||||
<Textarea id="acc-notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
|
||||||
</FormField>
|
|
||||||
</FormSheet>
|
|
||||||
</PageShell>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,32 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { PlusIcon, Trash2Icon, ArrowDownUpIcon, CalendarIcon, UserRoundIcon, ArrowLeftRightIcon, CoinsIcon, StickyNoteIcon } from 'lucide-react'
|
import {
|
||||||
|
PlusIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
ArrowDownUpIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
UserRoundIcon,
|
||||||
|
ArrowLeftRightIcon,
|
||||||
|
CoinsIcon,
|
||||||
|
StickyNoteIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
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 { PageShell } from '@/components/page-shell'
|
|
||||||
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 } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
|
||||||
import { SectionCards } from '@/components/section-cards'
|
import { SectionCards } from '@/components/section-cards'
|
||||||
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
import { BalanceEntrySheet, balanceEntryFormDefaults } from '@/components/domain/balance-entry-sheet'
|
||||||
import { FormField } from '@/components/form-field'
|
import type { BalanceLedgerFormValues } from '@/lib/schemas'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import type { BalanceLedgerRow } from '@/types/entities'
|
||||||
import { SelectField } from '@/components/select-field'
|
|
||||||
import { Textarea } from '@cfdm/ui/components/textarea'
|
|
||||||
|
|
||||||
import type { BalanceLedgerRow, LedgerDirection } from '@/types/entities'
|
|
||||||
import { formatCurrency } from '@/lib/format'
|
import { formatCurrency } from '@/lib/format'
|
||||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||||
|
|
||||||
@@ -33,26 +36,14 @@ export const Route = createFileRoute('/_auth/balance')({
|
|||||||
component: BalancePage,
|
component: BalancePage,
|
||||||
})
|
})
|
||||||
|
|
||||||
interface FormState {
|
|
||||||
providerAccountId: string
|
|
||||||
direction: LedgerDirection
|
|
||||||
amount: number
|
|
||||||
currency: string
|
|
||||||
date: string
|
|
||||||
note: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const TODAY = new Date().toISOString().slice(0, 10)
|
|
||||||
const EMPTY: FormState = { providerAccountId: '', direction: 'credit', amount: 0, currency: 'RUB', date: TODAY, note: '' }
|
|
||||||
|
|
||||||
function BalancePage() {
|
function BalancePage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [form, setForm] = useState<FormState>(EMPTY)
|
const [formDefaults, setFormDefaults] = useState<BalanceLedgerFormValues>(balanceEntryFormDefaults())
|
||||||
|
|
||||||
const addMut = useMutation({
|
const addMut = useMutation({
|
||||||
mutationFn: (r: FormState) => api.create('balanceLedger', r as unknown as BalanceLedgerRow),
|
mutationFn: (r: BalanceLedgerFormValues) => api.create('balanceLedger', r as unknown as BalanceLedgerRow),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||||
toast.success('Запись добавлена')
|
toast.success('Запись добавлена')
|
||||||
@@ -69,7 +60,10 @@ function BalancePage() {
|
|||||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const openCreate = () => { setForm({ ...EMPTY, providerAccountId: snapshot?.providerAccounts[0]?.id ?? '' }); setOpen(true) }
|
const openCreate = () => {
|
||||||
|
setFormDefaults(balanceEntryFormDefaults(snapshot?.providerAccounts[0]?.id ?? ''))
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
||||||
|
|
||||||
@@ -115,7 +109,8 @@ function BalancePage() {
|
|||||||
sortValue: (r) => Number(r.amount),
|
sortValue: (r) => Number(r.amount),
|
||||||
cell: (r) => (
|
cell: (r) => (
|
||||||
<span className={`tabular-nums font-medium ${r.direction === 'credit' ? '' : 'text-destructive'}`}>
|
<span className={`tabular-nums font-medium ${r.direction === 'credit' ? '' : 'text-destructive'}`}>
|
||||||
{r.direction === 'credit' ? '+' : '−'}{formatCurrency(Number(r.amount), r.currency ?? 'RUB')}
|
{r.direction === 'credit' ? '+' : '−'}
|
||||||
|
{formatCurrency(Number(r.amount), r.currency ?? 'RUB')}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -132,7 +127,11 @@ function BalancePage() {
|
|||||||
className: 'w-16 text-right',
|
className: 'w-16 text-right',
|
||||||
cell: (r) => (
|
cell: (r) => (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
trigger={<Button variant="ghost" size="icon-sm" aria-label="Удалить"><Trash2Icon /></Button>}
|
trigger={
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="Удалить">
|
||||||
|
<Trash2Icon />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
title="Удалить запись?"
|
title="Удалить запись?"
|
||||||
confirmLabel="Удалить"
|
confirmLabel="Удалить"
|
||||||
destructive
|
destructive
|
||||||
@@ -143,99 +142,84 @@ function BalancePage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
const rows = [...(snapshot?.balanceLedger ?? [])].sort((a, b) => b.date.localeCompare(a.date))
|
const rows = [...(snapshot?.balanceLedger ?? [])].sort((a, b) => b.date.localeCompare(a.date))
|
||||||
|
|
||||||
const totalCredit = rows.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
const totalCredit = rows.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
||||||
const totalDebit = rows.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
const totalDebit = rows.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<CrudListPage
|
||||||
<PageHeader
|
title="Баланс и списания"
|
||||||
title="Баланс и списания"
|
description="Журнал движений по аккаунтам"
|
||||||
description="Журнал движений по аккаунтам"
|
actions={
|
||||||
actions={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить запись</Button>}
|
<Button onClick={openCreate}>
|
||||||
/>
|
<PlusIcon data-icon="inline-start" />
|
||||||
<QueryState
|
Добавить запись
|
||||||
data={snapshot}
|
</Button>
|
||||||
isLoading={isLoading}
|
}
|
||||||
isError={isError}
|
data={snapshot}
|
||||||
error={error}
|
isLoading={isLoading}
|
||||||
onRetry={() => refetch()}
|
isError={isError}
|
||||||
skeleton={<SectionCardsSkeleton count={3} />}
|
error={error}
|
||||||
>
|
onRetry={() => refetch()}
|
||||||
{(snap) => (
|
skeleton={<TableSkeleton />}
|
||||||
<>
|
empty={rows.length === 0}
|
||||||
|
emptyTitle="Записей нет"
|
||||||
|
emptyDescription="Добавьте движение по балансу аккаунта"
|
||||||
|
emptyAction={
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<PlusIcon data-icon="inline-start" />
|
||||||
|
Добавить запись
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
sheet={
|
||||||
|
snapshot ? (
|
||||||
|
<BalanceEntrySheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
defaultValues={formDefaults}
|
||||||
|
providerAccounts={snapshot.providerAccounts}
|
||||||
|
providers={snapshot.providers}
|
||||||
|
onSubmit={(values) => addMut.mutate(values)}
|
||||||
|
submitting={addMut.isPending}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(snap) => {
|
||||||
|
const baseCurrency = snap.settings[0]?.baseCurrency ?? 'RUB'
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
<SectionCards
|
<SectionCards
|
||||||
items={[
|
items={[
|
||||||
{ label: 'Всего приходов', value: formatCurrency(totalCredit, snap.settings[0]?.baseCurrency ?? 'RUB') },
|
{ label: 'Всего приходов', value: formatCurrency(totalCredit, baseCurrency) },
|
||||||
{ label: 'Всего списаний', value: formatCurrency(totalDebit, snap.settings[0]?.baseCurrency ?? 'RUB') },
|
{ label: 'Всего списаний', value: formatCurrency(totalDebit, baseCurrency) },
|
||||||
{ label: 'Чистый баланс (ledger)', value: formatCurrency(totalCredit - totalDebit, snap.settings[0]?.baseCurrency ?? 'RUB') },
|
{
|
||||||
|
label: 'Чистый баланс (ledger)',
|
||||||
|
value: formatCurrency(totalCredit - totalDebit, baseCurrency),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataGridCard
|
<DataGridCard
|
||||||
columns={columnDefFromDataTable(columns)}
|
columns={columnDefFromDataTable(columns)}
|
||||||
data={rows}
|
data={rows}
|
||||||
rowId={(r) => r.id}
|
rowId={(r) => r.id}
|
||||||
emptyTitle="Записей нет"
|
|
||||||
emptyAction={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить</Button>}
|
|
||||||
pinLastColumn
|
pinLastColumn
|
||||||
footerContent={
|
footerContent={
|
||||||
<div className="flex justify-end gap-6 px-3 py-2 text-sm tabular-nums">
|
<div className="flex justify-end gap-6 px-3 py-2 text-sm tabular-nums">
|
||||||
<span>Приходы: <b className="text-foreground">{formatCurrency(totalCredit, snap.settings[0]?.baseCurrency ?? 'RUB')}</b></span>
|
<span>
|
||||||
<span>Списания: <b className="text-foreground">{formatCurrency(totalDebit, snap.settings[0]?.baseCurrency ?? 'RUB')}</b></span>
|
Приходы: <b className="text-foreground">{formatCurrency(totalCredit, baseCurrency)}</b>
|
||||||
<span>Итого: <b className="text-foreground">{formatCurrency(totalCredit - totalDebit, snap.settings[0]?.baseCurrency ?? 'RUB')}</b></span>
|
</span>
|
||||||
|
<span>
|
||||||
|
Списания: <b className="text-foreground">{formatCurrency(totalDebit, baseCurrency)}</b>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Итого: <b className="text-foreground">{formatCurrency(totalCredit - totalDebit, baseCurrency)}</b>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
)}
|
)
|
||||||
</QueryState>
|
}}
|
||||||
|
</CrudListPage>
|
||||||
<FormSheet
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
trigger={null}
|
|
||||||
title="Новая запись"
|
|
||||||
onSubmit={() => addMut.mutate(form)}
|
|
||||||
submitting={addMut.isPending}
|
|
||||||
>
|
|
||||||
<FormField label="Аккаунт" htmlFor="bl-acc">
|
|
||||||
<SelectField
|
|
||||||
triggerId="bl-acc"
|
|
||||||
placeholder="Выберите аккаунт"
|
|
||||||
value={form.providerAccountId}
|
|
||||||
onValueChange={(v) => setForm({ ...form, providerAccountId: v ?? '' })}
|
|
||||||
options={(snapshot?.providerAccounts ?? []).map((a) => ({
|
|
||||||
value: a.id,
|
|
||||||
label: accountSelectLabel(a, providerById),
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Движение" htmlFor="bl-dir">
|
|
||||||
<SelectField
|
|
||||||
triggerId="bl-dir"
|
|
||||||
value={form.direction}
|
|
||||||
onValueChange={(v) => setForm({ ...form, 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">
|
|
||||||
<Input id="bl-date" type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Сумма" htmlFor="bl-amount">
|
|
||||||
<Input id="bl-amount" type="number" step="0.01" value={form.amount} onChange={(e) => setForm({ ...form, amount: Number(e.target.value) })} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Валюта" htmlFor="bl-cur">
|
|
||||||
<Input id="bl-cur" value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })} />
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<FormField label="Заметка" htmlFor="bl-note">
|
|
||||||
<Textarea id="bl-note" value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
|
|
||||||
</FormField>
|
|
||||||
</FormSheet>
|
|
||||||
</PageShell>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import type { DataTableColumn } 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'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
@@ -36,6 +36,7 @@ import { cn } from '@cfdm/ui/lib/utils'
|
|||||||
import { computeInventoryHealth, getStaleSyncAccountIds } from '@/lib/inventory-health'
|
import { computeInventoryHealth, getStaleSyncAccountIds } from '@/lib/inventory-health'
|
||||||
import { formatInBaseCurrency, normalizeRatesPayload, vpsStatusLabel } from '@/lib/format'
|
import { formatInBaseCurrency, normalizeRatesPayload, vpsStatusLabel } from '@/lib/format'
|
||||||
import { accountBalanceApi } from '@/lib/account'
|
import { accountBalanceApi } from '@/lib/account'
|
||||||
|
import { exportActiveVpsCsv } from '@/lib/export-csv'
|
||||||
import { MonthlyTrendChart, MonthlyExpenseChart } from '@/components/domain/charts'
|
import { MonthlyTrendChart, MonthlyExpenseChart } from '@/components/domain/charts'
|
||||||
|
|
||||||
import type { Vps, ProviderAccount, Provider, SyncLogRow } from '@/types/entities'
|
import type { Vps, ProviderAccount, Provider, SyncLogRow } from '@/types/entities'
|
||||||
@@ -316,9 +317,7 @@ function DashboardPage() {
|
|||||||
<TabsTrigger value="issues" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
|
<TabsTrigger value="issues" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
|
||||||
Проблемы
|
Проблемы
|
||||||
{issues.length > 0 ? (
|
{issues.length > 0 ? (
|
||||||
<Badge variant="primary-light" size="sm">
|
<Badge variant="secondary">{issues.length}</Badge>
|
||||||
{issues.length}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
) : null}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="recent" className={DASHBOARD_TAB_TRIGGER_CLASS}>
|
<TabsTrigger value="recent" className={DASHBOARD_TAB_TRIGGER_CLASS}>
|
||||||
@@ -327,9 +326,7 @@ function DashboardPage() {
|
|||||||
<TabsTrigger value="risk" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
|
<TabsTrigger value="risk" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
|
||||||
Аккаунты
|
Аккаунты
|
||||||
{atRisk.length > 0 ? (
|
{atRisk.length > 0 ? (
|
||||||
<Badge variant="info-light" size="sm">
|
<Badge variant="outline">{atRisk.length}</Badge>
|
||||||
{atRisk.length}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
) : null}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -382,21 +379,7 @@ function DashboardPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => exportActiveVpsCsv(activeVps)}
|
||||||
const csv = ['ip,status,project,currency,monthlyRate']
|
|
||||||
for (const v of activeVps) {
|
|
||||||
csv.push(
|
|
||||||
[v.ip, v.status, v.project ?? '', v.currency, v.monthlyRate ?? ''].join(','),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const blob = new Blob([csv.join('\n')], { type: 'text/csv' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = 'vps-export.csv'
|
|
||||||
a.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<DownloadIcon data-icon="inline-start" />
|
<DownloadIcon data-icon="inline-start" />
|
||||||
Экспорт CSV
|
Экспорт CSV
|
||||||
|
|||||||
@@ -1,27 +1,20 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { PlusIcon, PencilIcon, Trash2Icon, CalendarIcon, UserRoundIcon, TagIcon, CoinsIcon, StickyNoteIcon } from 'lucide-react'
|
import { PlusIcon, CalendarIcon, UserRoundIcon, TagIcon, CoinsIcon, StickyNoteIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
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 { PageShell } from '@/components/page-shell'
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { RowActions } from '@/components/row-actions'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { PaymentEditSheet, paymentFormDefaults } from '@/components/domain/payment-edit-sheet'
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
import type { PaymentFormValues } from '@/lib/schemas'
|
||||||
import { FormField } from '@/components/form-field'
|
import type { Payment } from '@/types/entities'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
|
||||||
import { Textarea } from '@cfdm/ui/components/textarea'
|
|
||||||
import { SelectField } from '@/components/select-field'
|
|
||||||
|
|
||||||
import type { Payment, PaymentType } from '@/types/entities'
|
|
||||||
import { paymentTypeLabel, formatCurrency } from '@/lib/format'
|
import { paymentTypeLabel, formatCurrency } from '@/lib/format'
|
||||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||||
|
|
||||||
@@ -31,29 +24,19 @@ export const Route = createFileRoute('/_auth/payments')({
|
|||||||
component: PaymentsPage,
|
component: PaymentsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
interface FormState {
|
|
||||||
id?: string
|
|
||||||
type: PaymentType
|
|
||||||
date: string
|
|
||||||
amount: number
|
|
||||||
currency: string
|
|
||||||
providerAccountId: string
|
|
||||||
note: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const TODAY = new Date().toISOString().slice(0, 10)
|
|
||||||
const EMPTY: FormState = { type: 'provider_balance_topup', date: TODAY, amount: 0, currency: 'RUB', providerAccountId: '', note: '' }
|
|
||||||
|
|
||||||
function PaymentsPage() {
|
function PaymentsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [form, setForm] = useState<FormState>(EMPTY)
|
const [formDefaults, setFormDefaults] = useState<PaymentFormValues>(
|
||||||
|
paymentFormDefaults(null, snapshot?.providerAccounts[0]?.id ?? ''),
|
||||||
|
)
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: (r: FormState) => r.id
|
mutationFn: (r: PaymentFormValues) =>
|
||||||
? api.update<Payment>('payments', r.id, r as unknown as Partial<Payment>)
|
r.id
|
||||||
: api.create('payments', r as unknown as Payment),
|
? api.update<Payment>('payments', r.id, r as unknown as Partial<Payment>)
|
||||||
|
: api.create('payments', r as unknown as Payment),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||||
toast.success('Платёж сохранён')
|
toast.success('Платёж сохранён')
|
||||||
@@ -70,12 +53,22 @@ function PaymentsPage() {
|
|||||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const openCreate = () => { setForm({ ...EMPTY, providerAccountId: snapshot?.providerAccounts[0]?.id ?? '' }); setOpen(true) }
|
const openCreate = () => {
|
||||||
|
setFormDefaults(paymentFormDefaults(null, snapshot?.providerAccounts[0]?.id ?? ''))
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
const openEdit = (p: Payment) => {
|
const openEdit = (p: Payment) => {
|
||||||
setForm({
|
setFormDefaults(
|
||||||
id: p.id, type: p.type, date: p.date, amount: p.amount, currency: p.currency,
|
paymentFormDefaults({
|
||||||
providerAccountId: p.providerAccountId, note: p.note ?? '',
|
id: p.id,
|
||||||
})
|
type: p.type,
|
||||||
|
date: p.date,
|
||||||
|
amount: p.amount,
|
||||||
|
currency: p.currency,
|
||||||
|
providerAccountId: p.providerAccountId,
|
||||||
|
note: p.note ?? '',
|
||||||
|
}),
|
||||||
|
)
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,24 +126,16 @@ function PaymentsPage() {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
className: 'w-24 text-right',
|
className: 'w-24 text-right',
|
||||||
cell: (p) => (
|
cell: (p) => (
|
||||||
<div className="flex justify-end gap-1">
|
<RowActions
|
||||||
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(p)} aria-label="Редактировать">
|
onEdit={() => openEdit(p)}
|
||||||
<PencilIcon />
|
onDelete={() => delMut.mutate(p.id)}
|
||||||
</Button>
|
deleteTitle="Удалить платёж?"
|
||||||
<ConfirmDialog
|
/>
|
||||||
trigger={<Button variant="ghost" size="icon-sm" aria-label="Удалить"><Trash2Icon /></Button>}
|
|
||||||
title="Удалить платёж?"
|
|
||||||
confirmLabel="Удалить"
|
|
||||||
destructive
|
|
||||||
onConfirm={() => delMut.mutate(p.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const sorted = [...(snapshot?.payments ?? [])].sort((a, b) => b.date.localeCompare(a.date))
|
const sorted = [...(snapshot?.payments ?? [])].sort((a, b) => b.date.localeCompare(a.date))
|
||||||
|
|
||||||
const totalByCurrency = sorted.reduce<Record<string, number>>((acc, p) => {
|
const totalByCurrency = sorted.reduce<Record<string, number>>((acc, p) => {
|
||||||
const cur = p.currency ?? 'RUB'
|
const cur = p.currency ?? 'RUB'
|
||||||
acc[cur] = (acc[cur] ?? 0) + Number(p.amount || 0)
|
acc[cur] = (acc[cur] ?? 0) + Number(p.amount || 0)
|
||||||
@@ -158,90 +143,62 @@ function PaymentsPage() {
|
|||||||
}, {})
|
}, {})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<CrudListPage
|
||||||
<PageHeader
|
title="Платежи"
|
||||||
title="Платежи"
|
description="Пополнения балансов и прямые платежи за VPS"
|
||||||
description="Пополнения балансов и прямые платежи за VPS"
|
actions={
|
||||||
actions={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить</Button>}
|
<Button onClick={openCreate}>
|
||||||
/>
|
<PlusIcon data-icon="inline-start" />
|
||||||
<QueryState
|
Добавить
|
||||||
data={snapshot}
|
</Button>
|
||||||
isLoading={isLoading}
|
}
|
||||||
isError={isError}
|
data={snapshot}
|
||||||
error={error}
|
isLoading={isLoading}
|
||||||
onRetry={() => refetch()}
|
isError={isError}
|
||||||
skeleton={<TableSkeleton />}
|
error={error}
|
||||||
empty={snapshot?.payments.length === 0}
|
onRetry={() => refetch()}
|
||||||
emptyTitle="Платежей нет"
|
empty={snapshot?.payments.length === 0}
|
||||||
emptyAction={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить платёж</Button>}
|
emptyTitle="Платежей нет"
|
||||||
>
|
emptyDescription="Добавьте первый платёж или дождитесь синхронизации"
|
||||||
{() => (
|
emptyAction={
|
||||||
<DataGridCard
|
<Button onClick={openCreate}>
|
||||||
columns={columnDefFromDataTable(columns)}
|
<PlusIcon data-icon="inline-start" />
|
||||||
data={sorted}
|
Добавить платёж
|
||||||
rowId={(p) => p.id}
|
</Button>
|
||||||
pinLastColumn
|
}
|
||||||
virtualization={sorted.length > 200}
|
sheet={
|
||||||
height={560}
|
snapshot ? (
|
||||||
footerContent={
|
<PaymentEditSheet
|
||||||
<div className="flex flex-wrap justify-end gap-6 px-3 py-2 text-sm tabular-nums">
|
open={open}
|
||||||
{Object.entries(totalByCurrency).map(([cur, sum]) => (
|
onOpenChange={setOpen}
|
||||||
<span key={cur}>Итого {cur}: <b className="text-foreground">{formatCurrency(sum, cur)}</b></span>
|
defaultValues={formDefaults}
|
||||||
))}
|
providerAccounts={snapshot.providerAccounts}
|
||||||
</div>
|
providers={snapshot.providers}
|
||||||
}
|
onSubmit={(values) => saveMut.mutate(values)}
|
||||||
|
submitting={saveMut.isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null
|
||||||
</QueryState>
|
}
|
||||||
|
>
|
||||||
<FormSheet
|
{() => (
|
||||||
open={open}
|
<DataGridCard
|
||||||
onOpenChange={setOpen}
|
columns={columnDefFromDataTable(columns)}
|
||||||
trigger={null}
|
data={sorted}
|
||||||
title={form.id ? 'Редактировать платёж' : 'Новый платёж'}
|
rowId={(p) => p.id}
|
||||||
onSubmit={() => saveMut.mutate(form)}
|
pinLastColumn
|
||||||
submitting={saveMut.isPending}
|
virtualization={sorted.length > 200}
|
||||||
>
|
height={560}
|
||||||
<FormField label="Тип" htmlFor="pay-type">
|
footerContent={
|
||||||
<SelectField
|
<div className="flex flex-wrap justify-end gap-6 px-3 py-2 text-sm tabular-nums">
|
||||||
triggerId="pay-type"
|
{Object.entries(totalByCurrency).map(([cur, sum]) => (
|
||||||
value={form.type}
|
<span key={cur}>
|
||||||
onValueChange={(v) => setForm({ ...form, type: (v ?? 'provider_balance_topup') as PaymentType })}
|
Итого {cur}: <b className="text-foreground">{formatCurrency(sum, cur)}</b>
|
||||||
options={[
|
</span>
|
||||||
{ value: 'provider_balance_topup', label: paymentTypeLabel('provider_balance_topup') },
|
))}
|
||||||
{ value: 'direct_vps_payment', label: paymentTypeLabel('direct_vps_payment') },
|
</div>
|
||||||
{ value: 'daily_debit', label: paymentTypeLabel('daily_debit') },
|
}
|
||||||
{ value: 'monthly_debit', label: paymentTypeLabel('monthly_debit') },
|
/>
|
||||||
]}
|
)}
|
||||||
/>
|
</CrudListPage>
|
||||||
</FormField>
|
|
||||||
<FormField label="Аккаунт" htmlFor="pay-acc">
|
|
||||||
<SelectField
|
|
||||||
triggerId="pay-acc"
|
|
||||||
placeholder="Выберите аккаунт"
|
|
||||||
value={form.providerAccountId}
|
|
||||||
onValueChange={(v) => setForm({ ...form, providerAccountId: v ?? '' })}
|
|
||||||
options={(snapshot?.providerAccounts ?? []).map((a) => ({
|
|
||||||
value: a.id,
|
|
||||||
label: accountSelectLabel(a, providerById),
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<FormField label="Дата" htmlFor="pay-date">
|
|
||||||
<Input id="pay-date" type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Сумма" htmlFor="pay-amount">
|
|
||||||
<Input id="pay-amount" type="number" step="0.01" value={form.amount} onChange={(e) => setForm({ ...form, amount: Number(e.target.value) })} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Валюта" htmlFor="pay-cur">
|
|
||||||
<Input id="pay-cur" value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })} />
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<FormField label="Заметка" htmlFor="pay-note">
|
|
||||||
<Textarea id="pay-note" value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
|
|
||||||
</FormField>
|
|
||||||
</FormSheet>
|
|
||||||
</PageShell>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,13 @@ import { PlusIcon, FolderKanbanIcon } from 'lucide-react'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
import { PageShell } from '@/components/page-shell'
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
import { ProjectEditSheet } from '@/components/domain/project-edit-sheet'
|
||||||
import { FormField } from '@/components/form-field'
|
import type { ProjectFormValues } from '@/lib/schemas'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
|
||||||
|
|
||||||
interface ProjectRow {
|
interface ProjectRow {
|
||||||
id: string
|
id: string
|
||||||
@@ -33,23 +29,13 @@ function ProjectsPage() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [name, setName] = useState('')
|
|
||||||
|
|
||||||
const createMut = useMutation({
|
const createMut = useMutation({
|
||||||
mutationFn: (projectName: string) =>
|
mutationFn: (values: ProjectFormValues) => api.createProject(values.name.trim()),
|
||||||
fetch(`${import.meta.env.VITE_API_URL ?? ''}/api/projects`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: projectName }),
|
|
||||||
}).then(async (res) => {
|
|
||||||
if (!res.ok) throw new ApiError(await res.text(), res.status)
|
|
||||||
return res.json()
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||||
toast.success('Проект создан')
|
toast.success('Проект создан')
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setName('')
|
|
||||||
},
|
},
|
||||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||||
})
|
})
|
||||||
@@ -78,58 +64,45 @@ function ProjectsPage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<CrudListPage
|
||||||
<PageHeader
|
title="Проекты"
|
||||||
title="Проекты"
|
description="Группировка VPS по проектам"
|
||||||
description="Группировка VPS по проектам"
|
actions={
|
||||||
actions={
|
<Button onClick={() => setOpen(true)}>
|
||||||
<Button onClick={() => setOpen(true)}>
|
<PlusIcon data-icon="inline-start" />
|
||||||
<PlusIcon data-icon="inline-start" />
|
Добавить
|
||||||
Добавить
|
</Button>
|
||||||
</Button>
|
}
|
||||||
}
|
data={snapshot}
|
||||||
/>
|
isLoading={isLoading}
|
||||||
<QueryState
|
isError={isError}
|
||||||
data={snapshot}
|
error={error}
|
||||||
isLoading={isLoading}
|
onRetry={() => refetch()}
|
||||||
isError={isError}
|
empty={rows.length === 0}
|
||||||
error={error}
|
emptyTitle="Проектов нет"
|
||||||
onRetry={() => refetch()}
|
emptyDescription="Создайте проект или назначьте его при редактировании VPS"
|
||||||
skeleton={<TableSkeleton />}
|
emptyAction={
|
||||||
empty={rows.length === 0}
|
<Button onClick={() => setOpen(true)}>
|
||||||
emptyTitle="Проектов нет"
|
<PlusIcon data-icon="inline-start" />
|
||||||
emptyDescription="Создайте проект или назначьте его при редактировании VPS"
|
Создать проект
|
||||||
emptyAction={
|
</Button>
|
||||||
<Button onClick={() => setOpen(true)}>
|
}
|
||||||
<PlusIcon data-icon="inline-start" />
|
sheet={
|
||||||
Создать проект
|
<ProjectEditSheet
|
||||||
</Button>
|
open={open}
|
||||||
}
|
onOpenChange={setOpen}
|
||||||
>
|
onSubmit={(values) => createMut.mutate(values)}
|
||||||
{() => (
|
submitting={createMut.isPending}
|
||||||
<DataGridCard
|
/>
|
||||||
columns={columnDefFromDataTable(columns)}
|
}
|
||||||
data={rows}
|
>
|
||||||
rowId={(r) => r.id}
|
{() => (
|
||||||
dense
|
<DataGridCard
|
||||||
/>
|
columns={columnDefFromDataTable(columns)}
|
||||||
)}
|
data={rows}
|
||||||
</QueryState>
|
rowId={(r) => r.id}
|
||||||
|
/>
|
||||||
<FormSheet
|
)}
|
||||||
open={open}
|
</CrudListPage>
|
||||||
onOpenChange={setOpen}
|
|
||||||
trigger={null}
|
|
||||||
title="Новый проект"
|
|
||||||
description="Имя будет доступно в автодополнении на форме VPS"
|
|
||||||
onSubmit={() => createMut.mutate(name.trim())}
|
|
||||||
submitting={createMut.isPending}
|
|
||||||
submitDisabled={!name.trim()}
|
|
||||||
>
|
|
||||||
<FormField label="Название" htmlFor="project-name">
|
|
||||||
<Input id="project-name" value={name} onChange={(e) => setName(e.target.value)} />
|
|
||||||
</FormField>
|
|
||||||
</FormSheet>
|
|
||||||
</PageShell>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { PlusIcon, PencilIcon, Trash2Icon, BuildingIcon, PlugIcon, CircleDollarSignIcon } from 'lucide-react'
|
import { PlusIcon, BuildingIcon, PlugIcon, CircleDollarSignIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
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 { PageShell } from '@/components/page-shell'
|
|
||||||
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 } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||||
import { dataGridCellWithIcon } from '@/components/data-grid-cells'
|
import { dataGridCellWithIcon } from '@/components/data-grid-cells'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { RowActions } from '@/components/row-actions'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ProviderEditSheet, providerFormDefaults } from '@/components/domain/provider-edit-sheet'
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
import type { ProviderFormValues } from '@/lib/schemas'
|
||||||
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 { faviconUrlFromWebsite } from '@/lib/format'
|
import { faviconUrlFromWebsite } from '@/lib/format'
|
||||||
import type { Provider, ApiType } from '@/types/entities'
|
import type { Provider } from '@/types/entities'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/providers')({
|
export const Route = createFileRoute('/_auth/providers')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
@@ -30,33 +24,17 @@ export const Route = createFileRoute('/_auth/providers')({
|
|||||||
component: ProvidersPage,
|
component: ProvidersPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
interface FormState {
|
|
||||||
id?: string
|
|
||||||
name: string
|
|
||||||
website: string
|
|
||||||
apiType: ApiType
|
|
||||||
apiBaseUrl: string
|
|
||||||
baseCurrency: string
|
|
||||||
usdRate: string
|
|
||||||
eurRate: string
|
|
||||||
supportPhone: string
|
|
||||||
supportUrl: string
|
|
||||||
notes: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY: FormState = {
|
|
||||||
name: '', website: '', apiType: 'billmanager', apiBaseUrl: '', baseCurrency: 'RUB',
|
|
||||||
usdRate: '', eurRate: '', supportPhone: '', supportUrl: '', notes: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProvidersPage() {
|
function ProvidersPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [form, setForm] = useState<FormState>(EMPTY)
|
const [formDefaults, setFormDefaults] = useState<ProviderFormValues>(providerFormDefaults())
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: (r: FormState) => (r.id ? api.update<Provider>('providers', r.id, r as unknown as Provider) : api.create('providers', r as unknown as Provider)),
|
mutationFn: (r: ProviderFormValues) =>
|
||||||
|
r.id
|
||||||
|
? api.update<Provider>('providers', r.id, r as unknown as Provider)
|
||||||
|
: api.create('providers', r as unknown as Provider),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||||
toast.success('Хостер сохранён')
|
toast.success('Хостер сохранён')
|
||||||
@@ -73,13 +51,26 @@ function ProvidersPage() {
|
|||||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const openCreate = () => { setForm(EMPTY); setOpen(true) }
|
const openCreate = () => {
|
||||||
|
setFormDefaults(providerFormDefaults())
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
const openEdit = (p: Provider) => {
|
const openEdit = (p: Provider) => {
|
||||||
setForm({
|
setFormDefaults(
|
||||||
id: p.id, name: p.name, website: p.website ?? '', apiType: p.apiType, apiBaseUrl: p.apiBaseUrl ?? '',
|
providerFormDefaults({
|
||||||
baseCurrency: p.baseCurrency ?? 'RUB', usdRate: String(p.usdRate ?? ''), eurRate: String(p.eurRate ?? ''),
|
id: p.id,
|
||||||
supportPhone: p.supportPhone ?? '', supportUrl: p.supportUrl ?? '', notes: p.notes ?? '',
|
name: p.name,
|
||||||
})
|
website: p.website ?? '',
|
||||||
|
apiType: p.apiType,
|
||||||
|
apiBaseUrl: p.apiBaseUrl ?? '',
|
||||||
|
baseCurrency: p.baseCurrency ?? 'RUB',
|
||||||
|
usdRate: String(p.usdRate ?? ''),
|
||||||
|
eurRate: String(p.eurRate ?? ''),
|
||||||
|
supportPhone: p.supportPhone ?? '',
|
||||||
|
supportUrl: p.supportUrl ?? '',
|
||||||
|
notes: p.notes ?? '',
|
||||||
|
}),
|
||||||
|
)
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,87 +106,58 @@ function ProvidersPage() {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
className: 'w-24 text-right',
|
className: 'w-24 text-right',
|
||||||
cell: (p) => (
|
cell: (p) => (
|
||||||
<div className="flex justify-end gap-1">
|
<RowActions
|
||||||
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(p)} aria-label="Редактировать">
|
onEdit={() => openEdit(p)}
|
||||||
<PencilIcon />
|
onDelete={() => delMut.mutate(p.id)}
|
||||||
</Button>
|
deleteTitle="Удалить хостера?"
|
||||||
<ConfirmDialog
|
deleteDescription={`«${p.name}» будет удалён. Аккаунты и VPS не затрагиваются, но потеряют привязку.`}
|
||||||
trigger={<Button variant="ghost" size="icon-sm" aria-label="Удалить"><Trash2Icon /></Button>}
|
/>
|
||||||
title="Удалить хостера?"
|
|
||||||
description={`«${p.name}» будет удалён. Аккаунты и VPS не затрагиваются, но потеряют привязку.`}
|
|
||||||
destructive
|
|
||||||
confirmLabel="Удалить"
|
|
||||||
onConfirm={() => delMut.mutate(p.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<CrudListPage
|
||||||
<PageHeader
|
title="Хостеры"
|
||||||
title="Хостеры"
|
description="Провайдеры хостинга и параметры API"
|
||||||
description="Провайдеры хостинга и параметры API"
|
actions={
|
||||||
actions={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить</Button>}
|
<Button onClick={openCreate}>
|
||||||
/>
|
<PlusIcon data-icon="inline-start" />
|
||||||
<QueryState
|
Добавить
|
||||||
data={snapshot}
|
</Button>
|
||||||
isLoading={isLoading}
|
}
|
||||||
isError={isError}
|
data={snapshot}
|
||||||
error={error}
|
isLoading={isLoading}
|
||||||
onRetry={() => refetch()}
|
isError={isError}
|
||||||
skeleton={<TableSkeleton />}
|
error={error}
|
||||||
empty={snapshot?.providers.length === 0}
|
onRetry={() => refetch()}
|
||||||
emptyTitle="Хостеры не найдены"
|
empty={snapshot?.providers.length === 0}
|
||||||
emptyAction={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить хостера</Button>}
|
emptyTitle="Хостеры не найдены"
|
||||||
>
|
emptyDescription="Добавьте первого хостера для учёта VPS и синхронизации"
|
||||||
{(snap) => <DataGridCard columns={columnDefFromDataTable(columns)} data={snap.providers} rowId={(p) => p.id} pinLastColumn />}
|
emptyAction={
|
||||||
</QueryState>
|
<Button onClick={openCreate}>
|
||||||
|
<PlusIcon data-icon="inline-start" />
|
||||||
<FormSheet
|
Добавить хостера
|
||||||
open={open}
|
</Button>
|
||||||
onOpenChange={setOpen}
|
}
|
||||||
trigger={null}
|
sheet={
|
||||||
title={form.id ? 'Редактировать хостера' : 'Новый хостер'}
|
<ProviderEditSheet
|
||||||
onSubmit={() => saveMut.mutate(form)}
|
open={open}
|
||||||
submitting={saveMut.isPending}
|
onOpenChange={setOpen}
|
||||||
>
|
defaultValues={formDefaults}
|
||||||
<FormField label="Название" htmlFor="pr-name">
|
onSubmit={(values) => saveMut.mutate(values)}
|
||||||
<Input id="pr-name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
submitting={saveMut.isPending}
|
||||||
</FormField>
|
/>
|
||||||
<FormField label="Сайт" htmlFor="pr-site">
|
}
|
||||||
<Input id="pr-site" value={form.website} onChange={(e) => setForm({ ...form, website: e.target.value })} />
|
>
|
||||||
</FormField>
|
{(snap) => (
|
||||||
<FormField label="Тип API" htmlFor="pr-api">
|
<DataGridCard
|
||||||
<SelectField
|
columns={columnDefFromDataTable(columns)}
|
||||||
triggerId="pr-api"
|
data={snap.providers}
|
||||||
value={form.apiType}
|
rowId={(p) => p.id}
|
||||||
onValueChange={(v) => setForm({ ...form, apiType: (v ?? 'none') as ApiType })}
|
pinLastColumn
|
||||||
options={[
|
/>
|
||||||
{ value: 'billmanager', label: 'BILLmanager' },
|
)}
|
||||||
{ value: 'none', label: 'Нет' },
|
</CrudListPage>
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="API URL" htmlFor="pr-apiurl" description="Один URL на хостера для BILLmanager">
|
|
||||||
<Input id="pr-apiurl" value={form.apiBaseUrl} onChange={(e) => setForm({ ...form, apiBaseUrl: e.target.value })} />
|
|
||||||
</FormField>
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<FormField label="Валюта" htmlFor="pr-cur">
|
|
||||||
<Input id="pr-cur" value={form.baseCurrency} onChange={(e) => setForm({ ...form, baseCurrency: e.target.value })} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Курс USD" htmlFor="pr-usd">
|
|
||||||
<Input id="pr-usd" value={form.usdRate} onChange={(e) => setForm({ ...form, usdRate: e.target.value })} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Курс EUR" htmlFor="pr-eur">
|
|
||||||
<Input id="pr-eur" value={form.eurRate} onChange={(e) => setForm({ ...form, eurRate: e.target.value })} />
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<FormField label="Заметки" htmlFor="pr-notes">
|
|
||||||
<Textarea id="pr-notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
|
||||||
</FormField>
|
|
||||||
</FormSheet>
|
|
||||||
</PageShell>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { DownloadIcon } from 'lucide-react'
|
import { DownloadIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||||
import { PageShell } from '@/components/page-shell'
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { AnalyticsPage } from '@/components/analytics-page'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
|
||||||
import { SectionCards } from '@/components/section-cards'
|
import { SectionCards } from '@/components/section-cards'
|
||||||
import { ChartsGrid, MonthlyExpenseChart, PaymentsPieChart, MonthlyTrendChart } from '@/components/domain/charts'
|
import { ChartsGrid, MonthlyExpenseChart, PaymentsPieChart, MonthlyTrendChart } from '@/components/domain/charts'
|
||||||
|
import { exportVpsCsv } from '@/lib/export-csv'
|
||||||
|
|
||||||
import { normalizeRatesPayload, formatCurrency, toCsv, downloadTextFile } from '@/lib/format'
|
import { normalizeRatesPayload, formatCurrency } from '@/lib/format'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/reports')({
|
export const Route = createFileRoute('/_auth/reports')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
@@ -27,62 +25,82 @@ function ReportsPage() {
|
|||||||
|
|
||||||
const exportCsv = () => {
|
const exportCsv = () => {
|
||||||
if (!snapshot) return
|
if (!snapshot) return
|
||||||
const rows = snapshot.vps.map((v) => ({
|
exportVpsCsv(
|
||||||
ip: v.ip, project: v.project ?? '', status: v.status, vcpu: v.vcpu, ramGb: v.ramGb, diskGb: v.diskGb,
|
snapshot.vps.map((v) => ({
|
||||||
monthlyRate: v.monthlyRate ?? 0, currency: v.currency,
|
ip: v.ip,
|
||||||
}))
|
project: v.project ?? '',
|
||||||
downloadTextFile('vps-report.csv', toCsv(rows))
|
status: v.status,
|
||||||
|
vcpu: v.vcpu,
|
||||||
|
ramGb: v.ramGb,
|
||||||
|
diskGb: v.diskGb,
|
||||||
|
monthlyRate: v.monthlyRate ?? 0,
|
||||||
|
currency: v.currency,
|
||||||
|
})),
|
||||||
|
'vps-report.csv',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<AnalyticsPage
|
||||||
<PageHeader
|
title="Отчёты"
|
||||||
title="Отчёты"
|
description="Расходы, платежи и динамика"
|
||||||
description="Расходы, платежи и динамика"
|
actions={
|
||||||
actions={
|
<Button variant="outline" onClick={exportCsv} disabled={!snapshot}>
|
||||||
<Button variant="outline" onClick={exportCsv} disabled={!snapshot}>
|
<DownloadIcon data-icon="inline-start" />
|
||||||
<DownloadIcon data-icon="inline-start" />
|
Экспорт CSV
|
||||||
Экспорт CSV
|
</Button>
|
||||||
</Button>
|
}
|
||||||
}
|
data={snapshot}
|
||||||
/>
|
isLoading={isLoading}
|
||||||
<QueryState
|
isError={isError}
|
||||||
data={snapshot}
|
error={error}
|
||||||
isLoading={isLoading}
|
onRetry={() => refetch()}
|
||||||
isError={isError}
|
analyticsEmpty={snapshot?.vps.length === 0}
|
||||||
error={error}
|
emptyAction={
|
||||||
onRetry={() => refetch()}
|
<Button variant="outline" render={<Link to="/vps" />}>
|
||||||
skeleton={<SectionCardsSkeleton count={3} />}
|
Перейти к VPS
|
||||||
>
|
</Button>
|
||||||
{(snap) => {
|
}
|
||||||
const monthly = snap.vps.filter((v) => v.status === 'active').reduce((acc, v) => {
|
>
|
||||||
const burn = v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0)
|
{(snap) => {
|
||||||
|
const monthly = snap.vps
|
||||||
|
.filter((v) => v.status === 'active')
|
||||||
|
.reduce((acc, v) => {
|
||||||
|
const burn =
|
||||||
|
v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0)
|
||||||
return acc + burn
|
return acc + burn
|
||||||
}, 0)
|
}, 0)
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionCards
|
<SectionCards
|
||||||
items={[
|
items={[
|
||||||
{ label: 'Расход/мес (в валюте VPS)', value: formatCurrency(monthly, snap.vps[0]?.currency ?? 'RUB') },
|
{
|
||||||
{ label: 'Платежей всего', value: snap.payments.length },
|
label: 'Расход/мес (в валюте VPS)',
|
||||||
{ label: 'Активных VPS', value: snap.vps.filter((v) => v.status === 'active').length },
|
value: formatCurrency(monthly, snap.vps[0]?.currency ?? 'RUB'),
|
||||||
]}
|
},
|
||||||
|
{ label: 'Платежей всего', value: snap.payments.length },
|
||||||
|
{ label: 'Активных VPS', value: snap.vps.filter((v) => v.status === 'active').length },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ChartsGrid>
|
||||||
|
<MonthlyExpenseChart
|
||||||
|
vps={snap.vps}
|
||||||
|
providers={snap.providers}
|
||||||
|
providerAccounts={snap.providerAccounts}
|
||||||
|
settings={snap.settings}
|
||||||
|
ratesData={ratesData}
|
||||||
/>
|
/>
|
||||||
<ChartsGrid>
|
<PaymentsPieChart payments={snap.payments} settings={snap.settings} ratesData={ratesData} />
|
||||||
<MonthlyExpenseChart
|
<MonthlyTrendChart
|
||||||
vps={snap.vps}
|
payments={snap.payments}
|
||||||
providers={snap.providers}
|
settings={snap.settings}
|
||||||
providerAccounts={snap.providerAccounts}
|
ratesData={ratesData}
|
||||||
settings={snap.settings}
|
className="lg:col-span-2"
|
||||||
ratesData={ratesData}
|
/>
|
||||||
/>
|
</ChartsGrid>
|
||||||
<PaymentsPieChart payments={snap.payments} settings={snap.settings} ratesData={ratesData} />
|
</>
|
||||||
<MonthlyTrendChart payments={snap.payments} settings={snap.settings} ratesData={ratesData} className="lg:col-span-2" />
|
)
|
||||||
</ChartsGrid>
|
}}
|
||||||
</>
|
</AnalyticsPage>
|
||||||
)
|
|
||||||
}}
|
|
||||||
</QueryState>
|
|
||||||
</PageShell>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { CpuIcon, MemoryStickIcon, HardDriveIcon } from 'lucide-react'
|
import { CpuIcon, MemoryStickIcon, HardDriveIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { AnalyticsPage } from '@/components/analytics-page'
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
import { QueryState } from '@/components/query-state'
|
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
|
||||||
import { SectionCards } from '@/components/section-cards'
|
import { SectionCards } from '@/components/section-cards'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
@@ -33,73 +31,78 @@ function ResourcesPage() {
|
|||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<AnalyticsPage
|
||||||
<PageHeader title="Ресурсы" description="Сводка по вычислительным ресурсам активных VPS" />
|
title="Ресурсы"
|
||||||
<QueryState
|
description="Сводка по вычислительным ресурсам активных VPS"
|
||||||
data={snapshot}
|
data={snapshot}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isError={isError}
|
isError={isError}
|
||||||
error={error}
|
error={error}
|
||||||
onRetry={() => refetch()}
|
onRetry={() => refetch()}
|
||||||
skeleton={<SectionCardsSkeleton count={3} />}
|
analyticsEmpty={snapshot?.vps.filter((v) => v.status === 'active').length === 0}
|
||||||
>
|
emptyDescription="Нет активных VPS для построения сводки"
|
||||||
{(snap) => {
|
emptyAction={
|
||||||
const active = snap.vps.filter((v) => v.status === 'active')
|
<Button variant="outline" render={<Link to="/vps" />}>
|
||||||
const totals = active.reduce(
|
Перейти к VPS
|
||||||
(acc, v) => ({
|
</Button>
|
||||||
vcpu: acc.vcpu + Number(v.vcpu || 0),
|
}
|
||||||
ram: acc.ram + Number(v.ramGb || 0),
|
>
|
||||||
disk: acc.disk + Number(v.diskGb || 0),
|
{(snap) => {
|
||||||
}),
|
const active = snap.vps.filter((v) => v.status === 'active')
|
||||||
{ vcpu: 0, ram: 0, disk: 0 },
|
const totals = active.reduce(
|
||||||
)
|
(acc, v) => ({
|
||||||
|
vcpu: acc.vcpu + Number(v.vcpu || 0),
|
||||||
|
ram: acc.ram + Number(v.ramGb || 0),
|
||||||
|
disk: acc.disk + Number(v.diskGb || 0),
|
||||||
|
}),
|
||||||
|
{ vcpu: 0, ram: 0, disk: 0 },
|
||||||
|
)
|
||||||
|
|
||||||
const byProvider = new Map<string, { name: string; vcpu: number; ram: number; disk: number }>()
|
const byProvider = new Map<string, { name: string; vcpu: number; ram: number; disk: number }>()
|
||||||
for (const v of active) {
|
for (const v of active) {
|
||||||
const provider = snap.providers.find((p) => p.id === v.providerId)
|
const provider = snap.providers.find((p) => p.id === v.providerId)
|
||||||
const name = provider?.name ?? '—'
|
const name = provider?.name ?? '—'
|
||||||
const key = provider?.id ?? 'unknown'
|
const key = provider?.id ?? 'unknown'
|
||||||
const entry = byProvider.get(key) ?? { name, vcpu: 0, ram: 0, disk: 0 }
|
const entry = byProvider.get(key) ?? { name, vcpu: 0, ram: 0, disk: 0 }
|
||||||
entry.vcpu += Number(v.vcpu || 0)
|
entry.vcpu += Number(v.vcpu || 0)
|
||||||
entry.ram += Number(v.ramGb || 0)
|
entry.ram += Number(v.ramGb || 0)
|
||||||
entry.disk += Number(v.diskGb || 0)
|
entry.disk += Number(v.diskGb || 0)
|
||||||
byProvider.set(key, entry)
|
byProvider.set(key, entry)
|
||||||
}
|
}
|
||||||
const chartData = Array.from(byProvider.values())
|
const chartData = Array.from(byProvider.values())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionCards
|
<SectionCards
|
||||||
items={[
|
items={[
|
||||||
{ label: 'vCPU', value: totals.vcpu, icon: <CpuIcon className="size-4" /> },
|
{ label: 'vCPU', value: totals.vcpu, icon: <CpuIcon className="size-4" /> },
|
||||||
{ label: 'RAM (GB)', value: totals.ram, icon: <MemoryStickIcon className="size-4" /> },
|
{ label: 'RAM (GB)', value: totals.ram, icon: <MemoryStickIcon className="size-4" /> },
|
||||||
{ label: 'Disk (GB)', value: totals.disk, icon: <HardDriveIcon className="size-4" /> },
|
{ label: 'Disk (GB)', value: totals.disk, icon: <HardDriveIcon className="size-4" /> },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Ресурсы по хостерам</CardTitle>
|
<CardTitle>Ресурсы по хостерам</CardTitle>
|
||||||
<CardDescription>Только активные VPS</CardDescription>
|
<CardDescription>Только активные VPS</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ChartContainer config={RESOURCE_CONFIG} className="h-80 w-full">
|
<ChartContainer config={RESOURCE_CONFIG} className="h-80 w-full">
|
||||||
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
<YAxis tickLine={false} axisLine={false} width={48} />
|
<YAxis tickLine={false} axisLine={false} width={48} />
|
||||||
<RechartsTooltip cursor={false} content={<ChartTooltipContent />} />
|
<RechartsTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||||
<Bar dataKey="vcpu" fill="var(--color-vcpu)" radius={4} />
|
<Bar dataKey="vcpu" fill="var(--color-vcpu)" radius={4} />
|
||||||
<Bar dataKey="ram" fill="var(--color-ram)" radius={4} />
|
<Bar dataKey="ram" fill="var(--color-ram)" radius={4} />
|
||||||
<Bar dataKey="disk" fill="var(--color-disk)" radius={4} />
|
<Bar dataKey="disk" fill="var(--color-disk)" radius={4} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</QueryState>
|
</AnalyticsPage>
|
||||||
</PageShell>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { DownloadIcon, UploadIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
@@ -9,40 +12,14 @@ import { PageHeader } from '@/components/page-header'
|
|||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||||
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { SelectField } from '@/components/select-field'
|
import { SelectField } from '@/components/select-field'
|
||||||
|
import { FormField } from '@/components/form-field'
|
||||||
import type { Settings } from '@/types/entities'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { DownloadIcon, UploadIcon } from 'lucide-react'
|
import { settingsSchema, type SettingsFormValues } from '@/lib/schemas'
|
||||||
|
import type { Settings } from '@/types/entities'
|
||||||
function boolSelect(
|
|
||||||
draft: Partial<Settings>,
|
|
||||||
setForm: (v: Partial<Settings>) => void,
|
|
||||||
key: keyof Settings,
|
|
||||||
id: string,
|
|
||||||
label: string,
|
|
||||||
) {
|
|
||||||
const val = draft[key] === false ? 'off' : 'on'
|
|
||||||
return (
|
|
||||||
<Field orientation="horizontal">
|
|
||||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
|
||||||
<SelectField
|
|
||||||
triggerId={id}
|
|
||||||
triggerClassName="w-32"
|
|
||||||
value={val}
|
|
||||||
onValueChange={(v) => setForm({ ...draft, [key]: (v ?? 'on') === 'on' })}
|
|
||||||
options={[
|
|
||||||
{ value: 'on', label: 'Вкл' },
|
|
||||||
{ value: 'off', label: 'Выкл' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/settings')({
|
export const Route = createFileRoute('/_auth/settings')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
@@ -52,28 +29,74 @@ export const Route = createFileRoute('/_auth/settings')({
|
|||||||
|
|
||||||
const CURRENCIES = ['RUB', 'USD', 'EUR', 'UAH', 'KZT']
|
const CURRENCIES = ['RUB', 'USD', 'EUR', 'UAH', 'KZT']
|
||||||
|
|
||||||
|
function settingsToFormValues(s: Settings): SettingsFormValues {
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
baseCurrency: s.baseCurrency ?? 'RUB',
|
||||||
|
ratesUrl: s.ratesUrl ?? '',
|
||||||
|
autoConvert: s.autoConvert !== false,
|
||||||
|
syncEnabled: s.syncEnabled !== false,
|
||||||
|
syncIntervalMinutes: s.syncIntervalMinutes ?? 60,
|
||||||
|
syncTariffsIntervalMinutes: s.syncTariffsIntervalMinutes ?? 1440,
|
||||||
|
telegramChatId: s.telegramChatId ?? '',
|
||||||
|
telegramBotToken: s.telegramBotToken ?? '',
|
||||||
|
notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled !== false,
|
||||||
|
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
|
||||||
|
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false,
|
||||||
|
notifySyncDigestEnabled: s.notifySyncDigestEnabled !== false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function BoolSelect({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
value: boolean
|
||||||
|
onChange: (v: boolean) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FormField label={label} htmlFor={id}>
|
||||||
|
<SelectField
|
||||||
|
triggerId={id}
|
||||||
|
triggerClassName="w-32"
|
||||||
|
value={value ? 'on' : 'off'}
|
||||||
|
onValueChange={(v) => onChange((v ?? 'on') === 'on')}
|
||||||
|
options={[
|
||||||
|
{ value: 'on', label: 'Вкл' },
|
||||||
|
{ value: 'off', label: 'Выкл' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
const current = snapshot?.settings?.[0]
|
const current = snapshot?.settings?.[0]
|
||||||
const [form, setForm] = useState<Partial<Settings> | null>(null)
|
|
||||||
const draft = form ?? current ?? {}
|
const form = useForm<SettingsFormValues>({
|
||||||
|
resolver: zodResolver(settingsSchema),
|
||||||
|
values: current ? settingsToFormValues(current) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
const upsertMut = useMutation({
|
const upsertMut = useMutation({
|
||||||
mutationFn: (patch: Partial<Settings>) => {
|
mutationFn: (patch: SettingsFormValues) => {
|
||||||
if (current?.id) return api.update<Settings>('settings', current.id, patch)
|
if (current?.id) return api.update<Settings>('settings', current.id, patch)
|
||||||
return api.create<Settings>('settings', {
|
return api.create<Settings>('settings', {
|
||||||
id: 'settings-main',
|
id: 'settings-main',
|
||||||
baseCurrency: 'RUB',
|
|
||||||
ratesUrl: 'https://www.cbr-xml-daily.ru/latest.js',
|
ratesUrl: 'https://www.cbr-xml-daily.ru/latest.js',
|
||||||
autoConvert: true,
|
|
||||||
...patch,
|
...patch,
|
||||||
} as Settings)
|
} as Settings)
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||||
toast.success('Настройки сохранены')
|
toast.success('Настройки сохранены')
|
||||||
setForm(null)
|
form.reset(form.getValues())
|
||||||
},
|
},
|
||||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||||
})
|
})
|
||||||
@@ -84,9 +107,82 @@ function SettingsPage() {
|
|||||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки'),
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const backupActions = (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const blob = await api.downloadBackupJson()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `vps-tracker-backup-${new Date().toISOString().slice(0, 10)}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
toast.success('JSON выгружен')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Ошибка выгрузки')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DownloadIcon data-icon="inline-start" />
|
||||||
|
JSON
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const blob = await api.downloadBackupDatabase()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `vps-tracker-${new Date().toISOString().slice(0, 10)}.db`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
toast.success('База выгружена')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Ошибка выгрузки')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DownloadIcon data-icon="inline-start" />
|
||||||
|
SQLite
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = 'application/json,.json'
|
||||||
|
input.onchange = async () => {
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
await api.importBackupJson(JSON.parse(text))
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||||
|
toast.success('Импорт JSON выполнен')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UploadIcon data-icon="inline-start" />
|
||||||
|
Импорт JSON
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader title="Настройки" description="Базовая валюта, курсы, синк, Telegram" />
|
<PageHeader
|
||||||
|
title="Настройки"
|
||||||
|
description="Базовая валюта, курсы, синк, Telegram"
|
||||||
|
actions={backupActions}
|
||||||
|
/>
|
||||||
<QueryState
|
<QueryState
|
||||||
data={snapshot}
|
data={snapshot}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -96,221 +192,147 @@ function SettingsPage() {
|
|||||||
skeleton={<SectionCardsSkeleton count={1} />}
|
skeleton={<SectionCardsSkeleton count={1} />}
|
||||||
>
|
>
|
||||||
{() => (
|
{() => (
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<form
|
||||||
<Card>
|
className="flex flex-col gap-4"
|
||||||
<CardHeader>
|
onSubmit={(e) => void form.handleSubmit((values) => upsertMut.mutate(values))(e)}
|
||||||
<CardTitle>Валюта и курсы</CardTitle>
|
>
|
||||||
<CardDescription>Отображение сумм и источник курсов</CardDescription>
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
</CardHeader>
|
<Card>
|
||||||
<CardContent>
|
<CardHeader>
|
||||||
<FieldGroup>
|
<CardTitle>Валюта и курсы</CardTitle>
|
||||||
<Field>
|
<CardDescription>Отображение сумм и источник курсов</CardDescription>
|
||||||
<FieldLabel htmlFor="set-cur">Базовая валюта</FieldLabel>
|
</CardHeader>
|
||||||
<SelectField
|
<CardContent>
|
||||||
triggerId="set-cur"
|
<FieldGroup>
|
||||||
value={draft.baseCurrency ?? 'RUB'}
|
<FormField label="Базовая валюта" htmlFor="set-cur" error={form.formState.errors.baseCurrency?.message}>
|
||||||
onValueChange={(v) => setForm({ ...draft, baseCurrency: v ?? 'RUB' })}
|
<Controller
|
||||||
options={CURRENCIES.map((c) => ({ value: c, label: c }))}
|
control={form.control}
|
||||||
|
name="baseCurrency"
|
||||||
|
render={({ field }) => (
|
||||||
|
<SelectField
|
||||||
|
triggerId="set-cur"
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(v) => field.onChange(v ?? 'RUB')}
|
||||||
|
options={CURRENCIES.map((c) => ({ value: c, label: c }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="URL курсов (JSON)" htmlFor="set-rates" error={form.formState.errors.ratesUrl?.message}>
|
||||||
|
<Input
|
||||||
|
id="set-rates"
|
||||||
|
placeholder="https://www.cbr-xml-daily.ru/latest.js"
|
||||||
|
{...form.register('ratesUrl')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="autoConvert"
|
||||||
|
render={({ field }) => (
|
||||||
|
<BoolSelect
|
||||||
|
id="set-auto"
|
||||||
|
label="Автоконвертация"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FieldGroup>
|
||||||
<Field>
|
</CardContent>
|
||||||
<FieldLabel htmlFor="set-rates">URL курсов (JSON)</FieldLabel>
|
</Card>
|
||||||
<Input
|
|
||||||
id="set-rates"
|
|
||||||
value={draft.ratesUrl ?? ''}
|
|
||||||
onChange={(e) => setForm({ ...draft, ratesUrl: e.target.value })}
|
|
||||||
placeholder="https://www.cbr-xml-daily.ru/latest.js"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field orientation="horizontal">
|
|
||||||
<FieldLabel htmlFor="set-auto">Автоконвертация</FieldLabel>
|
|
||||||
<SelectField
|
|
||||||
triggerId="set-auto"
|
|
||||||
triggerClassName="w-32"
|
|
||||||
value={draft.autoConvert === false ? 'off' : 'on'}
|
|
||||||
onValueChange={(v) => setForm({ ...draft, autoConvert: (v ?? 'on') === 'on' })}
|
|
||||||
options={[
|
|
||||||
{ value: 'on', label: 'Включена' },
|
|
||||||
{ value: 'off', label: 'Выключена' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<LoadingButton
|
|
||||||
className="w-fit"
|
|
||||||
onClick={() => upsertMut.mutate(draft)}
|
|
||||||
loading={upsertMut.isPending}
|
|
||||||
disabled={!form}
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</LoadingButton>
|
|
||||||
</FieldGroup>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Telegram</CardTitle>
|
<CardTitle>Telegram</CardTitle>
|
||||||
<CardDescription>Уведомления о здоровье инвентаря</CardDescription>
|
<CardDescription>Уведомления о здоровье инвентаря</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field>
|
<FormField label="Chat ID" htmlFor="set-tg-chat">
|
||||||
<FieldLabel htmlFor="set-tg-chat">Chat ID</FieldLabel>
|
<Input id="set-tg-chat" placeholder="-1001234567890" {...form.register('telegramChatId')} />
|
||||||
<Input
|
</FormField>
|
||||||
id="set-tg-chat"
|
<FormField label="Bot token" htmlFor="set-tg-token">
|
||||||
value={draft.telegramChatId ?? ''}
|
<Input
|
||||||
onChange={(e) => setForm({ ...draft, telegramChatId: e.target.value })}
|
id="set-tg-token"
|
||||||
placeholder="-1001234567890"
|
type="password"
|
||||||
/>
|
placeholder="123456:ABC-DEF..."
|
||||||
</Field>
|
{...form.register('telegramBotToken')}
|
||||||
<Field>
|
/>
|
||||||
<FieldLabel htmlFor="set-tg-token">Bot token</FieldLabel>
|
</FormField>
|
||||||
<Input
|
<LoadingButton
|
||||||
id="set-tg-token"
|
type="button"
|
||||||
type="password"
|
variant="outline"
|
||||||
value={draft.telegramBotToken ?? ''}
|
onClick={() => telegramTestMut.mutate()}
|
||||||
onChange={(e) => setForm({ ...draft, telegramBotToken: e.target.value })}
|
loading={telegramTestMut.isPending}
|
||||||
placeholder="123456:ABC-DEF..."
|
>
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<LoadingButton onClick={() => upsertMut.mutate(draft)} loading={upsertMut.isPending} disabled={!form}>
|
|
||||||
Сохранить
|
|
||||||
</LoadingButton>
|
|
||||||
<LoadingButton variant="outline" onClick={() => telegramTestMut.mutate()} loading={telegramTestMut.isPending}>
|
|
||||||
Тест
|
Тест
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</div>
|
</FieldGroup>
|
||||||
</FieldGroup>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card className="md:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Синхронизация</CardTitle>
|
<CardTitle>Синхронизация</CardTitle>
|
||||||
<CardDescription>Автосинк BILLmanager и интервалы</CardDescription>
|
<CardDescription>Автосинк BILLmanager и интервалы</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
{boolSelect(draft, (v) => setForm(v), 'syncEnabled', 'set-sync', 'Автосинк')}
|
<Controller
|
||||||
<Field>
|
control={form.control}
|
||||||
<FieldLabel htmlFor="set-sync-int">Интервал синка (мин)</FieldLabel>
|
name="syncEnabled"
|
||||||
<Input
|
render={({ field }) => (
|
||||||
id="set-sync-int"
|
<BoolSelect id="set-sync" label="Автосинк" value={field.value ?? true} onChange={field.onChange} />
|
||||||
type="number"
|
)}
|
||||||
min={15}
|
|
||||||
value={draft.syncIntervalMinutes ?? 60}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm({ ...draft, syncIntervalMinutes: Number(e.target.value) || 60 })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
<FormField label="Интервал синка (мин)" htmlFor="set-sync-int">
|
||||||
<Field>
|
<Input id="set-sync-int" type="number" min={15} {...form.register('syncIntervalMinutes')} />
|
||||||
<FieldLabel htmlFor="set-tariff-int">Интервал тарифов (мин)</FieldLabel>
|
</FormField>
|
||||||
<Input
|
<FormField label="Интервал тарифов (мин)" htmlFor="set-tariff-int">
|
||||||
id="set-tariff-int"
|
<Input id="set-tariff-int" type="number" min={60} {...form.register('syncTariffsIntervalMinutes')} />
|
||||||
type="number"
|
</FormField>
|
||||||
min={60}
|
<Controller
|
||||||
value={draft.syncTariffsIntervalMinutes ?? 1440}
|
control={form.control}
|
||||||
onChange={(e) =>
|
name="notifyLowBalanceEnabled"
|
||||||
setForm({
|
render={({ field }) => (
|
||||||
...draft,
|
<BoolSelect id="set-notify-bal" label="Низкий баланс" value={field.value ?? true} onChange={field.onChange} />
|
||||||
syncTariffsIntervalMinutes: Number(e.target.value) || 1440,
|
)}
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
<Controller
|
||||||
{boolSelect(draft, (v) => setForm(v), 'notifyLowBalanceEnabled', 'set-notify-bal', 'Низкий баланс')}
|
control={form.control}
|
||||||
{boolSelect(draft, (v) => setForm(v), 'notifySyncDigestEnabled', 'set-notify-sync', 'Дайджест синка')}
|
name="notifySyncDigestEnabled"
|
||||||
{boolSelect(draft, (v) => setForm(v), 'notifyPaymentExpiryEnabled', 'set-notify-pay', 'Истечение оплаты')}
|
render={({ field }) => (
|
||||||
{boolSelect(draft, (v) => setForm(v), 'notifyNewTariffsEnabled', 'set-notify-tar', 'Новые тарифы')}
|
<BoolSelect id="set-notify-sync" label="Дайджест синка" value={field.value ?? true} onChange={field.onChange} />
|
||||||
<LoadingButton
|
)}
|
||||||
className="w-fit"
|
/>
|
||||||
onClick={() => upsertMut.mutate(draft)}
|
<Controller
|
||||||
loading={upsertMut.isPending}
|
control={form.control}
|
||||||
disabled={!form}
|
name="notifyPaymentExpiryEnabled"
|
||||||
>
|
render={({ field }) => (
|
||||||
Сохранить
|
<BoolSelect id="set-notify-pay" label="Истечение оплаты" value={field.value ?? true} onChange={field.onChange} />
|
||||||
</LoadingButton>
|
)}
|
||||||
</FieldGroup>
|
/>
|
||||||
</CardContent>
|
<Controller
|
||||||
</Card>
|
control={form.control}
|
||||||
|
name="notifyNewTariffsEnabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<BoolSelect id="set-notify-tar" label="Новые тарифы" value={field.value ?? true} onChange={field.onChange} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="md:col-span-2">
|
<LoadingButton
|
||||||
<CardHeader>
|
type="submit"
|
||||||
<CardTitle>Резервное копирование</CardTitle>
|
className="w-fit"
|
||||||
<CardDescription>Экспорт и импорт базы данных</CardDescription>
|
loading={upsertMut.isPending}
|
||||||
</CardHeader>
|
disabled={!form.formState.isDirty}
|
||||||
<CardContent>
|
>
|
||||||
<div className="flex flex-wrap gap-2">
|
Сохранить настройки
|
||||||
<Button
|
</LoadingButton>
|
||||||
variant="outline"
|
</form>
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
const blob = await api.downloadBackupJson()
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = `vps-tracker-backup-${new Date().toISOString().slice(0, 10)}.json`
|
|
||||||
a.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
toast.success('JSON выгружен')
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof ApiError ? e.message : 'Ошибка выгрузки')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DownloadIcon data-icon="inline-start" />
|
|
||||||
JSON
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
const blob = await api.downloadBackupDatabase()
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = `vps-tracker-${new Date().toISOString().slice(0, 10)}.db`
|
|
||||||
a.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
toast.success('База выгружена')
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof ApiError ? e.message : 'Ошибка выгрузки')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DownloadIcon data-icon="inline-start" />
|
|
||||||
SQLite
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
const input = document.createElement('input')
|
|
||||||
input.type = 'file'
|
|
||||||
input.accept = 'application/json,.json'
|
|
||||||
input.onchange = async () => {
|
|
||||||
const file = input.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
try {
|
|
||||||
const text = await file.text()
|
|
||||||
await api.importBackupJson(JSON.parse(text))
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
|
||||||
toast.success('Импорт JSON выполнен')
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
input.click()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UploadIcon data-icon="inline-start" />
|
|
||||||
Импорт JSON
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { HistoryIcon, UserRoundIcon, CheckCircle2Icon, XCircleIcon, LoaderIcon } from 'lucide-react'
|
import { HistoryIcon, UserRoundIcon, CheckCircle2Icon, XCircleIcon, LoaderIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||||
import { QueryState } from '@/components/query-state'
|
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
|
||||||
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 { formatSyncSummaryLine } from '@/lib/inventory-health'
|
import { formatSyncSummaryLine } from '@/lib/inventory-health'
|
||||||
import { formatRelativeSyncTime } from '@/lib/sync-format'
|
import { formatRelativeSyncTime } from '@/lib/sync-format'
|
||||||
|
|
||||||
@@ -37,10 +35,11 @@ function SyncJournalPage() {
|
|||||||
header: 'Запуск',
|
header: 'Запуск',
|
||||||
icon: HistoryIcon,
|
icon: HistoryIcon,
|
||||||
sortValue: (r) => r.startedAt ?? '',
|
sortValue: (r) => r.startedAt ?? '',
|
||||||
cell: (r) => dataGridCellStack(
|
cell: (r) =>
|
||||||
r.startedAt ? new Date(r.startedAt).toLocaleString('ru-RU') : '—',
|
dataGridCellStack(
|
||||||
r.finishedAt ? `завершён ${formatRelativeSyncTime(r.finishedAt)}` : undefined,
|
r.startedAt ? new Date(r.startedAt).toLocaleString('ru-RU') : '—',
|
||||||
),
|
r.finishedAt ? `завершён ${formatRelativeSyncTime(r.finishedAt)}` : undefined,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'account',
|
key: 'account',
|
||||||
@@ -57,7 +56,10 @@ function SyncJournalPage() {
|
|||||||
cell: (r) => (
|
cell: (r) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{statusIcon(r.status)}
|
{statusIcon(r.status)}
|
||||||
<StatusBadge status={r.status} label={r.status === 'ok' ? 'OK' : r.status === 'error' ? 'Ошибка' : 'Выполняется'} />
|
<StatusBadge
|
||||||
|
status={r.status}
|
||||||
|
label={r.status === 'ok' ? 'OK' : r.status === 'error' ? 'Ошибка' : 'Выполняется'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -73,36 +75,36 @@ function SyncJournalPage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<CrudListPage
|
||||||
<PageHeader
|
title="Журнал синка"
|
||||||
title="Журнал синка"
|
description="История синхронизаций BILLmanager по аккаунтам"
|
||||||
description="История синхронизаций BILLmanager по аккаунтам"
|
actions={
|
||||||
actions={
|
<Button variant="link" render={<Link to="/accounts" />}>
|
||||||
<Link to="/accounts" className="text-sm text-muted-foreground underline-offset-4 hover:underline">
|
Управление аккаунтами
|
||||||
Управление аккаунтами
|
</Button>
|
||||||
</Link>
|
}
|
||||||
}
|
data={snapshot}
|
||||||
/>
|
isLoading={isLoading}
|
||||||
<QueryState
|
isError={isError}
|
||||||
data={snapshot}
|
error={error}
|
||||||
isLoading={isLoading}
|
onRetry={() => refetch()}
|
||||||
isError={isError}
|
empty={!snapshot?.syncLog?.length}
|
||||||
error={error}
|
emptyTitle="Записей синка нет"
|
||||||
onRetry={() => refetch()}
|
emptyDescription="Запустите синхронизацию на странице аккаунтов"
|
||||||
skeleton={<TableSkeleton />}
|
emptyAction={
|
||||||
empty={!snapshot?.syncLog?.length}
|
<Button variant="link" render={<Link to="/accounts" />}>
|
||||||
emptyTitle="Записей синка нет"
|
Перейти к аккаунтам
|
||||||
emptyDescription="Запустите синхронизацию на странице аккаунтов"
|
</Button>
|
||||||
>
|
}
|
||||||
{(snap) => (
|
>
|
||||||
<DataGridCard
|
{(snap) => (
|
||||||
columns={columnDefFromDataTable(columns)}
|
<DataGridCard
|
||||||
data={snap.syncLog ?? []}
|
columns={columnDefFromDataTable(columns)}
|
||||||
rowId={(r) => r.id}
|
data={snap.syncLog ?? []}
|
||||||
dense
|
rowId={(r) => r.id}
|
||||||
/>
|
dense
|
||||||
)}
|
/>
|
||||||
</QueryState>
|
)}
|
||||||
</PageShell>
|
</CrudListPage>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ 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 { PageShell } from '@/components/page-shell'
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { ServerIcon, UserRoundIcon, CpuIcon, CoinsIcon, HardDriveIcon, RefreshCwIcon } from 'lucide-react'
|
import { ServerIcon, UserRoundIcon, CpuIcon, CoinsIcon, HardDriveIcon, RefreshCwIcon } from 'lucide-react'
|
||||||
|
|
||||||
@@ -97,7 +94,11 @@ function TariffsPage() {
|
|||||||
headerClassName: 'text-right',
|
headerClassName: 'text-right',
|
||||||
className: 'text-right',
|
className: 'text-right',
|
||||||
sortValue: (t) => Number(t.monthlyRate ?? 0),
|
sortValue: (t) => Number(t.monthlyRate ?? 0),
|
||||||
cell: (t) => <span className="tabular-nums font-medium">{formatCurrency(Number(t.monthlyRate ?? 0), t.currency ?? 'RUB')}</span>,
|
cell: (t) => (
|
||||||
|
<span className="tabular-nums font-medium">
|
||||||
|
{formatCurrency(Number(t.monthlyRate ?? 0), t.currency ?? 'RUB')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'disk',
|
key: 'disk',
|
||||||
@@ -108,45 +109,46 @@ function TariffsPage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<CrudListPage
|
||||||
<PageHeader
|
title="Активные тарифы"
|
||||||
title="Активные тарифы"
|
description="Тарифы, загруженные из BILLmanager vds.order"
|
||||||
description="Тарифы, загруженные из BILLmanager vds.order"
|
actions={
|
||||||
actions={
|
syncableCount > 0 ? (
|
||||||
syncableCount > 0 ? (
|
<Button variant="outline" disabled={syncTariffsMut.isPending} onClick={() => syncTariffsMut.mutate()}>
|
||||||
<Button variant="outline" disabled={syncTariffsMut.isPending} onClick={() => syncTariffsMut.mutate()}>
|
<RefreshCwIcon data-icon="inline-start" />
|
||||||
|
Загрузить тарифы
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
data={snapshot}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
error={error}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
empty={snapshot?.activeTariffs.length === 0}
|
||||||
|
emptyTitle="Тарифы не загружены"
|
||||||
|
emptyDescription="Синхронизация аккаунта BILLmanager загружает тарифы вместе с VPS и платежами"
|
||||||
|
emptyAction={
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
{syncableCount > 0 ? (
|
||||||
|
<Button disabled={syncTariffsMut.isPending} onClick={() => syncTariffsMut.mutate()}>
|
||||||
<RefreshCwIcon data-icon="inline-start" />
|
<RefreshCwIcon data-icon="inline-start" />
|
||||||
Загрузить тарифы
|
Загрузить тарифы
|
||||||
</Button>
|
</Button>
|
||||||
) : undefined
|
) : null}
|
||||||
}
|
<Button variant="outline" render={<Link to="/accounts" />}>
|
||||||
/>
|
Перейти к аккаунтам
|
||||||
<QueryState
|
</Button>
|
||||||
data={snapshot}
|
</div>
|
||||||
isLoading={isLoading}
|
}
|
||||||
isError={isError}
|
>
|
||||||
error={error}
|
{(snap) => (
|
||||||
onRetry={() => refetch()}
|
<DataGridCard
|
||||||
skeleton={<TableSkeleton />}
|
columns={columnDefFromDataTable(columns)}
|
||||||
empty={snapshot?.activeTariffs.length === 0}
|
data={snap.activeTariffs}
|
||||||
emptyTitle="Тарифы не загружены"
|
rowId={(t) => t.id}
|
||||||
emptyDescription="Синхронизация аккаунта BILLmanager загружает тарифы вместе с VPS и платежами"
|
/>
|
||||||
emptyAction={
|
)}
|
||||||
<div className="flex flex-wrap justify-center gap-2">
|
</CrudListPage>
|
||||||
{syncableCount > 0 ? (
|
|
||||||
<Button disabled={syncTariffsMut.isPending} onClick={() => syncTariffsMut.mutate()}>
|
|
||||||
<RefreshCwIcon data-icon="inline-start" />
|
|
||||||
Загрузить тарифы
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button variant="outline" render={<Link to="/accounts" />}>
|
|
||||||
Перейти к аккаунтам
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(snap) => <DataGridCard columns={columnDefFromDataTable(columns)} data={snap.activeTariffs} rowId={(t) => t.id} />}
|
|
||||||
</QueryState>
|
|
||||||
</PageShell>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState, useMemo, useEffect } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import { Controller } from 'react-hook-form'
|
import { PlusIcon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon } from 'lucide-react'
|
||||||
import { PlusIcon, PencilIcon, Trash2Icon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon } from 'lucide-react'
|
|
||||||
import { toast } from 'sonner'
|
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 { vpsSchema, type VpsFormValues } from '@/lib/schemas'
|
import type { VpsFormValues } from '@/lib/schemas'
|
||||||
import { normalizeRatesPayload, effectiveVpsTariffCurrency, formatInProviderCurrency } from '@/lib/format'
|
import { normalizeRatesPayload, effectiveVpsTariffCurrency, formatInProviderCurrency, vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
|
||||||
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 { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
@@ -19,21 +18,8 @@ import { CountryFlag } from '@/components/country-flag'
|
|||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { RowActions } from '@/components/row-actions'
|
||||||
import { FormSheetRhf } from '@/components/form-sheet-rhf'
|
import { VpsEditSheet, VPS_FORM_EMPTY, vpsFormFromRow } from '@/components/domain/vps-edit-sheet'
|
||||||
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 {
|
import {
|
||||||
applyVpsFilters,
|
applyVpsFilters,
|
||||||
buildDefaultVpsFilters,
|
buildDefaultVpsFilters,
|
||||||
@@ -43,9 +29,8 @@ import {
|
|||||||
import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
|
import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
|
||||||
|
|
||||||
import type { Vps } from '@/types/entities'
|
import type { Vps } from '@/types/entities'
|
||||||
import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
|
|
||||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||||
import { COUNTRIES, COUNTRY_BY_NAME_RU, buildCityOptions, cityMatchesCountry, resolveCountryForCityFromRows } from '@cfdm/shared/geo'
|
import { COUNTRIES, COUNTRY_BY_NAME_RU, buildCityOptions } from '@cfdm/shared/geo'
|
||||||
import { getPaidUntilDate } from '@/lib/paid-until'
|
import { getPaidUntilDate } from '@/lib/paid-until'
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
@@ -62,12 +47,7 @@ export const Route = createFileRoute('/_auth/vps')({
|
|||||||
component: VpsPage,
|
component: VpsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const EMPTY_FORM: VpsFormValues = {
|
const EMPTY_FORM = VPS_FORM_EMPTY
|
||||||
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: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
function VpsPage() {
|
function VpsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -89,26 +69,7 @@ function VpsPage() {
|
|||||||
const row = snapshot.vps.find((v) => v.id === edit)
|
const row = snapshot.vps.find((v) => v.id === edit)
|
||||||
if (row) {
|
if (row) {
|
||||||
setEditingId(row.id)
|
setEditingId(row.id)
|
||||||
setDefaultValues({
|
setDefaultValues(vpsFormFromRow(row))
|
||||||
ip: row.ip,
|
|
||||||
dns: row.dns ?? '',
|
|
||||||
providerId: row.providerId,
|
|
||||||
providerAccountId: row.providerAccountId,
|
|
||||||
country: row.country ?? '',
|
|
||||||
city: row.city ?? '',
|
|
||||||
datacenter: row.datacenter ?? '',
|
|
||||||
vcpu: row.vcpu,
|
|
||||||
ramGb: row.ramGb,
|
|
||||||
diskGb: row.diskGb,
|
|
||||||
status: row.status,
|
|
||||||
tariffType: row.tariffType,
|
|
||||||
currency: row.currency,
|
|
||||||
monthlyRate: Number(row.monthlyRate || 0),
|
|
||||||
dailyRate: Number(row.dailyRate || 0),
|
|
||||||
paidUntil: row.paidUntil ?? '',
|
|
||||||
project: row.project ?? '',
|
|
||||||
notes: row.notes ?? '',
|
|
||||||
})
|
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
void navigate({ to: '/vps', search: { edit: undefined }, replace: true })
|
void navigate({ to: '/vps', search: { edit: undefined }, replace: true })
|
||||||
}
|
}
|
||||||
@@ -159,13 +120,7 @@ function VpsPage() {
|
|||||||
}
|
}
|
||||||
const openEdit = (v: Vps) => {
|
const openEdit = (v: Vps) => {
|
||||||
setEditingId(v.id)
|
setEditingId(v.id)
|
||||||
setDefaultValues({
|
setDefaultValues(vpsFormFromRow(v))
|
||||||
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 ?? '',
|
|
||||||
})
|
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
const submit = (values: VpsFormValues) => {
|
const submit = (values: VpsFormValues) => {
|
||||||
@@ -369,23 +324,12 @@ function VpsPage() {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
className: 'w-24 text-right',
|
className: 'w-24 text-right',
|
||||||
cell: (v) => (
|
cell: (v) => (
|
||||||
<div className="flex justify-end gap-1">
|
<RowActions
|
||||||
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(v)} aria-label="Редактировать">
|
onEdit={() => openEdit(v)}
|
||||||
<PencilIcon />
|
onDelete={() => deleteMutation.mutate(v.id)}
|
||||||
</Button>
|
deleteTitle="Удалить VPS?"
|
||||||
<ConfirmDialog
|
deleteDescription={`IP ${v.ip} будет удалён безвозвратно.`}
|
||||||
trigger={
|
/>
|
||||||
<Button variant="ghost" size="icon-sm" aria-label="Удалить">
|
|
||||||
<Trash2Icon />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
title="Удалить VPS?"
|
|
||||||
description={`IP ${v.ip} будет удалён безвозвратно.`}
|
|
||||||
destructive
|
|
||||||
confirmLabel="Удалить"
|
|
||||||
onConfirm={() => deleteMutation.mutate(v.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -480,248 +424,20 @@ function VpsPage() {
|
|||||||
}}
|
}}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
|
|
||||||
<FormSheetRhf
|
{snapshot ? (
|
||||||
open={sheetOpen}
|
<VpsEditSheet
|
||||||
onOpenChange={setSheetOpen}
|
open={sheetOpen}
|
||||||
title={editingId ? 'Редактировать VPS' : 'Новый VPS'}
|
onOpenChange={setSheetOpen}
|
||||||
description="Заполните параметры сервера"
|
editingId={editingId}
|
||||||
schema={vpsSchema as unknown as import('zod').ZodType<VpsFormValues>}
|
defaultValues={defaultValues}
|
||||||
defaultValues={defaultValues}
|
providers={snapshot.providers}
|
||||||
onSubmit={submit}
|
providerAccounts={snapshot.providerAccounts}
|
||||||
submitting={createMutation.isPending || updateMutation.isPending}
|
vpsRows={snapshot.vps}
|
||||||
>
|
formCountryOptions={formCountryOptions}
|
||||||
{(form) => {
|
onSubmit={submit}
|
||||||
const { register, formState: { errors }, watch, setValue } = form
|
submitting={createMutation.isPending || updateMutation.isPending}
|
||||||
const providerId = watch('providerId')
|
/>
|
||||||
const formCountry = watch('country') ?? ''
|
) : null}
|
||||||
const formCity = watch('city') ?? ''
|
|
||||||
const formCityOptions = buildCityOptions(
|
|
||||||
snapshot?.vps,
|
|
||||||
formCountry.trim() || undefined,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FormField label="IP" htmlFor="vps-ip" error={errors.ip?.message} invalid={!!errors.ip}>
|
|
||||||
<Input id="vps-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={(snapshot?.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={(snapshot?.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, snapshot?.vps)) {
|
|
||||||
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, snapshot?.vps)
|
|
||||||
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={form.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={form.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={form.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 'active' | 'paused' | 'archived')}
|
|
||||||
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 'daily' | 'monthly')}
|
|
||||||
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={form.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={form.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={form.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>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const projectSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Укажите название проекта').max(120),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ProjectFormValues = z.infer<typeof projectSchema>
|
||||||
@@ -4,3 +4,4 @@ export * from './contracts/vps.js'
|
|||||||
export * from './contracts/payment.js'
|
export * from './contracts/payment.js'
|
||||||
export * from './contracts/balance-ledger.js'
|
export * from './contracts/balance-ledger.js'
|
||||||
export * from './contracts/settings.js'
|
export * from './contracts/settings.js'
|
||||||
|
export * from './contracts/project.js'
|
||||||
|
|||||||
Reference in New Issue
Block a user