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