feat(web): доработка UX/UI — дашборд, навигация и data foundation
Docker / build (push) Has been cancelled

Добавлены syncLog в snapshot, API статистики дашборда и маппинг цен тарифов; переработаны shell, главная страница, empty states и новые экраны журнала синка и проектов.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-28 00:27:09 +07:00
co-authored by Cursor
parent 9df91bf2cc
commit 1e291b3759
35 changed files with 1527 additions and 264 deletions
+4
View File
@@ -6,6 +6,7 @@ import { balanceLedgerRepository } from './balance-ledger.js'
import { settingsRepository } from './settings.js'
import { activeTariffsRepository, tariffSyncOptionsRepository } from './tariffs.js'
import { projectsRepository } from './projects.js'
import { syncLogRepository } from './sync-log.js'
export interface Snapshot {
vps: ReturnType<typeof vpsRepository.list>
@@ -17,6 +18,7 @@ export interface Snapshot {
settings: ReturnType<typeof settingsRepository.list>
activeTariffs: ReturnType<typeof activeTariffsRepository.list>
tariffSyncOptions: ReturnType<typeof tariffSyncOptionsRepository.list>
syncLog: ReturnType<typeof syncLogRepository.listRecent>
}
export function getSnapshot(): Snapshot {
@@ -30,6 +32,7 @@ export function getSnapshot(): Snapshot {
settings: settingsRepository.list(),
activeTariffs: activeTariffsRepository.list(),
tariffSyncOptions: tariffSyncOptionsRepository.list(),
syncLog: syncLogRepository.listRecent(50),
}
}
@@ -43,4 +46,5 @@ export {
activeTariffsRepository,
tariffSyncOptionsRepository,
projectsRepository,
syncLogRepository,
}
+49
View File
@@ -0,0 +1,49 @@
import { desc } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
export interface SyncLogDto {
id: string
accountId: string
status: 'ok' | 'error' | 'running' | string | null
startedAt: string
finishedAt: string | null
vpsCount: number | null
paymentsCount: number | null
error: string | null
summary: Record<string, unknown> | null
}
function toDto(row: typeof schema.syncLog.$inferSelect): SyncLogDto {
let summary: Record<string, unknown> | null = null
if (row.summary) {
try {
const parsed = JSON.parse(row.summary) as unknown
summary = parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null
} catch {
summary = null
}
}
return {
id: row.id,
accountId: row.accountId,
status: row.status,
startedAt: row.startedAt,
finishedAt: row.finishedAt,
vpsCount: row.vpsCount,
paymentsCount: row.paymentsCount,
error: row.error,
summary,
}
}
export const syncLogRepository = {
listRecent(limit = 50): SyncLogDto[] {
const rows = getDb()
.select()
.from(schema.syncLog)
.orderBy(desc(schema.syncLog.startedAt))
.limit(limit)
.all()
return rows.map(toDto)
},
}
+25 -2
View File
@@ -3,17 +3,40 @@ import { getDb, schema } from '../index.js'
type Row = typeof schema.activeTariffs.$inferSelect
export type ActiveTariffDto = Omit<Row, 'orderAvailable' | 'ramGb'> & {
export type ActiveTariffDto = Omit<Row, 'orderAvailable' | 'ramGb' | 'price'> & {
orderAvailable: boolean
ramGb: number
monthlyRate: number | null
currency: string | null
}
/** Парсит строку цены BILLmanager: «100.50 RUB», «€12», «12 USD». */
export function parseTariffPrice(price: string | null | undefined): {
monthlyRate: number | null
currency: string | null
} {
const raw = String(price ?? '').trim()
if (!raw) return { monthlyRate: null, currency: null }
const match = raw.match(/([\d.,]+)\s*([A-Za-z]{3})?/)
if (!match) return { monthlyRate: null, currency: null }
const monthlyRate = Number.parseFloat(match[1].replace(',', '.'))
const currency = match[2]?.toUpperCase() ?? null
return {
monthlyRate: Number.isFinite(monthlyRate) ? monthlyRate : null,
currency,
}
}
function toDto(row: Row | undefined): ActiveTariffDto | undefined {
if (!row) return undefined
const { monthlyRate, currency } = parseTariffPrice(row.price)
const { price: _price, ...rest } = row
return {
...row,
...rest,
orderAvailable: Boolean(row.orderAvailable),
ramGb: row.ramGb != null ? Number(row.ramGb) : 0,
monthlyRate,
currency,
}
}
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }