diff --git a/frontend/src/app/core/pages/contracts-create/index.tsx b/frontend/src/app/core/pages/contracts-create/index.tsx index fbf5fc4c..fc15d95b 100644 --- a/frontend/src/app/core/pages/contracts-create/index.tsx +++ b/frontend/src/app/core/pages/contracts-create/index.tsx @@ -67,7 +67,15 @@ export const ContractsCreate: React.FC = () => { const codVault = ContractsService.loadAccount(vault.wallet.key.publicKey) const codTxInvocation = ContractsService.getTxInvocation( codVault, - INNER_FEE + INNER_FEE, + /*{ + signers: [opex], + header: { + fee: BUMP_FEE, + source: opex.getPublicKey(), + timeout: 60, + }, + }*/ ) const codClient = new StellarPlus.Contracts.CertificateOfDeposit({ @@ -95,7 +103,7 @@ export const ContractsCreate: React.FC = () => { if (!codContractId) throw new Error('Invalid Contract ID') const contract = { - name: 'Yield-bearing asset', + name: 'Yield-bearing Asset', asset_id: asset.id.toString(), vault_id: vault.id.toString(), address: codClient.getContractId() || '', diff --git a/frontend/src/app/core/pages/cost-center/index.tsx b/frontend/src/app/core/pages/cost-center/index.tsx new file mode 100644 index 00000000..fb3b32e6 --- /dev/null +++ b/frontend/src/app/core/pages/cost-center/index.tsx @@ -0,0 +1,303 @@ +import { Flex, VStack, useMediaQuery } from '@chakra-ui/react' +import React, { useCallback, useEffect, useState } from 'react' + +import { useAssets } from 'hooks/useAssets' +import { useAuth } from 'hooks/useAuth' +import { useHorizon } from 'hooks/useHorizon' +import { useTransactions } from 'hooks/useTransactions' +import { useVaults } from 'hooks/useVaults' +import { GAService } from 'utils/ga' + +import { PathRoute } from 'components/enums/path-route' +import { SettingsOptions } from 'components/enums/settings-options' +import { MenuSettings } from 'components/organisms/menu-settings' +import { Sidebar } from 'components/organisms/sidebar' +import { CostCenterTemplate } from 'components/templates/cost-center' + +export interface IHorizonData { + name: string + type: string + asset: string +} + +export const CostCenter: React.FC = () => { + const [isLargerThanMd] = useMediaQuery('(min-width: 768px)') + const [sponsorAccount, setSponsorAccount] = useState() + const [transactions, setTransactions] = + useState() + const [accountData, setAccountData] = + useState() + const [latestFeeCharged, setLatestFeeCharged] = useState() + const [mostRepeatedType, setMostRepeatedType] = useState() + + const [vaults, setVaults] = useState() + const [assets, setAssets] = useState() + const [historyTransactions, setHistoryTransactions] = useState< + Hooks.UseHorizonTypes.ITransactions[] + >([]) + + const { userPermissions, getUserPermissions } = useAuth() + const { getSponsorPK } = useTransactions() + const { getTransactions, getAccount } = useHorizon() + const { getVaults } = useVaults() + const { getAssets } = useAssets() + + useEffect(() => { + GAService.GAPageView('Coast center') + }, []) + + useEffect(() => { + getUserPermissions() + getSponsorPK().then(sponsor => setSponsorAccount(sponsor)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + getVaults(true).then(vaults => setVaults(vaults)) + }, [getVaults]) + + useEffect(() => { + getAssets(true).then(assets => setAssets(assets)) + }, [getAssets]) + + useEffect(() => { + if (sponsorAccount) { + getTransactions(sponsorAccount).then(transactions => { + setTransactions(transactions) + setLatestFeeCharged( + transactions?._embedded.records.reduce( + (total, transaction) => total + Number(transaction.fee_charged), + 0 + ) + ) + }) + } + }, [sponsorAccount, getTransactions]) + + useEffect(() => { + if (sponsorAccount) { + getAccount(sponsorAccount).then(account => setAccountData(account)) + } + }, [sponsorAccount, getTransactions, getAccount]) + + const getTransactionsByLink = (action: 'prev' | 'next'): void => { + if (action === 'prev') { + const transactionsPrev = + historyTransactions[historyTransactions.length - 1] + setTransactions(transactionsPrev) + setHistoryTransactions(previous => previous.slice(0, -1)) + return + } + + const link = transactions?._links.next.href + if (link) { + setHistoryTransactions(history => [...history, transactions]) + getTransactions(undefined, link).then(transactions => { + setTransactions(transactions) + }) + } + } + + const formatAccount = (account: string): string => { + return `${account.substring(0, 4)}...${account.substring( + account.length - 4, + account.length + )}` + } + + const walletToName = useCallback((publicKey: string): string => { + if ( + assets && + assets.find(asset => asset.distributor.key.publicKey === publicKey) + ) { + return 'Asset issuer' + } + + return ( + vaults?.find(vault => vault.wallet.key.publicKey === publicKey)?.name || + formatAccount(publicKey) + ) + }, [assets, vaults]) + + const getTransactionData = useCallback( + (transaction: Hooks.UseHorizonTypes.ITransactionItem): IHorizonData => { + const accountCreated = transaction.operations?.find( + operation => operation.type === 'create_account' + ) + if (accountCreated) { + return { + name: walletToName(accountCreated.account || ''), + type: 'Account created', + asset: '-', + } + } + + const payment = transaction.operations?.find( + operation => operation.type === 'payment' + ) + + if ( + payment && + assets?.find(asset => asset.distributor.key.publicKey === payment.to) + ) { + return { + name: '-', + type: 'Minted', + asset: payment.asset_code || '-', + } + } + + if ( + payment && + assets?.find(asset => asset.distributor.key.publicKey === payment.from) + ) { + return { + name: '-', + type: assets?.find(asset => asset.issuer.key.publicKey === payment.to) + ? 'Burned' + : 'Distribute', + asset: payment.asset_code || '-', + } + } + + if (payment) { + return { + name: `${walletToName(payment.from || '')} to ${walletToName( + payment.to || '-' + )}`, + type: 'Payment', + asset: payment.asset_code || '-', + } + } + + const contractInvoke = transaction.operations?.find( + operation => operation.type === 'invoke_host_function' + ) + if (contractInvoke) { + return { + name: walletToName(contractInvoke.source_account || ''), + type: 'Contract invoke', + asset: '-', + } + } + + const trustlineOperation = transaction.operations?.find( + operation => operation.type === 'set_trust_line_flags' + ) + + if (trustlineOperation) { + if (trustlineOperation.set_flags_s?.includes('authorized')) { + return { + name: walletToName(trustlineOperation.trustor || ''), + type: 'Authorized', + asset: trustlineOperation.asset_code || '-', + } + } + + if (trustlineOperation.clear_flags_s?.includes('authorized')) { + return { + name: walletToName(trustlineOperation.trustor || ''), + type: 'Freezed', + asset: trustlineOperation.asset_code || '-', + } + } + } + + const clawback = transaction.operations?.find( + operation => operation.type === 'clawback' + ) + + if (clawback) { + return { + name: walletToName(clawback.from || ''), + type: 'Clawbacked', + asset: clawback.asset_code || '-', + } + } + + const changeTrust = transaction.operations?.find( + operation => operation.type === 'change_trust' + ) + if (changeTrust) { + return { + name: walletToName(changeTrust.trustor || ''), + type: 'Change trustline', + asset: changeTrust.asset_code || '-', + } + } + + const contractRestore = transaction.operations?.find( + operation => operation.type === 'restore_footprint' + ) + if (contractRestore) { + return { + name: walletToName(contractRestore.source_account || ''), + type: 'Contract restore', + asset: '-', + } + } + + return { + name: '-', + type: '-', + asset: '-', + } + }, + [assets, walletToName] + ) + + useEffect(() => { + const counter: Record = {} + + transactions?._embedded.records.forEach(transaction => { + const transactionData = getTransactionData(transaction) + counter[transactionData.type] = (counter[transactionData.type] || 0) + 1 + }) + + let mostRepeatedType: string | undefined + let maxOccurrences = 0 + + for (const type in counter) { + if (counter[type] > maxOccurrences) { + maxOccurrences = counter[type] + mostRepeatedType = type + } + } + + setMostRepeatedType(mostRepeatedType) + }, [transactions, assets, vaults, getTransactionData]) + + return ( + + + + + + + {isLargerThanMd && ( + + + + )} + + + + ) +} diff --git a/frontend/src/app/core/routes/routes.ts b/frontend/src/app/core/routes/routes.ts index 8bf7ecd7..db28161b 100644 --- a/frontend/src/app/core/routes/routes.ts +++ b/frontend/src/app/core/routes/routes.ts @@ -7,6 +7,7 @@ import { ClawbackAsset } from '../pages/clawback-asset' import { Contracts } from '../pages/contracts' import { ContractsCreate } from '../pages/contracts-create' import { ContractsDetail } from '../pages/contracts-detail' +import { CostCenter } from '../pages/cost-center' import { Dashboards } from '../pages/dashboards' import { DistributeAsset } from '../pages/distribute-asset' import { ForgeAsset } from '../pages/forge-asset' @@ -133,4 +134,9 @@ export const coreRoutes: AppRoute[] = [ component: TomlFile, isPrivate: true, }, + { + path: PathRoute.COST_CENTER, + component: CostCenter, + isPrivate: true, + }, ] diff --git a/frontend/src/components/enums/path-route.ts b/frontend/src/components/enums/path-route.ts index 44980974..5b832a19 100644 --- a/frontend/src/components/enums/path-route.ts +++ b/frontend/src/components/enums/path-route.ts @@ -29,4 +29,5 @@ export enum PathRoute { TOKEN_MANAGEMENT = '/token-management', DASHBOARDS = '/dashboards', TOML_FILE = '/.well-known/stellar.toml', + COST_CENTER = '/cost-center', } diff --git a/frontend/src/components/enums/settings-options.ts b/frontend/src/components/enums/settings-options.ts index 6276be52..d6d46e55 100644 --- a/frontend/src/components/enums/settings-options.ts +++ b/frontend/src/components/enums/settings-options.ts @@ -2,4 +2,5 @@ export enum SettingsOptions { TEAM_MEMBERS = 'Team members', ROLE_PERMISSIONS = 'Role permissions', ROLES_MANAGE = 'Roles', + COST_CENTER = 'Cost Center' } diff --git a/frontend/src/components/icons/fonts/coins.svg b/frontend/src/components/icons/fonts/coins.svg new file mode 100644 index 00000000..afa03f6d --- /dev/null +++ b/frontend/src/components/icons/fonts/coins.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/icons/index.tsx b/frontend/src/components/icons/index.tsx index 35141e86..b71c5318 100644 --- a/frontend/src/components/icons/index.tsx +++ b/frontend/src/components/icons/index.tsx @@ -56,6 +56,7 @@ import { ReactComponent as TransferIcon } from './fonts/transfer.svg' import { ReactComponent as UsdcIcon } from './fonts/usdc.svg' import { ReactComponent as VaultIcon } from './fonts/vault.svg' import { ReactComponent as WalletIcon } from './fonts/wallet.svg' +import { ReactComponent as CoinsIcon } from './fonts/coins.svg' export { MenuIcon, @@ -116,4 +117,5 @@ export { ArrowForwardIcon, CircleCheckIcon, FileIcon, + CoinsIcon } diff --git a/frontend/src/components/molecules/contracts-breadcrumb/index.tsx b/frontend/src/components/molecules/contracts-breadcrumb/index.tsx index 7d0163de..6df99447 100644 --- a/frontend/src/components/molecules/contracts-breadcrumb/index.tsx +++ b/frontend/src/components/molecules/contracts-breadcrumb/index.tsx @@ -39,7 +39,7 @@ export const ContractsBreadcrumb: React.FC = ({ fill="gray.650" _dark={{ fill: 'white' }} > - Yield-bearing asset + Yield-bearing Asset diff --git a/frontend/src/components/organisms/menu-admin-mobile/index.tsx b/frontend/src/components/organisms/menu-admin-mobile/index.tsx index 2f5b014e..d510a0ce 100644 --- a/frontend/src/components/organisms/menu-admin-mobile/index.tsx +++ b/frontend/src/components/organisms/menu-admin-mobile/index.tsx @@ -5,18 +5,19 @@ import { ItemActionAssetMobile } from 'components/atoms/item-action-asset-mobile import { PathRoute } from 'components/enums/path-route' import { ChevronDownIcon, + CoinsIcon, MembersIcon, PermissionsIcon, RoleIcon, } from 'components/icons' interface IMenuAdminMobile { - selected: 'TEAM_MEMBERS' | 'ROLE_PERMISSIONS' | 'ROLES' + selected: 'TEAM_MEMBERS' | 'ROLE_PERMISSIONS' | 'ROLES' | 'COST_CENTER' } export const MenuAdminMobile: React.FC = ({ selected }) => { const isSelected = ( - page: 'TEAM_MEMBERS' | 'ROLE_PERMISSIONS' | 'ROLES' + page: 'TEAM_MEMBERS' | 'ROLE_PERMISSIONS' | 'ROLES' | 'COST_CENTER' ): boolean => { return selected === page } @@ -61,6 +62,13 @@ export const MenuAdminMobile: React.FC = ({ selected }) => { icon={} path={`${PathRoute.ROLES_MANAGE}`} /> + + } + path={`${PathRoute.COST_CENTER}`} + /> ) diff --git a/frontend/src/components/organisms/menu-settings/index.tsx b/frontend/src/components/organisms/menu-settings/index.tsx index 09e60ac2..fbcaeaf0 100644 --- a/frontend/src/components/organisms/menu-settings/index.tsx +++ b/frontend/src/components/organisms/menu-settings/index.tsx @@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom' import { PathRoute } from 'components/enums/path-route' import { SettingsOptions } from 'components/enums/settings-options' -import { MembersIcon, PermissionsIcon, RoleIcon } from 'components/icons' +import { CoinsIcon, MembersIcon, PermissionsIcon, RoleIcon } from 'components/icons' interface IMenuSettings { option: SettingsOptions @@ -74,6 +74,24 @@ export const MenuSettings: React.FC = ({ option }) => { > Roles + ) diff --git a/frontend/src/components/organisms/sidebar/index.tsx b/frontend/src/components/organisms/sidebar/index.tsx index d01ac7da..0b85d3da 100644 --- a/frontend/src/components/organisms/sidebar/index.tsx +++ b/frontend/src/components/organisms/sidebar/index.tsx @@ -43,7 +43,7 @@ const linkItems: ILinkItemProps[] = [ path: PathRoute.TOKEN_MANAGEMENT, }, { - name: 'Yield-bearing asset', + name: 'Yield-bearing Asset', icon: , path: PathRoute.SOROBAN_SMART_CONTRACTS, }, diff --git a/frontend/src/components/templates/contracts-create/index.tsx b/frontend/src/components/templates/contracts-create/index.tsx index da02c1b0..a653e10a 100644 --- a/frontend/src/components/templates/contracts-create/index.tsx +++ b/frontend/src/components/templates/contracts-create/index.tsx @@ -128,7 +128,7 @@ export const ContractsCreateTemplate: React.FC = ({ justifyContent="center" > - + {errorSubmit && ( @@ -148,7 +148,7 @@ export const ContractsCreateTemplate: React.FC = ({ _dark={{ fill: 'white', stroke: 'white', borderColor: 'black.800' }} > - New Yield-bearing asset + New Yield-bearing Asset {loading ? ( @@ -343,7 +343,7 @@ export const ContractsCreateTemplate: React.FC = ({ mt="1.5rem" isLoading={creatingContract} > - Create Yield-bearing asset + Create Yield-bearing Asset diff --git a/frontend/src/components/templates/contracts-detail/components/contract-info/index.tsx b/frontend/src/components/templates/contracts-detail/components/contract-info/index.tsx index ece1a830..3e3f88ee 100644 --- a/frontend/src/components/templates/contracts-detail/components/contract-info/index.tsx +++ b/frontend/src/components/templates/contracts-detail/components/contract-info/index.tsx @@ -29,7 +29,7 @@ export const ContractInfo: React.FC = ({ contract }) => { boxShadow="lower" _dark={{ bg: 'black.800' }} > - Yield-bearing asset + Yield-bearing Asset = ({ - Yield-bearing asset + Yield-bearing Asset {havePermission(Permissions.CREATE_CERTIFICATES, userPermissions) && ( )} diff --git a/frontend/src/components/templates/cost-center/components/opex-card/index.tsx b/frontend/src/components/templates/cost-center/components/opex-card/index.tsx new file mode 100644 index 00000000..5f88ce33 --- /dev/null +++ b/frontend/src/components/templates/cost-center/components/opex-card/index.tsx @@ -0,0 +1,119 @@ +import { Container, Flex, Progress, Text } from '@chakra-ui/react' +import React from 'react' + +import { toCrypto } from 'utils/formatter' + +interface IOpexCard { + accountData: Hooks.UseHorizonTypes.IAccount | undefined + latestFeeCharged: number | undefined + mostRepeatedType: string | undefined +} + +export const OpexCard: React.FC = ({ + accountData, + latestFeeCharged, + mostRepeatedType, +}) => { + const BASE_RESERVE = 0.5 + + const getNativeBalance = (): Hooks.UseHorizonTypes.IBalance | undefined => { + return accountData?.balances.find( + balance => balance.asset_type === 'native' + ) + } + + const getReserved = (): number => { + const subentry = + Number(accountData?.subentry_count || 0) + + Number(accountData?.num_sponsoring || 0) + const lockedReserves = subentry * BASE_RESERVE + 1 + return lockedReserves + } + + const getProgress = (): number => { + const totalBalance = Number(getNativeBalance()?.balance || 0) + const reserved = getReserved() + const result = ((totalBalance - reserved) / totalBalance) * 100 + return result + } + + return ( + <> + {!accountData ? ( +
+ ) : ( + + Sandbox Expenses account + + Operating xpenses account + + + {accountData.account_id || '-'} + + + Account balance + + + + + {`${toCrypto( + Number(getNativeBalance()?.balance) - getReserved() + )} XLM avaliable / ${toCrypto(getReserved())} XLM reserved`} + + + + {`Total balance ${toCrypto( + Number(getNativeBalance()?.balance) + )} XLM`} + + + + + + + Total sponsored reserves + + + {accountData.num_sponsoring} + + + + + Average Fee Charged + + (based on the last 10 transactions) + + {`${toCrypto( + (latestFeeCharged || 0) / 10, + undefined, + true + )} XLM`} + + + + + Main type of transaction + + (based on the last 10 transactions) + + {mostRepeatedType || '-'} + + + + + )} + + ) +} diff --git a/frontend/src/components/templates/cost-center/components/transactions-card/index.tsx b/frontend/src/components/templates/cost-center/components/transactions-card/index.tsx new file mode 100644 index 00000000..89ee1599 --- /dev/null +++ b/frontend/src/components/templates/cost-center/components/transactions-card/index.tsx @@ -0,0 +1,163 @@ +import { + Button, + Container, + Flex, + Table, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + useMediaQuery, +} from '@chakra-ui/react' +import React from 'react' + +import { formatDateFullClean, toCrypto } from 'utils/formatter' + +import { NavLeftIcon, NavRightIcon, SendedIcon } from 'components/icons' + +import { IHorizonData } from 'app/core/pages/cost-center' + +interface ITransactionsCard { + transactions: Hooks.UseHorizonTypes.ITransactions | undefined + isPrevDisabled: boolean + getTransactionsByLink(link: 'prev' | 'next'): void + getTransactionData( + transaction: Hooks.UseHorizonTypes.ITransactionItem + ): IHorizonData +} + +export const TransactionsCard: React.FC = ({ + transactions, + isPrevDisabled, + getTransactionsByLink, + getTransactionData, +}) => { + const [isLargerThanSm] = useMediaQuery('(min-width: 480px)') + + return ( + + Transaction's history + {isLargerThanSm ? ( + + + + + + + + + + + + {transactions?._embedded.records.map(transaction => { + const transactionData = getTransactionData(transaction) + return ( + + + + + + + + + ) + })} + +
+ TransactionDateAssetAccountsCovered fee
+ + {transactionData.type} + + {formatDateFullClean(transaction.created_at)} + + {transactionData.asset}{transactionData.name}{`${toCrypto( + Number(transaction.fee_charged), + undefined, + true + )} XLM`}
+ ) : ( + + {transactions?._embedded.records?.map((transaction, index) => { + const transactionData = getTransactionData(transaction) + return ( + + + {formatDateFullClean(transaction.created_at)} + + + Transaction + {`${transactionData.type}`} + + + Asset + {transactionData.asset} + + + Accounts + {`${transactionData.name}`} + + + Fee charged + {`${toCrypto( + Number(transaction.fee_charged), + undefined, + true + )} XLM`} + + + ) + })} + + )} + + + + +
+ ) +} diff --git a/frontend/src/components/templates/cost-center/index.tsx b/frontend/src/components/templates/cost-center/index.tsx new file mode 100644 index 00000000..e40699c4 --- /dev/null +++ b/frontend/src/components/templates/cost-center/index.tsx @@ -0,0 +1,69 @@ +import { Flex, Text, useMediaQuery } from '@chakra-ui/react' +import React from 'react' + +import { MAX_PAGE_WIDTH } from 'utils/constants/sizes' + +import { OpexCard } from './components/opex-card' +import { TransactionsCard } from './components/transactions-card' +import { MenuAdminMobile } from 'components/organisms/menu-admin-mobile' + +import { IHorizonData } from 'app/core/pages/cost-center' + +interface ICostCenterTemplate { + transactions: Hooks.UseHorizonTypes.ITransactions | undefined + userPermissions: Hooks.UseAuthTypes.IUserPermission[] | undefined + accountData: Hooks.UseHorizonTypes.IAccount | undefined + sponsorAccount: string | undefined + latestFeeCharged: number | undefined + vaults: Hooks.UseVaultsTypes.IVault[] | undefined + assets: Hooks.UseAssetsTypes.IAssetDto[] | undefined + isPrevDisabled: boolean + mostRepeatedType: string | undefined + getTransactionsByLink(link: 'prev' | 'next'): void + getTransactionData( + transaction: Hooks.UseHorizonTypes.ITransactionItem + ): IHorizonData +} + +export const CostCenterTemplate: React.FC = ({ + transactions, + accountData, + latestFeeCharged, + isPrevDisabled, + mostRepeatedType, + getTransactionsByLink, + getTransactionData +}) => { + const [isSmallerThanMd] = useMediaQuery('(max-width: 768px)') + + return ( + + + + + Administration + + {isSmallerThanMd && } + + + + + + + + ) +} diff --git a/frontend/src/components/templates/login/components/overview/index.tsx b/frontend/src/components/templates/login/components/overview/index.tsx index f8040a36..e9ffc66e 100644 --- a/frontend/src/components/templates/login/components/overview/index.tsx +++ b/frontend/src/components/templates/login/components/overview/index.tsx @@ -140,7 +140,7 @@ export const Overview: React.FC = ({
- Create Yield-bearing asset + Create Yield-bearing Asset In the Sandbox you can define permissions for each type of role diff --git a/frontend/src/components/templates/role-permissions-template/index.tsx b/frontend/src/components/templates/role-permissions-template/index.tsx index e6333c90..940db641 100644 --- a/frontend/src/components/templates/role-permissions-template/index.tsx +++ b/frontend/src/components/templates/role-permissions-template/index.tsx @@ -18,6 +18,7 @@ import React, { Dispatch, SetStateAction } from 'react' import { MAX_PAGE_WIDTH } from 'utils/constants/sizes' +import { InfoTag } from 'components/atoms/info-tag' import { MenuAdminMobile } from 'components/organisms/menu-admin-mobile' import { IChange } from 'app/core/pages/role-permissions' @@ -108,11 +109,12 @@ export const RolePermissionsTemplate: React.FC = ({ > Permissions +