feat(web, db): autocomplete проекта VPS, защита полей при синке и стиль Command
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Проект выбирается из справочника; ручные правки автоматически попадают в userOverrides без сброса при сохранении. CommandDialog приведён к эталону shadcn/ReUI. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: 'Тип тарифа' },
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user