From 752f356e5042ce922f3bd6dd413b80b5203d3519 Mon Sep 17 00:00:00 2001 From: kastov Date: Fri, 27 Mar 2026 17:34:49 +0300 Subject: [PATCH] feat: mobile view for infra-billing --- public/locales/en/remnawave.json | 21 +- .../infra-billing.page.component.tsx | 41 ++-- .../ui/overlays/base-overlay-header/index.tsx | 6 +- .../dashboard/infra-billing/mobile/index.ts | 1 + .../mobile/mobile-infra-billing.module.css | 29 +++ .../mobile/mobile-infra-billing.widget.tsx | 196 ++++++++++++++++++ .../mobile/mobile-nodes-list.widget.tsx | 150 ++++++++++++++ .../mobile/mobile-providers-list.widget.tsx | 182 ++++++++++++++++ .../mobile/mobile-records-list.widget.tsx | 153 ++++++++++++++ .../mobile/mobile-stats.widget.tsx | 64 ++++++ 10 files changed, 827 insertions(+), 16 deletions(-) create mode 100644 src/widgets/dashboard/infra-billing/mobile/index.ts create mode 100644 src/widgets/dashboard/infra-billing/mobile/mobile-infra-billing.module.css create mode 100644 src/widgets/dashboard/infra-billing/mobile/mobile-infra-billing.widget.tsx create mode 100644 src/widgets/dashboard/infra-billing/mobile/mobile-nodes-list.widget.tsx create mode 100644 src/widgets/dashboard/infra-billing/mobile/mobile-providers-list.widget.tsx create mode 100644 src/widgets/dashboard/infra-billing/mobile/mobile-records-list.widget.tsx create mode 100644 src/widgets/dashboard/infra-billing/mobile/mobile-stats.widget.tsx diff --git a/public/locales/en/remnawave.json b/public/locales/en/remnawave.json index 22d2dbff..7e581116 100644 --- a/public/locales/en/remnawave.json +++ b/public/locales/en/remnawave.json @@ -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" + } } -} \ No newline at end of file +} diff --git a/src/pages/dashboard/crm/infra-billing/components/infra-billing.page.component.tsx b/src/pages/dashboard/crm/infra-billing/components/infra-billing.page.component.tsx index a68581f4..b2c459c5 100644 --- a/src/pages/dashboard/crm/infra-billing/components/infra-billing.page.component.tsx +++ b/src/pages/dashboard/crm/infra-billing/components/infra-billing.page.component.tsx @@ -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 ( @@ -35,23 +42,29 @@ export const InfraBillingPageComponent = () => { initial={{ opacity: 0 }} transition={{ duration: 0.5 }} > - + {isMobile ? ( + + ) : ( + <> + - - - - - + + + + + - + - - - - + + + + - - + + + + )} diff --git a/src/shared/ui/overlays/base-overlay-header/index.tsx b/src/shared/ui/overlays/base-overlay-header/index.tsx index ce359367..ce5d5b9a 100644 --- a/src/shared/ui/overlays/base-overlay-header/index.tsx +++ b/src/shared/ui/overlays/base-overlay-header/index.tsx @@ -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) => { )} + {icon && icon} + {countryCode && countryCode !== 'XX' && ( )} diff --git a/src/widgets/dashboard/infra-billing/mobile/index.ts b/src/widgets/dashboard/infra-billing/mobile/index.ts new file mode 100644 index 00000000..212f9c88 --- /dev/null +++ b/src/widgets/dashboard/infra-billing/mobile/index.ts @@ -0,0 +1 @@ +export { MobileInfraBillingWidget } from './mobile-infra-billing.widget' diff --git a/src/widgets/dashboard/infra-billing/mobile/mobile-infra-billing.module.css b/src/widgets/dashboard/infra-billing/mobile/mobile-infra-billing.module.css new file mode 100644 index 00000000..9f3727e5 --- /dev/null +++ b/src/widgets/dashboard/infra-billing/mobile/mobile-infra-billing.module.css @@ -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; +} diff --git a/src/widgets/dashboard/infra-billing/mobile/mobile-infra-billing.widget.tsx b/src/widgets/dashboard/infra-billing/mobile/mobile-infra-billing.widget.tsx new file mode 100644 index 00000000..91bf648a --- /dev/null +++ b/src/widgets/dashboard/infra-billing/mobile/mobile-infra-billing.widget.tsx @@ -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('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 + } + + return ( + + + + { + if (value) { + setActiveTab(value as TabValue) + } + }} + value={activeTab} + variant="unstyled" + > + + } value="nodes"> + {t('constants.nodes')} + + } value="records"> + {t('mobile-infra-billing.widget.history')} + + } value="providers"> + {t('mobile-infra-billing.widget.providers')} + + + + + + {(styles) => ( + + )} + + + + + + {(styles) => ( + + )} + + + + + + {(styles) => ( + + )} + + + + + + + + + + + + + + ) +} diff --git a/src/widgets/dashboard/infra-billing/mobile/mobile-nodes-list.widget.tsx b/src/widgets/dashboard/infra-billing/mobile/mobile-nodes-list.widget.tsx new file mode 100644 index 00000000..7f7e0ea0 --- /dev/null +++ b/src/widgets/dashboard/infra-billing/mobile/mobile-nodes-list.widget.tsx @@ -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>(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 ( + + + {t('infra-billing-nodes.widget.no-nodes-found')} + + + ) + } + + return ( + + {nodes.map((node) => { + const status = getNodeStatus(node.nextBillingAt, i18n.language) + + return ( + + + + + + } + radius="sm" + size="md" + variant="soft" + > + {status.label} + + + + + + + handleClickBillingAt(node)} + style={{ cursor: 'pointer' }} + wrap="nowrap" + > + + + {formatTimeUtil({ + time: node.nextBillingAt, + template: 'FULL_DATE', + language: i18n.language + })} + + + + + + handleQuickUpdate(node.uuid, node.nextBillingAt) + } + size="input-xs" + variant="soft" + > + + + + + + + ) + })} + + ) +} diff --git a/src/widgets/dashboard/infra-billing/mobile/mobile-providers-list.widget.tsx b/src/widgets/dashboard/infra-billing/mobile/mobile-providers-list.widget.tsx new file mode 100644 index 00000000..968d3e1e --- /dev/null +++ b/src/widgets/dashboard/infra-billing/mobile/mobile-providers-list.widget.tsx @@ -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 ( + + + {t('infra-providers-table.widget.no-providers-found')} + + + ) + } + + return ( + + {providers.map((provider) => ( + + + + { + 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} + /> + + + {provider.loginUrl && ( + { + window.open( + provider.loginUrl!, + '_blank', + 'noopener,noreferrer' + ) + }} + size="input-xs" + variant="soft" + > + + + )} + handleOpenProvider(provider)} + size="input-xs" + variant="soft" + > + + + handleDeleteProvider(provider.uuid)} + size="input-xs" + variant="soft" + > + + + + + + + + + + + {provider.billingNodes.length} + + + {t('constants.nodes')} + + + + + {provider.billingHistory.totalBills} + + + {t('mobile-providers-list.widget.invoices')} + + + + + {formatCurrencyWithIntl(provider.billingHistory.totalAmount)} + + + {t('users-metrics.widget.total')} + + + + + + {provider.billingNodes.length > 0 && ( + + + {provider.billingNodes.slice(0, 3).map((node, index) => ( + + + + {node.name} + + + ))} + {provider.billingNodes.length > 3 && ( + + +{provider.billingNodes.length - 3} + + )} + + + )} + + ))} + + ) +} diff --git a/src/widgets/dashboard/infra-billing/mobile/mobile-records-list.widget.tsx b/src/widgets/dashboard/infra-billing/mobile/mobile-records-list.widget.tsx new file mode 100644 index 00000000..76f98ba3 --- /dev/null +++ b/src/widgets/dashboard/infra-billing/mobile/mobile-records-list.widget.tsx @@ -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() + + 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 ( + + + {t('infra-billing-records-table.widget.no-billing-records-found')} + + + ) + } + + return ( + + {groupedByMonth.map((group) => ( + + + {group.label} + + } + labelPosition="center" + /> + + {group.records.map((record) => ( + + + + { + 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} + /> + + + + {formatCurrencyWithIntl(record.amount)} + + handleDelete(record.uuid)} + size="input-xs" + variant="soft" + > + + + + + + + ))} + + ))} + + ) +} diff --git a/src/widgets/dashboard/infra-billing/mobile/mobile-stats.widget.tsx b/src/widgets/dashboard/infra-billing/mobile/mobile-stats.widget.tsx new file mode 100644 index 00000000..c9949a78 --- /dev/null +++ b/src/widgets/dashboard/infra-billing/mobile/mobile-stats.widget.tsx @@ -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 ( + + {stats.map((stat, index) => ( + + + + + + + + + {stat.value} + + + {stat.label} + + + + + + ))} + + ) +}