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

Co-authored-by: Cursor <[email protected]>
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' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot' import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client' import { api, ApiError } from '@/lib/api-client'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge' 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 { dataGridCellStack } from '@/components/data-grid-cells'
import { CrudListPage } from '@/components/crud-list-page' import { CrudListPage } from '@/components/crud-list-page'
import { SectionCards } from '@/components/section-cards' import { SectionCards } from '@/components/section-cards'
import { TableSkeleton } from '@/components/skeletons' import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { BalanceEntrySheet, balanceEntryFormDefaults } from '@/components/domain/balance-entry-sheet' import { BalanceEntrySheet, balanceEntryFormDefaults } from '@/components/domain/balance-entry-sheet'
import type { BalanceLedgerFormValues } from '@/lib/schemas' import type { BalanceLedgerFormValues } from '@/lib/schemas'
import type { BalanceLedgerRow } from '@/types/entities' 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' import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
export const Route = createFileRoute('/_auth/balance')({ export const Route = createFileRoute('/_auth/balance')({
@@ -42,6 +42,9 @@ export const Route = createFileRoute('/_auth/balance')({
function BalancePage() { function BalancePage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions()) 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 [open, setOpen] = useState(false)
const [formDefaults, setFormDefaults] = useState<BalanceLedgerFormValues>(balanceEntryFormDefaults()) 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 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 baseCurrency = (snapshot?.settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
const totalDebit = rows.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0) 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 ( return (
<CrudListPage <CrudListPage
@@ -163,7 +179,12 @@ function BalancePage() {
isError={isError} isError={isError}
error={error} error={error}
onRetry={() => refetch()} onRetry={() => refetch()}
skeleton={<TableSkeleton />} skeleton={
<div className="flex flex-col gap-4">
<SectionCardsSkeleton count={3} />
<TableSkeleton />
</div>
}
empty={rows.length === 0} empty={rows.length === 0}
emptyTitle="Записей нет" emptyTitle="Записей нет"
emptyDescription="Добавьте движение по балансу аккаунта" emptyDescription="Добавьте движение по балансу аккаунта"
@@ -187,9 +208,7 @@ function BalancePage() {
) : null ) : null
} }
> >
{(snap) => { {() => (
const baseCurrency = snap.settings[0]?.baseCurrency ?? 'RUB'
return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<SectionCards <SectionCards
items={[ items={[
@@ -233,8 +252,7 @@ function BalancePage() {
} }
/> />
</div> </div>
) )}
}}
</CrudListPage> </CrudListPage>
) )
} }
+82 -22
View File
@@ -1,21 +1,31 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' 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 { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot' import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client' import { api, ApiError } from '@/lib/api-client'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card' import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
import type { DataTableColumn } from '@/components/data-grid-types' import type { DataTableColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells' import { dataGridCellStack } from '@/components/data-grid-cells'
import { CrudListPage } from '@/components/crud-list-page' 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 { RowActions } from '@/components/row-actions'
import { PaymentEditSheet, paymentFormDefaults } from '@/components/domain/payment-edit-sheet' import { PaymentEditSheet, paymentFormDefaults } from '@/components/domain/payment-edit-sheet'
import type { PaymentFormValues } from '@/lib/schemas' import type { PaymentFormValues } from '@/lib/schemas'
import type { Payment } from '@/types/entities' 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' import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
export const Route = createFileRoute('/_auth/payments')({ export const Route = createFileRoute('/_auth/payments')({
@@ -27,6 +37,9 @@ export const Route = createFileRoute('/_auth/payments')({
function PaymentsPage() { function PaymentsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions()) 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 [open, setOpen] = useState(false)
const [formDefaults, setFormDefaults] = useState<PaymentFormValues>( const [formDefaults, setFormDefaults] = useState<PaymentFormValues>(
paymentFormDefaults(null, snapshot?.providerAccounts[0]?.id ?? ''), paymentFormDefaults(null, snapshot?.providerAccounts[0]?.id ?? ''),
@@ -163,6 +176,12 @@ function PaymentsPage() {
isError={isError} isError={isError}
error={error} error={error}
onRetry={() => refetch()} onRetry={() => refetch()}
skeleton={
<div className="flex flex-col gap-4">
<SectionCardsSkeleton count={3} />
<TableSkeleton />
</div>
}
empty={snapshot?.payments.length === 0} empty={snapshot?.payments.length === 0}
emptyTitle="Платежей нет" emptyTitle="Платежей нет"
emptyDescription="Добавьте первый платёж или дождитесь синхронизации" emptyDescription="Добавьте первый платёж или дождитесь синхронизации"
@@ -187,25 +206,66 @@ function PaymentsPage() {
) : null ) : null
} }
> >
{() => ( {(snap) => {
<DataGridCard const baseCurrency = (snap.settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
columns={columnDefFromDataTable(columns)} const totalSum = snap.payments.reduce(
data={sorted} (acc, p) =>
rowId={(p) => p.id} acc + convertCurrency(Number(p.amount), toIsoCurrency(p.currency), baseCurrency, ratesData),
pinLastColumn 0,
virtualization={sorted.length > 200} )
height={560} const cutoff = new Date()
footerContent={ cutoff.setDate(cutoff.getDate() - 30)
<div className="flex flex-wrap justify-end gap-6 px-3 py-2 text-sm tabular-nums"> const cutoffStr = cutoff.toISOString().slice(0, 10)
{Object.entries(totalByCurrency).map(([cur, sum]) => ( const recent = snap.payments.filter((p) => p.date >= cutoffStr)
<span key={cur}> const recentSum = recent.reduce(
Итого {cur}: <b className="text-foreground">{formatCurrency(sum, cur)}</b> (acc, p) =>
</span> acc + convertCurrency(Number(p.amount), toIsoCurrency(p.currency), baseCurrency, ratesData),
))} 0,
</div> )
}
/> 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> </CrudListPage>
) )
} }
+46 -2
View File
@@ -1,18 +1,21 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react' 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 { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state' 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 { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { Badge } from '@cfdm/ui/components/badge' import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { SelectField } from '@/components/select-field' import { SelectField } from '@/components/select-field'
import { getPaidUntilDate } from '@/lib/paid-until' import { getPaidUntilDate } from '@/lib/paid-until'
import { providerByIdMap } from '@/lib/billmanager' import { providerByIdMap } from '@/lib/billmanager'
import { convertCurrency, formatCurrency, normalizeRatesPayload, toIsoCurrency } from '@/lib/format'
export const Route = createFileRoute('/_auth/renewals')({ export const Route = createFileRoute('/_auth/renewals')({
loader: ({ context: { queryClient } }) => loader: ({ context: { queryClient } }) =>
@@ -34,6 +37,10 @@ interface RenewalItem {
function RenewalsPage() { function RenewalsPage() {
const [horizon, setHorizon] = useState<Horizon>('30') const [horizon, setHorizon] = useState<Horizon>('30')
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions()) 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(() => { const items = useMemo(() => {
if (!snapshot) return [] if (!snapshot) return []
@@ -70,6 +77,20 @@ function RenewalsPage() {
return list.sort((a, b) => a.date.getTime() - b.date.getTime()) return list.sort((a, b) => a.date.getTime() - b.date.getTime())
}, [snapshot, horizon]) }, [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 grouped = useMemo(() => {
const map = new Map<string, RenewalItem[]>() const map = new Map<string, RenewalItem[]>()
for (const item of items) { for (const item of items) {
@@ -106,12 +127,35 @@ function RenewalsPage() {
isError={isError} isError={isError}
error={error} error={error}
onRetry={() => refetch()} onRetry={() => refetch()}
skeleton={<SectionCardsSkeleton count={3} />}
empty={items.length === 0} empty={items.length === 0}
emptyTitle="Нет продлений в выбранном периоде" emptyTitle="Нет продлений в выбранном периоде"
emptyDescription="Активные VPS с расчётной датой оплаты не найдены" emptyDescription="Активные VPS с расчётной датой оплаты не найдены"
> >
{() => ( {() => (
<div className="flex flex-col gap-4"> <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]) => ( {grouped.map(([weekLabel, weekItems]) => (
<Card key={weekLabel}> <Card key={weekLabel}>
<CardHeader> <CardHeader>