feat(web): добавить KPI-карточки на страницы платежей, баланса и продлений
Docker / build (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-28 19:47:16 +07:00
co-authored by Cursor
parent 5a3241a7d1
commit f1fbd637a7
3 changed files with 157 additions and 35 deletions
+29 -11
View File
@@ -16,7 +16,7 @@ import {
} from 'lucide-react'
import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
@@ -25,12 +25,12 @@ import type { DataTableColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { CrudListPage } from '@/components/crud-list-page'
import { SectionCards } from '@/components/section-cards'
import { TableSkeleton } from '@/components/skeletons'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { BalanceEntrySheet, balanceEntryFormDefaults } from '@/components/domain/balance-entry-sheet'
import type { BalanceLedgerFormValues } from '@/lib/schemas'
import type { BalanceLedgerRow } from '@/types/entities'
import { formatCurrency } from '@/lib/format'
import { formatCurrency, convertCurrency, normalizeRatesPayload, toIsoCurrency } from '@/lib/format'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
export const Route = createFileRoute('/_auth/balance')({
@@ -42,6 +42,9 @@ export const Route = createFileRoute('/_auth/balance')({
function BalancePage() {
const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const settings = snapshot?.settings?.[0]
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
const [open, setOpen] = useState(false)
const [formDefaults, setFormDefaults] = useState<BalanceLedgerFormValues>(balanceEntryFormDefaults())
@@ -145,8 +148,21 @@ function BalancePage() {
]
const rows = [...(snapshot?.balanceLedger ?? [])].sort((a, b) => b.date.localeCompare(a.date))
const totalCredit = rows.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
const totalDebit = rows.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
const baseCurrency = (snapshot?.settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
const totalCredit = rows
.filter((r) => r.direction === 'credit')
.reduce(
(acc, r) =>
acc + convertCurrency(Number(r.amount), toIsoCurrency(r.currency), baseCurrency, ratesData),
0,
)
const totalDebit = rows
.filter((r) => r.direction === 'debit')
.reduce(
(acc, r) =>
acc + convertCurrency(Number(r.amount), toIsoCurrency(r.currency), baseCurrency, ratesData),
0,
)
return (
<CrudListPage
@@ -163,7 +179,12 @@ function BalancePage() {
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<TableSkeleton />}
skeleton={
<div className="flex flex-col gap-4">
<SectionCardsSkeleton count={3} />
<TableSkeleton />
</div>
}
empty={rows.length === 0}
emptyTitle="Записей нет"
emptyDescription="Добавьте движение по балансу аккаунта"
@@ -187,9 +208,7 @@ function BalancePage() {
) : null
}
>
{(snap) => {
const baseCurrency = snap.settings[0]?.baseCurrency ?? 'RUB'
return (
{() => (
<div className="flex flex-col gap-4">
<SectionCards
items={[
@@ -233,8 +252,7 @@ function BalancePage() {
}
/>
</div>
)
}}
)}
</CrudListPage>
)
}
+82 -22
View File
@@ -1,21 +1,31 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { PlusIcon, CalendarIcon, UserRoundIcon, TagIcon, CoinsIcon, StickyNoteIcon } from 'lucide-react'
import {
PlusIcon,
CalendarIcon,
UserRoundIcon,
TagIcon,
CoinsIcon,
StickyNoteIcon,
CreditCardIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { Button } from '@cfdm/ui/components/button'
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
import type { DataTableColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { CrudListPage } from '@/components/crud-list-page'
import { SectionCards } from '@/components/section-cards'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { RowActions } from '@/components/row-actions'
import { PaymentEditSheet, paymentFormDefaults } from '@/components/domain/payment-edit-sheet'
import type { PaymentFormValues } from '@/lib/schemas'
import type { Payment } from '@/types/entities'
import { paymentTypeLabel, formatCurrency } from '@/lib/format'
import { paymentTypeLabel, formatCurrency, convertCurrency, normalizeRatesPayload, toIsoCurrency } from '@/lib/format'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
export const Route = createFileRoute('/_auth/payments')({
@@ -27,6 +37,9 @@ export const Route = createFileRoute('/_auth/payments')({
function PaymentsPage() {
const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const settings = snapshot?.settings?.[0]
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
const [open, setOpen] = useState(false)
const [formDefaults, setFormDefaults] = useState<PaymentFormValues>(
paymentFormDefaults(null, snapshot?.providerAccounts[0]?.id ?? ''),
@@ -163,6 +176,12 @@ function PaymentsPage() {
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={
<div className="flex flex-col gap-4">
<SectionCardsSkeleton count={3} />
<TableSkeleton />
</div>
}
empty={snapshot?.payments.length === 0}
emptyTitle="Платежей нет"
emptyDescription="Добавьте первый платёж или дождитесь синхронизации"
@@ -187,25 +206,66 @@ function PaymentsPage() {
) : null
}
>
{() => (
<DataGridCard
columns={columnDefFromDataTable(columns)}
data={sorted}
rowId={(p) => p.id}
pinLastColumn
virtualization={sorted.length > 200}
height={560}
footerContent={
<div className="flex flex-wrap justify-end gap-6 px-3 py-2 text-sm tabular-nums">
{Object.entries(totalByCurrency).map(([cur, sum]) => (
<span key={cur}>
Итого {cur}: <b className="text-foreground">{formatCurrency(sum, cur)}</b>
</span>
))}
</div>
}
/>
)}
{(snap) => {
const baseCurrency = (snap.settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
const totalSum = snap.payments.reduce(
(acc, p) =>
acc + convertCurrency(Number(p.amount), toIsoCurrency(p.currency), baseCurrency, ratesData),
0,
)
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - 30)
const cutoffStr = cutoff.toISOString().slice(0, 10)
const recent = snap.payments.filter((p) => p.date >= cutoffStr)
const recentSum = recent.reduce(
(acc, p) =>
acc + convertCurrency(Number(p.amount), toIsoCurrency(p.currency), baseCurrency, ratesData),
0,
)
return (
<div className="flex flex-col gap-4">
<SectionCards
items={[
{
label: 'Всего платежей',
value: snap.payments.length,
icon: <CreditCardIcon className="size-4" />,
},
{
label: 'Общая сумма',
value: formatCurrency(totalSum, baseCurrency),
icon: <CoinsIcon className="size-4" />,
hint: baseCurrency,
},
{
label: 'За 30 дней',
value: recent.length,
icon: <CalendarIcon className="size-4" />,
hint: formatCurrency(recentSum, baseCurrency),
},
]}
/>
<DataGridCard
columns={columnDefFromDataTable(columns)}
data={sorted}
rowId={(p) => p.id}
pinLastColumn
virtualization={sorted.length > 200}
height={560}
footerContent={
<div className="flex flex-wrap justify-end gap-6 px-3 py-2 text-sm tabular-nums">
{Object.entries(totalByCurrency).map(([cur, sum]) => (
<span key={cur}>
Итого {cur}: <b className="text-foreground">{formatCurrency(sum, cur)}</b>
</span>
))}
</div>
}
/>
</div>
)
}}
</CrudListPage>
)
}
+46 -2
View File
@@ -1,18 +1,21 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { CalendarIcon } from 'lucide-react'
import { CalendarIcon, AlertTriangleIcon, ServerIcon, CoinsIcon } from 'lucide-react'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import { SelectField } from '@/components/select-field'
import { getPaidUntilDate } from '@/lib/paid-until'
import { providerByIdMap } from '@/lib/billmanager'
import { convertCurrency, formatCurrency, normalizeRatesPayload, toIsoCurrency } from '@/lib/format'
export const Route = createFileRoute('/_auth/renewals')({
loader: ({ context: { queryClient } }) =>
@@ -34,6 +37,10 @@ interface RenewalItem {
function RenewalsPage() {
const [horizon, setHorizon] = useState<Horizon>('30')
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const settings = snapshot?.settings?.[0]
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
const baseCurrency = (settings?.baseCurrency ?? 'RUB').toUpperCase()
const items = useMemo(() => {
if (!snapshot) return []
@@ -70,6 +77,20 @@ function RenewalsPage() {
return list.sort((a, b) => a.date.getTime() - b.date.getTime())
}, [snapshot, horizon])
const overdueCount = items.filter((i) => i.overdue).length
const renewalCost = useMemo(() => {
if (!snapshot) return 0
const vpsById = new Map(snapshot.vps.map((v) => [v.id, v]))
return items.reduce((acc, item) => {
const v = vpsById.get(item.id)
if (!v) return acc
const burn =
v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0)
return acc + convertCurrency(burn, toIsoCurrency(v.currency), baseCurrency, ratesData)
}, 0)
}, [snapshot, items, baseCurrency, ratesData])
const grouped = useMemo(() => {
const map = new Map<string, RenewalItem[]>()
for (const item of items) {
@@ -106,12 +127,35 @@ function RenewalsPage() {
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<SectionCardsSkeleton count={3} />}
empty={items.length === 0}
emptyTitle="Нет продлений в выбранном периоде"
emptyDescription="Активные VPS с расчётной датой оплаты не найдены"
>
{() => (
<div className="flex flex-col gap-4">
<SectionCards
items={[
{
label: 'Просрочено',
value: overdueCount,
icon: <AlertTriangleIcon className="size-4" />,
variant: overdueCount > 0 ? 'destructive' : 'default',
},
{
label: 'В периоде',
value: items.length,
icon: <ServerIcon className="size-4" />,
hint: `за ${horizon} дн`,
},
{
label: 'Стоимость продлений',
value: formatCurrency(renewalCost, baseCurrency),
icon: <CoinsIcon className="size-4" />,
hint: baseCurrency,
},
]}
/>
{grouped.map(([weekLabel, weekItems]) => (
<Card key={weekLabel}>
<CardHeader>