diff --git a/centrifuge-app/src/components/Charts/CashflowsChart.tsx b/centrifuge-app/src/components/Charts/CashflowsChart.tsx new file mode 100644 index 0000000000..6af6183751 --- /dev/null +++ b/centrifuge-app/src/components/Charts/CashflowsChart.tsx @@ -0,0 +1,217 @@ +import { CurrencyBalance, DailyPoolState, Pool } from '@centrifuge/centrifuge-js' +import { Box, Grid, Shelf, Stack, Text } from '@centrifuge/fabric' +import capitalize from 'lodash/capitalize' +import startCase from 'lodash/startCase' +import * as React from 'react' +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' +import styled, { useTheme } from 'styled-components' +import { daysBetween, formatDate } from '../../utils/date' +import { formatBalance, formatBalanceAbbreviated } from '../../utils/formatting' +import { TinlakePool } from '../../utils/tinlake/useTinlakePools' +import { TooltipContainer, TooltipTitle } from '../Charts/Tooltip' +import { getRangeNumber } from './utils' + +type Props = { + poolStates?: DailyPoolState[] + pool: Pool | TinlakePool +} + +const RangeFilterButton = styled(Stack)` + &:hover { + cursor: pointer; + } +` + +const rangeFilters = [ + { value: '30d', label: '30 days' }, + { value: '90d', label: '90 days' }, + { value: 'ytd', label: 'Year to date' }, + { value: 'all', label: 'All' }, +] as const + +export const CashflowsChart = ({ poolStates, pool }: Props) => { + const theme = useTheme() + const [range, setRange] = React.useState<(typeof rangeFilters)[number]>({ value: 'ytd', label: 'Year to date' }) + + const poolAge = pool.createdAt ? daysBetween(pool.createdAt, new Date()) : 0 + const rangeNumber = getRangeNumber(range.value, poolAge) + + const data = React.useMemo( + () => + poolStates?.map((day) => { + const purchases = day.sumBorrowedAmountByPeriod + ? new CurrencyBalance(day.sumBorrowedAmountByPeriod, pool.currency.decimals).toDecimal().toNumber() + : 0 + const principalRepayments = day.sumRepaidAmountByPeriod + ? new CurrencyBalance(day.sumRepaidAmountByPeriod, pool.currency.decimals).toDecimal().toNumber() + : 0 + const interest = day.sumInterestRepaidAmountByPeriod + ? new CurrencyBalance(day.sumInterestRepaidAmountByPeriod, pool.currency.decimals).toDecimal().toNumber() + : 0 + return { name: new Date(day.timestamp), purchases, principalRepayments, interest } + }) || [], + [poolStates, pool.currency.decimals] + ) + + const chartData = data.slice(-rangeNumber) + + const today = { + totalPurchases: data.reduce((acc, cur) => acc + cur.purchases, 0), + interest: data.reduce((acc, cur) => acc + cur.interest, 0), + principalRepayments: data.reduce((acc, cur) => acc + cur.principalRepayments, 0), + } + + const getXAxisInterval = () => { + if (rangeNumber <= 30) return 5 + if (rangeNumber > 30 && rangeNumber <= 90) { + return 14 + } + if (rangeNumber > 90 && rangeNumber <= 180) { + return 30 + } + return 45 + } + + return ( + + + + + {chartData.length > 0 && + rangeFilters.map((rangeFilter, index) => ( + + setRange(rangeFilter)}> + + {rangeFilter.label} + + + + {index !== rangeFilters.length - 1 && ( + + )} + + ))} + + + + + + + { + if (data.length > 180) { + return new Date(tick).toLocaleString('en-US', { month: 'short' }) + } + return new Date(tick).toLocaleString('en-US', { day: 'numeric', month: 'short' }) + }} + interval={getXAxisInterval()} + /> + formatBalanceAbbreviated(tick, '', 0)} + /> + { + if (payload) { + return ( + + {formatDate(label)} + {payload.map(({ color, name, value }, index) => { + return ( + + + + + + {typeof name === 'string' ? capitalize(startCase(name)) : '-'} + + + + {typeof value === 'number' ? formatBalance(value, 'USD', 2, 2) : '-'} + + + + ) + })} + + ) + } + return null + }} + /> + + + + {/* */} + + + + + ) +} + +function CustomLegend({ + data, +}: { + data: { + totalPurchases: number + principalRepayments: number + interest: number + } +}) { + const theme = useTheme() + + return ( + + + + + Total purchases + + {formatBalance(data.totalPurchases, 'USD', 2)} + + + + Principal repayments + + {formatBalance(data.principalRepayments, 'USD', 2)} + + + + Interest + + {formatBalance(data.interest, 'USD', 2)} + + {/* + + Fees + + {formatBalance(0, 'USD', 2)} + */} + + + ) +} diff --git a/centrifuge-app/src/components/Charts/PoolAssetReserveChart.tsx b/centrifuge-app/src/components/Charts/PoolAssetReserveChart.tsx deleted file mode 100644 index 45171f6414..0000000000 --- a/centrifuge-app/src/components/Charts/PoolAssetReserveChart.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { Grid, Shelf, Stack, Text } from '@centrifuge/fabric' -import * as React from 'react' -import { useParams } from 'react-router' -import { Area, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' -import { useTheme } from 'styled-components' -import { daysBetween } from '../../utils/date' -import { formatBalance, formatBalanceAbbreviated } from '../../utils/formatting' -import { useDailyPoolStates, usePool } from '../../utils/usePools' -import { Tooltips } from '../Tooltips' -import { CustomizedTooltip } from './Tooltip' - -type ChartData = { - day: Date - poolValue: number - assetValue: number - reserve: [number, number] -} - -function PoolAssetReserveChart() { - const theme = useTheme() - const { pid: poolId } = useParams<{ pid: string }>() - const { poolStates } = useDailyPoolStates(poolId) || {} - const pool = usePool(poolId) - const poolAge = pool.createdAt ? daysBetween(pool.createdAt, new Date()) : 0 - - const data: ChartData[] = React.useMemo(() => { - return ( - poolStates?.map((day) => { - const assetValue = day.poolState.portfolioValuation.toDecimal().toNumber() - const poolValue = day.poolValue.toDecimal().toNumber() - return { day: new Date(day.timestamp), poolValue, assetValue, reserve: [assetValue, poolValue] } - }) || [] - ) - }, [poolStates]) - - if (poolStates && poolStates?.length < 1 && poolAge > 0) return No data available - - // querying chain for more accurate data, since data for today from subquery is not necessarily up to date - const todayPoolValue = pool?.value.toDecimal().toNumber() || 0 - const todayAssetValue = pool?.nav.latest.toDecimal().toNumber() || 0 - const today: ChartData = { - day: new Date(), - poolValue: todayPoolValue, - assetValue: todayAssetValue, - reserve: [todayAssetValue, todayPoolValue], - } - - const chartData = [...data.slice(0, data.length - 1), today] - - return ( - - - - {chartData?.length ? ( - - - { - if (data.length > 180) { - return new Date(tick).toLocaleString('en-US', { month: 'short' }) - } - return new Date(tick).toLocaleString('en-US', { day: 'numeric', month: 'short' }) - }} - style={{ fontSize: '10px', fill: theme.colors.textSecondary, letterSpacing: '-0.5px' }} - /> - formatBalanceAbbreviated(tick, '', 0)} - /> - - } /> - - - - - - ) : ( - No data yet - )} - - - ) -} - -function CustomLegend({ data, currency }: { currency: string; data: ChartData }) { - const theme = useTheme() - - return ( - - - - - {formatBalance(data.poolValue, currency)} - - - - {formatBalance(data.assetValue, currency)} - - - - {formatBalance(data.reserve[1] - data.reserve[0], currency)} - - - - ) -} - -export default PoolAssetReserveChart diff --git a/centrifuge-app/src/components/Charts/PoolPerformanceChart.tsx b/centrifuge-app/src/components/Charts/PoolPerformanceChart.tsx new file mode 100644 index 0000000000..6f2e82547a --- /dev/null +++ b/centrifuge-app/src/components/Charts/PoolPerformanceChart.tsx @@ -0,0 +1,235 @@ +import { Box, Grid, Shelf, Stack, Text } from '@centrifuge/fabric' +import * as React from 'react' +import { useParams } from 'react-router' +import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' +import styled, { useTheme } from 'styled-components' +import { daysBetween, formatDate } from '../../utils/date' +import { formatBalance, formatBalanceAbbreviated } from '../../utils/formatting' +import { useLoans } from '../../utils/useLoans' +import { useDailyPoolStates, usePool } from '../../utils/usePools' +import { TooltipContainer, TooltipTitle } from './Tooltip' +import { getRangeNumber } from './utils' + +type ChartData = { + day: Date + nav: number +} + +const RangeFilterButton = styled(Stack)` + &:hover { + cursor: pointer; + } +` + +const rangeFilters = [ + { value: '30d', label: '30 days' }, + { value: '90d', label: '90 days' }, + { value: 'ytd', label: 'Year to date' }, + { value: 'all', label: 'All' }, +] as const + +const chartColor = '#A4D5D8' + +function PoolPerformanceChart() { + const theme = useTheme() + const { pid: poolId } = useParams<{ pid: string }>() + const { poolStates } = useDailyPoolStates(poolId) || {} + const pool = usePool(poolId) + const poolAge = pool.createdAt ? daysBetween(pool.createdAt, new Date()) : 0 + const loans = useLoans(poolId) + + const firstOriginationDate = loans?.reduce((acc, cur) => { + if ('originationDate' in cur) { + if (!acc) return cur.originationDate + return acc < cur.originationDate ? acc : cur.originationDate + } + return acc + }, '') + + const truncatedPoolStates = poolStates?.filter((poolState) => { + if (firstOriginationDate) { + return new Date(poolState.timestamp) >= new Date(firstOriginationDate) + } + return true + }) + + const [range, setRange] = React.useState<(typeof rangeFilters)[number]>({ value: 'ytd', label: 'Year to date' }) + const rangeNumber = getRangeNumber(range.value, poolAge) + + const data: ChartData[] = React.useMemo( + () => + truncatedPoolStates?.map((day) => { + const nav = + day.poolState.portfolioValuation.toDecimal().toNumber() + day.poolState.totalReserve.toDecimal().toNumber() + + return { day: new Date(day.timestamp), nav } + }) || [], + [truncatedPoolStates] + ) + + if (truncatedPoolStates && truncatedPoolStates?.length < 1 && poolAge > 0) + return No data available + + // querying chain for more accurate data, since data for today from subquery is not necessarily up to date + const todayAssetValue = pool?.nav.latest.toDecimal().toNumber() || 0 + const todayReserve = pool?.reserve.total.toDecimal().toNumber() || 0 + + const chartData = data.slice(-rangeNumber) + + const today = { + nav: todayReserve + todayAssetValue, + navChange: chartData.length > 0 ? todayReserve + todayAssetValue - chartData[0]?.nav : 0, + } + + const getXAxisInterval = () => { + if (rangeNumber <= 30) return 5 + if (rangeNumber > 30 && rangeNumber <= 90) { + return 14 + } + if (rangeNumber > 90 && rangeNumber <= 180) { + return 30 + } + return 45 + } + + return ( + + + + + {chartData.length > 0 && + rangeFilters.map((rangeFilter, index) => ( + + setRange(rangeFilter)}> + + {rangeFilter.label} + + + + {index !== rangeFilters.length - 1 && ( + + )} + + ))} + + + + + {chartData?.length ? ( + + + + + + + + + { + if (data.length > 180) { + return new Date(tick).toLocaleString('en-US', { month: 'short' }) + } + return new Date(tick).toLocaleString('en-US', { day: 'numeric', month: 'short' }) + }} + style={{ fontSize: '10px', fill: theme.colors.textSecondary, letterSpacing: '-0.5px' }} + dy={4} + interval={getXAxisInterval()} + /> + formatBalanceAbbreviated(tick, '', 0)} + /> + + { + if (payload && payload?.length > 0) { + return ( + + {formatDate(payload[0].payload.day)} + {payload.map(({ value }, index) => ( + + NAV + + {typeof value === 'number' ? formatBalance(value, 'USD' || '') : '-'} + + + ))} + + ) + } + return null + }} + /> + + + + ) : ( + No data yet + )} + + + ) +} + +function CustomLegend({ + data, +}: { + data: { + nav: number + navChange: number + } +}) { + const theme = useTheme() + + // const navChangePercentageChange = (data.navChange / data.nav) * 100 + // const navChangePercentageChangeString = + // data.navChange === data.nav || navChangePercentageChange === 0 + // ? '' + // : ` (${navChangePercentageChange > 0 ? '+' : ''}${navChangePercentageChange.toFixed(2)}%)` + + return ( + + + + + NAV + + {formatBalance(data.nav, 'USD')} + + {/* + + NAV change + + 0 && 'statusOk'}> + {data.navChange > 0 && '+'} + {formatBalance(data.navChange, 'USD')} + {navChangePercentageChangeString} + + */} + + + ) +} + +export default PoolPerformanceChart diff --git a/centrifuge-app/src/components/Charts/utils.ts b/centrifuge-app/src/components/Charts/utils.ts new file mode 100644 index 0000000000..b279bcc8c3 --- /dev/null +++ b/centrifuge-app/src/components/Charts/utils.ts @@ -0,0 +1,23 @@ +export const getRangeNumber = (rangeValue: string, poolAge?: number) => { + if (rangeValue === '30d') { + return 30 + } + if (rangeValue === '90d') { + return 90 + } + + if (rangeValue === 'ytd') { + const today = new Date() + const januaryFirst = new Date(today.getFullYear(), 0, 1) + const timeDifference = new Date(today).getTime() - new Date(januaryFirst).getTime() + const daysSinceJanuary1 = Math.floor(timeDifference / (1000 * 60 * 60 * 24)) + + return daysSinceJanuary1 + 1 + } + + if (rangeValue === 'all' && poolAge) { + return poolAge + } + + return 30 +} diff --git a/centrifuge-app/src/components/IssuerSection.tsx b/centrifuge-app/src/components/IssuerSection.tsx index d606cf7dd3..942191d73a 100644 --- a/centrifuge-app/src/components/IssuerSection.tsx +++ b/centrifuge-app/src/components/IssuerSection.tsx @@ -2,97 +2,17 @@ import { PoolMetadata } from '@centrifuge/centrifuge-js' import { useCentrifuge } from '@centrifuge/centrifuge-react' import { Accordion, AnchorButton, Box, Card, Grid, IconExternalLink, Shelf, Stack, Text } from '@centrifuge/fabric' import * as React from 'react' -import styled from 'styled-components' import { ExecutiveSummaryDialog } from './Dialogs/ExecutiveSummaryDialog' import { LabelValueStack } from './LabelValueStack' -import { AnchorPillButton, PillButton } from './PillButton' +import { PillButton } from './PillButton' import { AnchorTextLink } from './TextLink' type IssuerSectionProps = { metadata: Partial | undefined } - export function IssuerSection({ metadata }: IssuerSectionProps) { const cent = useCentrifuge() - const [isDialogOpen, setIsDialogOpen] = React.useState(false) - - return ( - <> - - - - {metadata?.pool?.issuer.logo && ( - - )} - - {metadata?.pool?.issuer.description} - - - {metadata?.pool?.links.executiveSummary && ( - - setIsDialogOpen(true)}> - Executive summary - - setIsDialogOpen(false)} - /> - - } - /> - )} - - {(metadata?.pool?.links.website || metadata?.pool?.links.forum || metadata?.pool?.issuer.email) && ( - - {metadata?.pool?.links.website && ( - - - Website - - - )} - {metadata?.pool?.links.forum && ( - - - Forum - - - )} - {metadata?.pool?.issuer.email && ( - - - Email - - - )} - - } - /> - )} - - - {!!metadata?.pool?.details?.length && } - - ) -} - -const StyledImage = styled.img` - min-height: 104px; - min-width: 100px; - max-height: 104px; -` - -export function IssuerSectionNew({ metadata }: IssuerSectionProps) { - const cent = useCentrifuge() const report = metadata?.pool?.reports?.[0] return ( @@ -133,20 +53,53 @@ export function IssuerSectionNew({ metadata }: IssuerSectionProps) { ) } +export function ReportDetails({ metadata }: IssuerSectionProps) { + const cent = useCentrifuge() + const report = metadata?.pool?.reports?.[0] + return ( + report && ( + <> + + {report.author.avatar?.uri && ( + + )} + + Reviewer: {report.author.name} +
+ {report.author.title} +
+
+
+ + View full report + +
+ + ) + ) +} + export function IssuerDetails({ metadata }: IssuerSectionProps) { const cent = useCentrifuge() const [isDialogOpen, setIsDialogOpen] = React.useState(false) return ( <> - - {metadata?.pool?.issuer.logo && ( - {metadata?.pool?.issuer.name} - )} - + {metadata?.pool?.issuer.logo && ( + + )} {metadata?.pool?.issuer.name} {metadata?.pool?.issuer.description} diff --git a/centrifuge-app/src/components/LoanList.tsx b/centrifuge-app/src/components/LoanList.tsx index 92394e5609..6d0f318664 100644 --- a/centrifuge-app/src/components/LoanList.tsx +++ b/centrifuge-app/src/components/LoanList.tsx @@ -15,6 +15,7 @@ import get from 'lodash/get' import * as React from 'react' import { useParams, useRouteMatch } from 'react-router' import currencyDollar from '../assets/images/currency-dollar.svg' +import daiLogo from '../assets/images/dai-logo.svg' import usdcLogo from '../assets/images/usdc-logo.svg' import { formatNftAttribute } from '../pages/Loan/utils' import { nftMetadataSchema } from '../schemas' @@ -72,7 +73,7 @@ export function LoanList({ loans }: Props) { const { data: templateMetadata } = useMetadata(templateId) const loansWithLabelStatus = React.useMemo(() => { return loans - .filter((loan) => 'valuationMethod' in loan.pricing && loan.pricing.valuationMethod !== 'cash') + .filter((loan) => isTinlakePool || ('valuationMethod' in loan.pricing && loan.pricing.valuationMethod !== 'cash')) .map((loan) => ({ ...loan, labelStatus: getLoanStatus(loan), @@ -265,7 +266,7 @@ function AssetMetadataField({ loan, name, attribute }: { loan: Row; name: string variant="body2" style={{ overflow: 'hidden', maxWidth: '300px', textOverflow: 'ellipsis' }} > - {formatNftAttribute(metadata?.properties?.[name], attribute)} + {metadata?.properties?.[name] ? formatNftAttribute(metadata?.properties?.[name], attribute) : '-'} ) @@ -279,7 +280,7 @@ function AssetName({ loan }: { loan: Row }) { return ( - + { + return ( + + Asset By Maturity + + ) +} diff --git a/centrifuge-app/src/components/PoolOverview/Cashflows.tsx b/centrifuge-app/src/components/PoolOverview/Cashflows.tsx new file mode 100644 index 0000000000..11335d3959 --- /dev/null +++ b/centrifuge-app/src/components/PoolOverview/Cashflows.tsx @@ -0,0 +1,92 @@ +import { CurrencyBalance } from '@centrifuge/centrifuge-js' +import { AnchorButton, Card, IconDownload, Shelf, Stack, Text } from '@centrifuge/fabric' +import { useParams } from 'react-router' +import { formatDate } from '../../utils/date' +import { formatBalance } from '../../utils/formatting' +import { getCSVDownloadUrl } from '../../utils/getCSVDownloadUrl' +import { useLoans } from '../../utils/useLoans' +import { useDailyPoolStates, usePool } from '../../utils/usePools' +import { CashflowsChart } from '../Charts/CashflowsChart' + +export const Cashflows = () => { + const { pid: poolId } = useParams<{ pid: string }>() + const { poolStates } = useDailyPoolStates(poolId) || {} + const pool = usePool(poolId) + const loans = useLoans(poolId) + + const firstOriginationDate = loans?.reduce((acc, cur) => { + if ('originationDate' in cur) { + if (!acc) return cur.originationDate + return acc < cur.originationDate ? acc : cur.originationDate + } + return acc + }, '') + + const truncatedPoolStates = poolStates?.filter((poolState) => { + if (firstOriginationDate) { + return new Date(poolState.timestamp) >= new Date(firstOriginationDate) + } + return true + }) + + const csvData = truncatedPoolStates?.map((poolState) => { + return { + Date: `"${formatDate(poolState.timestamp, { + year: 'numeric', + month: 'long', + day: 'numeric', + })}"`, + Purchases: poolState.sumBorrowedAmountByPeriod + ? `"${formatBalance( + new CurrencyBalance(poolState.sumBorrowedAmountByPeriod, pool.currency.decimals).toDecimal().toNumber(), + 'USD', + 2, + 2 + )}"` + : '-', + 'Principal repayments': poolState.sumRepaidAmountByPeriod + ? `"${formatBalance( + new CurrencyBalance(poolState.sumRepaidAmountByPeriod, pool.currency.decimals).toDecimal().toNumber(), + 'USD', + 2, + 2 + )}"` + : '-', + Interest: poolState.sumInterestRepaidAmountByPeriod + ? `"${formatBalance( + new CurrencyBalance(poolState.sumInterestRepaidAmountByPeriod, pool.currency.decimals) + .toDecimal() + .toNumber(), + 'USD', + 2, + 2 + )}"` + : '-', + } + }) + + const csvUrl = csvData?.length ? getCSVDownloadUrl(csvData) : '' + + return ( + + + + + Cashflows + + + Download + + + + + + ) +} diff --git a/centrifuge-app/src/components/PoolOverview/KeyMetrics.tsx b/centrifuge-app/src/components/PoolOverview/KeyMetrics.tsx new file mode 100644 index 0000000000..b96bdd4f83 --- /dev/null +++ b/centrifuge-app/src/components/PoolOverview/KeyMetrics.tsx @@ -0,0 +1,107 @@ +import { ActiveLoan, Loan, TinlakeLoan } from '@centrifuge/centrifuge-js' +import { Box, Card, Grid, Stack, Text } from '@centrifuge/fabric' +import capitalize from 'lodash/capitalize' +import startCase from 'lodash/startCase' +import { daysBetween } from '../../utils/date' + +type Props = { + assetType?: { class: string; subClass: string } + averageMaturity: string + loans: TinlakeLoan[] | Loan[] | null | undefined + poolId: string +} + +export const KeyMetrics = ({ assetType, averageMaturity, loans, poolId }: Props) => { + const ongoingAssetCount = + loans && [...loans].filter((loan) => loan.status === 'Active' && !loan.outstandingDebt.isZero()).length + + const writtenOffAssetCount = + loans && [...loans].filter((loan) => loan.status === 'Active' && (loan as ActiveLoan).writeOffStatus).length + + const overdueAssetCount = + loans && + [...loans].filter((loan) => { + const today = new Date() + today.setUTCHours(0, 0, 0, 0) + const days = daysBetween(today, loan.pricing.maturityDate) + return loan.status === 'Active' && loan.pricing.maturityDate && days < 0 + }).length + + const isBT3BT4 = + poolId.toLowerCase() === '0x90040f96ab8f291b6d43a8972806e977631affde' || + poolId.toLowerCase() === '0x55d86d51ac3bcab7ab7d2124931fba106c8b60c7' + + const metrics = [ + { + metric: 'Asset class', + value: `${capitalize(startCase(assetType?.class)).replace(/^Us /, 'US ')} - ${capitalize( + startCase(assetType?.subClass) + ).replace(/^Us /, 'US ')}`, + }, + ...(isBT3BT4 + ? [] + : [ + { + metric: 'Average asset maturity', + value: averageMaturity, + }, + ]), + { + metric: 'Total assets', + value: loans?.length || 0, + }, + { + metric: 'Ongoing assets', + value: ongoingAssetCount, + }, + ...(writtenOffAssetCount + ? [ + { + metric: 'Written off assets', + value: writtenOffAssetCount, + }, + ] + : []), + ...(overdueAssetCount + ? [ + { + metric: 'Overdue assets', + value: overdueAssetCount, + }, + ] + : []), + ] + + return ( + + + + Key metrics + + + {metrics.map(({ metric, value }, index) => ( + + + {metric} + + + {value} + + + ))} + + + + ) +} diff --git a/centrifuge-app/src/components/PoolOverview/PoolPerfomance.tsx b/centrifuge-app/src/components/PoolOverview/PoolPerfomance.tsx new file mode 100644 index 0000000000..16c10a7cc8 --- /dev/null +++ b/centrifuge-app/src/components/PoolOverview/PoolPerfomance.tsx @@ -0,0 +1,15 @@ +import { Card, Stack, Text } from '@centrifuge/fabric' +import PoolPerformanceChart from '../Charts/PoolPerformanceChart' + +export const PoolPerformance = () => { + return ( + + + + Pool performance + + + + + ) +} diff --git a/centrifuge-app/src/components/PoolOverview/PoolStructure.tsx b/centrifuge-app/src/components/PoolOverview/PoolStructure.tsx new file mode 100644 index 0000000000..0ec50d64b6 --- /dev/null +++ b/centrifuge-app/src/components/PoolOverview/PoolStructure.tsx @@ -0,0 +1,126 @@ +import { getChainInfo, useWallet } from '@centrifuge/centrifuge-react' +import { Box, Card, Grid, Stack, Text, Tooltip } from '@centrifuge/fabric' +import capitalize from 'lodash/capitalize' +import { useActiveDomains } from '../../utils/useLiquidityPools' +import { useInvestorTransactions } from '../../utils/usePools' + +type Props = { + numOfTranches: number + poolId: string + poolStatus?: string +} + +export const PoolStructure = ({ numOfTranches, poolId, poolStatus }: Props) => { + const investorTransactions = useInvestorTransactions(poolId) + const { data: domains } = useActiveDomains(poolId) + const { + evm: { chains }, + } = useWallet() + + const firstInvestment = investorTransactions?.find( + (investorTransaction) => investorTransaction.type === 'INVEST_EXECUTION' + )?.timestamp + const deployedLpChains = + domains?.map((domain) => { + return getChainInfo(chains, domain.chainId).name + }) ?? [] + + const metrics = [ + { + metric: 'Pool type', + value: capitalize(poolStatus), + }, + { + metric: 'Pool structure', + value: 'Revolving pool', + }, + { + metric: 'Tranche structure', + value: numOfTranches === 1 ? 'Unitranche' : `${numOfTranches} tranches`, + }, + // { + // metric: 'First investment', + // value: firstInvestment ? formatDate(firstInvestment) : '-', + // }, + { + metric: 'Available networks', + value: `Centrifuge${deployedLpChains.length ? `, ${deployedLpChains.join(', ')}` : ''}`, + }, + // { + // metric: 'Protocol fee', + // value: '1% of NAV', // TODO: get fees + // }, + // { + // metric: 'Priority fee', + // value: '1% of NAV', // TODO: get fees + // }, + // { + // metric: 'Manangement fee', + // value: '1% of NAV', // TODO: get fees + // }, + ] + + const getValue = (metric: string, value: string) => { + if (metric === 'Pool structure') + return ( + + + {value} + + + ) + if (metric === 'Tranche structure') + return ( + + + {value} + + + ) + + if (metric === 'Pool type' && value === 'Open') + return ( + + + Open + + + ) + return ( + + {value} + + ) + } + + return ( + + + + Structure + + + {metrics.map(({ metric, value }, index) => ( + + + {metric} + + {getValue(metric, value)} + + ))} + + + + ) +} diff --git a/centrifuge-app/src/components/TrancheTokenCards.tsx b/centrifuge-app/src/components/PoolOverview/TrancheTokenCards.tsx similarity index 87% rename from centrifuge-app/src/components/TrancheTokenCards.tsx rename to centrifuge-app/src/components/PoolOverview/TrancheTokenCards.tsx index 1d45e6c283..6a4ae52dbf 100644 --- a/centrifuge-app/src/components/TrancheTokenCards.tsx +++ b/centrifuge-app/src/components/PoolOverview/TrancheTokenCards.tsx @@ -1,9 +1,9 @@ import { Perquintill } from '@centrifuge/centrifuge-js' import { Box, Shelf, Stack, Text } from '@centrifuge/fabric' -import { InvestButton, Token } from '../pages/Pool/Overview' -import { daysBetween } from '../utils/date' -import { formatBalance, formatPercentage } from '../utils/formatting' -import { Tooltips } from './Tooltips' +import { InvestButton, Token } from '../../pages/Pool/Overview' +import { daysBetween } from '../../utils/date' +import { formatBalance, formatPercentage } from '../../utils/formatting' +import { Tooltips } from '../Tooltips' export const TrancheTokenCards = ({ trancheTokens, @@ -62,14 +62,9 @@ const TrancheTokenCard = ({ }` const calculateApy = () => { - if (isTinlakePool && trancheText === 'senior') { - return formatPercentage(trancheToken.apy) - } - - if (daysSinceCreation < 30 || !trancheToken.yield30DaysAnnualized) { - return 'N/A' - } - + if (poolId === '4139607887') return formatPercentage(5) + if (isTinlakePool && trancheText === 'senior') return formatPercentage(trancheToken.apy) + if (daysSinceCreation < 30 || !trancheToken.yield30DaysAnnualized) return 'N/A' return formatPercentage(new Perquintill(trancheToken.yield30DaysAnnualized)) } @@ -82,7 +77,7 @@ const TrancheTokenCard = ({ - + {calculateApy()} diff --git a/centrifuge-app/src/components/PoolOverview/TransactionHistory.tsx b/centrifuge-app/src/components/PoolOverview/TransactionHistory.tsx new file mode 100644 index 0000000000..4c3e10d801 --- /dev/null +++ b/centrifuge-app/src/components/PoolOverview/TransactionHistory.tsx @@ -0,0 +1,214 @@ +import { AssetTransaction, AssetTransactionType, AssetType, CurrencyBalance } from '@centrifuge/centrifuge-js' +import { AnchorButton, IconDownload, IconExternalLink, Shelf, Stack, StatusChip, Text } from '@centrifuge/fabric' +import BN from 'bn.js' +import { nftMetadataSchema } from '../../schemas' +import { formatDate } from '../../utils/date' +import { formatBalance } from '../../utils/formatting' +import { getCSVDownloadUrl } from '../../utils/getCSVDownloadUrl' +import { useMetadataMulti } from '../../utils/useMetadata' +import { useAssetTransactions } from '../../utils/usePools' +import { DataTable, SortableTableHeader } from '../DataTable' +import { AnchorTextLink } from '../TextLink' + +type Row = { + type: string + transactionDate: string + assetId: string + amount: CurrencyBalance | undefined + hash: string + assetName: string +} + +const getTransactionTypeStatus = (type: string): 'default' | 'info' | 'ok' | 'warning' | 'critical' => { + return 'default' +} + +export const columns = [ + { + align: 'left', + header: 'Type', + cell: ({ type }: Row) => {type}, + }, + { + align: 'left', + header: , + cell: ({ transactionDate }: Row) => ( + + {formatDate(transactionDate)} + + ), + sortKey: 'transactionDate', + }, + { + align: 'left', + header: 'Asset name', + cell: ({ assetId, assetName }: Row) => { + const [poolId, id] = assetId.split('-') + return ( + + {assetName} + + ) + }, + }, + { + align: 'right', + header: , + cell: ({ amount }: Row) => ( + + {amount ? formatBalance(amount, 'USD', 2, 2) : ''} + + ), + sortKey: 'amount', + }, + { + align: 'right', + header: 'View transaction', + cell: ({ hash }: Row) => { + return ( + + + + ) + }, + }, +] + +export const TransactionHistory = ({ poolId, preview = true }: { poolId: string; preview?: boolean }) => { + const transactions = useAssetTransactions(poolId, new Date(0)) + + const assetMetadata = useMetadataMulti( + [...new Set(transactions?.map((transaction) => transaction.asset.metadata))] || [], + nftMetadataSchema + ) + + const getLabelAndAmount = ( + transaction: Omit & { type: AssetTransactionType | 'SETTLED' } + ) => { + if (transaction.asset.type == AssetType.OffchainCash) { + return { + label: 'Cash transfer', + amount: transaction.amount, + } + } + + if (transaction.type === 'BORROWED' || transaction.type === 'SETTLED') { + return { + label: 'Purchase', + amount: transaction.amount, + } + } + if (transaction.type === 'REPAID' && !new BN(transaction.interestAmount || 0).isZero()) { + return { + label: 'Interest', + amount: transaction.interestAmount, + } + } + + return { + label: 'Principal payment', + amount: transaction.principalAmount, + } + } + + const settlements = transactions?.reduce((acc, transaction, index) => { + if (transaction.hash === transactions[index + 1]?.hash) { + acc[transaction.hash] = { ...transaction, type: 'SETTLED' } + } + + return acc + }, {} as Record & { type: AssetTransactionType | 'SETTLED' }>) + + const transformedTransactions = [ + ...(transactions?.filter((transaction) => !settlements?.[transaction.hash]) || []), + ...Object.values(settlements || []), + ] + .filter( + (transaction) => transaction.type !== 'CREATED' && transaction.type !== 'CLOSED' && transaction.type !== 'PRICED' + ) + .sort((a, b) => (a.timestamp > b.timestamp ? -1 : 1)) + + const csvData = transformedTransactions.map((transaction) => { + const { label, amount } = getLabelAndAmount(transaction) + const [, id] = transaction.asset.id.split('-') + return { + Type: label, + 'Transaction Date': `"${formatDate(transaction.timestamp, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + timeZoneName: 'short', + })}"`, + 'Asset Name': + transaction.asset.type == AssetType.OffchainCash + ? transaction.type === 'BORROWED' + ? `Onchain reserve > Settlement Account` + : `Settlement Account > onchain reserve` + : transaction.type === 'SETTLED' + ? `Settlement Account > ${assetMetadata[Number(id) - 1].data?.name || '-'}` + : assetMetadata[Number(id) - 1].data?.name || '-', + Amount: amount ? `"${formatBalance(amount, 'USD', 2, 2)}"` : '-', + Transaction: `${import.meta.env.REACT_APP_SUBSCAN_URL}/extrinsic/${transaction.hash}`, + } + }) + + const csvUrl = csvData?.length ? getCSVDownloadUrl(csvData) : '' + + const tableData = + transformedTransactions.slice(0, preview ? 8 : Infinity).map((transaction) => { + const [, id] = transaction.asset.id.split('-') + const { label, amount } = getLabelAndAmount(transaction) + return { + type: label, + transactionDate: transaction.timestamp, + assetId: transaction.asset.id, + assetName: + transaction.asset.type == AssetType.OffchainCash + ? transaction.type === 'BORROWED' + ? `Onchain reserve > Settlement account` + : `Settlement account > onchain reserve` + : transaction.type === 'SETTLED' + ? `${assetMetadata[Number(id) - 1].data?.name || '-'}` + : assetMetadata[Number(id) - 1].data?.name || '-', + amount: amount || 0, + hash: transaction.hash, + } + }) || [] + + return ( + + + + Transaction history + + {transactions?.length && ( + + Download + + )} + + + {transactions?.length! > 8 && preview && ( + + View all + + )} + + ) +} diff --git a/centrifuge-app/src/components/Portfolio/PortfolioValue.tsx b/centrifuge-app/src/components/Portfolio/PortfolioValue.tsx index ee4cbf88ed..bfe17f179d 100644 --- a/centrifuge-app/src/components/Portfolio/PortfolioValue.tsx +++ b/centrifuge-app/src/components/Portfolio/PortfolioValue.tsx @@ -2,6 +2,7 @@ import { Card, Stack, Text } from '@centrifuge/fabric' import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' import { formatDate } from '../../utils/date' import { formatBalance } from '../../utils/formatting' +import { getRangeNumber } from '../Charts/utils' import { useDailyPortfolioValue } from './usePortfolio' const chartColor = '#006ef5' @@ -103,19 +104,3 @@ export function PortfolioValue({ rangeValue, address }: { rangeValue: string; ad ) } - -const getRangeNumber = (rangeValue: string) => { - if (rangeValue === '30d') { - return 30 - } - if (rangeValue === '90d') { - return 90 - } - - const today = new Date() - const januaryFirst = new Date(today.getFullYear(), 0, 1) - const timeDifference = new Date(today).getTime() - new Date(januaryFirst).getTime() - const daysSinceJanuary1 = Math.floor(timeDifference / (1000 * 60 * 60 * 24)) - - return daysSinceJanuary1 -} diff --git a/centrifuge-app/src/components/Report/AssetTransactions.tsx b/centrifuge-app/src/components/Report/AssetTransactions.tsx index 83cc9a11e9..155133082b 100644 --- a/centrifuge-app/src/components/Report/AssetTransactions.tsx +++ b/centrifuge-app/src/components/Report/AssetTransactions.tsx @@ -7,9 +7,9 @@ import { getCSVDownloadUrl } from '../../utils/getCSVDownloadUrl' import { useAssetTransactions } from '../../utils/usePools' import { DataTable } from '../DataTable' import { Spinner } from '../Spinner' -import type { TableDataRow } from './index' import { ReportContext } from './ReportContext' import { UserFeedback } from './UserFeedback' +import type { TableDataRow } from './index' import { formatAssetTransactionType } from './utils' export function AssetTransactions({ pool }: { pool: Pool }) { @@ -26,7 +26,7 @@ export function AssetTransactions({ pool }: { pool: Pool }) { return transactions?.map((tx) => ({ name: '', value: [ - tx.assetId.split('-').at(-1)!, + tx.asset.id.split('-').at(-1)!, tx.epochId.split('-').at(-1)!, formatDate(tx.timestamp.toString()), formatAssetTransactionType(tx.type), @@ -77,4 +77,4 @@ export function AssetTransactions({ pool }: { pool: Pool }) { ) : ( ) -} \ No newline at end of file +} diff --git a/centrifuge-app/src/components/Root.tsx b/centrifuge-app/src/components/Root.tsx index 30ad40e09e..84f5fc6528 100644 --- a/centrifuge-app/src/components/Root.tsx +++ b/centrifuge-app/src/components/Root.tsx @@ -5,11 +5,11 @@ import { TransactionToasts, WalletProvider, } from '@centrifuge/centrifuge-react' -import { FabricProvider, GlobalStyle as FabricGlobalStyle } from '@centrifuge/fabric' +import { GlobalStyle as FabricGlobalStyle, FabricProvider } from '@centrifuge/fabric' import * as React from 'react' import { HelmetProvider } from 'react-helmet-async' import { QueryClient, QueryClientProvider } from 'react-query' -import { BrowserRouter as Router, LinkProps, matchPath, Redirect, Route, RouteProps, Switch } from 'react-router-dom' +import { LinkProps, Redirect, Route, RouteProps, BrowserRouter as Router, Switch, matchPath } from 'react-router-dom' import { config, evmChains } from '../config' import PoolsPage from '../pages/Pools' import { pinToApi } from '../utils/pinToApi' @@ -131,6 +131,7 @@ const TransactionHistoryPage = React.lazy(() => import('../pages/Portfolio/Trans const TokenOverviewPage = React.lazy(() => import('../pages/Tokens')) const PrimePage = React.lazy(() => import('../pages/Prime')) const PrimeDetailPage = React.lazy(() => import('../pages/Prime/Detail')) +const PoolTransactionsPage = React.lazy(() => import('../pages/PoolTransactions')) const routes: RouteProps[] = [ { path: '/nfts/collection/:cid/object/mint', component: MintNFTPage }, @@ -144,6 +145,7 @@ const routes: RouteProps[] = [ { path: '/issuer/:pid', component: IssuerPoolPage }, { path: '/pools/:pid/assets/:aid', component: LoanPage }, { path: '/pools/tokens', component: TokenOverviewPage }, + { path: '/pools/:pid/transactions', component: PoolTransactionsPage }, { path: '/pools/:pid', component: PoolDetailPage }, { path: '/pools', component: PoolsPage }, { path: '/history/:address', component: TransactionHistoryPage }, diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolReportsInput.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolReportsInput.tsx new file mode 100644 index 0000000000..60b31f71c4 --- /dev/null +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolReportsInput.tsx @@ -0,0 +1,50 @@ +import { FileUpload, Grid, TextInput } from '@centrifuge/fabric' +import { Field, FieldProps } from 'formik' +import { FieldWithErrorMessage } from '../../components/FieldWithErrorMessage' +import { combineAsync, imageFile, maxFileSize, maxImageSize } from '../../utils/validation' +import { validate } from './validate' + +export function PoolReportsInput() { + return ( + + + + + + {({ field, meta, form }: FieldProps) => ( + { + form.setFieldTouched('reportAuthorAvatar', true, false) + form.setFieldValue('reportAuthorAvatar', file) + }} + label="Reviewer avatar (JPG/PNG/SVG, max 40x40px)" + errorMessage={meta.touched && meta.error ? meta.error : undefined} + accept="image/*" + /> + )} + + + ) +} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx index 44a28bcfa6..628093a041 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx @@ -46,6 +46,7 @@ import { usePools } from '../../utils/usePools' import { truncate } from '../../utils/web3' import { AdminMultisigSection } from './AdminMultisig' import { IssuerInput } from './IssuerInput' +import { PoolReportsInput } from './PoolReportsInput' import { TrancheSection } from './TrancheInput' import { useStoredIssuer } from './useStoredIssuer' import { validate } from './validate' @@ -86,11 +87,15 @@ export const createEmptyTranche = (junior?: boolean): Tranche => ({ export type CreatePoolValues = Omit< PoolMetadataInput, - 'poolIcon' | 'issuerLogo' | 'executiveSummary' | 'adminMultisig' + 'poolIcon' | 'issuerLogo' | 'executiveSummary' | 'adminMultisig' | 'poolReport' > & { poolIcon: File | null issuerLogo: File | null executiveSummary: File | null + reportAuthorName: string + reportAuthorTitle: string + reportAuthorAvatar: File | null + reportUrl: string adminMultisigEnabled: boolean adminMultisig: Exclude } @@ -117,6 +122,10 @@ const initialValues: CreatePoolValues = { forum: '', email: '', details: [], + reportAuthorName: '', + reportAuthorTitle: '', + reportAuthorAvatar: null, + reportUrl: '', tranches: [createEmptyTranche(true)], adminMultisig: { @@ -332,6 +341,14 @@ function CreatePoolForm() { prevRiskBuffer = t.minRiskBuffer } }) + if (values.reportUrl) { + if (!values.reportAuthorName) { + errors = setIn(errors, 'reportAuthorName', 'Required') + } + if (!values.reportAuthorTitle) { + errors = setIn(errors, 'reportAuthorTitle', 'Required') + } + } return errors }, @@ -367,6 +384,22 @@ function CreatePoolForm() { metadataValues.executiveSummary = { uri: pinnedExecSummary.uri, mime: values.executiveSummary.type } metadataValues.poolIcon = { uri: pinnedPoolIcon.uri, mime: values.poolIcon.type } + if (values.reportUrl) { + let avatar = null + if (values.reportAuthorAvatar) { + const pinned = await lastValueFrom( + centrifuge.metadata.pinFile(await getFileDataURI(values.reportAuthorAvatar)) + ) + avatar = { uri: pinned.uri, mime: values.reportAuthorAvatar.type } + } + metadataValues.poolReport = { + authorAvatar: avatar, + authorName: values.reportAuthorName, + authorTitle: values.reportAuthorTitle, + url: values.reportUrl, + } + } + // tranches must be reversed (most junior is the first in the UI but the last in the API) const nonJuniorTranches = metadataValues.tranches.slice(1) const tranches = [ @@ -590,6 +623,9 @@ function CreatePoolForm() { + + + diff --git a/centrifuge-app/src/pages/IssuerPool/Configuration/Issuer.tsx b/centrifuge-app/src/pages/IssuerPool/Configuration/Issuer.tsx index f23f0689dc..c4af695e02 100644 --- a/centrifuge-app/src/pages/IssuerPool/Configuration/Issuer.tsx +++ b/centrifuge-app/src/pages/IssuerPool/Configuration/Issuer.tsx @@ -1,12 +1,12 @@ import { PoolMetadata } from '@centrifuge/centrifuge-js' import { useCentrifuge, useCentrifugeTransaction } from '@centrifuge/centrifuge-react' -import { Button, Stack } from '@centrifuge/fabric' -import { Form, FormikProvider, useFormik } from 'formik' +import { Button, Stack, Text } from '@centrifuge/fabric' +import { Form, FormikErrors, FormikProvider, setIn, useFormik } from 'formik' import * as React from 'react' import { useParams } from 'react-router' import { lastValueFrom } from 'rxjs' import { ButtonGroup } from '../../../components/ButtonGroup' -import { IssuerDetails } from '../../../components/IssuerSection' +import { IssuerDetails, ReportDetails } from '../../../components/IssuerSection' import { PageSection } from '../../../components/PageSection' import { getFileDataURI } from '../../../utils/getFileDataURI' import { useFile } from '../../../utils/useFile' @@ -15,6 +15,7 @@ import { useSuitableAccounts } from '../../../utils/usePermissions' import { usePool, usePoolMetadata } from '../../../utils/usePools' import { CreatePoolValues } from '../../IssuerCreatePool' import { IssuerInput } from '../../IssuerCreatePool/IssuerInput' +import { PoolReportsInput } from '../../IssuerCreatePool/PoolReportsInput' type Values = Pick< CreatePoolValues, @@ -27,7 +28,12 @@ type Values = Pick< | 'forum' | 'email' | 'details' -> + | 'reportUrl' + | 'reportAuthorName' + | 'reportAuthorTitle' +> & { + reportAuthorAvatar: string | null | File +} export function Issuer() { const { pid: poolId } = useParams<{ pid: string }>() @@ -50,6 +56,12 @@ export function Issuer() { forum: metadata?.pool?.links?.forum ?? '', email: metadata?.pool?.issuer?.email ?? '', details: metadata?.pool?.details, + reportUrl: metadata?.pool?.reports?.[0]?.uri ?? '', + reportAuthorName: metadata?.pool?.reports?.[0]?.author?.name ?? '', + reportAuthorTitle: metadata?.pool?.reports?.[0]?.author?.title ?? '', + reportAuthorAvatar: metadata?.pool?.reports?.[0]?.author?.avatar + ? `avatar.${metadata.pool.reports[0].author.avatar.mime?.split('/')[1]}` + : null, }), [metadata, logoFile] ) @@ -62,6 +74,20 @@ export function Issuer() { const form = useFormik({ initialValues, + validate: (values) => { + let errors: FormikErrors = {} + + if (values.reportUrl) { + if (!values.reportAuthorName) { + errors = setIn(errors, 'reportAuthorName', 'Required') + } + if (!values.reportAuthorTitle) { + errors = setIn(errors, 'reportAuthorTitle', 'Required') + } + } + + return errors + }, onSubmit: async (values, actions) => { const oldMetadata = metadata as PoolMetadata const execSummaryChanged = values.executiveSummary !== initialValues.executiveSummary @@ -107,6 +133,27 @@ export function Issuer() { }, } + if (values.reportUrl) { + let avatar = null + const avatarChanged = values.reportAuthorAvatar !== initialValues.reportAuthorAvatar + if (avatarChanged && values.reportAuthorAvatar) { + const pinned = await lastValueFrom( + cent.metadata.pinFile(await getFileDataURI(values.reportAuthorAvatar as File)) + ) + avatar = { uri: pinned.uri, mime: (values.reportAuthorAvatar as File).type } + } + newPoolMetadata.pool.reports = [ + { + author: { + avatar: avatar, + name: values.reportAuthorName, + title: values.reportAuthorTitle, + }, + uri: values.reportUrl, + }, + ] + } + execute([poolId, newPoolMetadata], { account }) actions.setSubmitting(false) }, @@ -157,10 +204,20 @@ export function Issuer() { } > {isEditing ? ( - + + + Pool analysis + + ) : ( + {metadata?.pool?.reports?.[0] && ( + + Pool analysis + + + )} )} diff --git a/centrifuge-app/src/pages/Loan/PricingValues.tsx b/centrifuge-app/src/pages/Loan/PricingValues.tsx index 345156f792..1937941b41 100644 --- a/centrifuge-app/src/pages/Loan/PricingValues.tsx +++ b/centrifuge-app/src/pages/Loan/PricingValues.tsx @@ -27,7 +27,7 @@ export function PricingValues({ loan, pool }: Props) { const days = getAge(new Date(pricing.oracle.timestamp).toISOString()) const borrowerAssetTransactions = assetTransactions?.filter( - (assetTransaction) => assetTransaction.loanId === `${loan.poolId}-${loan.id}` + (assetTransaction) => assetTransaction.asset.id === `${loan.poolId}-${loan.id}` ) const latestPrice = getLatestPrice(pricing.oracle.value, borrowerAssetTransactions, pool.currency.decimals) diff --git a/centrifuge-app/src/pages/Pool/Assets/index.tsx b/centrifuge-app/src/pages/Pool/Assets/index.tsx index 5133cee25c..044e73f2c5 100644 --- a/centrifuge-app/src/pages/Pool/Assets/index.tsx +++ b/centrifuge-app/src/pages/Pool/Assets/index.tsx @@ -3,6 +3,7 @@ import { Box, Shelf, Text } from '@centrifuge/fabric' import * as React from 'react' import { useParams } from 'react-router' import currencyDollar from '../../../assets/images/currency-dollar.svg' +import daiLogo from '../../../assets/images/dai-logo.svg' import usdcLogo from '../../../assets/images/usdc-logo.svg' import { LayoutBase } from '../../../components/LayoutBase' import { LoadBoundary } from '../../../components/LoadBoundary' @@ -72,30 +73,34 @@ export function PoolDetailAssets() { { label: ( - + ), value: formatBalance(pool.reserve.total || 0, pool.currency.symbol), }, - { - label: ( - - - - - ), - value: formatBalance(offchainReserve, 'USD'), - }, - { - label: 'Total assets', - value: loans.length, - }, - { label: , value: ongoingAssets.length || 0 }, - { - label: 'Overdue assets', - value: 0 ? 'statusCritical' : 'inherit'}>{overdueAssets.length}, - }, + ...(!isTinlakePool + ? [ + { + label: ( + + + + + ), + value: formatBalance(offchainReserve, 'USD'), + }, + { + label: 'Total assets', + value: loans.length, + }, + { label: , value: ongoingAssets.length || 0 }, + { + label: 'Overdue assets', + value: 0 ? 'statusCritical' : 'inherit'}>{overdueAssets.length}, + }, + ] + : []), ] return ( diff --git a/centrifuge-app/src/pages/Pool/Overview/index.tsx b/centrifuge-app/src/pages/Pool/Overview/index.tsx index e1c0b76e6e..135c621a27 100644 --- a/centrifuge-app/src/pages/Pool/Overview/index.tsx +++ b/centrifuge-app/src/pages/Pool/Overview/index.tsx @@ -1,32 +1,31 @@ import { CurrencyBalance, Price } from '@centrifuge/centrifuge-js' -import { useWallet } from '@centrifuge/centrifuge-react' -import { Button, Shelf, Stack, Text, TextWithPlaceholder } from '@centrifuge/fabric' +import { Box, Button, Card, Grid, TextWithPlaceholder } from '@centrifuge/fabric' import Decimal from 'decimal.js-light' import * as React from 'react' -import { useLocation, useParams } from 'react-router' +import { useParams } from 'react-router' +import { useTheme } from 'styled-components' import { InvestRedeemProps } from '../../../components/InvestRedeem/InvestRedeem' import { InvestRedeemDrawer } from '../../../components/InvestRedeem/InvestRedeemDrawer' import { IssuerSection } from '../../../components/IssuerSection' -import { LabelValueStack } from '../../../components/LabelValueStack' import { LayoutBase } from '../../../components/LayoutBase' import { LoadBoundary } from '../../../components/LoadBoundary' -import { PageSection } from '../../../components/PageSection' -import { PageSummary } from '../../../components/PageSummary' -import { PoolToken } from '../../../components/PoolToken' +import { Cashflows } from '../../../components/PoolOverview/Cashflows' +import { KeyMetrics } from '../../../components/PoolOverview/KeyMetrics' +import { PoolPerformance } from '../../../components/PoolOverview/PoolPerfomance' +import { PoolStructure } from '../../../components/PoolOverview/PoolStructure' +import { TrancheTokenCards } from '../../../components/PoolOverview/TrancheTokenCards' +import { TransactionHistory } from '../../../components/PoolOverview/TransactionHistory' import { Spinner } from '../../../components/Spinner' import { Tooltips } from '../../../components/Tooltips' import { Dec } from '../../../utils/Decimal' -import { formatDate } from '../../../utils/date' -import { formatBalance, formatBalanceAbbreviated, formatPercentage } from '../../../utils/formatting' +import { formatBalance } from '../../../utils/formatting' import { getPoolValueLocked } from '../../../utils/getPoolValueLocked' -import { useTinlakePermissions } from '../../../utils/tinlake/useTinlakePermissions' import { useAverageMaturity } from '../../../utils/useAverageMaturity' import { useConnectBeforeAction } from '../../../utils/useConnectBeforeAction' +import { useLoans } from '../../../utils/useLoans' import { usePool, usePoolMetadata } from '../../../utils/usePools' import { PoolDetailHeader } from '../Header' -const PoolAssetReserveChart = React.lazy(() => import('../../../components/Charts/PoolAssetReserveChart')) - export type Token = { poolId: string apy: Decimal @@ -58,13 +57,13 @@ function AverageMaturity({ poolId }: { poolId: string }) { } export function PoolDetailOverview() { + const theme = useTheme() const { pid: poolId } = useParams<{ pid: string }>() const isTinlakePool = poolId.startsWith('0x') - const { state } = useLocation<{ token: string }>() const pool = usePool(poolId) const { data: metadata, isLoading: metadataIsLoading } = usePoolMetadata(pool) - const { evm } = useWallet() - const { data: tinlakePermissions } = useTinlakePermissions(poolId, evm?.selectedAddress || '') + const averageMaturity = useAverageMaturity(poolId) + const loans = useLoans(poolId) const pageSummaryData = [ { @@ -103,93 +102,75 @@ export function PoolDetailOverview() { }) .reverse() - const hasScrolledToToken = React.useRef(false) - function handleTokenMount(node: HTMLDivElement, id: string) { - if (hasScrolledToToken.current === true || id !== state?.token) return - node.scrollIntoView({ behavior: 'smooth', block: 'center' }) - hasScrolledToToken.current = true - } - - const getTrancheAvailability = (token: string) => { - if (isTinlakePool && metadata?.pool?.newInvestmentsStatus) { - const trancheName = token.split('-')[1] === '0' ? 'junior' : 'senior' - - const isMember = tinlakePermissions?.[trancheName].inMemberlist - - return isMember || metadata.pool.newInvestmentsStatus[trancheName] !== 'closed' - } - - return true - } - return ( - <> - + + + + }> + + + }> + + + + + {tokens.length > 0 && ( + + }> + + + + )} + + }> + + + {!isTinlakePool && ( - - + <> + + + }> + + + {/* }> + + */} + + + + }> + + + + + + }> - + + + + + - - + + )} - - - {tokens?.map((token, i) => ( -
node && handleTokenMount(node, token.id)}> - - - } - value={formatPercentage(token.protection)} - /> - } - value={formatBalance(token.valueLocked, pool?.currency.symbol)} - /> - {token.seniority === 0 ? ( - } - value="Variable" - /> - ) : ( - } - value={formatPercentage(token.apy)} - /> - )} - - {formatBalanceAbbreviated(token.capacity, pool?.currency.symbol)} - - } - /> - - {token.tokenPrice && formatBalance(token.tokenPrice, pool?.currency.symbol, 4, 2)} - - } - /> - {getTrancheAvailability(token.id) && } - - -
- ))} -
-
- - - - +
) } @@ -206,3 +187,11 @@ export function InvestButton(props: InvestRedeemProps) { ) } + +const PoolOverviewSection = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ) +} diff --git a/centrifuge-app/src/pages/PoolTransactions.tsx b/centrifuge-app/src/pages/PoolTransactions.tsx new file mode 100644 index 0000000000..bb860855ef --- /dev/null +++ b/centrifuge-app/src/pages/PoolTransactions.tsx @@ -0,0 +1,29 @@ +import { Box, Stack, Text } from '@centrifuge/fabric' +import { useParams } from 'react-router' +import { LayoutBase } from '../components/LayoutBase' +import { LayoutSection } from '../components/LayoutBase/LayoutSection' +import { TransactionHistory } from '../components/PoolOverview/TransactionHistory' +import { usePool, usePoolMetadata } from '../utils/usePools' + +const PoolTransactions = () => { + const { pid: poolId } = useParams<{ pid: string }>() + const pool = usePool(poolId) + const { data: metadata } = usePoolMetadata(pool) + + return ( + + + + + {metadata?.pool?.name} + + + + + + + + ) +} + +export default PoolTransactions diff --git a/centrifuge-app/src/utils/date.ts b/centrifuge-app/src/utils/date.ts index ff949fe89f..1ce028ddc3 100644 --- a/centrifuge-app/src/utils/date.ts +++ b/centrifuge-app/src/utils/date.ts @@ -25,7 +25,10 @@ export const formatAge = (ageInDays: number, decimals: number = 1) => { } else if (ageInDays < 0) { return '0 days' } - return `${Math.floor(ageInDays)} days` + + const days = Math.floor(ageInDays) + + return `${days} ${days === 1 ? 'day' : 'days'}` } export const getAge = (createdAt: string | undefined | null) => { diff --git a/centrifuge-app/src/utils/tinlake/useTinlakePools.ts b/centrifuge-app/src/utils/tinlake/useTinlakePools.ts index a69a842493..a169da0476 100644 --- a/centrifuge-app/src/utils/tinlake/useTinlakePools.ts +++ b/centrifuge-app/src/utils/tinlake/useTinlakePools.ts @@ -1,12 +1,12 @@ import { - CurrencyBalance, - Perquintill, - Pool, - PoolMetadata, - Price, - Rate, - TinlakeLoan, - TokenBalance, + CurrencyBalance, + Perquintill, + Pool, + PoolMetadata, + Price, + Rate, + TinlakeLoan, + TokenBalance, } from '@centrifuge/centrifuge-js' import { useCentrifuge } from '@centrifuge/centrifuge-react' import { BigNumber } from '@ethersproject/bignumber' @@ -20,14 +20,14 @@ import { currencies } from './currencies' import { Call, multicall } from './multicall' import { Fixed27Base } from './ratios' import { - ActivePool, - ArchivedPool, - IpfsPools, - LaunchingPool, - PoolMetadataDetails, - PoolStatus, - TinlakeMetadataPool, - UpcomingPool, + ActivePool, + ArchivedPool, + IpfsPools, + LaunchingPool, + PoolMetadataDetails, + PoolStatus, + TinlakeMetadataPool, + UpcomingPool, } from './types' export interface PoolData { @@ -789,7 +789,6 @@ async function getPools(pools: IpfsPools): Promise<{ pools: TinlakePool[] }> { } }) - return { pools: combined } } diff --git a/centrifuge-app/src/utils/usePools.ts b/centrifuge-app/src/utils/usePools.ts index f54997748b..7a98a0ba7c 100644 --- a/centrifuge-app/src/utils/usePools.ts +++ b/centrifuge-app/src/utils/usePools.ts @@ -117,7 +117,7 @@ export function useBorrowerAssetTransactions(poolId: string, assetId: string, fr return assetTransactions.pipe( map((transactions: AssetTransaction[]) => - transactions.filter((transaction) => transaction.assetId.split('-')[1] === assetId) + transactions.filter((transaction) => transaction.asset.id.split('-')[1] === assetId) ) ) }, diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index e9bf8c6fe9..76b9855073 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -10,22 +10,22 @@ import { SolverResult, calculateOptimalSolution } from '..' import { Centrifuge } from '../Centrifuge' import { Account, TransactionOptions } from '../types' import { - AssetTransactionType, - InvestorTransactionType, - SubqueryAssetTransaction, - SubqueryCurrencyBalances, - SubqueryInvestorTransaction, - SubqueryPoolSnapshot, - SubqueryTrancheBalances, - SubqueryTrancheSnapshot, + AssetTransactionType, + InvestorTransactionType, + SubqueryAssetTransaction, + SubqueryCurrencyBalances, + SubqueryInvestorTransaction, + SubqueryPoolSnapshot, + SubqueryTrancheBalances, + SubqueryTrancheSnapshot, } from '../types/subquery' import { - addressToHex, - computeTrancheId, - getDateMonthsFromNow, - getDateYearsFromNow, - getRandomUint, - isSameAddress, + addressToHex, + computeTrancheId, + getDateMonthsFromNow, + getDateYearsFromNow, + getRandomUint, + isSameAddress, } from '../utils' import { CurrencyBalance, Perquintill, Price, Rate, TokenBalance } from '../utils/BN' import { Dec } from '../utils/Decimal' @@ -559,7 +559,8 @@ export type DailyPoolState = { timestamp: string tranches: { [trancheId: string]: DailyTrancheState } - sumBorrowedAmountByPeriod?: number | null + sumBorrowedAmountByPeriod?: string | null + sumInterestRepaidAmountByPeriod?: string | null sumRepaidAmountByPeriod?: number | null sumInvestedAmountByPeriod?: number | null sumRedeemedAmountByPeriod?: number | null @@ -609,6 +610,13 @@ export interface PoolMetadataInput { issuerLogo?: FileType | null issuerDescription: string + poolReport?: { + authorName: string + authorTitle: string + authorAvatar: FileType | null + url: string + } + executiveSummary: FileType | null website: string forum: string @@ -725,17 +733,40 @@ type InvestorTransaction = { evmAddress?: string } +export enum AssetType { + OnchainCash = 'OnchainCash', + OffchainCash = 'OffchainCash', + Other = 'Other', +} + export type AssetTransaction = { id: string timestamp: string poolId: string accountId: string epochId: string - loanId: string type: AssetTransactionType amount: CurrencyBalance | undefined settlementPrice: string | null quantity: string | null + principalAmount: CurrencyBalance | undefined + interestAmount: CurrencyBalance | undefined + hash: string + asset: { + id: string + metadata: string + type: AssetType + } + fromAsset?: { + id: string + metadata: string + type: AssetType + } + toAsset?: { + id: string + metadata: string + type: AssetType + } } type Holder = { @@ -876,6 +907,18 @@ export function getPoolsModule(inst: Centrifuge) { details: metadata.details, status: 'open', listed: metadata.listed ?? true, + reports: metadata.poolReport + ? [ + { + author: { + name: metadata.poolReport.authorName, + title: metadata.poolReport.authorTitle, + avatar: metadata.poolReport.authorAvatar, + }, + uri: metadata.poolReport.url, + }, + ] + : undefined, }, pod: { node: metadata.podEndpoint ?? null, @@ -2070,6 +2113,7 @@ export function getPoolsModule(inst: Centrifuge) { sumRepaidAmountByPeriod sumInvestedAmountByPeriod sumRedeemedAmountByPeriod + sumInterestRepaidAmountByPeriod } pageInfo { hasNextPage @@ -2521,13 +2565,30 @@ export function getPoolsModule(inst: Centrifuge) { timestamp: { greaterThan: $from, lessThan: $to }, }) { nodes { - assetId + principalAmount + interestAmount epochId type timestamp amount settlementPrice quantity + hash + asset { + id + metadata + type + } + fromAsset { + id + metadata + type + } + toAsset { + id + metadata + type + } } } } @@ -2546,6 +2607,8 @@ export function getPoolsModule(inst: Centrifuge) { return data!.assetTransactions.nodes.map((tx) => ({ ...tx, amount: tx.amount ? new CurrencyBalance(tx.amount, currency.decimals) : undefined, + principalAmount: tx.principalAmount ? new CurrencyBalance(tx.principalAmount, currency.decimals) : undefined, + interestAmount: tx.interestAmount ? new CurrencyBalance(tx.interestAmount, currency.decimals) : undefined, timestamp: new Date(`${tx.timestamp}+00:00`), })) as unknown as AssetTransaction[] }) @@ -2933,11 +2996,11 @@ export function getPoolsModule(inst: Centrifuge) { } > = {} // oracles.forEach(() => { - // const { timestamp, value } = oracle[1].toPrimitive() as any - // oraclePrices[(oracle[0].toHuman() as any)[0].Isin] = { - // timestamp, - // value: new CurrencyBalance(value, currency.decimals), - // } + // const { timestamp, value } = oracle[1].toPrimitive() as any + // oraclePrices[(oracle[0].toHuman() as any)[0].Isin] = { + // timestamp, + // value: new CurrencyBalance(value, currency.decimals), + // } // }) const activeLoansPortfolio: Record< diff --git a/centrifuge-js/src/types/subquery.ts b/centrifuge-js/src/types/subquery.ts index 4cc7707737..12e8563371 100644 --- a/centrifuge-js/src/types/subquery.ts +++ b/centrifuge-js/src/types/subquery.ts @@ -14,7 +14,8 @@ export type SubqueryPoolSnapshot = { totalInvested?: number | null totalRedeemed?: number | null sumBorrowedAmount?: number | null - sumBorrowedAmountByPeriod?: number | null + sumBorrowedAmountByPeriod?: string | null + sumInterestRepaidAmountByPeriod?: string | null sumRepaidAmountByPeriod?: number | null sumInvestedAmountByPeriod?: number | null sumRedeemedAmountByPeriod?: number | null @@ -83,11 +84,16 @@ export type SubqueryAssetTransaction = { poolId: string accountId: string epochId: string - assetId: string type: AssetTransactionType - amount?: number | null + amount: CurrencyBalance | undefined + principalAmount: CurrencyBalance | undefined + interestAmount: CurrencyBalance | undefined settlementPrice: string | null quantity: string | null + asset: { + id: string + metadata: string + } } export type SubqueryTrancheBalances = {