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 <cursoragent@cursor.com>
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,
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user