fix(web): фильтры VPS, вкладки дашборда и здоровье инвентаря
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Фильтры стран и городов показывают только значения из БД; отключено автозаполнение поиска. Вкладки дашборда приведены к line tabs по REUI. VPS без проекта больше не считается проблемой. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -96,7 +96,6 @@ export function computeDashboardStats(): DashboardStats {
|
||||
}).length
|
||||
|
||||
let issuesCount = 0
|
||||
if (activeVps.some((v) => !(v.project || '').trim())) issuesCount++
|
||||
if (
|
||||
activeVps.some((v) => {
|
||||
const dr = Number(v.dailyRate || 0)
|
||||
|
||||
@@ -339,6 +339,9 @@ export function VpsFiltersToolbar({
|
||||
value={filters.search}
|
||||
onChange={(e) => onChange({ ...filters, search: e.target.value })}
|
||||
className="pl-8"
|
||||
autoComplete="off"
|
||||
name="vps-inventory-search"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -87,11 +87,6 @@ export function computeInventoryHealth(input: InventoryHealthInput): InventoryIs
|
||||
|
||||
const issues: InventoryIssue[] = []
|
||||
|
||||
const noProject = vps.filter((v) => v.status === 'active' && !(v.project || '').trim())
|
||||
if (noProject.length) {
|
||||
issues.push({ key: 'no-project', title: 'Активные VPS без проекта', count: noProject.length, to: '/vps?health=no-project' })
|
||||
}
|
||||
|
||||
const noRate = vps.filter((v) => {
|
||||
if (v.status !== 'active') return false
|
||||
const dr = Number(v.dailyRate || 0)
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { DataTableColumn } from '@/components/data-grid-types'
|
||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
@@ -306,25 +307,36 @@ function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="issues">
|
||||
<TabsList>
|
||||
<TabsTrigger value="issues">Проблемы ({issues.length})</TabsTrigger>
|
||||
<TabsTrigger value="recent">Последние VPS</TabsTrigger>
|
||||
<TabsTrigger value="risk">Аккаунты ({atRisk.length})</TabsTrigger>
|
||||
<Tabs defaultValue="issues" className="gap-4">
|
||||
<TabsList
|
||||
variant="line"
|
||||
className="**:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:bg-muted-foreground/30 **:data-[slot=badge]:px-1"
|
||||
>
|
||||
<TabsTrigger value="issues" className="flex-none gap-2 px-3 py-1.5 font-normal">
|
||||
Проблемы
|
||||
{issues.length > 0 ? <Badge variant="secondary">{issues.length}</Badge> : null}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="recent" className="flex-none px-3 py-1.5 font-normal">
|
||||
Последние VPS
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="risk" className="flex-none gap-2 px-3 py-1.5 font-normal">
|
||||
Аккаунты
|
||||
{atRisk.length > 0 ? <Badge variant="secondary">{atRisk.length}</Badge> : null}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="issues" className="mt-4">
|
||||
<TabsContent value="issues" className="mt-0">
|
||||
<DataGridCard
|
||||
title="Здоровье инвентаря"
|
||||
description="Нет проекта, ставки, просрочка, устаревший синк, расхождения баланса"
|
||||
description="Ставка, просрочка оплаты, устаревший синк, расхождения баланса"
|
||||
columns={columnDefFromDataTable(issueColumns)}
|
||||
data={issues}
|
||||
rowId={(i) => i.key}
|
||||
emptyTitle="Проблем не найдено"
|
||||
emptyDescription="Все активные VPS имеют проект, ставку и актуальный синк"
|
||||
emptyDescription="Критичных проблем в инвентаре не обнаружено"
|
||||
pagination={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="recent" className="mt-4">
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
<DataGridCard
|
||||
title="Последние VPS"
|
||||
description="Активные серверы"
|
||||
@@ -340,7 +352,7 @@ function DashboardPage() {
|
||||
onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="risk" className="mt-4">
|
||||
<TabsContent value="risk" className="mt-0">
|
||||
<DataGridCard
|
||||
title="Аккаунты под риском"
|
||||
description="Низкий баланс или устаревший синк BILLmanager"
|
||||
|
||||
@@ -203,9 +203,7 @@ function VpsPage() {
|
||||
balanceLedger: snapshot.balanceLedger,
|
||||
now,
|
||||
}
|
||||
if (health === 'no-project') {
|
||||
rows = rows.filter((v) => v.status === 'active' && !(v.project || '').trim())
|
||||
} else if (health === 'no-rate') {
|
||||
if (health === 'no-rate') {
|
||||
rows = rows.filter((v) => {
|
||||
if (v.status !== 'active') return false
|
||||
const dr = Number(v.dailyRate || 0)
|
||||
@@ -224,16 +222,17 @@ function VpsPage() {
|
||||
return rows
|
||||
}, [snapshot, filters, health])
|
||||
|
||||
const countryOptions = useMemo(() => {
|
||||
const dbCountryNames = useMemo(() => {
|
||||
const names = new Set<string>()
|
||||
// Из существующих VPS — могут быть произвольные строки
|
||||
for (const v of snapshot?.vps ?? []) {
|
||||
const c = (v.country || '').trim()
|
||||
if (c) names.add(c)
|
||||
}
|
||||
// Из стандартизированного справочника
|
||||
for (const c of COUNTRIES) names.add(c.name)
|
||||
return [...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => {
|
||||
return names
|
||||
}, [snapshot?.vps])
|
||||
|
||||
const mapCountryOptions = (names: Iterable<string>) =>
|
||||
[...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => {
|
||||
const ref = COUNTRY_BY_NAME_RU[name.toLowerCase()]
|
||||
return {
|
||||
value: name,
|
||||
@@ -242,10 +241,20 @@ function VpsPage() {
|
||||
leading: <CountryFlag code={ref?.code} country={name} />,
|
||||
}
|
||||
})
|
||||
}, [snapshot?.vps])
|
||||
|
||||
const cityOptions = useMemo(
|
||||
() => buildCityOptions(snapshot?.vps, filters.country[0]),
|
||||
const filterCountryOptions = useMemo(
|
||||
() => mapCountryOptions(dbCountryNames),
|
||||
[dbCountryNames],
|
||||
)
|
||||
|
||||
const formCountryOptions = useMemo(() => {
|
||||
const names = new Set(dbCountryNames)
|
||||
for (const c of COUNTRIES) names.add(c.name)
|
||||
return mapCountryOptions(names)
|
||||
}, [dbCountryNames])
|
||||
|
||||
const filterCityOptions = useMemo(
|
||||
() => buildCityOptions(snapshot?.vps, filters.country[0], { includeCatalog: false }),
|
||||
[snapshot?.vps, filters.country],
|
||||
)
|
||||
|
||||
@@ -425,8 +434,8 @@ function VpsPage() {
|
||||
providerAccounts={snap.providerAccounts}
|
||||
vps={snap.vps}
|
||||
projectNameOptions={projectNameOptions}
|
||||
countryOptions={countryOptions}
|
||||
cityOptions={cityOptions}
|
||||
countryOptions={filterCountryOptions}
|
||||
cityOptions={filterCityOptions}
|
||||
/>
|
||||
<EmptyState
|
||||
title="Ничего не найдено"
|
||||
@@ -450,8 +459,8 @@ function VpsPage() {
|
||||
providerAccounts={snap.providerAccounts}
|
||||
vps={snap.vps}
|
||||
projectNameOptions={projectNameOptions}
|
||||
countryOptions={countryOptions}
|
||||
cityOptions={cityOptions}
|
||||
countryOptions={filterCountryOptions}
|
||||
cityOptions={filterCityOptions}
|
||||
/>
|
||||
{tableSections.map((section) => (
|
||||
<DataGridCard
|
||||
@@ -532,7 +541,7 @@ function VpsPage() {
|
||||
setValue('city', '')
|
||||
}
|
||||
}}
|
||||
options={countryOptions}
|
||||
options={formCountryOptions}
|
||||
searchPlaceholder="Поиск страны…"
|
||||
emptyText="Нет вариантов"
|
||||
/>
|
||||
|
||||
@@ -217,11 +217,13 @@ export function cityMatchesCountry(
|
||||
)
|
||||
}
|
||||
|
||||
/** Опции городов: из VPS и справочника, опционально по стране. */
|
||||
/** Опции городов: из VPS и опционально справочника, опционально по стране. */
|
||||
export function buildCityOptions(
|
||||
rows: readonly CityLocationRow[] | undefined,
|
||||
countryName?: string,
|
||||
options?: { includeCatalog?: boolean },
|
||||
): { value: string; label: string }[] {
|
||||
const includeCatalog = options?.includeCatalog ?? true
|
||||
const names = new Set<string>()
|
||||
const countryCode = countryName?.trim()
|
||||
? COUNTRY_BY_NAME_RU[countryName.trim().toLowerCase()]?.code
|
||||
@@ -245,8 +247,10 @@ export function buildCityOptions(
|
||||
}
|
||||
}
|
||||
|
||||
for (const { name } of listCities(countryCode)) {
|
||||
names.add(name)
|
||||
if (includeCatalog) {
|
||||
for (const { name } of listCities(countryCode)) {
|
||||
names.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
return [...names]
|
||||
|
||||
Reference in New Issue
Block a user