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