mirror of
https://github.com/remnawave/frontend.git
synced 2026-05-13 12:16:40 +00:00
feat: mobile view for infra-billing
This commit is contained in:
parent
7aa86a3196
commit
752f356e50
10 changed files with 827 additions and 16 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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' }} />
|
||||
)}
|
||||
|
|
|
|||
1
src/widgets/dashboard/infra-billing/mobile/index.ts
Normal file
1
src/widgets/dashboard/infra-billing/mobile/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { MobileInfraBillingWidget } from './mobile-infra-billing.widget'
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue