diff --git a/src/components/CashoutModal.tsx b/src/components/CashoutModal.tsx index 0fa5ed5..510e38a 100644 --- a/src/components/CashoutModal.tsx +++ b/src/components/CashoutModal.tsx @@ -1,6 +1,5 @@ import { ReactElement, useState } from 'react' import Button from '@material-ui/core/Button' -import Input from '@material-ui/core/Input' import Dialog from '@material-ui/core/Dialog' import DialogActions from '@material-ui/core/DialogActions' import DialogContent from '@material-ui/core/DialogContent' @@ -11,10 +10,15 @@ import { Snackbar, Container, CircularProgress } from '@material-ui/core' import { beeDebugApi } from '../services/bee' import EthereumAddress from './EthereumAddress' +import { fromBZZbaseUnit } from '../utils' -export default function DepositModal(): ReactElement { +interface Props { + peerId: string + uncashedAmount: number +} + +export default function DepositModal({ peerId, uncashedAmount }: Props): ReactElement { const [open, setOpen] = useState(false) - const [peerId, setPeerId] = useState('') const [loadingCashout, setLoadingCashout] = useState(false) const [showToast, setToastVisibility] = useState(false) const [toastContent, setToastContent] = useState(null) @@ -67,32 +71,33 @@ export default function DepositModal(): ReactElement { Cashout Cheque - {loadingCashout ? ( - - - - ) : ( - - - Specify the peer Id of the peer you would like to cashout. - - setPeerId(e.target.value)} - /> - - )} + + + {loadingCashout && ( + <> + + Cashing out {fromBZZbaseUnit(uncashedAmount).toFixed(7)} from Peer{' '} + {peerId}. Please wait... + + + + + + )} + {!loadingCashout && ( + + Are you sure you want to cashout {fromBZZbaseUnit(uncashedAmount).toFixed(7)} BZZ from + Peer {peerId}? + + )} + + - diff --git a/src/hooks/accounting.ts b/src/hooks/accounting.ts new file mode 100644 index 0000000..e258422 --- /dev/null +++ b/src/hooks/accounting.ts @@ -0,0 +1,93 @@ +import { LastCashoutActionResponse, PeerBalance, Settlements } from '@ethersphere/bee-js' +import { useEffect, useState } from 'react' +import { beeDebugApi } from '../services/bee' +import { useApiPeerBalances, useApiSettlements } from './apiHooks' + +interface UseAccountingHook { + isLoading: boolean + isLoadingUncashed: boolean + error: Error | null + totalsent: number + totalreceived: number + accounting: Accounting[] | null +} + +/** + * Merges the balances, settlements and uncashedAmounts arrays into single array which is sorted by uncashed amounts (if any) + * + * @param balances Balances for all peers + * @param settlements Settlements for all peers which has some settlement + * @param uncashedAmounts Array of getPeerLastCashout responses which is needed to calculate uncashed amount + * + * @returns + */ +function mergeAccounting( + balances?: PeerBalance[], + settlements?: Settlements[], + uncashedAmounts?: LastCashoutActionResponse[], +): Accounting[] | null { + // Settlements or balances are still loading or there is an error -> return null + if (!balances || !settlements) return null + + const accounting: Record = {} + + balances.forEach( + // Some peers may not have settlement but all have balance (therefore initialize sent, received and uncashed to 0) + ({ peer, balance }) => + (accounting[peer] = { peer, balance, sent: 0, received: 0, uncashedAmount: 0, total: balance }), + ) + + settlements.forEach( + ({ peer, sent, received }) => + (accounting[peer] = { ...accounting[peer], sent, received, total: accounting[peer].balance + received - sent }), + ) + + // If there are no cheques (and hence last cashout actions), we don't need to sort and can return values right away + if (!uncashedAmounts) return Object.values(accounting) + + uncashedAmounts?.forEach( + ({ peer, cumulativePayout }) => (accounting[peer].uncashedAmount = accounting[peer].received - cumulativePayout), + ) + + return Object.values(accounting).sort((a, b) => b.uncashedAmount - a.uncashedAmount) +} + +export const useAccounting = (): UseAccountingHook => { + const settlements = useApiSettlements() + const balances = useApiPeerBalances() + + const [err, setErr] = useState(null) + const [isLoadingUncashed, setIsloadingUncashed] = useState(false) + const [uncashedAmounts, setUncashedAmounts] = useState(undefined) + + const error = balances.error || settlements.error || err + + useEffect(() => { + // We don't have any settlements loaded yet or we are already loading/have loaded the uncashed amounts + if (isLoadingUncashed || !settlements.settlements || uncashedAmounts || error) return + + setIsloadingUncashed(true) + const promises = settlements.settlements.settlements.map(({ peer }) => + beeDebugApi.chequebook.getPeerLastCashout(peer), + ) + Promise.all(promises) + .then(setUncashedAmounts) + .catch(setErr) + .finally(() => setIsloadingUncashed(false)) + }, [settlements, isLoadingUncashed, uncashedAmounts, error]) + + const accounting = mergeAccounting( + balances.peerBalances?.balances, + settlements.settlements?.settlements, + uncashedAmounts, + ) + + return { + isLoading: settlements.isLoadingSettlements || balances.isLoadingPeerBalances, + isLoadingUncashed, + error, + accounting, + totalsent: settlements.settlements?.totalsent || 0, + totalreceived: settlements.settlements?.totalreceived || 0, + } +} diff --git a/src/pages/accounting/AccountCard.tsx b/src/pages/accounting/AccountCard.tsx index 99f56bb..d0e16af 100644 --- a/src/pages/accounting/AccountCard.tsx +++ b/src/pages/accounting/AccountCard.tsx @@ -1,34 +1,34 @@ import { ReactElement } from 'react' import { createStyles, makeStyles } from '@material-ui/core/styles' -import { Card, CardContent, Typography, Grid } from '@material-ui/core/' +import { Card, CardContent, Typography, Theme } from '@material-ui/core/' import { Skeleton } from '@material-ui/lab' import WithdrawModal from '../../containers/WithdrawModal' import DepositModal from '../../containers/DepositModal' -import CashoutModal from '../../components/CashoutModal' import { fromBZZbaseUnit } from '../../utils' -import type { AllSettlements, ChequebookAddressResponse } from '@ethersphere/bee-js' +import type { ChequebookAddressResponse } from '@ethersphere/bee-js' -const useStyles = makeStyles(() => +const useStyles = makeStyles((theme: Theme) => createStyles({ root: { display: 'flex', }, - details: { + buttons: { display: 'flex', + columnGap: theme.spacing(1), }, - address: { - color: 'grey', - fontWeight: 400, - }, - content: { - flexGrow: 1, - }, - status: { - color: '#fff', - backgroundColor: '#76a9fa', + gridContainer: { + display: 'flex', + width: '100%', + marginLeft: theme.spacing(1), + marginRight: theme.spacing(1), + columnGap: theme.spacing(1), + rowGap: theme.spacing(1), + flex: '0 1 auto', + flexWrap: 'wrap', + justifyContent: 'space-between', }, }), ) @@ -40,81 +40,58 @@ interface ChequebookBalance { interface Props { chequebookAddress: ChequebookAddressResponse | null - isLoadingChequebookAddress: boolean chequebookBalance: ChequebookBalance | null - isLoadingChequebookBalance: boolean - settlements: AllSettlements | null - isLoadingSettlements: boolean + totalsent: number + totalreceived: number + isLoading: boolean } -function AccountCard(props: Props): ReactElement { +function AccountCard({ totalreceived, totalsent, chequebookBalance, isLoading }: Props): ReactElement { const classes = useStyles() return (
-

Accounting

-
+

Chequebook

+
-
- {!props.isLoadingChequebookBalance && !props.isLoadingSettlements && props.chequebookBalance ? ( -
- - - - - Total Balance (BZZ) - - - {fromBZZbaseUnit(props.chequebookBalance.totalBalance)} - - - - - Available Balance (BZZ) - - - {fromBZZbaseUnit(props.chequebookBalance.availableBalance)} - - - - - Total Sent / Received (BZZ) - - - - {fromBZZbaseUnit(props.settlements?.totalsent || 0)} /{' '} - {fromBZZbaseUnit(props.settlements?.totalreceived || 0)} - - props.settlements?.totalreceived - ? '#c9201f' - : '#32c48d', - }} - > - ( - {fromBZZbaseUnit( - (props.settlements && props.settlements?.totalsent - props.settlements?.totalreceived) || 0, - )} - ) - - - - - -
- ) : ( -
- - - + {!isLoading && ( + +
+ + Total Balance + + + {fromBZZbaseUnit(chequebookBalance?.totalBalance || 0).toFixed(7)} BZZ + +
+
+ + Available Uncommitted Balance + + + {fromBZZbaseUnit(chequebookBalance?.availableBalance || 0).toFixed(7)} BZZ + +
+
+ + Total Sent / Received + + + {fromBZZbaseUnit(totalsent).toFixed(7)} / {fromBZZbaseUnit(totalreceived).toFixed(7)} BZZ + +
+
+ )} + {isLoading && ( +
+ +
)} diff --git a/src/pages/accounting/BalancesTable.tsx b/src/pages/accounting/BalancesTable.tsx index c1ebbcd..06e556b 100644 --- a/src/pages/accounting/BalancesTable.tsx +++ b/src/pages/accounting/BalancesTable.tsx @@ -1,79 +1,89 @@ import type { ReactElement } from 'react' import { makeStyles } from '@material-ui/core/styles' -import { - Table, - TableBody, - TableCell, - TableContainer, - TableRow, - TableHead, - Paper, - Container, - CircularProgress, -} from '@material-ui/core' +import { Table, TableBody, TableCell, TableContainer, TableRow, TableHead, Paper } from '@material-ui/core' import { fromBZZbaseUnit } from '../../utils' +import ClipboardCopy from '../../components/ClipboardCopy' +import CashoutModal from '../../components/CashoutModal' +import PeerDetailDrawer from './PeerDetail' const useStyles = makeStyles({ table: { minWidth: 650, }, + values: { + textAlign: 'right', + fontFamily: 'monospace, monospace', + }, }) - -interface PeerBalance { - balance: number - peer: string -} - -interface PeerBalances { - balances: Array -} - interface Props { - peerBalances: PeerBalances | null - loading?: boolean + isLoadingUncashed: boolean + accounting: Accounting[] | null } -function BalancesTable(props: Props): ReactElement { +function BalancesTable({ accounting, isLoadingUncashed }: Props): ReactElement | null { + if (accounting === null) return null const classes = useStyles() return ( -
- {props.loading ? ( - - - - ) : ( - - - - - Peer - Balance (BZZ) - - - - - {props.peerBalances?.balances.map((peerBalance: PeerBalance) => ( - - {peerBalance.peer} - 0 ? '#32c48d' : '#c9201f', - textAlign: 'right', - fontFamily: 'monospace, monospace', - }} - > - {fromBZZbaseUnit(peerBalance.balance).toFixed(7).toLocaleString()} - - - - ))} - -
-
- )} -
+ + + + + Peer + Outstanding Balance + Settlements Sent / Received + Total + Uncashed Amount + + + + + {accounting.map(({ peer, balance, received, sent, uncashedAmount, total }) => ( + + +
+ + + + +
+
+ + 0 ? '#32c48d' : '#c9201f', + }} + > + {fromBZZbaseUnit(balance).toFixed(7).toLocaleString()} + {' '} + BZZ + + + -{fromBZZbaseUnit(sent).toFixed(7)} / {fromBZZbaseUnit(received).toFixed(7)} BZZ + + + 0 ? '#32c48d' : '#c9201f', + }} + > + {fromBZZbaseUnit(total).toFixed(7)} + {' '} + BZZ + + + {isLoadingUncashed && 'loading...'} + {!isLoadingUncashed && <>{uncashedAmount > 0 ? fromBZZbaseUnit(uncashedAmount).toFixed(7) : '0'} BZZ} + + + {uncashedAmount > 0 && } + +
+ ))} +
+
+
) } diff --git a/src/pages/accounting/ChequebookTable.tsx b/src/pages/accounting/ChequebookTable.tsx deleted file mode 100644 index 4c81982..0000000 --- a/src/pages/accounting/ChequebookTable.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import type { ReactElement } from 'react' -import { makeStyles } from '@material-ui/core/styles' -import { - Table, - TableBody, - TableCell, - TableContainer, - TableRow, - TableHead, - Paper, - Container, - CircularProgress, -} from '@material-ui/core' - -import { fromBZZbaseUnit } from '../../utils' -import EthereumAddress from '../../components/EthereumAddress' -import ClipboardCopy from '../../components/ClipboardCopy' -import PeerDetailDrawer from './PeerDetailDrawer' - -const useStyles = makeStyles({ - table: { - minWidth: 650, - }, -}) - -interface ChequeEvent { - beneficiary: string - chequebook: string - payout: number -} - -interface PeerCheque { - lastreceived?: ChequeEvent - lastsent?: ChequeEvent - peer: string -} - -interface PeerCheques { - lastcheques: Array -} - -interface Props { - peerCheques: PeerCheques | null - loading?: boolean -} - -function ChequebookTable(props: Props): ReactElement { - const classes = useStyles() - - return ( -
- {props.loading ? ( - - - - ) : ( -
- - - - - Peer - Last Received - Last Sent - - - - - {props.peerCheques?.lastcheques.map((peerCheque: PeerCheque) => ( - - -
- - - - -
-
- -

- - {peerCheque.lastreceived?.payout - ? `${fromBZZbaseUnit(peerCheque.lastreceived?.payout).toFixed(7).toLocaleString()} from` - : '-'} - - {peerCheque.lastreceived ? ( - - ) : null} -

-
- -

- - {peerCheque.lastsent?.payout - ? `${fromBZZbaseUnit(peerCheque.lastsent?.payout).toFixed(7).toLocaleString()} to` - : '-'} - - {peerCheque.lastsent ? ( - - ) : null} -

-
- -
- ))} -
-
-
-
- )} -
- ) -} - -export default ChequebookTable diff --git a/src/pages/accounting/PeerDetail.tsx b/src/pages/accounting/PeerDetail.tsx new file mode 100644 index 0000000..afa415c --- /dev/null +++ b/src/pages/accounting/PeerDetail.tsx @@ -0,0 +1,23 @@ +import type { ReactElement } from 'react' +import { Typography } from '@material-ui/core' + +function truncStringPortion(str: string, firstCharCount = 10, endCharCount = 10) { + return `${str.substring(0, firstCharCount)}...${str.substring(str.length - endCharCount, str.length)}` +} + +interface Props { + peerId: string +} + +export default function PeerDetail(props: Props): ReactElement { + return ( + + {truncStringPortion(props.peerId)} + + ) +} diff --git a/src/pages/accounting/PeerDetailDrawer.tsx b/src/pages/accounting/PeerDetailDrawer.tsx deleted file mode 100644 index babcefd..0000000 --- a/src/pages/accounting/PeerDetailDrawer.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import React, { ReactElement, useState } from 'react' -import { Paper, Container, Drawer, Button, Typography, CircularProgress, Grid } from '@material-ui/core' -import ClipboardCopy from '../../components/ClipboardCopy' -import { beeDebugApi } from '../../services/bee' -import EthereumAddress from '../../components/EthereumAddress' -import { fromBZZbaseUnit } from '../../utils' -import { LastCashoutActionResponse, LastChequesForPeerResponse } from '@ethersphere/bee-js' - -function truncStringPortion(str: string, firstCharCount = 10, endCharCount = 10) { - let convertedStr = '' - convertedStr += str.substring(0, firstCharCount) - convertedStr += '.'.repeat(3) - convertedStr += str.substring(str.length - endCharCount, str.length) - - return convertedStr -} - -interface Props { - peerId: string -} - -export default function Index(props: Props): ReactElement { - const [open, setOpen] = useState(false) - const [peerCashout, setPeerCashout] = useState(null) - const [peerCheque, setPeerCheque] = useState(null) - - const [isLoadingPeerCheque, setIsLoadingPeerCheque] = useState(false) - const [isLoadingPeerCashout, setIsLoadingPeerCashout] = useState(false) - - const handleClickOpen = (peerId: string) => { - setIsLoadingPeerCashout(true) - beeDebugApi.chequebook - .getPeerLastCashout(peerId) - .then(res => { - setPeerCashout(res) - }) - .catch(() => { - // FIXME: handle the error - }) - .finally(() => { - setIsLoadingPeerCashout(false) - }) - - setIsLoadingPeerCheque(true) - beeDebugApi.chequebook - .getPeerLastCheques(peerId) - .then(res => { - setPeerCheque(res) - }) - .catch(() => { - // FIXME: handle the error - }) - .finally(() => { - setIsLoadingPeerCheque(false) - }) - - setOpen(true) - } - - const handleClose = () => { - setOpen(false) - } - - return ( -
- - -
- - Peer: {truncStringPortion(props.peerId)} - - - - {isLoadingPeerCashout || isLoadingPeerCheque ? ( - - - - ) : ( -
-

Last Cheque

- - -
Last Sent
-

- Payout: - - {' '} - {peerCheque?.lastsent?.payout ? fromBZZbaseUnit(peerCheque?.lastsent?.payout) : '-'} - -

-

- Beneficiary: - -

-

- Chequebook: - -

-
- -
Last Received
-

- Payout: - - {' '} - {peerCheque?.lastreceived?.payout ? fromBZZbaseUnit(peerCheque?.lastreceived?.payout) : '-'} - -

-

- Beneficiary: - -

-

- Chequebook: - -

-
-
-

Last Cashout

- {peerCashout && peerCashout?.cumulativePayout > 0 ? ( -
-

- Cumulative Payout: - - {peerCashout?.cumulativePayout ? fromBZZbaseUnit(peerCashout?.cumulativePayout) : '-'} - -

-

- Last Payout: - - {' '} - {peerCashout?.result.lastPayout ? fromBZZbaseUnit(peerCashout?.result.lastPayout) : '-'} - - {peerCashout?.result.bounced ? 'Bounced' : ''} -

-

- Beneficiary: - -

-

- Chequebook: - -

-

- Recipient: - -

-

- Transaction: - -

-
- ) : ( - 'None' - )} -
- )} -
-
-
-
- ) -} diff --git a/src/pages/accounting/SettlementsTable.tsx b/src/pages/accounting/SettlementsTable.tsx deleted file mode 100644 index b12f479..0000000 --- a/src/pages/accounting/SettlementsTable.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { ReactElement } from 'react' -import { makeStyles } from '@material-ui/core/styles' -import { - Table, - TableBody, - TableCell, - TableContainer, - TableRow, - TableHead, - Paper, - Container, - CircularProgress, -} from '@material-ui/core' - -import { fromBZZbaseUnit } from '../../utils' - -import type { AllSettlements, Settlements } from '@ethersphere/bee-js' - -const useStyles = makeStyles({ - table: { - minWidth: 650, - }, -}) - -interface Props { - nodeSettlements: AllSettlements | null - loading?: boolean -} - -function SettlementsTable(props: Props): ReactElement { - const classes = useStyles() - - return ( -
- {props.loading ? ( - - - - ) : ( - - - - - Peer - Received (BZZ) - Sent (BZZ) - - - - {props.nodeSettlements?.settlements.map((item: Settlements) => ( - - {item.peer} - - {item.received > 0 ? fromBZZbaseUnit(item.received).toFixed(7).toLocaleString() : item.received} - - - {item.sent > 0 ? fromBZZbaseUnit(item.sent).toFixed(7).toLocaleString() : item.sent} - - - ))} - -
-
- )} -
- ) -} - -export default SettlementsTable diff --git a/src/pages/accounting/index.tsx b/src/pages/accounting/index.tsx index 5f32c8c..554f630 100644 --- a/src/pages/accounting/index.tsx +++ b/src/pages/accounting/index.tsx @@ -1,11 +1,9 @@ -import { ReactElement, useState, ChangeEvent, ReactChild } from 'react' -import { withStyles, Theme, createStyles, makeStyles } from '@material-ui/core/styles' -import { Tabs, Tab, Box, Typography, Container, CircularProgress } from '@material-ui/core' +import type { ReactElement } from 'react' +import { Theme, createStyles, makeStyles } from '@material-ui/core/styles' +import { Container, CircularProgress } from '@material-ui/core' import AccountCard from '../accounting/AccountCard' import BalancesTable from './BalancesTable' -import ChequebookTable from './ChequebookTable' -import SettlementsTable from './SettlementsTable' import EthereumAddressCard from '../../components/EthereumAddressCard' import TroubleshootConnectionCard from '../../components/TroubleshootConnectionCard' @@ -13,25 +11,10 @@ import { useApiNodeAddresses, useApiChequebookAddress, useApiChequebookBalance, - useApiPeerBalances, - useApiPeerCheques, - useApiSettlements, useApiHealth, useDebugApiHealth, } from '../../hooks/apiHooks' - -interface TabPanelProps { - children?: ReactChild - index: number - value: number -} - -function a11yProps(index: number) { - return { - id: `simple-tab-${index}`, - 'aria-controls': `simple-tabpanel-${index}`, - } -} +import { useAccounting } from '../../hooks/accounting' const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -44,137 +27,46 @@ const useStyles = makeStyles((theme: Theme) => ) export default function Accounting(): ReactElement { - const [value, setValue] = useState(0) const classes = useStyles() - const handleChange = (event: ChangeEvent, newValue: number) => { - setValue(newValue) - } - const { chequebookAddress, isLoadingChequebookAddress } = useApiChequebookAddress() const { chequebookBalance, isLoadingChequebookBalance } = useApiChequebookBalance() - const { peerBalances, isLoadingPeerBalances } = useApiPeerBalances() const { nodeAddresses, isLoadingNodeAddresses } = useApiNodeAddresses() const { health, isLoadingHealth } = useApiHealth() const { nodeHealth, isLoadingNodeHealth } = useDebugApiHealth() + const { isLoading, totalsent, totalreceived, accounting, isLoadingUncashed, error } = useAccounting() - const { peerCheques, isLoadingPeerCheques } = useApiPeerCheques() - const { settlements, isLoadingSettlements } = useApiSettlements() - - function TabPanel(props: TabPanelProps) { - const { children, value, index, ...other } = props - + if (isLoadingHealth || isLoadingNodeHealth) { return ( - + + + ) } - const AntTabs = withStyles({ - root: { - borderBottom: '1px solid #e8e8e8', - }, - indicator: { - backgroundColor: '#3f51b5', - }, - })(Tabs) - - interface StyledTabProps { - label: string - } - - const AntTab = withStyles((theme: Theme) => - createStyles({ - root: { - textTransform: 'none', - minWidth: 72, - backgroundColor: 'transparent', - fontWeight: theme.typography.fontWeightRegular, - marginRight: theme.spacing(4), - fontFamily: [ - '-apple-system', - 'BlinkMacSystemFont', - '"Segoe UI"', - 'Roboto', - '"Helvetica Neue"', - 'Arial', - 'sans-serif', - '"Apple Color Emoji"', - '"Segoe UI Emoji"', - '"Segoe UI Symbol"', - ].join(','), - '&:hover': { - color: '#3f51b5', - opacity: 1, - }, - '&$selected': { - color: '#3f51b5', - fontWeight: theme.typography.fontWeightMedium, - }, - '&:focus': { - color: '#3f51b5', - }, - }, - }), - )((props: StyledTabProps) => ) + if (nodeHealth?.status !== 'ok' || !health) return return ( -
- { - // FIXME: this should be broken up - /* eslint-disable no-nested-ternary */ - nodeHealth?.status === 'ok' && health ? ( -
- - -
- - - - - - - - - - - - - - -
-
- ) : isLoadingHealth || isLoadingNodeHealth ? ( - - - - ) : ( - - ) /* eslint-enable no-nested-ternary */ - } +
+ + + {error && ( + + Error loading accountin details: {error.message} + + )} + {!error && }
) } diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index 5b75ca4..9aba9fd 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -21,7 +21,17 @@ interface StatusEthereumConnectionHook extends StatusHookCommon { interface StatusTopologyHook extends StatusHookCommon { topology: Topology | null } + interface StatusChequebookHook extends StatusHookCommon { chequebookBalance: ChequebookBalanceResponse | null chequebookAddress: ChequebookAddressResponse | null } + +interface Accounting { + peer: string + uncashedAmount: number + balance: number + received: number + sent: number + total: number +}