diff --git a/apps/web/src/routes/_auth/balance.tsx b/apps/web/src/routes/_auth/balance.tsx index 4bb46ef..74dacd6 100644 --- a/apps/web/src/routes/_auth/balance.tsx +++ b/apps/web/src/routes/_auth/balance.tsx @@ -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(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 ( refetch()} - skeleton={} + skeleton={ +
+ + +
+ } empty={rows.length === 0} emptyTitle="Записей нет" emptyDescription="Добавьте движение по балансу аккаунта" @@ -187,9 +208,7 @@ function BalancePage() { ) : null } > - {(snap) => { - const baseCurrency = snap.settings[0]?.baseCurrency ?? 'RUB' - return ( + {() => (
- ) - }} + )}
) } diff --git a/apps/web/src/routes/_auth/payments.tsx b/apps/web/src/routes/_auth/payments.tsx index 2cf651a..749c020 100644 --- a/apps/web/src/routes/_auth/payments.tsx +++ b/apps/web/src/routes/_auth/payments.tsx @@ -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( paymentFormDefaults(null, snapshot?.providerAccounts[0]?.id ?? ''), @@ -163,6 +176,12 @@ function PaymentsPage() { isError={isError} error={error} onRetry={() => refetch()} + skeleton={ +
+ + +
+ } empty={snapshot?.payments.length === 0} emptyTitle="Платежей нет" emptyDescription="Добавьте первый платёж или дождитесь синхронизации" @@ -187,25 +206,66 @@ function PaymentsPage() { ) : null } > - {() => ( - p.id} - pinLastColumn - virtualization={sorted.length > 200} - height={560} - footerContent={ -
- {Object.entries(totalByCurrency).map(([cur, sum]) => ( - - Итого {cur}: {formatCurrency(sum, cur)} - - ))} -
- } - /> - )} + {(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 ( +
+ , + }, + { + label: 'Общая сумма', + value: formatCurrency(totalSum, baseCurrency), + icon: , + hint: baseCurrency, + }, + { + label: 'За 30 дней', + value: recent.length, + icon: , + hint: formatCurrency(recentSum, baseCurrency), + }, + ]} + /> + p.id} + pinLastColumn + virtualization={sorted.length > 200} + height={560} + footerContent={ +
+ {Object.entries(totalByCurrency).map(([cur, sum]) => ( + + Итого {cur}: {formatCurrency(sum, cur)} + + ))} +
+ } + /> +
+ ) + }} ) } diff --git a/apps/web/src/routes/_auth/renewals.tsx b/apps/web/src/routes/_auth/renewals.tsx index c7b4a1d..dc9abc0 100644 --- a/apps/web/src/routes/_auth/renewals.tsx +++ b/apps/web/src/routes/_auth/renewals.tsx @@ -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('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() for (const item of items) { @@ -106,12 +127,35 @@ function RenewalsPage() { isError={isError} error={error} onRetry={() => refetch()} + skeleton={} empty={items.length === 0} emptyTitle="Нет продлений в выбранном периоде" emptyDescription="Активные VPS с расчётной датой оплаты не найдены" > {() => (
+ , + variant: overdueCount > 0 ? 'destructive' : 'default', + }, + { + label: 'В периоде', + value: items.length, + icon: , + hint: `за ${horizon} дн`, + }, + { + label: 'Стоимость продлений', + value: formatCurrency(renewalCost, baseCurrency), + icon: , + hint: baseCurrency, + }, + ]} + /> {grouped.map(([weekLabel, weekItems]) => (