feat(web): добавить KPI-карточки на страницы платежей, баланса и продлений
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user