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 { FormField } from '@/components/form-field'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
import { SelectField } from '@/components/select-field'
|
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 { Textarea } from '@cfdm/ui/components/textarea'
|
||||||
import { Checkbox } from '@cfdm/ui/components/checkbox'
|
|
||||||
import { Label } from '@cfdm/ui/components/label'
|
|
||||||
import {
|
import {
|
||||||
NumberField,
|
NumberField,
|
||||||
NumberFieldGroup,
|
NumberFieldGroup,
|
||||||
@@ -18,7 +16,7 @@ import { FormDatePicker } from '@/components/form-date-picker'
|
|||||||
import { vpsSchema, type VpsFormValues } from '@/lib/schemas'
|
import { vpsSchema, type VpsFormValues } from '@/lib/schemas'
|
||||||
import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
|
import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
|
||||||
import { buildCityOptions, cityMatchesCountry, resolveCountryForCityFromRows } from '@cfdm/shared/geo'
|
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 { CustomFieldValues } from '@/components/domain/custom-field-values'
|
||||||
import { parseCustomData, type CustomFieldDef } from '@/lib/custom-fields'
|
import { parseCustomData, type CustomFieldDef } from '@/lib/custom-fields'
|
||||||
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
|
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
|
||||||
@@ -82,6 +80,7 @@ interface VpsEditSheetProps {
|
|||||||
providerAccounts: ProviderAccount[]
|
providerAccounts: ProviderAccount[]
|
||||||
vpsRows: Vps[]
|
vpsRows: Vps[]
|
||||||
formCountryOptions: Array<{ value: string; label: string }>
|
formCountryOptions: Array<{ value: string; label: string }>
|
||||||
|
projectFormOptions: AutoCompleteOption[]
|
||||||
customFieldDefs?: CustomFieldDef[]
|
customFieldDefs?: CustomFieldDef[]
|
||||||
onSubmit: (values: VpsFormValues) => void
|
onSubmit: (values: VpsFormValues) => void
|
||||||
submitting?: boolean
|
submitting?: boolean
|
||||||
@@ -96,6 +95,7 @@ export function VpsEditSheet({
|
|||||||
providerAccounts,
|
providerAccounts,
|
||||||
vpsRows,
|
vpsRows,
|
||||||
formCountryOptions,
|
formCountryOptions,
|
||||||
|
projectFormOptions,
|
||||||
customFieldDefs = [],
|
customFieldDefs = [],
|
||||||
onSubmit,
|
onSubmit,
|
||||||
submitting,
|
submitting,
|
||||||
@@ -116,6 +116,7 @@ export function VpsEditSheet({
|
|||||||
const providerId = watch('providerId')
|
const providerId = watch('providerId')
|
||||||
const formCountry = watch('country') ?? ''
|
const formCountry = watch('country') ?? ''
|
||||||
const formCity = watch('city') ?? ''
|
const formCity = watch('city') ?? ''
|
||||||
|
const formProject = watch('project') ?? ''
|
||||||
const formCityOptions = buildCityOptions(vpsRows, formCountry.trim() || undefined)
|
const formCityOptions = buildCityOptions(vpsRows, formCountry.trim() || undefined)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -147,7 +148,16 @@ export function VpsEditSheet({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Проект" htmlFor="vps-project">
|
<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>
|
||||||
<FormField label="Страна" htmlFor="vps-country">
|
<FormField label="Страна" htmlFor="vps-country">
|
||||||
<AutoCompleteInput
|
<AutoCompleteInput
|
||||||
@@ -155,7 +165,7 @@ export function VpsEditSheet({
|
|||||||
placeholder="Любая"
|
placeholder="Любая"
|
||||||
value={formCountry}
|
value={formCountry}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setValue('country', v)
|
setValue('country', v, { shouldDirty: true })
|
||||||
if (v.trim() && formCity.trim() && !cityMatchesCountry(formCity, v, vpsRows)) {
|
if (v.trim() && formCity.trim() && !cityMatchesCountry(formCity, v, vpsRows)) {
|
||||||
setValue('city', '')
|
setValue('city', '')
|
||||||
}
|
}
|
||||||
@@ -171,9 +181,9 @@ export function VpsEditSheet({
|
|||||||
placeholder="Любой"
|
placeholder="Любой"
|
||||||
value={formCity}
|
value={formCity}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setValue('city', v)
|
setValue('city', v, { shouldDirty: true })
|
||||||
const country = resolveCountryForCityFromRows(v, vpsRows)
|
const country = resolveCountryForCityFromRows(v, vpsRows)
|
||||||
if (country) setValue('country', country)
|
if (country) setValue('country', country, { shouldDirty: true })
|
||||||
}}
|
}}
|
||||||
options={formCityOptions}
|
options={formCityOptions}
|
||||||
searchPlaceholder="Поиск города…"
|
searchPlaceholder="Поиск города…"
|
||||||
@@ -338,36 +348,6 @@ export function VpsEditSheet({
|
|||||||
<Textarea id="vps-notes" {...register('notes')} />
|
<Textarea id="vps-notes" {...register('notes')} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<CustomFieldValues defs={customFieldDefs} watch={watch} setValue={setValue} />
|
<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'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Command,
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
@@ -43,10 +44,11 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog open={open} onOpenChange={onOpenChange} title="Поиск" description="VPS, аккаунты, проекты и навигация">
|
<CommandDialog open={open} onOpenChange={onOpenChange} title="Поиск" description="VPS, аккаунты, проекты и навигация">
|
||||||
<CommandInput placeholder="IP, DNS, проект, аккаунт…" />
|
<Command>
|
||||||
<CommandList>
|
<CommandInput placeholder="IP, DNS, проект, аккаунт…" />
|
||||||
<CommandEmpty>Ничего не найдено</CommandEmpty>
|
<CommandList>
|
||||||
<CommandGroup heading="Навигация">
|
<CommandEmpty>Ничего не найдено</CommandEmpty>
|
||||||
|
<CommandGroup heading="Навигация">
|
||||||
<CommandItem onSelect={() => go('/dashboard')}>
|
<CommandItem onSelect={() => go('/dashboard')}>
|
||||||
<LayoutDashboardIcon />
|
<LayoutDashboardIcon />
|
||||||
Дашборд
|
Дашборд
|
||||||
@@ -55,9 +57,9 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
|
|||||||
<ServerIcon />
|
<ServerIcon />
|
||||||
Все VPS
|
Все VPS
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandSeparator />
|
<CommandSeparator />
|
||||||
<CommandGroup heading="VPS">
|
<CommandGroup heading="VPS">
|
||||||
{vpsItems.slice(0, 50).map((v) => (
|
{vpsItems.slice(0, 50).map((v) => (
|
||||||
<CommandItem key={v.id} value={`${v.ip} ${v.dns} ${v.project}`} onSelect={() => go('/vps/$vpsId', { vpsId: v.id })}>
|
<CommandItem key={v.id} value={`${v.ip} ${v.dns} ${v.project}`} onSelect={() => go('/vps/$vpsId', { vpsId: v.id })}>
|
||||||
<ServerIcon />
|
<ServerIcon />
|
||||||
@@ -65,8 +67,8 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
|
|||||||
{v.project ? <span className="text-muted-foreground text-xs">· {v.project}</span> : null}
|
{v.project ? <span className="text-muted-foreground text-xs">· {v.project}</span> : null}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandGroup heading="Аккаунты">
|
<CommandGroup heading="Аккаунты">
|
||||||
{accountItems.map((a) => (
|
{accountItems.map((a) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={a.id}
|
key={a.id}
|
||||||
@@ -77,8 +79,8 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
|
|||||||
{a.name}
|
{a.name}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandGroup heading="Проекты">
|
<CommandGroup heading="Проекты">
|
||||||
{projectItems.map((p) => {
|
{projectItems.map((p) => {
|
||||||
const row = p as { id: string; name: string }
|
const row = p as { id: string; name: string }
|
||||||
return (
|
return (
|
||||||
@@ -88,16 +90,17 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
|
|||||||
</CommandItem>
|
</CommandItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandGroup heading="Хостеры">
|
<CommandGroup heading="Хостеры">
|
||||||
{(snapshot?.providers ?? []).map((p) => (
|
{(snapshot?.providers ?? []).map((p) => (
|
||||||
<CommandItem key={p.id} value={p.name} onSelect={() => go('/providers')}>
|
<CommandItem key={p.id} value={p.name} onSelect={() => go('/providers')}>
|
||||||
<Building2Icon />
|
<Building2Icon />
|
||||||
{p.name}
|
{p.name}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
|
</Command>
|
||||||
</CommandDialog>
|
</CommandDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const VPS_SYNC_OVERRIDE_FIELDS = [
|
|||||||
{ key: 'city', label: 'Город' },
|
{ key: 'city', label: 'Город' },
|
||||||
{ key: 'datacenter', label: 'Дата-центр' },
|
{ key: 'datacenter', label: 'Дата-центр' },
|
||||||
{ key: 'os', label: 'ОС' },
|
{ key: 'os', label: 'ОС' },
|
||||||
|
{ key: 'project', label: 'Проект' },
|
||||||
{ key: 'notes', label: 'Заметки' },
|
{ key: 'notes', label: 'Заметки' },
|
||||||
{ key: 'status', label: 'Статус' },
|
{ key: 'status', label: 'Статус' },
|
||||||
{ key: 'tariffType', label: 'Тип тарифа' },
|
{ key: 'tariffType', label: 'Тип тарифа' },
|
||||||
|
|||||||
@@ -149,8 +149,9 @@ function VpsPage() {
|
|||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
const submit = (values: VpsFormValues) => {
|
const submit = (values: VpsFormValues) => {
|
||||||
|
const { userOverrides: _ignored, ...rest } = values
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...rest,
|
||||||
customData: JSON.stringify(values.customData ?? {}),
|
customData: JSON.stringify(values.customData ?? {}),
|
||||||
} as unknown as Partial<Vps>
|
} as unknown as Partial<Vps>
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
@@ -175,6 +176,25 @@ function VpsPage() {
|
|||||||
return [...names].sort((a, b) => a.localeCompare(b, 'ru'))
|
return [...names].sort((a, b) => a.localeCompare(b, 'ru'))
|
||||||
}, [snapshot])
|
}, [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(() => {
|
const filteredVps = useMemo(() => {
|
||||||
let rows = applyVpsFilters(snapshot?.vps ?? [], filters)
|
let rows = applyVpsFilters(snapshot?.vps ?? [], filters)
|
||||||
if (!health || !snapshot) return rows
|
if (!health || !snapshot) return rows
|
||||||
@@ -593,6 +613,7 @@ function VpsPage() {
|
|||||||
providerAccounts={snapshot.providerAccounts}
|
providerAccounts={snapshot.providerAccounts}
|
||||||
vpsRows={snapshot.vps}
|
vpsRows={snapshot.vps}
|
||||||
formCountryOptions={formCountryOptions}
|
formCountryOptions={formCountryOptions}
|
||||||
|
projectFormOptions={projectFormOptions}
|
||||||
customFieldDefs={parseCustomFieldDefs(
|
customFieldDefs={parseCustomFieldDefs(
|
||||||
(snapshot.settings[0] as { customFields?: unknown })?.customFields,
|
(snapshot.settings[0] as { customFields?: unknown })?.customFields,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,11 +16,38 @@ export type VpsDto = Omit<VpsRow, 'additionalIps' | 'userOverrides' | 'projectId
|
|||||||
}
|
}
|
||||||
|
|
||||||
const USER_OVERRIDABLE_FIELDS = [
|
const USER_OVERRIDABLE_FIELDS = [
|
||||||
'country', 'city', 'datacenter', 'os', 'vcpu', 'ramGb', 'diskGb', 'diskType',
|
'country',
|
||||||
'virtualization', 'purpose', 'environment', 'project', 'notes', 'sshPort',
|
'city',
|
||||||
'rootUser', 'bandwidthTb', 'monitoringEnabled', 'backupEnabled',
|
'datacenter',
|
||||||
|
'os',
|
||||||
|
'notes',
|
||||||
|
'status',
|
||||||
|
'tariffType',
|
||||||
|
'currency',
|
||||||
|
'dailyRate',
|
||||||
|
'monthlyRate',
|
||||||
|
'paidUntil',
|
||||||
|
'project',
|
||||||
|
'vcpu',
|
||||||
|
'ramGb',
|
||||||
|
'diskGb',
|
||||||
|
'diskType',
|
||||||
|
'virtualization',
|
||||||
|
'purpose',
|
||||||
|
'environment',
|
||||||
|
'sshPort',
|
||||||
|
'rootUser',
|
||||||
|
'bandwidthTb',
|
||||||
|
'monitoringEnabled',
|
||||||
|
'backupEnabled',
|
||||||
] as const
|
] 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 {
|
function toDto(row: VpsRow | undefined): VpsDto | undefined {
|
||||||
if (!row) return undefined
|
if (!row) return undefined
|
||||||
let additionalIps: string[] = []
|
let additionalIps: string[] = []
|
||||||
@@ -177,9 +204,7 @@ export const vpsRepository = {
|
|||||||
} catch {
|
} catch {
|
||||||
userOverrides = []
|
userOverrides = []
|
||||||
}
|
}
|
||||||
const clearOverrides =
|
const clearOverrides = input.userOverrides === 'clear'
|
||||||
input.userOverrides === 'clear' ||
|
|
||||||
(Array.isArray(input.userOverrides) && input.userOverrides.length === 0)
|
|
||||||
if (clearOverrides) userOverrides = []
|
if (clearOverrides) userOverrides = []
|
||||||
|
|
||||||
const additionalIps = Array.isArray(input.additionalIps) ? JSON.stringify(input.additionalIps) : '[]'
|
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 newVal = (input as Record<string, unknown>)[f]
|
||||||
const oldVal = (existing 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)) {
|
if (changed && !userOverrides.includes(f)) {
|
||||||
userOverrides.push(f)
|
userOverrides.push(f)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,9 +60,7 @@ function CommandDialog({
|
|||||||
)}
|
)}
|
||||||
showCloseButton={showCloseButton}
|
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}
|
||||||
{children}
|
|
||||||
</Command>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user