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