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

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

Co-authored-by: Cursor <[email protected]>
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>
)
}