feat: mobile view for infra-billing

This commit is contained in:
kastov 2026-03-27 17:34:49 +03:00
parent 7aa86a3196
commit 752f356e50
No known key found for this signature in database
GPG key ID: 1B27BE29057F4C90
10 changed files with 827 additions and 16 deletions

View file

@ -2114,5 +2114,24 @@
"search-by-ip": "Search by IP address...",
"no-results-for-ip": "No users found with IP matching \"{{ip}}\""
}
},
"mobile-stats": {
"widget": {
"billing-nodes": "Billing nodes",
"upcoming": "Upcoming",
"per-month": "Per month",
"total-spent": "Total spent"
}
},
"mobile-providers-list": {
"widget": {
"invoices": "Invoices"
}
},
"mobile-infra-billing": {
"widget": {
"history": "History",
"providers": "Providers"
}
}
}
}

View file

@ -1,5 +1,6 @@
import { Split } from '@gfazioli/mantine-split-pane'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from '@mantine/hooks'
import { useLayoutEffect } from 'react'
import { Stack } from '@mantine/core'
import { motion } from 'motion/react'
@ -13,20 +14,26 @@ import { InfraBillingNodesTableWidget } from '@widgets/dashboard/infra-billing/i
import { InfraProvidersTableWidget } from '@widgets/dashboard/infra-billing/infra-providers-table/infra-providers-table.widget'
import { UpdateBillingDateModalWidget } from '@widgets/dashboard/infra-billing/update-billing-date-modal'
import { StatsWidget } from '@widgets/dashboard/infra-billing/stats-widget/stats.widget'
import { MobileInfraBillingWidget } from '@widgets/dashboard/infra-billing/mobile'
import { preventBackScrollTables } from '@shared/utils/misc'
import { Page } from '@shared/ui/page'
export const InfraBillingPageComponent = () => {
const { t } = useTranslation()
const isMobile = useMediaQuery('(max-width: 48em)')
useLayoutEffect(() => {
if (isMobile) {
return undefined
}
document.body.addEventListener('wheel', preventBackScrollTables, {
passive: false
})
return () => {
document.body.removeEventListener('wheel', preventBackScrollTables)
}
}, [])
}, [isMobile])
return (
<Page title={t('constants.infra-billing')}>
@ -35,23 +42,29 @@ export const InfraBillingPageComponent = () => {
initial={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
<StatsWidget />
{isMobile ? (
<MobileInfraBillingWidget />
) : (
<>
<StatsWidget />
<Stack>
<Split spacing="sm" variant="dotted">
<Split.Pane initialWidth="60%">
<InfraBillingNodesTableWidget />
</Split.Pane>
<Stack>
<Split spacing="sm" variant="dotted">
<Split.Pane initialWidth="60%">
<InfraBillingNodesTableWidget />
</Split.Pane>
<Split.Resizer />
<Split.Resizer />
<Split.Pane grow initialWidth="40%">
<InfraBillingRecordsTableWidget />
</Split.Pane>
</Split>
<Split.Pane grow initialWidth="40%">
<InfraBillingRecordsTableWidget />
</Split.Pane>
</Split>
<InfraProvidersTableWidget />
</Stack>
<InfraProvidersTableWidget />
</Stack>
</>
)}
</motion.div>
<ViewInfraProviderDrawerWidget />

View file

@ -6,6 +6,7 @@ import { ReactNode } from 'react'
type IProps = {
countryCode?: string
hideIcon?: boolean
icon?: ReactNode
iconColor?: ThemeIconProps['color']
IconComponent: React.ComponentType<{ size: number }>
iconSize?: number
@ -29,7 +30,8 @@ export const BaseOverlayHeader = (props: IProps) => {
title,
titleOrder = 4,
withCopy = false,
hideIcon = false
hideIcon = false,
icon
} = props
const { copy } = useClipboard()
@ -42,6 +44,8 @@ export const BaseOverlayHeader = (props: IProps) => {
</ThemeIcon>
)}
{icon && icon}
{countryCode && countryCode !== 'XX' && (
<ReactCountryFlag countryCode={countryCode} style={{ fontSize: '1.5em' }} />
)}

View file

@ -0,0 +1 @@
export { MobileInfraBillingWidget } from './mobile-infra-billing.widget'

View file

@ -0,0 +1,29 @@
.tab {
position: relative;
border: 1px solid var(--mantine-color-dark-4);
background-color: var(--mantine-color-dark-6);
border-radius: 8px;
margin: 4px;
font-weight: 500;
@mixin hover {
background-color: var(--mantine-color-dark-5);
}
&[data-active] {
z-index: 1;
background-color: transparent;
border: 1px solid transparent;
outline: 2px solid var(--mantine-color-cyan-filled);
outline-offset: -2px;
@mixin hover {
background-color: var(--mantine-color-cyan-outline-hover);
}
}
}
.tabLabel {
color: var(--mantine-color-white);
font-weight: 500;
}

View file

@ -0,0 +1,196 @@
import { TbCloud, TbHistory, TbPlus, TbRefresh, TbServer } from 'react-icons/tb'
import { ActionIcon, Group, Stack, Tabs, Transition } from '@mantine/core'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import {
useGetInfraBillingHistoryRecords,
useGetInfraBillingNodes,
useGetInfraProviders
} from '@shared/api/hooks'
import { MODALS, useModalsStoreOpenWithData } from '@entities/dashboard/modal-store'
import { LoadingScreen } from '@shared/ui'
import { MobileProvidersListWidget } from './mobile-providers-list.widget'
import { MobileRecordsListWidget } from './mobile-records-list.widget'
import { MobileNodesListWidget } from './mobile-nodes-list.widget'
import { MobileStatsWidget } from './mobile-stats.widget'
import styles from './mobile-infra-billing.module.css'
type TabValue = 'nodes' | 'providers' | 'records'
export function MobileInfraBillingWidget() {
const [activeTab, setActiveTab] = useState<TabValue>('nodes')
const {
data: infraProviders,
isLoading: isInfraProvidersLoading,
refetch: refetchInfraProviders,
isRefetching: isInfraProvidersRefetching
} = useGetInfraProviders()
const {
data: infraBillingNodes,
isLoading: isInfraBillingNodesLoading,
refetch: refetchInfraBillingNodes,
isRefetching: isInfraBillingNodesRefetching
} = useGetInfraBillingNodes()
const {
data: infraBillingRecords,
refetch: refetchRecords,
isLoading: isInfraBillingRecordsLoading,
isRefetching: isInfraBillingRecordsRefetching
} = useGetInfraBillingHistoryRecords({
query: { start: 0, size: 200 }
})
const openModalWithData = useModalsStoreOpenWithData()
const { t } = useTranslation()
const handleAdd = () => {
switch (activeTab) {
case 'nodes':
openModalWithData(MODALS.CREATE_INFRA_BILLING_NODE_MODAL, undefined)
break
case 'providers':
openModalWithData(MODALS.CREATE_INFRA_PROVIDER_DRAWER, undefined)
break
case 'records':
openModalWithData(MODALS.CREATE_INFRA_BILLING_RECORD_DRAWER, undefined)
break
default:
break
}
}
const handleRefetch = () => {
refetchRecords()
refetchInfraBillingNodes()
refetchInfraProviders()
}
if (
isInfraBillingNodesLoading ||
isInfraProvidersLoading ||
isInfraBillingRecordsLoading ||
!infraBillingNodes ||
!infraProviders ||
!infraBillingRecords
) {
return <LoadingScreen />
}
return (
<Stack gap="xs" pos="relative">
<MobileStatsWidget />
<Tabs
classNames={{
tab: styles.tab,
tabLabel: styles.tabLabel
}}
color="cyan"
onChange={(value) => {
if (value) {
setActiveTab(value as TabValue)
}
}}
value={activeTab}
variant="unstyled"
>
<Tabs.List grow mb="md">
<Tabs.Tab leftSection={<TbServer size={16} />} value="nodes">
{t('constants.nodes')}
</Tabs.Tab>
<Tabs.Tab leftSection={<TbHistory size={16} />} value="records">
{t('mobile-infra-billing.widget.history')}
</Tabs.Tab>
<Tabs.Tab leftSection={<TbCloud size={16} />} value="providers">
{t('mobile-infra-billing.widget.providers')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="nodes">
<Transition
duration={200}
keepMounted
mounted={activeTab === 'nodes'}
timingFunction="linear"
transition="fade"
>
{(styles) => (
<MobileNodesListWidget
nodes={infraBillingNodes.billingNodes}
style={styles}
/>
)}
</Transition>
</Tabs.Panel>
<Tabs.Panel value="records">
<Transition
duration={200}
keepMounted
mounted={activeTab === 'records'}
timingFunction="linear"
transition="fade"
>
{(styles) => (
<MobileRecordsListWidget
records={infraBillingRecords.records}
refetchRecords={refetchRecords}
style={styles}
/>
)}
</Transition>
</Tabs.Panel>
<Tabs.Panel value="providers">
<Transition
duration={200}
keepMounted
mounted={activeTab === 'providers'}
timingFunction="linear"
transition="fade"
>
{(styles) => (
<MobileProvidersListWidget
providers={infraProviders.providers}
style={styles}
/>
)}
</Transition>
</Tabs.Panel>
</Tabs>
<Group
bottom={16}
gap={8}
pos="fixed"
right={15}
style={{
zIndex: 100,
backgroundColor: 'var(--mantine-color-dark-6)',
padding: 6,
borderRadius: 'var(--mantine-radius-md)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)'
}}
>
<ActionIcon
color="cyan"
loading={
isInfraProvidersRefetching ||
isInfraBillingNodesRefetching ||
isInfraBillingRecordsRefetching
}
onClick={handleRefetch}
size={40}
variant="soft"
>
<TbRefresh size={22} />
</ActionIcon>
<ActionIcon color="teal" onClick={handleAdd} size={40} variant="soft">
<TbPlus size={22} />
</ActionIcon>
</Group>
</Stack>
)
}

View file

@ -0,0 +1,150 @@
import { ActionIcon, Badge, Group, MantineStyleProp, Stack, Text } from '@mantine/core'
import { TbCalendar, TbCheck, TbCreditCard, TbServer } from 'react-icons/tb'
import { GetInfraBillingNodesCommand } from '@remnawave/backend-contract'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import dayjs from 'dayjs'
import { MODALS, useModalsStoreOpenWithData } from '@entities/dashboard/modal-store'
import { BaseOverlayHeader } from '@shared/ui/overlays/base-overlay-header'
import { QueryKeys, useUpdateInfraBillingNode } from '@shared/api/hooks'
import { formatTimeUtil } from '@shared/utils/time-utils'
import { SectionCard } from '@shared/ui/section-card'
import { queryClient } from '@shared/api'
type BillingNode = GetInfraBillingNodesCommand.Response['response']['billingNodes'][number]
function getNodeStatus(nextBillingAt: Date, language: string) {
const now = dayjs().startOf('day').locale(language)
const target = dayjs(nextBillingAt).startOf('day').locale(language)
const isOverdue = target.isBefore(now)
return {
label: target.fromNow(),
color: isOverdue ? 'red' : 'teal',
isOverdue
}
}
interface IProps {
nodes: GetInfraBillingNodesCommand.Response['response']['billingNodes']
style: MantineStyleProp
}
export function MobileNodesListWidget(props: IProps) {
const { nodes, style } = props
const openModalWithData = useModalsStoreOpenWithData()
const { t, i18n } = useTranslation()
const [updatingUuids, setUpdatingUuids] = useState<Set<string>>(new Set())
const { mutate: updateNode } = useUpdateInfraBillingNode({
mutationFns: {
onSuccess: (data) => {
queryClient.setQueryData(QueryKeys.infraBilling.getInfraBillingNodes.queryKey, data)
setUpdatingUuids(new Set())
},
onError: () => setUpdatingUuids(new Set())
}
})
const handleQuickUpdate = (uuid: string, currentDate: Date) => {
setUpdatingUuids((prev) => new Set(prev).add(uuid))
updateNode({
variables: {
uuids: [uuid],
// @ts-expect-error - TODO: fix ZOD schema
nextBillingAt: dayjs(currentDate).add(1, 'month').toISOString()
}
})
}
const handleClickBillingAt = (node: BillingNode) => {
openModalWithData(MODALS.UPDATE_BILLING_DATE_MODAL, {
uuids: [node.uuid],
nextBillingAt: node.nextBillingAt
})
}
if (nodes.length === 0) {
return (
<Stack align="center" gap="xs" py="xl">
<Text c="dimmed" size="sm">
{t('infra-billing-nodes.widget.no-nodes-found')}
</Text>
</Stack>
)
}
return (
<Stack gap="xs" style={style}>
{nodes.map((node) => {
const status = getNodeStatus(node.nextBillingAt, i18n.language)
return (
<SectionCard.Root key={node.uuid}>
<SectionCard.Section>
<Group justify="space-between" wrap="nowrap">
<BaseOverlayHeader
countryCode={node.node.countryCode}
hideIcon={true}
iconColor="blue"
IconComponent={TbServer}
iconVariant="soft"
subtitle={node.provider.name}
title={node.node.name}
/>
<Badge
color={status.color}
leftSection={<TbCreditCard size={16} />}
radius="sm"
size="md"
variant="soft"
>
{status.label}
</Badge>
</Group>
</SectionCard.Section>
<SectionCard.Section>
<Group justify="space-between" wrap="nowrap">
<Group
gap={6}
onClick={() => handleClickBillingAt(node)}
style={{ cursor: 'pointer' }}
wrap="nowrap"
>
<TbCalendar
color={`var(--mantine-color-${status.color}-5)`}
size={16}
/>
<Text c={status.color} fw={600} size="sm">
{formatTimeUtil({
time: node.nextBillingAt,
template: 'FULL_DATE',
language: i18n.language
})}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<ActionIcon
color="teal"
loading={updatingUuids.has(node.uuid)}
onClick={() =>
handleQuickUpdate(node.uuid, node.nextBillingAt)
}
size="input-xs"
variant="soft"
>
<TbCheck size={18} />
</ActionIcon>
</Group>
</Group>
</SectionCard.Section>
</SectionCard.Root>
)
})}
</Stack>
)
}

View file

@ -0,0 +1,182 @@
import { ActionIcon, Avatar, Group, MantineStyleProp, Stack, Text } from '@mantine/core'
import { GetInfraProvidersCommand } from '@remnawave/backend-contract'
import { TbCloud, TbEdit, TbLink, TbTrash } from 'react-icons/tb'
import { useTranslation } from 'react-i18next'
import { modals } from '@mantine/modals'
import { MODALS, useModalsStoreOpenWithData } from '@entities/dashboard/modal-store'
import { faviconResolver, formatCurrencyWithIntl } from '@shared/utils/misc'
import { BaseOverlayHeader } from '@shared/ui/overlays/base-overlay-header'
import { QueryKeys, useDeleteInfraProvider } from '@shared/api/hooks'
import { CountryFlag } from '@shared/ui/get-country-flag'
import { SectionCard } from '@shared/ui/section-card'
import { queryClient } from '@shared/api'
interface IProps {
providers: GetInfraProvidersCommand.Response['response']['providers']
style: MantineStyleProp
}
export function MobileProvidersListWidget(props: IProps) {
const { providers, style } = props
const openModalWithData = useModalsStoreOpenWithData()
const { t } = useTranslation()
const { mutate: deleteProvider } = useDeleteInfraProvider({
mutationFns: {
onSuccess: () => {
queryClient.refetchQueries({
queryKey: QueryKeys.infraBilling.getInfraProviders.queryKey
})
queryClient.refetchQueries({
queryKey: QueryKeys.infraBilling.getInfraBillingNodes.queryKey
})
}
}
})
const handleOpenProvider = (
provider: GetInfraProvidersCommand.Response['response']['providers'][number]
) => {
openModalWithData(MODALS.VIEW_INFRA_PROVIDER_DRAWER, provider)
}
const handleDeleteProvider = (uuid: string) =>
modals.openConfirmModal({
title: t('common.confirm-action'),
children: t('common.confirm-action-description'),
labels: { confirm: t('common.delete'), cancel: t('common.cancel') },
centered: true,
confirmProps: { color: 'red' },
onConfirm: () => deleteProvider({ route: { uuid } })
})
if (providers.length === 0) {
return (
<Stack align="center" gap="xs" py="xl">
<Text c="dimmed" size="sm">
{t('infra-providers-table.widget.no-providers-found')}
</Text>
</Stack>
)
}
return (
<Stack gap="xs" style={style}>
{providers.map((provider) => (
<SectionCard.Root key={provider.uuid}>
<SectionCard.Section>
<Group justify="space-between" wrap="nowrap">
<BaseOverlayHeader
icon={
<Avatar
alt={provider.name}
color="initials"
name={provider.name}
onLoad={(event) => {
const img = event.target as HTMLImageElement
if (img.naturalWidth <= 24 && img.naturalHeight <= 24) {
img.src = ''
}
}}
radius="sm"
size={18}
src={faviconResolver(provider.faviconLink)}
/>
}
iconColor="violet"
IconComponent={TbCloud}
iconVariant="soft"
title={provider.name}
/>
<Group gap={4} wrap="nowrap">
{provider.loginUrl && (
<ActionIcon
color="teal"
onClick={() => {
window.open(
provider.loginUrl!,
'_blank',
'noopener,noreferrer'
)
}}
size="input-xs"
variant="soft"
>
<TbLink size={16} />
</ActionIcon>
)}
<ActionIcon
color="blue"
onClick={() => handleOpenProvider(provider)}
size="input-xs"
variant="soft"
>
<TbEdit size={16} />
</ActionIcon>
<ActionIcon
color="red"
onClick={() => handleDeleteProvider(provider.uuid)}
size="input-xs"
variant="soft"
>
<TbTrash size={16} />
</ActionIcon>
</Group>
</Group>
</SectionCard.Section>
<SectionCard.Section>
<Group grow>
<Stack align="center" gap={0}>
<Text fw={700} size="sm">
{provider.billingNodes.length}
</Text>
<Text c="dimmed" size="xs">
{t('constants.nodes')}
</Text>
</Stack>
<Stack align="center" gap={0}>
<Text fw={700} size="sm">
{provider.billingHistory.totalBills}
</Text>
<Text c="dimmed" size="xs">
{t('mobile-providers-list.widget.invoices')}
</Text>
</Stack>
<Stack align="center" gap={0}>
<Text c="teal" fw={700} size="sm">
{formatCurrencyWithIntl(provider.billingHistory.totalAmount)}
</Text>
<Text c="dimmed" size="xs">
{t('users-metrics.widget.total')}
</Text>
</Stack>
</Group>
</SectionCard.Section>
{provider.billingNodes.length > 0 && (
<SectionCard.Section>
<Group gap="xs">
{provider.billingNodes.slice(0, 3).map((node, index) => (
<Group gap={4} key={`${node.nodeUuid}-${index}`}>
<CountryFlag countryCode={node.countryCode} />
<Text c="white" fw={500} size="xs">
{node.name}
</Text>
</Group>
))}
{provider.billingNodes.length > 3 && (
<Text c="dimmed" fw={500} size="xs">
+{provider.billingNodes.length - 3}
</Text>
)}
</Group>
</SectionCard.Section>
)}
</SectionCard.Root>
))}
</Stack>
)
}

View file

@ -0,0 +1,153 @@
import { ActionIcon, Avatar, Divider, Group, MantineStyleProp, Stack, Text } from '@mantine/core'
import { GetInfraBillingHistoryRecordsCommand } from '@remnawave/backend-contract'
import { TbCreditCard, TbTrash } from 'react-icons/tb'
import { useTranslation } from 'react-i18next'
import { modals } from '@mantine/modals'
import { useMemo } from 'react'
import dayjs from 'dayjs'
import { faviconResolver, formatCurrencyWithIntl } from '@shared/utils/misc'
import { BaseOverlayHeader } from '@shared/ui/overlays/base-overlay-header'
import { useDeleteInfraBillingHistoryRecord } from '@shared/api/hooks'
import { formatTimeUtil } from '@shared/utils/time-utils'
import { SectionCard } from '@shared/ui/section-card'
type Record = GetInfraBillingHistoryRecordsCommand.Response['response']['records'][number]
interface MonthGroup {
label: string
records: Record[]
}
interface IProps {
records: GetInfraBillingHistoryRecordsCommand.Response['response']['records']
refetchRecords: () => void
style: MantineStyleProp
}
export function MobileRecordsListWidget(props: IProps) {
const { records, refetchRecords, style } = props
const { i18n, t } = useTranslation()
const { mutate: deleteRecord } = useDeleteInfraBillingHistoryRecord({
mutationFns: {
onSuccess: () => {
refetchRecords()
}
}
})
const handleDelete = (uuid: string) =>
modals.openConfirmModal({
title: t('common.confirm-action'),
children: t('common.confirm-action-description'),
labels: { confirm: t('common.delete'), cancel: t('common.cancel') },
centered: true,
confirmProps: { color: 'red' },
onConfirm: () => deleteRecord({ route: { uuid } })
})
const groupedByMonth = useMemo((): MonthGroup[] => {
const groups = new Map<string, Record[]>()
for (const record of records) {
const key = dayjs(record.billedAt).format('YYYY-MM')
const existing = groups.get(key)
if (existing) {
existing.push(record)
} else {
groups.set(key, [record])
}
}
return Array.from(groups.entries()).map(([key, groupRecords]) => ({
label: dayjs(key).locale(i18n.language).format('MMMM YYYY'),
records: groupRecords
}))
}, [records, i18n.language])
if (records.length === 0) {
return (
<Stack align="center" gap="xs" py="xl">
<Text c="dimmed" size="sm">
{t('infra-billing-records-table.widget.no-billing-records-found')}
</Text>
</Stack>
)
}
return (
<Stack gap="md" style={style}>
{groupedByMonth.map((group) => (
<Stack gap="xs" key={group.label}>
<Divider
label={
<Text fw={600} size="xs" tt="capitalize">
{group.label}
</Text>
}
labelPosition="center"
/>
{group.records.map((record) => (
<SectionCard.Root key={record.uuid}>
<SectionCard.Section>
<Group justify="space-between" wrap="nowrap">
<BaseOverlayHeader
icon={
<Avatar
alt={record.provider.name}
color="initials"
name={record.provider.name}
onLoad={(event) => {
const img = event.target as HTMLImageElement
if (
img.naturalWidth <= 24 &&
img.naturalHeight <= 24
) {
img.src = ''
}
}}
radius="sm"
size={18}
src={faviconResolver(record.provider.faviconLink)}
/>
}
iconColor="teal"
IconComponent={TbCreditCard}
iconVariant="soft"
subtitle={formatTimeUtil({
language: i18n.language,
template: 'FULL_DATE',
time: record.billedAt
})}
title={record.provider.name}
/>
<Group gap="xs" wrap="nowrap">
<Text
c="teal"
fw={700}
size="sm"
style={{ whiteSpace: 'nowrap' }}
>
{formatCurrencyWithIntl(record.amount)}
</Text>
<ActionIcon
color="red"
onClick={() => handleDelete(record.uuid)}
size="input-xs"
variant="soft"
>
<TbTrash size={16} />
</ActionIcon>
</Group>
</Group>
</SectionCard.Section>
</SectionCard.Root>
))}
</Stack>
))}
</Stack>
)
}

View file

@ -0,0 +1,64 @@
import { Card, Grid, Group, Stack, Text, ThemeIcon } from '@mantine/core'
import { MdPayment, MdTrendingUp } from 'react-icons/md'
import { useTranslation } from 'react-i18next'
import { TbCalendarUp } from 'react-icons/tb'
import { FaServer } from 'react-icons/fa'
import { useGetInfraBillingNodes } from '@shared/api/hooks'
import { formatCurrency } from '@shared/utils/misc'
export function MobileStatsWidget() {
const { data: nodes } = useGetInfraBillingNodes()
const { t } = useTranslation()
const stats = [
{
icon: FaServer,
color: 'blue',
value: nodes?.totalBillingNodes ?? 0,
label: t('mobile-stats.widget.billing-nodes')
},
{
icon: TbCalendarUp,
color: 'orange',
value: nodes?.stats.upcomingNodesCount ?? 0,
label: t('mobile-stats.widget.upcoming')
},
{
icon: MdPayment,
color: 'green',
value: formatCurrency(nodes?.stats.currentMonthPayments ?? 0),
label: t('mobile-stats.widget.per-month')
},
{
icon: MdTrendingUp,
color: 'violet',
value: formatCurrency(nodes?.stats.totalSpent ?? 0),
label: t('mobile-stats.widget.total-spent')
}
]
return (
<Grid gutter="xs" mb="md">
{stats.map((stat, index) => (
<Grid.Col key={index} span={6}>
<Card padding="sm">
<Group gap="xs" wrap="nowrap">
<ThemeIcon color={stat.color} radius="md" size="lg" variant="soft">
<stat.icon size={18} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={700} size="lg">
{stat.value}
</Text>
<Text c="dimmed" size="xs">
{stat.label}
</Text>
</Stack>
</Group>
</Card>
</Grid.Col>
))}
</Grid>
)
}