feat(web): доработка UX/UI — дашборд, навигация и data foundation
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Добавлены syncLog в snapshot, API статистики дашборда и маппинг цен тарифов; переработаны shell, главная страница, empty states и новые экраны журнала синка и проектов. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user