Добавлены bulk-операции VPS, карточка /vps/:id, CRUD проектов, Command Palette, календарь продлений, webhooks, uptime-проверки, audit log, кастомные поля и адаптеры провайдеров. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,12 +1,18 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { PlugIcon, RefreshCwIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
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 { LoadingButton } from '@/components/loading-button'
|
||||
import type { ZodType } from 'zod'
|
||||
import { providerAccountSchema, type ProviderAccountFormValues } from '@/lib/schemas'
|
||||
import type { BillingMode, Provider } from '@/types/entities'
|
||||
import { billingModeLabel } from '@/lib/format'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
|
||||
const EMPTY: ProviderAccountFormValues = {
|
||||
providerId: '',
|
||||
@@ -25,6 +31,7 @@ interface ProviderAccountEditSheetProps {
|
||||
providers: Provider[]
|
||||
onSubmit: (values: ProviderAccountFormValues) => void
|
||||
submitting?: boolean
|
||||
onBalanceRefreshed?: () => void
|
||||
}
|
||||
|
||||
export function providerAccountFormDefaults(
|
||||
@@ -48,9 +55,26 @@ export function ProviderAccountEditSheet({
|
||||
providers,
|
||||
onSubmit,
|
||||
submitting,
|
||||
onBalanceRefreshed,
|
||||
}: ProviderAccountEditSheetProps) {
|
||||
const isEdit = Boolean(defaultValues.id)
|
||||
|
||||
const testMut = useMutation({
|
||||
mutationFn: async (values: { apiBaseUrl: string; apiCredentials: string }) =>
|
||||
api.testConnection(values.apiBaseUrl, values.apiCredentials),
|
||||
onSuccess: () => toast.success('Подключение успешно'),
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка подключения'),
|
||||
})
|
||||
|
||||
const balanceMut = useMutation({
|
||||
mutationFn: (accountId: string) => api.fetchAccountBalance(accountId),
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Баланс: ${data.balance} ${data.currency}`)
|
||||
onBalanceRefreshed?.()
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка обновления баланса'),
|
||||
})
|
||||
|
||||
return (
|
||||
<FormSheetRhf
|
||||
open={open}
|
||||
@@ -64,13 +88,19 @@ export function ProviderAccountEditSheet({
|
||||
>
|
||||
{(form) => {
|
||||
const { register, formState: { errors }, watch, setValue } = form
|
||||
const providerId = watch('providerId')
|
||||
const provider = providers.find((p) => p.id === providerId)
|
||||
const apiBaseUrl = (provider?.apiBaseUrl ?? '').trim()
|
||||
const creds = watch('apiCredentials')?.trim() ?? ''
|
||||
const canTest = Boolean(apiBaseUrl && creds)
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField label="Хостер" htmlFor="acc-provider" error={errors.providerId?.message}>
|
||||
<SelectField
|
||||
triggerId="acc-provider"
|
||||
placeholder="Выберите хостера"
|
||||
value={watch('providerId')}
|
||||
value={providerId}
|
||||
onValueChange={(v) => setValue('providerId', v ?? '', { shouldValidate: true })}
|
||||
options={providers.map((p) => ({ value: p.id, label: p.name }))}
|
||||
/>
|
||||
@@ -88,6 +118,31 @@ export function ProviderAccountEditSheet({
|
||||
>
|
||||
<Input id="acc-creds" type="password" {...register('apiCredentials')} />
|
||||
</FormField>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LoadingButton
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canTest}
|
||||
loading={testMut.isPending}
|
||||
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials: creds })}
|
||||
>
|
||||
<PlugIcon data-icon="inline-start" />
|
||||
Проверить подключение
|
||||
</LoadingButton>
|
||||
{isEdit && defaultValues.id ? (
|
||||
<LoadingButton
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={balanceMut.isPending}
|
||||
onClick={() => balanceMut.mutate(defaultValues.id!)}
|
||||
>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Обновить баланс
|
||||
</LoadingButton>
|
||||
) : null}
|
||||
</div>
|
||||
<FormField label="Режим биллинга" htmlFor="acc-mode">
|
||||
<SelectField
|
||||
triggerId="acc-mode"
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 type { PaymentType, Provider, ProviderAccount, Vps } from '@/types/entities'
|
||||
import { paymentTypeLabel } from '@/lib/format'
|
||||
import { accountSelectLabel, providerByIdMap } from '@/lib/billmanager'
|
||||
|
||||
@@ -17,6 +17,7 @@ const EMPTY: PaymentFormValues = {
|
||||
amount: 0,
|
||||
currency: 'RUB',
|
||||
providerAccountId: '',
|
||||
vpsId: '',
|
||||
note: '',
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ interface PaymentEditSheetProps {
|
||||
defaultValues: PaymentFormValues
|
||||
providerAccounts: ProviderAccount[]
|
||||
providers: Provider[]
|
||||
vpsRows: Vps[]
|
||||
onSubmit: (values: PaymentFormValues) => void
|
||||
submitting?: boolean
|
||||
}
|
||||
@@ -37,7 +39,7 @@ export function paymentFormDefaults(
|
||||
if (!edit) {
|
||||
return { ...EMPTY, providerAccountId: fallbackAccountId }
|
||||
}
|
||||
return { ...EMPTY, ...edit }
|
||||
return { ...EMPTY, ...edit, vpsId: edit.vpsId ?? '' }
|
||||
}
|
||||
|
||||
export function PaymentEditSheet({
|
||||
@@ -46,6 +48,7 @@ export function PaymentEditSheet({
|
||||
defaultValues,
|
||||
providerAccounts,
|
||||
providers,
|
||||
vpsRows,
|
||||
onSubmit,
|
||||
submitting,
|
||||
}: PaymentEditSheetProps) {
|
||||
@@ -63,6 +66,14 @@ export function PaymentEditSheet({
|
||||
>
|
||||
{(form) => {
|
||||
const { register, formState: { errors }, watch, setValue } = form
|
||||
const accountId = watch('providerAccountId')
|
||||
const vpsOptions = vpsRows
|
||||
.filter((v) => !accountId || v.providerAccountId === accountId)
|
||||
.map((v) => ({
|
||||
value: v.id,
|
||||
label: [v.ip, v.dns, v.project].filter(Boolean).join(' · ') || v.id,
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField label="Тип" htmlFor="pay-type" error={errors.type?.message}>
|
||||
@@ -82,14 +93,26 @@ export function PaymentEditSheet({
|
||||
<SelectField
|
||||
triggerId="pay-acc"
|
||||
placeholder="Выберите аккаунт"
|
||||
value={watch('providerAccountId')}
|
||||
onValueChange={(v) => setValue('providerAccountId', v ?? '', { shouldValidate: true })}
|
||||
value={accountId}
|
||||
onValueChange={(v) => {
|
||||
setValue('providerAccountId', v ?? '', { shouldValidate: true })
|
||||
setValue('vpsId', '')
|
||||
}}
|
||||
options={providerAccounts.map((a) => ({
|
||||
value: a.id,
|
||||
label: accountSelectLabel(a, providerById),
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="VPS (необязательно)" htmlFor="pay-vps">
|
||||
<SelectField
|
||||
triggerId="pay-vps"
|
||||
placeholder="Не привязан"
|
||||
value={watch('vpsId') || ''}
|
||||
onValueChange={(v) => setValue('vpsId', v ?? '')}
|
||||
options={[{ value: '', label: '—' }, ...vpsOptions]}
|
||||
/>
|
||||
</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')} />
|
||||
|
||||
@@ -3,33 +3,52 @@ import { FormField } from '@/components/form-field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { projectSchema, type ProjectFormValues } from '@/lib/schemas'
|
||||
|
||||
const EMPTY: ProjectFormValues = { name: '' }
|
||||
const EMPTY: ProjectFormValues = { name: '', color: '' }
|
||||
|
||||
interface ProjectEditSheetProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
defaultValues?: ProjectFormValues
|
||||
onSubmit: (values: ProjectFormValues) => void
|
||||
submitting?: boolean
|
||||
}
|
||||
|
||||
export function ProjectEditSheet({ open, onOpenChange, onSubmit, submitting }: ProjectEditSheetProps) {
|
||||
export function projectFormDefaults(edit?: Partial<ProjectFormValues> | null): ProjectFormValues {
|
||||
if (!edit) return { ...EMPTY }
|
||||
return { ...EMPTY, ...edit, color: edit.color ?? '' }
|
||||
}
|
||||
|
||||
export function ProjectEditSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultValues = EMPTY,
|
||||
onSubmit,
|
||||
submitting,
|
||||
}: ProjectEditSheetProps) {
|
||||
const isEdit = Boolean(defaultValues.id)
|
||||
|
||||
return (
|
||||
<FormSheetRhf
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новый проект"
|
||||
title={isEdit ? 'Редактировать проект' : 'Новый проект'}
|
||||
description="Имя будет доступно в автодополнении на форме VPS"
|
||||
schema={projectSchema}
|
||||
defaultValues={EMPTY}
|
||||
schema={projectSchema as import('zod').ZodType<ProjectFormValues>}
|
||||
defaultValues={defaultValues}
|
||||
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>
|
||||
<>
|
||||
<FormField label="Название" htmlFor="project-name" error={errors.name?.message} invalid={!!errors.name}>
|
||||
<Input id="project-name" aria-invalid={!!errors.name} {...register('name')} />
|
||||
</FormField>
|
||||
<FormField label="Цвет (hex)" htmlFor="project-color" description="Например #3b82f6 — для badge в списке VPS">
|
||||
<Input id="project-color" placeholder="#3b82f6" {...register('color')} />
|
||||
</FormField>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</FormSheetRhf>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ArchiveIcon, FolderKanbanIcon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { useState } from 'react'
|
||||
import { vpsStatusLabel } from '@/lib/format'
|
||||
|
||||
interface VpsBulkToolbarProps {
|
||||
selectedCount: number
|
||||
projectOptions: string[]
|
||||
onSetStatus: (status: 'active' | 'paused' | 'archived') => void
|
||||
onSetProject: (project: string) => void
|
||||
onDelete: () => void
|
||||
busy?: boolean
|
||||
}
|
||||
|
||||
export function VpsBulkToolbar({
|
||||
selectedCount,
|
||||
projectOptions,
|
||||
onSetStatus,
|
||||
onSetProject,
|
||||
onDelete,
|
||||
busy,
|
||||
}: VpsBulkToolbarProps) {
|
||||
const [projectValue, setProjectValue] = useState('')
|
||||
|
||||
if (selectedCount === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2">
|
||||
<span className="text-sm font-medium tabular-nums">Выбрано: {selectedCount}</span>
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => onSetStatus('active')}>
|
||||
<PlayIcon data-icon="inline-start" />
|
||||
{vpsStatusLabel('active')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => onSetStatus('paused')}>
|
||||
<PauseIcon data-icon="inline-start" />
|
||||
{vpsStatusLabel('paused')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => onSetStatus('archived')}>
|
||||
<ArchiveIcon data-icon="inline-start" />
|
||||
{vpsStatusLabel('archived')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<SelectField
|
||||
placeholder="Проект…"
|
||||
value={projectValue}
|
||||
onValueChange={(v) => setProjectValue(v ?? '')}
|
||||
options={projectOptions.map((p) => ({ value: p, label: p }))}
|
||||
triggerClassName="w-40"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || !projectValue}
|
||||
onClick={() => {
|
||||
onSetProject(projectValue)
|
||||
setProjectValue('')
|
||||
}}
|
||||
>
|
||||
<FolderKanbanIcon data-icon="inline-start" />
|
||||
Назначить
|
||||
</Button>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="destructive" size="sm" disabled={busy}>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
Удалить
|
||||
</Button>
|
||||
}
|
||||
title={`Удалить ${selectedCount} VPS?`}
|
||||
description="Записи будут удалены безвозвратно."
|
||||
confirmLabel="Удалить"
|
||||
destructive
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,8 @@ 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 { Checkbox } from '@cfdm/ui/components/checkbox'
|
||||
import { Label } from '@cfdm/ui/components/label'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldGroup,
|
||||
@@ -16,6 +18,8 @@ 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 { VPS_SYNC_OVERRIDE_FIELDS, parseUserOverrides } from '@/lib/vps-sync-fields'
|
||||
import { parseCustomData, type CustomFieldDef } from '@/lib/custom-fields'
|
||||
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
|
||||
import type { ZodType } from 'zod'
|
||||
|
||||
@@ -38,6 +42,8 @@ export const VPS_FORM_EMPTY: VpsFormValues = {
|
||||
paidUntil: '',
|
||||
project: '',
|
||||
notes: '',
|
||||
userOverrides: [] as string[],
|
||||
customData: {} as Record<string, string | number | boolean>,
|
||||
}
|
||||
|
||||
export function vpsFormFromRow(v: Vps): VpsFormValues {
|
||||
@@ -61,6 +67,8 @@ export function vpsFormFromRow(v: Vps): VpsFormValues {
|
||||
paidUntil: v.paidUntil ?? '',
|
||||
project: v.project ?? '',
|
||||
notes: v.notes ?? '',
|
||||
userOverrides: parseUserOverrides((v as Vps & { userOverrides?: unknown }).userOverrides),
|
||||
customData: parseCustomData((v as Vps & { customData?: unknown }).customData),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +81,7 @@ interface VpsEditSheetProps {
|
||||
providerAccounts: ProviderAccount[]
|
||||
vpsRows: Vps[]
|
||||
formCountryOptions: Array<{ value: string; label: string }>
|
||||
customFieldDefs?: CustomFieldDef[]
|
||||
onSubmit: (values: VpsFormValues) => void
|
||||
submitting?: boolean
|
||||
}
|
||||
@@ -86,6 +95,7 @@ export function VpsEditSheet({
|
||||
providerAccounts,
|
||||
vpsRows,
|
||||
formCountryOptions,
|
||||
customFieldDefs = [],
|
||||
onSubmit,
|
||||
submitting,
|
||||
}: VpsEditSheetProps) {
|
||||
@@ -326,6 +336,74 @@ export function VpsEditSheet({
|
||||
<FormField label="Заметки" htmlFor="vps-notes">
|
||||
<Textarea id="vps-notes" {...register('notes')} />
|
||||
</FormField>
|
||||
{customFieldDefs.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm font-medium">Дополнительные поля</p>
|
||||
{customFieldDefs.map((field) => {
|
||||
const customData = watch('customData') ?? {}
|
||||
if (field.type === 'bool') {
|
||||
return (
|
||||
<div key={field.key} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`custom-${field.key}`}
|
||||
checked={Boolean(customData[field.key])}
|
||||
onCheckedChange={(v) =>
|
||||
setValue('customData', { ...customData, [field.key]: Boolean(v) })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`custom-${field.key}`} className="font-normal">
|
||||
{field.label}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<FormField key={field.key} label={field.label} htmlFor={`custom-${field.key}`}>
|
||||
<Input
|
||||
id={`custom-${field.key}`}
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={String(customData[field.key] ?? '')}
|
||||
onChange={(e) => {
|
||||
const val =
|
||||
field.type === 'number' ? Number(e.target.value) : e.target.value
|
||||
setValue('customData', { ...customData, [field.key]: val })
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{editingId ? (
|
||||
<FormField
|
||||
label="Не перезаписывать при синке"
|
||||
description="Отмеченные поля сохранят ручные значения при синхронизации с BILLmanager"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{VPS_SYNC_OVERRIDE_FIELDS.map(({ key, label }) => {
|
||||
const overrides = watch('userOverrides') ?? []
|
||||
const checked = overrides.includes(key)
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`vps-override-${key}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => {
|
||||
const next = value
|
||||
? [...overrides, key]
|
||||
: overrides.filter((f) => f !== key)
|
||||
setValue('userOverrides', next)
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`vps-override-${key}`} className="font-normal">
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FormField>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user