feat(web, db): autocomplete проекта VPS, защита полей при синке и стиль Command
Docker / build (push) Has been cancelled

Проект выбирается из справочника; ручные правки автоматически попадают в userOverrides без сброса при сохранении. CommandDialog приведён к эталону shadcn/ReUI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-28 22:22:21 +07:00
co-authored by Cursor
parent 246ad1738a
commit baa13260c6
6 changed files with 95 additions and 64 deletions
@@ -3,10 +3,8 @@ 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 { AutoCompleteInput, type AutoCompleteOption } 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,
@@ -18,7 +16,7 @@ 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 { parseUserOverrides } from '@/lib/vps-sync-fields'
import { CustomFieldValues } from '@/components/domain/custom-field-values'
import { parseCustomData, type CustomFieldDef } from '@/lib/custom-fields'
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
@@ -82,6 +80,7 @@ interface VpsEditSheetProps {
providerAccounts: ProviderAccount[]
vpsRows: Vps[]
formCountryOptions: Array<{ value: string; label: string }>
projectFormOptions: AutoCompleteOption[]
customFieldDefs?: CustomFieldDef[]
onSubmit: (values: VpsFormValues) => void
submitting?: boolean
@@ -96,6 +95,7 @@ export function VpsEditSheet({
providerAccounts,
vpsRows,
formCountryOptions,
projectFormOptions,
customFieldDefs = [],
onSubmit,
submitting,
@@ -116,6 +116,7 @@ export function VpsEditSheet({
const providerId = watch('providerId')
const formCountry = watch('country') ?? ''
const formCity = watch('city') ?? ''
const formProject = watch('project') ?? ''
const formCityOptions = buildCityOptions(vpsRows, formCountry.trim() || undefined)
return (
@@ -147,7 +148,16 @@ export function VpsEditSheet({
/>
</FormField>
<FormField label="Проект" htmlFor="vps-project">
<Input id="vps-project" {...register('project')} />
<AutoCompleteInput
id="vps-project"
placeholder="Любой"
value={formProject}
onChange={(v) => setValue('project', v, { shouldDirty: true })}
options={projectFormOptions}
searchPlaceholder="Поиск проекта…"
emptyText="Нет вариантов"
showLeadingInInput={false}
/>
</FormField>
<FormField label="Страна" htmlFor="vps-country">
<AutoCompleteInput
@@ -155,7 +165,7 @@ export function VpsEditSheet({
placeholder="Любая"
value={formCountry}
onChange={(v) => {
setValue('country', v)
setValue('country', v, { shouldDirty: true })
if (v.trim() && formCity.trim() && !cityMatchesCountry(formCity, v, vpsRows)) {
setValue('city', '')
}
@@ -171,9 +181,9 @@ export function VpsEditSheet({
placeholder="Любой"
value={formCity}
onChange={(v) => {
setValue('city', v)
setValue('city', v, { shouldDirty: true })
const country = resolveCountryForCityFromRows(v, vpsRows)
if (country) setValue('country', country)
if (country) setValue('country', country, { shouldDirty: true })
}}
options={formCityOptions}
searchPlaceholder="Поиск города…"
@@ -338,36 +348,6 @@ export function VpsEditSheet({
<Textarea id="vps-notes" {...register('notes')} />
</FormField>
<CustomFieldValues defs={customFieldDefs} watch={watch} setValue={setValue} />
{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}
</>
)
}}
+18 -15
View File
@@ -11,6 +11,7 @@ import {
} from 'lucide-react'
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
@@ -43,10 +44,11 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
return (
<CommandDialog open={open} onOpenChange={onOpenChange} title="Поиск" description="VPS, аккаунты, проекты и навигация">
<CommandInput placeholder="IP, DNS, проект, аккаунт…" />
<CommandList>
<CommandEmpty>Ничего не найдено</CommandEmpty>
<CommandGroup heading="Навигация">
<Command>
<CommandInput placeholder="IP, DNS, проект, аккаунт…" />
<CommandList>
<CommandEmpty>Ничего не найдено</CommandEmpty>
<CommandGroup heading="Навигация">
<CommandItem onSelect={() => go('/dashboard')}>
<LayoutDashboardIcon />
Дашборд
@@ -55,9 +57,9 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
<ServerIcon />
Все VPS
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="VPS">
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="VPS">
{vpsItems.slice(0, 50).map((v) => (
<CommandItem key={v.id} value={`${v.ip} ${v.dns} ${v.project}`} onSelect={() => go('/vps/$vpsId', { vpsId: v.id })}>
<ServerIcon />
@@ -65,8 +67,8 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
{v.project ? <span className="text-muted-foreground text-xs">· {v.project}</span> : null}
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Аккаунты">
</CommandGroup>
<CommandGroup heading="Аккаунты">
{accountItems.map((a) => (
<CommandItem
key={a.id}
@@ -77,8 +79,8 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
{a.name}
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Проекты">
</CommandGroup>
<CommandGroup heading="Проекты">
{projectItems.map((p) => {
const row = p as { id: string; name: string }
return (
@@ -88,16 +90,17 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
</CommandItem>
)
})}
</CommandGroup>
<CommandGroup heading="Хостеры">
</CommandGroup>
<CommandGroup heading="Хостеры">
{(snapshot?.providers ?? []).map((p) => (
<CommandItem key={p.id} value={p.name} onSelect={() => go('/providers')}>
<Building2Icon />
{p.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
)
}
+1
View File
@@ -4,6 +4,7 @@ export const VPS_SYNC_OVERRIDE_FIELDS = [
{ key: 'city', label: 'Город' },
{ key: 'datacenter', label: 'Дата-центр' },
{ key: 'os', label: 'ОС' },
{ key: 'project', label: 'Проект' },
{ key: 'notes', label: 'Заметки' },
{ key: 'status', label: 'Статус' },
{ key: 'tariffType', label: 'Тип тарифа' },
+22 -1
View File
@@ -149,8 +149,9 @@ function VpsPage() {
setSheetOpen(true)
}
const submit = (values: VpsFormValues) => {
const { userOverrides: _ignored, ...rest } = values
const payload = {
...values,
...rest,
customData: JSON.stringify(values.customData ?? {}),
} as unknown as Partial<Vps>
if (editingId) {
@@ -175,6 +176,25 @@ function VpsPage() {
return [...names].sort((a, b) => a.localeCompare(b, 'ru'))
}, [snapshot])
const projectFormOptions = useMemo(() => {
const colorByName = new Map<string, string | null>()
for (const p of snapshot?.serverProjects ?? []) {
const row = p as { name?: string; color?: string | null }
const name = row.name?.trim()
if (name) colorByName.set(name, row.color ?? null)
}
return projectNameOptions.map((name) => {
const color = colorByName.get(name)
return {
value: name,
label: name,
leading: color ? (
<span className="size-2.5 shrink-0 rounded-full ring-1 ring-foreground/10" style={{ backgroundColor: color }} />
) : undefined,
}
})
}, [snapshot?.serverProjects, projectNameOptions])
const filteredVps = useMemo(() => {
let rows = applyVpsFilters(snapshot?.vps ?? [], filters)
if (!health || !snapshot) return rows
@@ -593,6 +613,7 @@ function VpsPage() {
providerAccounts={snapshot.providerAccounts}
vpsRows={snapshot.vps}
formCountryOptions={formCountryOptions}
projectFormOptions={projectFormOptions}
customFieldDefs={parseCustomFieldDefs(
(snapshot.settings[0] as { customFields?: unknown })?.customFields,
)}
+35 -7
View File
@@ -16,11 +16,38 @@ export type VpsDto = Omit<VpsRow, 'additionalIps' | 'userOverrides' | 'projectId
}
const USER_OVERRIDABLE_FIELDS = [
'country', 'city', 'datacenter', 'os', 'vcpu', 'ramGb', 'diskGb', 'diskType',
'virtualization', 'purpose', 'environment', 'project', 'notes', 'sshPort',
'rootUser', 'bandwidthTb', 'monitoringEnabled', 'backupEnabled',
'country',
'city',
'datacenter',
'os',
'notes',
'status',
'tariffType',
'currency',
'dailyRate',
'monthlyRate',
'paidUntil',
'project',
'vcpu',
'ramGb',
'diskGb',
'diskType',
'virtualization',
'purpose',
'environment',
'sshPort',
'rootUser',
'bandwidthTb',
'monitoringEnabled',
'backupEnabled',
] as const
function normNum(v: unknown): number | null {
if (v === '' || v == null) return null
const n = Number(v)
return Number.isFinite(n) ? n : null
}
function toDto(row: VpsRow | undefined): VpsDto | undefined {
if (!row) return undefined
let additionalIps: string[] = []
@@ -177,9 +204,7 @@ export const vpsRepository = {
} catch {
userOverrides = []
}
const clearOverrides =
input.userOverrides === 'clear' ||
(Array.isArray(input.userOverrides) && input.userOverrides.length === 0)
const clearOverrides = input.userOverrides === 'clear'
if (clearOverrides) userOverrides = []
const additionalIps = Array.isArray(input.additionalIps) ? JSON.stringify(input.additionalIps) : '[]'
@@ -213,7 +238,10 @@ export const vpsRepository = {
}
const newVal = (input as Record<string, unknown>)[f]
const oldVal = (existing as Record<string, unknown>)[f]
const changed = String(newVal ?? '') !== String(oldVal ?? '')
const changed =
f === 'dailyRate' || f === 'monthlyRate'
? normNum(newVal) !== normNum(oldVal)
: String(newVal ?? '') !== String(oldVal ?? '')
if (changed && !userOverrides.includes(f)) {
userOverrides.push(f)
}
+1 -3
View File
@@ -60,9 +60,7 @@ function CommandDialog({
)}
showCloseButton={showCloseButton}
>
<Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2">
{children}
</Command>
{children}
</DialogContent>
</Dialog>
)