diff --git a/centrifuge-app/src/components/Charts/PoolPerformanceChart.tsx b/centrifuge-app/src/components/Charts/PoolPerformanceChart.tsx
index e4842b5c43..b09f8f3f5f 100644
--- a/centrifuge-app/src/components/Charts/PoolPerformanceChart.tsx
+++ b/centrifuge-app/src/components/Charts/PoolPerformanceChart.tsx
@@ -103,7 +103,7 @@ function PoolPerformanceChart() {
const { poolStates } = useDailyPoolStates(poolId) || {}
const pool = usePool(poolId)
const poolAge = pool.createdAt ? daysBetween(pool.createdAt, new Date()) : 0
- const { data: loans } = useLoans(poolId)
+ const { data: loans } = useLoans([poolId])
const firstOriginationDate = loans?.reduce((acc, cur) => {
if ('originationDate' in cur) {
diff --git a/centrifuge-app/src/components/Dashboard/PoolCard.tsx b/centrifuge-app/src/components/Dashboard/PoolCard.tsx
new file mode 100644
index 0000000000..b48aab6a52
--- /dev/null
+++ b/centrifuge-app/src/components/Dashboard/PoolCard.tsx
@@ -0,0 +1,53 @@
+import { Pool } from '@centrifuge/centrifuge-js'
+import { useCentrifuge } from '@centrifuge/centrifuge-react'
+import { Box, Text, Thumbnail } from '@centrifuge/fabric'
+import { useTheme } from 'styled-components'
+import { usePoolMetadata } from '../../../src/utils/usePools'
+
+export const PoolCard = ({
+ children,
+ active,
+ onClick,
+ pool,
+}: {
+ children: React.ReactNode
+ active: boolean
+ onClick: () => void
+ pool: Pool
+}) => {
+ const cent = useCentrifuge()
+ const theme = useTheme()
+ const { data: poolMetadata } = usePoolMetadata(pool)
+ // TODO - remove cent usage
+ const poolUri = poolMetadata?.pool?.icon?.uri
+ ? cent.metadata.parseMetadataUrl(poolMetadata?.pool?.icon?.uri)
+ : undefined
+ return (
+
+
+ {poolUri ? (
+
+ ) : (
+
+ )}
+
+ {poolMetadata?.pool?.name}
+
+
+ {children}
+
+ )
+}
diff --git a/centrifuge-app/src/components/Dashboard/assets/AssetTemplateSection.tsx b/centrifuge-app/src/components/Dashboard/assets/AssetTemplateSection.tsx
new file mode 100644
index 0000000000..530a5bff02
--- /dev/null
+++ b/centrifuge-app/src/components/Dashboard/assets/AssetTemplateSection.tsx
@@ -0,0 +1,87 @@
+import { CurrencyInput, DateInput, NumberInput, Select, TextAreaInput, TextInput } from '@centrifuge/fabric'
+import { Field, FieldProps } from 'formik'
+import { FieldWithErrorMessage } from '../../../../src/components/FieldWithErrorMessage'
+import { combine, max, min, positiveNumber, required } from '../../../../src/utils/validation'
+
+export function AssetTemplateSection({ label, input, name }: { label: string; input: any; name: string }) {
+ switch (input.type) {
+ case 'single-select':
+ return (
+
+ {({ field, form }: any) => (
+
+ )
+ case 'currency': {
+ return (
+
+ {({ field, meta, form }: FieldProps) => {
+ return (
+ form.setFieldValue(name, value)}
+ min={input.min}
+ max={input.max}
+ />
+ )
+ }}
+
+ )
+ }
+ case 'number':
+ return (
+
+ )
+ case 'date':
+ return (
+
+ )
+
+ default: {
+ const { type, ...rest } = input.type as any
+ return (
+
+ )
+ }
+ }
+}
diff --git a/centrifuge-app/src/components/Dashboard/assets/AssetsTable.tsx b/centrifuge-app/src/components/Dashboard/assets/AssetsTable.tsx
new file mode 100644
index 0000000000..a80c9554c7
--- /dev/null
+++ b/centrifuge-app/src/components/Dashboard/assets/AssetsTable.tsx
@@ -0,0 +1,314 @@
+import { CurrencyBalance, CurrencyMetadata, Loan, Pool } from '@centrifuge/centrifuge-js'
+import { useCentrifuge } from '@centrifuge/centrifuge-react'
+import { AnchorButton, Box, Button, Grid, IconDownload, IconPlus, Text } from '@centrifuge/fabric'
+import { useMemo, useState } from 'react'
+import styled, { useTheme } from 'styled-components'
+import { useSelectedPools } from '../../../utils/contexts/SelectedPoolsContext'
+import { formatDate } from '../../../utils/date'
+import { formatBalance } from '../../../utils/formatting'
+import { getCSVDownloadUrl } from '../../../utils/getCSVDownloadUrl'
+import { useFilters } from '../../../utils/useFilters'
+import { useAllPoolAssetSnapshotsMulti } from '../../../utils/usePools'
+import { DataTable, FilterableTableHeader, SortableTableHeader } from '../../DataTable'
+import { LoanLabel, getLoanLabelStatus } from '../../LoanLabel'
+import { Amount, getAmount } from '../../LoanList'
+import { Spinner } from '../../Spinner'
+import { CreateAssetsDrawer } from './CreateAssetsDrawer'
+import { TransformedLoan, usePoolMetadataMap } from './utils'
+
+const StyledButton = styled(AnchorButton)`
+ & > span {
+ min-height: 36px;
+ }
+`
+
+const status = ['Ongoing', 'Overdue', 'Repaid', 'Closed', 'Active']
+
+type Row = Loan & {
+ poolName: string
+ asset: string
+ maturityDate: string
+ quantity: CurrencyBalance
+ value: CurrencyBalance
+ currency: CurrencyMetadata
+ unrealizedPL: CurrencyBalance
+ realizedPL: CurrencyBalance
+ loan: Loan
+ status: typeof status
+ assetName: string
+ poolIcon: string
+ poolId: string
+ assetId: string
+ valuationMethod: string
+ pool: Pool
+}
+
+export default function AssetsTable({ loans }: { loans: TransformedLoan[] }) {
+ const theme = useTheme()
+ const cent = useCentrifuge()
+ const { selectedPools } = useSelectedPools()
+ const extractedPools = loans.map((loan) => loan.pool)
+ const poolMetadataMap = usePoolMetadataMap(extractedPools)
+ const today = new Date().toISOString().slice(0, 10)
+ const [allSnapshots, isLoading] = useAllPoolAssetSnapshotsMulti(extractedPools, today)
+ const [drawerOpen, setDrawerOpen] = useState(false)
+ const [drawerType, setDrawerType] = useState<'create-asset' | 'upload-template'>('create-asset')
+
+ const loansData = loans
+ .flatMap((loan) => {
+ const snapshots = allSnapshots?.[loan.pool.id] ?? []
+ const metadata = poolMetadataMap.get(loan.pool.id)
+ const poolIcon = metadata?.pool?.icon?.uri && cent.metadata.parseMetadataUrl(metadata?.pool?.icon?.uri)
+ const poolName = metadata?.pool?.name
+ return (
+ snapshots
+ ?.filter((snapshot) => {
+ const snapshotLoanId = snapshot.assetId.split('-')[1]
+ return snapshotLoanId === loan.id
+ })
+ .map((snapshot) => ({
+ poolIcon,
+ currency: loan.pool.currency,
+ poolName,
+ assetName: snapshot.asset.name,
+ maturityDate: snapshot.actualMaturityDate,
+ poolId: loan.pool.id,
+ quantity: snapshot.outstandingQuantity,
+ value: loan.presentValue,
+ unrealizedPL: snapshot.unrealizedProfitAtMarketPrice,
+ realizedPL: snapshot.sumRealizedProfitFifo,
+ status: loan.status,
+ loan,
+ assetId: snapshot.assetId.split('-')[1],
+ pool: loan.pool,
+ })) || []
+ )
+ })
+ .filter((item) => selectedPools.includes(item.poolId))
+
+ const data = useMemo(
+ () =>
+ loansData.map((loan) => {
+ const [, text] = getLoanLabelStatus(loan.loan)
+ const {
+ quantity,
+ value,
+ unrealizedPL,
+ realizedPL,
+ assetName,
+ poolId,
+ currency,
+ poolName,
+ maturityDate,
+ assetId,
+ poolIcon,
+ pool,
+ } = loan
+ return {
+ poolName,
+ poolIcon,
+ assetId,
+ maturityDate,
+ quantity,
+ value,
+ unrealizedPL,
+ realizedPL,
+ loan: loan.loan,
+ status: text,
+ assetName,
+ poolId,
+ currency,
+ pool,
+ }
+ }),
+ [loansData]
+ )
+
+ const filters = useFilters({
+ data,
+ })
+
+ const columns = [
+ {
+ align: 'left',
+ header: ,
+ cell: ({ poolName, poolIcon }: Row) => {
+ return (
+
+ {poolIcon && }
+
+ {poolName}
+
+
+ )
+ },
+ sortKey: 'poolName',
+ width: '200px',
+ },
+ {
+ align: 'left',
+ header: ,
+ cell: ({ assetName }: Row) => (
+
+ {assetName}
+
+ ),
+ sortKey: 'assetName',
+ },
+ {
+ align: 'left',
+ header: ,
+ cell: ({ maturityDate }: Row) => (
+
+ {maturityDate ? formatDate(maturityDate) : '-'}
+
+ ),
+ sortKey: 'maturityDate',
+ },
+ {
+ align: 'left',
+ header: ,
+ cell: ({ loan }: Row) => {
+ return
+ },
+ sortKey: 'quantity',
+ width: '120px',
+ },
+ {
+ align: 'left',
+ header: ,
+ cell: ({ value, currency }: Row) => (
+
+ {value ? formatBalance(value, currency.displayName, 2) : '-'}
+
+ ),
+ sortKey: 'value',
+ },
+ {
+ align: 'left',
+ header: ,
+ cell: ({ unrealizedPL, currency }: Row) => (
+
+ {unrealizedPL ? formatBalance(unrealizedPL, currency.symbol, 2, 2) : '-'}
+
+ ),
+ sortKey: 'unrealizedPL',
+ },
+ {
+ align: 'left',
+ header: ,
+ cell: ({ realizedPL, currency }: Row) => (
+
+ {realizedPL ? formatBalance(realizedPL, currency.symbol, 2, 2) : '-'}
+
+ ),
+ sortKey: 'realizedPL',
+ },
+ {
+ align: 'left',
+ header: ,
+ cell: ({ loan }: Row) => ,
+ },
+ ]
+
+ const csvData = useMemo(() => {
+ if (!data.length) return undefined
+
+ return data.map((loan) => {
+ const quantity = getAmount(loan.loan, loan.pool)
+
+ return {
+ Pool: loan.poolName,
+ Asset: loan.maturityDate ? loan.maturityDate : '-',
+ 'Maturity Date': loan.maturityDate ? loan.maturityDate : '-',
+ Quantity: `${quantity ?? '-'}`,
+ Value: loan.value ? loan.value : '-',
+ 'Unrealized P&L': loan.unrealizedPL ? loan.unrealizedPL : '-',
+ 'Realized P&L': loan.realizedPL ? loan.realizedPL : '-',
+ Status: loan.status ? loan.status : '-',
+ }
+ })
+ }, [data])
+
+ const csvUrl = useMemo(() => csvData && getCSVDownloadUrl(csvData as any), [csvData])
+
+ if (isLoading) return
+
+ return (
+ <>
+
+
+
+ {filters.data.length}
+
+
+ Assets
+
+
+ {!!selectedPools.length && (
+ }
+ small
+ onClick={() => {
+ setDrawerOpen(true)
+ setDrawerType('create-asset')
+ }}
+ >
+ Create asset
+
+ )}
+ {!!selectedPools.length && (
+
+ )}
+ {!!filters.data.length && (
+
+ )}
+
+
+
+ {filters.data.length ? (
+ `/pools/${row.poolId}/assets/${row.assetId}`}
+ />
+ ) : (
+
+ No data available
+
+ )}
+
+ {!!selectedPools.length && (
+
+ )}
+ >
+ )
+}
diff --git a/centrifuge-app/src/components/Dashboard/assets/CreateAssetForm.tsx b/centrifuge-app/src/components/Dashboard/assets/CreateAssetForm.tsx
new file mode 100644
index 0000000000..cf293e0ebf
--- /dev/null
+++ b/centrifuge-app/src/components/Dashboard/assets/CreateAssetForm.tsx
@@ -0,0 +1,237 @@
+import {
+ Accordion,
+ Box,
+ Divider,
+ IconHelpCircle,
+ ImageUpload,
+ RadioButton,
+ Tabs,
+ TabsItem,
+ Text,
+ TextAreaInput,
+ TextInput,
+} from '@centrifuge/fabric'
+import { Field, FieldProps, useFormikContext } from 'formik'
+import { useState } from 'react'
+import { useTheme } from 'styled-components'
+import { FieldWithErrorMessage } from '../../../../src/components/FieldWithErrorMessage'
+import { Tooltips, tooltipText } from '../../../../src/components/Tooltips'
+import { validate } from '../../../../src/pages/IssuerCreatePool/validate'
+import { LoanTemplate } from '../../../../src/types'
+import { useMetadata } from '../../../../src/utils/useMetadata'
+import { useSuitableAccounts } from '../../../../src/utils/usePermissions'
+import { AssetTemplateSection } from './AssetTemplateSection'
+import { CreateAssetFormValues } from './CreateAssetsDrawer'
+import { PricingSection } from './PricingSection'
+
+const assetTypes = [
+ { label: 'Cash', tooltip: 'cashAsset', id: 'cash' },
+ { label: 'Liquid assets', tooltip: 'liquidAsset', id: 'liquid' },
+ { label: 'Fund shares', tooltip: 'fundShares', id: 'fund' },
+ { label: 'Custom assets', tooltip: 'customAsset', id: 'custom' },
+]
+
+export function CreateAssetsForm() {
+ const theme = useTheme()
+ const form = useFormikContext()
+ const pool = form.values.selectedPool
+ const templateIds = pool?.meta?.loanTemplates?.map((s: { id: string }) => s.id) || []
+ const templateId = templateIds.at(-1)
+ const hasTemplates = !!pool?.meta?.loanTemplates?.length
+ const { data: templateMetadata } = useMetadata(templateId)
+ const sectionsName = templateMetadata?.sections?.map((s) => s.name) ?? []
+ const [selectedTabIndex, setSelectedTabIndex] = useState(0)
+
+ const canCreateAssets =
+ useSuitableAccounts({ poolId: pool?.id, poolRole: ['Borrower', 'PoolAdmin'], proxyType: ['Borrow', 'PoolAdmin'] })
+ .length > 0
+
+ const renderBody = (index: number) => {
+ const sectionsAttrs =
+ templateMetadata?.sections
+ ?.map((s, i) => {
+ return s.attributes.map((attr) => ({
+ index: i,
+ attr,
+ }))
+ })
+ .flat() ?? []
+ const attrs = { ...templateMetadata?.attributes }
+ return (
+
+ {sectionsAttrs.map((section) => {
+ if (section.index === index) {
+ const name = `attributes.${section.attr}`
+ if (!attrs[section.attr]) return <>>
+ return (
+
+
+
+ )
+ } else return <>>
+ })}
+
+ )
+ }
+
+ return (
+
+
+
+ Select asset type*
+ {assetTypes.map((asset) => (
+ }
+ />
+ }
+ onChange={() => form.setFieldValue('assetType', asset.id)}
+ checked={form.values.assetType === asset.id}
+ styles={{ padding: '0px 8px', margin: '8px 0px' }}
+ border
+ />
+ ))}
+
+
+
+ {({ field, form }: FieldProps) => (
+ {
+ form.setFieldValue('assetName', event.target.value)
+ }}
+ />
+ )}
+
+
+
+ {hasTemplates && canCreateAssets && form.values.assetType !== 'cash' && (
+
+ {form.values.assetType === 'custom' && (
+
+ setSelectedTabIndex(index)}>
+
+
+ {({ field, form }: FieldProps) => (
+ form.setFieldValue('customType', 'atPar')}>
+ At par
+ {}
+ }
+ />
+
+ )}
+
+
+
+
+ {({ field, form }: FieldProps) => (
+ form.setFieldValue('customType', 'discountedCashFlow')}
+ >
+ Discounted cash flow
+ {}
+ }
+ />
+
+ )}
+
+
+
+
+ )}
+ ,
+ },
+ ...(sectionsName &&
+ sectionsName.map((section, index) => ({
+ title: section,
+ body: renderBody(index),
+ }))),
+ ]}
+ />
+ {(templateMetadata?.options?.image || templateMetadata?.options?.description) && (
+
+
+
+ )}
+ {templateMetadata?.options?.image && (
+
+
+ {({ field, meta, form }: FieldProps) => (
+ {
+ form.setFieldTouched('image', true, false)
+ form.setFieldValue('image', file)
+ }}
+ accept="JPG/PNG/SVG, max 1MB"
+ label="Asset image"
+ errorMessage={meta.touched ? meta.error : undefined}
+ />
+ )}
+
+
+ )}
+ {templateMetadata?.options?.description && (
+
+
+
+ )}
+
+ )}
+ {form.values.assetType !== 'cash' && (
+
+
+
+ )}
+
+ )
+}
diff --git a/centrifuge-app/src/components/Dashboard/assets/CreateAssetsDrawer.tsx b/centrifuge-app/src/components/Dashboard/assets/CreateAssetsDrawer.tsx
new file mode 100644
index 0000000000..5e85496694
--- /dev/null
+++ b/centrifuge-app/src/components/Dashboard/assets/CreateAssetsDrawer.tsx
@@ -0,0 +1,330 @@
+import {
+ CurrencyBalance,
+ LoanInfoInput,
+ NFTMetadataInput,
+ Pool,
+ PoolMetadata,
+ Price,
+ Rate,
+} from '@centrifuge/centrifuge-js'
+import {
+ useCentrifuge,
+ useCentrifugeApi,
+ useCentrifugeTransaction,
+ wrapProxyCallsForAccount,
+} from '@centrifuge/centrifuge-react'
+import { Box, Divider, Drawer, Select } from '@centrifuge/fabric'
+import { BN } from 'bn.js'
+import { Field, FieldProps, Form, FormikProvider, useFormik } from 'formik'
+import { useMemo, useState } from 'react'
+import { Navigate } from 'react-router'
+import { firstValueFrom, lastValueFrom, switchMap } from 'rxjs'
+import { LoadBoundary } from '../../../../src/components/LoadBoundary'
+import { LoanTemplate } from '../../../../src/types'
+import { getFileDataURI } from '../../../../src/utils/getFileDataURI'
+import { useMetadata } from '../../../../src/utils/useMetadata'
+import {
+ useFilterPoolsByUserRole,
+ usePoolAccess,
+ usePoolAdmin,
+ useSuitableAccounts,
+} from '../../../utils/usePermissions'
+import { CreateAssetsForm } from './CreateAssetForm'
+import { FooterActionButtons } from './FooterActionButtons'
+import { UploadAssetTemplateForm } from './UploadAssetTemplateForm'
+import { usePoolMetadataMap, valuesToNftProperties } from './utils'
+
+export type PoolWithMetadata = Pool & { meta: PoolMetadata }
+
+export type UploadedTemplate = {
+ id: string
+ createdAt: string
+}
+interface CreateAssetsDrawerProps {
+ open: boolean
+ setOpen: (open: boolean) => void
+ type: 'create-asset' | 'upload-template'
+ setType: (type: 'create-asset' | 'upload-template') => void
+}
+
+export type CreateAssetFormValues = {
+ image: File | null
+ description: string
+ attributes: Record
+ assetType: 'cash' | 'liquid' | 'fund' | 'custom'
+ assetName: string
+ customType: 'atPar' | 'discountedCashFlow'
+ selectedPool: PoolWithMetadata
+ maxBorrowQuantity: number | ''
+ uploadedTemplates: UploadedTemplate[]
+ oracleSource: 'isin' | 'assetSpecific'
+ maxBorrowAmount: 'upToTotalBorrowed' | 'upToOutstandingDebt'
+ maturity: 'fixed' | 'none' | 'fixedWithExtension'
+ value: number | ''
+ maturityDate: string
+ maturityExtensionDays: number
+ advanceRate: number | ''
+ interestRate: number | ''
+ probabilityOfDefault: number | ''
+ lossGivenDefault: number | ''
+ discountRate: number | ''
+ isin: string
+ notional: number | ''
+ withLinearPricing: boolean
+}
+
+export function CreateAssetsDrawer({ open, setOpen, type, setType }: CreateAssetsDrawerProps) {
+ const api = useCentrifugeApi()
+ const centrifuge = useCentrifuge()
+ const filteredPools = useFilterPoolsByUserRole(type === 'upload-template' ? ['PoolAdmin'] : ['Borrower', 'PoolAdmin'])
+ const metas = usePoolMetadataMap(filteredPools || [])
+ const [isUploadingTemplates, setIsUploadingTemplates] = useState(false)
+ const [redirect, setRedirect] = useState(null)
+ const [isLoading, setIsLoading] = useState(false)
+
+ const poolsMetadata = useMemo(() => {
+ return (
+ filteredPools?.map((pool) => {
+ const meta = metas.get(pool.id)
+ return {
+ ...pool,
+ meta,
+ }
+ }) || []
+ )
+ }, [filteredPools, metas])
+
+ const [pid, setPid] = useState(poolsMetadata[0].id)
+ const [account] = useSuitableAccounts({ poolId: pid, poolRole: ['Borrower'], proxyType: ['Borrow'] })
+ const { assetOriginators } = usePoolAccess(pid)
+
+ const collateralCollectionId = assetOriginators.find((ao) => ao.address === account?.actingAddress)
+ ?.collateralCollections[0]?.id
+
+ const templateIds =
+ poolsMetadata.find((pool) => pool.id === pid)?.meta?.loanTemplates?.map((s: { id: string }) => s.id) ?? []
+ const templateId = templateIds.at(-1)
+ const { data: template } = useMetadata(templateId)
+
+ const { isLoading: isTxLoading, execute: doTransaction } = useCentrifugeTransaction(
+ 'Create asset',
+ (cent) =>
+ (
+ [collectionId, nftId, owner, metadataUri, pricingInfo]: [string, string, string, string, LoanInfoInput],
+ options
+ ) => {
+ return centrifuge.pools.createLoan([pid, collectionId, nftId, pricingInfo], { batch: true }).pipe(
+ switchMap((createTx) => {
+ const tx = api.tx.utility.batchAll([
+ wrapProxyCallsForAccount(api, api.tx.uniques.mint(collectionId, nftId, owner), account, 'PodOperation'),
+ wrapProxyCallsForAccount(
+ api,
+ api.tx.uniques.setMetadata(collectionId, nftId, metadataUri, false),
+ account,
+ 'PodOperation'
+ ),
+ wrapProxyCallsForAccount(api, createTx, account, 'Borrow'),
+ ])
+ return cent.wrapSignAndSend(api, tx, { ...options, proxies: undefined })
+ })
+ )
+ },
+ {
+ onSuccess: (_, result) => {
+ const event = result.events.find(({ event }) => api.events.loans.Created.is(event))
+ if (event) {
+ const eventData = event.toHuman() as any
+ const loanId = eventData.event.data.loanId.replace(/\D/g, '')
+
+ // Doing the redirect via state, so it only happens if the user is still on this
+ // page when the transaction completes
+ setRedirect(`/issuer/${pid}/assets/${loanId}`)
+ }
+ },
+ }
+ )
+
+ const form = useFormik({
+ initialValues: {
+ image: null,
+ description: '',
+ attributes: {},
+ assetType: 'cash',
+ assetName: '',
+ customType: 'atPar',
+ selectedPool: poolsMetadata[0],
+ uploadedTemplates: poolsMetadata[0]?.meta?.loanTemplates || ([] as UploadedTemplate[]),
+ valuationMethod: 'oracle',
+ maxBorrowAmount: 'upToTotalBorrowed',
+ maturity: 'fixed',
+ value: '',
+ maturityDate: '',
+ maturityExtensionDays: 0,
+ advanceRate: '',
+ interestRate: '',
+ probabilityOfDefault: '',
+ lossGivenDefault: '',
+ discountRate: '',
+ maxBorrowQuantity: '',
+ isin: '',
+ notional: 100,
+ withLinearPricing: false,
+ oracleSource: 'isin',
+ },
+ onSubmit: async (values) => {
+ if (!pid || !collateralCollectionId || !template || !account) return
+ setIsLoading(true)
+ const decimals = form.values.selectedPool.currency.decimals
+ let pricingInfo: LoanInfoInput | undefined
+ switch (values.assetType) {
+ case 'cash':
+ pricingInfo = {
+ valuationMethod: 'cash',
+ advanceRate: Rate.fromPercent(100),
+ interestRate: Rate.fromPercent(0),
+ value: new BN(2).pow(new BN(128)).subn(1), // max uint128
+ maxBorrowAmount: 'upToOutstandingDebt' as const,
+ maturityDate: null,
+ }
+ break
+ case 'liquid':
+ case 'fund': {
+ const loanId = await firstValueFrom(centrifuge.pools.getNextLoanId([pid]))
+ pricingInfo = {
+ valuationMethod: 'oracle',
+ maxPriceVariation: Rate.fromPercent(9999),
+ maxBorrowAmount: values.maxBorrowQuantity ? Price.fromFloat(values.maxBorrowQuantity) : null,
+ priceId:
+ values.oracleSource === 'isin'
+ ? { isin: values.isin }
+ : { poolLoanId: [pid, loanId.toString()] as [string, string] },
+ maturityDate: values.maturity !== 'none' ? new Date(values.maturityDate) : null,
+ interestRate: Rate.fromPercent(values.notional === 0 ? 0 : values.interestRate),
+ notional: CurrencyBalance.fromFloat(values.notional, decimals),
+ withLinearPricing: values.withLinearPricing,
+ }
+ break
+ }
+ case 'custom':
+ if (values.customType === 'atPar') {
+ pricingInfo = {
+ valuationMethod: 'outstandingDebt',
+ maxBorrowAmount: 'upToOutstandingDebt',
+ value: CurrencyBalance.fromFloat(values.value, decimals),
+ maturityDate: values.maturity !== 'none' ? new Date(values.maturityDate) : null,
+ maturityExtensionDays: values.maturity === 'fixedWithExtension' ? values.maturityExtensionDays : null,
+ advanceRate: Rate.fromPercent(values.advanceRate),
+ interestRate: Rate.fromPercent(values.interestRate),
+ }
+ } else if (values.customType === 'discountedCashFlow') {
+ pricingInfo = {
+ valuationMethod: 'discountedCashFlow',
+ maxBorrowAmount: 'upToTotalBorrowed',
+ value: CurrencyBalance.fromFloat(values.value, decimals),
+ maturityDate: values.maturity !== 'none' ? new Date(values.maturityDate) : null,
+ maturityExtensionDays: values.maturity === 'fixedWithExtension' ? values.maturityExtensionDays : null,
+ advanceRate: Rate.fromPercent(values.advanceRate),
+ interestRate: Rate.fromPercent(values.interestRate),
+ probabilityOfDefault: Rate.fromPercent(values.probabilityOfDefault || 0),
+ lossGivenDefault: Rate.fromPercent(values.lossGivenDefault || 0),
+ discountRate: Rate.fromPercent(values.discountRate || 0),
+ }
+ }
+ break
+ default:
+ break
+ }
+
+ if (!pricingInfo) {
+ throw new Error(`Pricing information is not set for asset type: ${values.assetType}`)
+ }
+
+ const properties =
+ values.valuationMethod === 'cash'
+ ? {}
+ : { ...(valuesToNftProperties(values.attributes, template as any) as any), _template: templateId }
+
+ const metadataValues: NFTMetadataInput = {
+ name: values.assetName,
+ description: values.description,
+ properties,
+ }
+
+ if (values.image) {
+ const fileDataUri = await getFileDataURI(values.image)
+ const imageMetadataHash = await lastValueFrom(centrifuge.metadata.pinFile(fileDataUri))
+ metadataValues.image = imageMetadataHash.uri
+ }
+
+ const metadataHash = await lastValueFrom(centrifuge.metadata.pinJson(metadataValues))
+ const nftId = await centrifuge.nfts.getAvailableNftId(collateralCollectionId)
+
+ doTransaction([collateralCollectionId, nftId, account.actingAddress, metadataHash.uri, pricingInfo], {
+ account,
+ forceProxyType: 'Borrow',
+ })
+ setIsLoading(false)
+ },
+ })
+
+ const poolAdmin = usePoolAdmin(form.values.selectedPool?.id ?? '')
+
+ const resetToDefault = () => {
+ setOpen(false)
+ setType('create-asset')
+ setIsUploadingTemplates(false)
+ form.resetForm()
+ }
+
+ if (redirect) {
+ return
+ }
+
+ if (!filteredPools?.length || !poolsMetadata.length) return null
+
+ return (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/centrifuge-app/src/components/Dashboard/assets/FooterActionButtons.tsx b/centrifuge-app/src/components/Dashboard/assets/FooterActionButtons.tsx
new file mode 100644
index 0000000000..582e659507
--- /dev/null
+++ b/centrifuge-app/src/components/Dashboard/assets/FooterActionButtons.tsx
@@ -0,0 +1,163 @@
+import { PoolMetadata } from '@centrifuge/centrifuge-js'
+import { useCentrifugeTransaction } from '@centrifuge/centrifuge-react'
+import { Box, Button, IconWarning, Text } from '@centrifuge/fabric'
+import { useFormikContext } from 'formik'
+import { useCallback, useMemo } from 'react'
+import { usePoolAdmin, useSuitableAccounts } from '../../../../src/utils/usePermissions'
+import { CreateAssetFormValues } from './CreateAssetsDrawer'
+
+export const FooterActionButtons = ({
+ type,
+ setType,
+ setOpen,
+ isUploadingTemplates,
+ resetToDefault,
+ isLoading,
+}: {
+ type: string
+ setType: (type: 'create-asset' | 'upload-template') => void
+ setOpen: (open: boolean) => void
+ isUploadingTemplates: boolean
+ resetToDefault: () => void
+ isLoading: boolean
+}) => {
+ const form = useFormikContext()
+ const pool = form.values.selectedPool
+ const isCash = form.values.assetType === 'cash'
+ const poolAdmin = usePoolAdmin(pool?.id ?? '')
+ const loanTemplates = pool?.meta?.loanTemplates || []
+ const [account] = useSuitableAccounts({ poolId: pool?.id ?? '', poolRole: ['PoolAdmin'] })
+
+ const hasTemplates = loanTemplates.length > 0
+ const isAdmin = !!poolAdmin
+
+ const { execute: updateTemplatesTx, isLoading: isTemplatesTxLoading } = useCentrifugeTransaction(
+ 'Create asset template',
+ (cent) => cent.pools.setMetadata,
+ {
+ onSuccess: () => resetToDefault(),
+ }
+ )
+
+ const uploadTemplates = useCallback(() => {
+ const loanTemplatesPayload = form.values.uploadedTemplates.map((template) => ({
+ id: template.id,
+ createdAt: template.createdAt || new Date().toISOString(),
+ }))
+
+ const newPoolMetadata = {
+ ...(pool?.meta as PoolMetadata),
+ loanTemplates: loanTemplatesPayload,
+ }
+
+ updateTemplatesTx([pool?.id, newPoolMetadata], { account })
+ }, [form.values.uploadedTemplates, pool?.meta, pool?.id, account, updateTemplatesTx])
+
+ const createButton = useMemo(() => {
+ // If the mode is 'upload-template', show a Save button.
+ if (type === 'upload-template') {
+ return (
+
+
+
+ )
+ }
+
+ // If the asset type is cash, no template is needed.
+ if (isCash) {
+ return (
+
+ )
+ }
+
+ // For non-cash asset types:
+ if (hasTemplates) {
+ // Templates exist: allow both admins and borrowers to create assets.
+ return (
+
+ )
+ } else {
+ // No templates exist.
+ if (isAdmin) {
+ // Admins can upload a template.
+ return (
+
+
+
+ Template must be in .JSON format. 5MB size limit
+
+
+ )
+ } else {
+ // Borrowers cannot upload a template – show a warning message.
+ return (
+
+
+
+
+ Asset template required
+
+
+
+ The pool manager needs to add an asset template before any new assets can be created.
+
+
+ )
+ }
+ }
+ }, [
+ type,
+ form,
+ isCash,
+ hasTemplates,
+ isAdmin,
+ setType,
+ isLoading,
+ isTemplatesTxLoading,
+ isUploadingTemplates,
+ uploadTemplates,
+ ])
+
+ return (
+
+ {createButton}
+
+
+
+
+ )
+}
diff --git a/centrifuge-app/src/components/Dashboard/assets/PricingSection.tsx b/centrifuge-app/src/components/Dashboard/assets/PricingSection.tsx
new file mode 100644
index 0000000000..1657d4ea3d
--- /dev/null
+++ b/centrifuge-app/src/components/Dashboard/assets/PricingSection.tsx
@@ -0,0 +1,192 @@
+import { CurrencyInput, DateInput, Grid, NumberInput, Select, Text, TextInput } from '@centrifuge/fabric'
+import { Field, FieldProps, useFormikContext } from 'formik'
+import { useTheme } from 'styled-components'
+import { FieldWithErrorMessage } from '../../../../src/components/FieldWithErrorMessage'
+import { Tooltips } from '../../../../src/components/Tooltips'
+import { validate } from '../../../../src/pages/IssuerCreatePool/validate'
+import { combine, max, nonNegativeNumber, positiveNumber, required } from '../../../../src/utils/validation'
+import { CreateAssetFormValues } from './CreateAssetsDrawer'
+
+export function PricingSection() {
+ const theme = useTheme()
+ const form = useFormikContext()
+ const { values } = form
+ const isOracle = values.assetType === 'liquid' || values.assetType === 'fund'
+ return (
+
+ {values.assetType === 'custom' && (
+ <>
+
+ {({ field, meta, form }: FieldProps) => (
+
+
+ {({ field, meta, form }: FieldProps) => (
+ form.setFieldValue('value', value)}
+ />
+ )}
+
+ >
+ )}
+ {isOracle && (
+ <>
+
+ {({ field, meta, form }: FieldProps) => (
+
+ {values.oracleSource === 'isin' && (
+ ISIN*} />}
+ placeholder="Type here..."
+ name="isin"
+ validate={validate.isin}
+ />
+ )}
+
+ {({ field, meta, form }: FieldProps) => (
+ Notional value*} />}
+ placeholder="0.00"
+ errorMessage={meta.touched ? meta.error : undefined}
+ onChange={(value) => {
+ form.setFieldValue('notional', value)
+ if (value === 0) {
+ form.setFieldValue('interestRate', 0)
+ }
+ }}
+ currency={values.selectedPool.currency.symbol}
+ />
+ )}
+
+
+ {({ field, meta }: FieldProps) => (
+
+ >
+ )}
+ Interest rate*} />}
+ placeholder="0.00"
+ symbol="%"
+ disabled={Number(values.notional) <= 0}
+ name="interestRate"
+ validate={combine(required(), nonNegativeNumber(), max(100))}
+ />
+
+ {({ field, meta, form }: FieldProps) => (
+
+ {values.maturity.startsWith('fixed') && (
+
+ )}
+ {values.assetType === 'custom' && (
+ Advance rate*} />}
+ placeholder="0.00"
+ symbol="%"
+ name="pricing.advanceRate"
+ validate={validate.advanceRate}
+ />
+ )}
+ {values.assetType === 'custom' && values.customType === 'discountedCashFlow' && (
+ <>
+ Probability of default*} />
+ }
+ placeholder="0.00"
+ symbol="%"
+ name="probabilityOfDefault"
+ validate={validate.probabilityOfDefault}
+ />
+ Loss given default*} />}
+ placeholder="0.00"
+ symbol="%"
+ name="lossGivenDefault"
+ validate={validate.lossGivenDefault}
+ />
+ Discount rate*} />}
+ placeholder="0.00"
+ symbol="%"
+ name="discountRate"
+ validate={validate.discountRate}
+ />
+ >
+ )}
+
+ )
+}
diff --git a/centrifuge-app/src/components/Dashboard/assets/UploadAssetTemplateForm.tsx b/centrifuge-app/src/components/Dashboard/assets/UploadAssetTemplateForm.tsx
new file mode 100644
index 0000000000..d7f4c96892
--- /dev/null
+++ b/centrifuge-app/src/components/Dashboard/assets/UploadAssetTemplateForm.tsx
@@ -0,0 +1,179 @@
+import { useCentrifuge } from '@centrifuge/centrifuge-react'
+import {
+ AnchorButton,
+ Box,
+ FileUploadButton,
+ IconDownload,
+ IconFile,
+ IconPlus,
+ IconWarning,
+ Text,
+} from '@centrifuge/fabric'
+import { useFormikContext } from 'formik'
+import { useMemo } from 'react'
+import { lastValueFrom } from 'rxjs'
+import { LoanTemplate } from 'src/types'
+import { useTheme } from 'styled-components'
+import { useMetadataMulti } from '../../../../src/utils/useMetadata'
+import { createDownloadJson } from '../../../utils/createDownloadJson'
+import { usePoolAdmin } from '../../../utils/usePermissions'
+import { CreateAssetFormValues, UploadedTemplate } from './CreateAssetsDrawer'
+
+export interface UploadedFile {
+ id: string
+ file: File
+ url: string
+ fileName: string
+ data: any
+ downloadUrl: string
+ name: string
+}
+
+interface DownloadItem {
+ id: string
+ name: string
+ url: string
+ downloadFileName: string
+ revoke?: () => void
+}
+
+export const UploadAssetTemplateForm = ({
+ setIsUploadingTemplates,
+}: {
+ setIsUploadingTemplates: (isUploadingTemplates: boolean) => void
+}) => {
+ const theme = useTheme()
+ const cent = useCentrifuge()
+ const form = useFormikContext()
+ const selectedPool = form.values.selectedPool
+ const uploadedFiles: UploadedTemplate[] = form.values.uploadedTemplates
+ const templateIds = useMemo(() => {
+ return uploadedFiles.map((s: { id: string }) => s.id)
+ }, [uploadedFiles])
+ const templatesMetadataResults = useMetadataMulti(templateIds)
+ const templatesMetadata = templatesMetadataResults.filter(Boolean)
+ const poolAdmin = usePoolAdmin(form.values.selectedPool?.id)
+
+ const templatesData = useMemo(() => {
+ return templateIds.map((id, i) => {
+ const meta = templatesMetadata[i].data
+ const metaMeta = selectedPool?.meta?.loanTemplates?.[i]
+ return {
+ id,
+ name: `Version ${i + 1}`,
+ createdAt: metaMeta?.createdAt ? new Date(metaMeta?.createdAt) : null,
+ data: meta,
+ }
+ })
+ }, [templateIds, templatesMetadata, selectedPool])
+
+ const templateDownloadItems: DownloadItem[] = useMemo(() => {
+ return templatesData.map((template) => {
+ const info = createDownloadJson(template, `loan-template-${template.id}.json`)
+ return {
+ id: template.id,
+ name: template.name,
+ url: info.url,
+ downloadFileName: info.fileName,
+ revoke: info.revoke,
+ }
+ })
+ }, [templatesData])
+
+ const pinFiles = async (newUpload: UploadedFile) => {
+ setIsUploadingTemplates(true)
+ try {
+ const templateMetadataHash = await lastValueFrom(cent.metadata.pinJson(newUpload.data))
+ const updatedUpload = { id: templateMetadataHash.ipfsHash, createdAt: new Date().toISOString() }
+ form.setFieldValue('uploadedTemplates', [...form.values.uploadedTemplates, updatedUpload])
+ setIsUploadingTemplates(false)
+ } catch (error) {
+ console.error('Error pinning template:', error)
+ setIsUploadingTemplates(false)
+ }
+ }
+
+ return (
+
+ {templateDownloadItems.map((item) => (
+
+
+
+
+ {item?.name?.length > 20 ? `${item.name.slice(0, 20)}...` : item?.name}
+
+
+ }
+ small
+ download={item.downloadFileName}
+ />
+
+ ))}
+
+
+ {!!poolAdmin ? (
+ }
+ small
+ text={templateDownloadItems.length ? 'Upload another template' : 'Upload asset template'}
+ onFileChange={(files) => {
+ Array.from(files).forEach((file) => {
+ const reader = new FileReader()
+ reader.onload = (event) => {
+ try {
+ const text = event.target?.result as string
+ const parsedData = JSON.parse(text)
+ if (typeof parsedData !== 'object' || parsedData === null) {
+ throw new Error('Uploaded JSON is not a valid object.')
+ }
+ const blob = new Blob([JSON.stringify(parsedData, null, 2)], {
+ type: 'application/json',
+ })
+ const downloadUrl = URL.createObjectURL(blob)
+ const url = URL.createObjectURL(file)
+ const id = `${file.name}-${Date.now()}`
+ const newUpload: UploadedFile = {
+ id,
+ file,
+ url,
+ fileName: file.name,
+ data: parsedData,
+ downloadUrl,
+ name: file.name,
+ }
+ pinFiles(newUpload)
+ } catch (error) {
+ alert(`Error parsing file "${file.name}": ${error instanceof Error ? error.message : error}`)
+ }
+ }
+ reader.readAsText(file)
+ })
+ }}
+ />
+ ) : (
+
+
+ Only pool admins can upload asset templates.
+
+ )}
+
+
+ )
+}
diff --git a/centrifuge-app/src/components/Dashboard/assets/utils.ts b/centrifuge-app/src/components/Dashboard/assets/utils.ts
new file mode 100644
index 0000000000..47e99a9c91
--- /dev/null
+++ b/centrifuge-app/src/components/Dashboard/assets/utils.ts
@@ -0,0 +1,139 @@
+import { CurrencyBalance, Loan, Pool } from '@centrifuge/centrifuge-js'
+import { useMemo } from 'react'
+import { LoanTemplate } from '../../../../src/types'
+import { Dec } from '../../../utils/Decimal'
+import { usePoolMetadataMulti } from '../../../utils/usePools'
+import { getAmount } from '../../LoanList'
+import { CreateAssetFormValues } from './CreateAssetsDrawer'
+
+export type TransformedLoan = Loan & {
+ pool: Pool
+ outstandingQuantity: CurrencyBalance
+ presentValue: CurrencyBalance
+}
+
+const hasValuationMethod = (pricing: any): pricing is { valuationMethod: string; presentValue: CurrencyBalance } => {
+ return pricing && typeof pricing.valuationMethod === 'string'
+}
+
+export const useLoanCalculations = (transformedLoans: TransformedLoan[]) => {
+ const totalLoans = useMemo(() => transformedLoans.length, [transformedLoans])
+
+ const totalAssets = useMemo(() => {
+ return transformedLoans.reduce((sum, loan) => {
+ if (hasValuationMethod(loan.pricing) && loan.pricing.valuationMethod !== 'cash') {
+ const amount = new CurrencyBalance(
+ getAmount(loan, loan.pool, false, true),
+ loan.pool.currency.decimals
+ ).toDecimal()
+ return sum.add(amount)
+ }
+ return sum
+ }, Dec(0))
+ }, [transformedLoans])
+
+ const offchainAssets = useMemo(() => {
+ return transformedLoans.filter(
+ (loan) => hasValuationMethod(loan.pricing) && loan.pricing.valuationMethod === 'cash'
+ )
+ }, [transformedLoans])
+
+ const offchainReserve = useMemo(() => {
+ return transformedLoans.reduce((sum, loan) => {
+ if (hasValuationMethod(loan.pricing) && loan.pricing.valuationMethod === 'cash' && loan.status === 'Active') {
+ const amount = new CurrencyBalance(
+ getAmount(loan, loan.pool, false, true),
+ loan.pool.currency.decimals
+ ).toDecimal()
+ return sum.add(amount)
+ }
+ return sum
+ }, Dec(0))
+ }, [transformedLoans])
+
+ const uniquePools = useMemo(() => {
+ const poolMap = new Map()
+ transformedLoans.forEach((loan) => {
+ if (!poolMap.has(loan.pool.id)) {
+ poolMap.set(loan.pool.id, loan)
+ }
+ })
+ return Array.from(poolMap.values())
+ }, [transformedLoans])
+
+ const onchainReserve = useMemo(() => {
+ return uniquePools.reduce((sum, loan) => {
+ const navTotal = loan.pool.reserve?.total || '0'
+ const navAmount = new CurrencyBalance(navTotal, loan.pool.currency.decimals).toDecimal()
+ return sum.add(navAmount)
+ }, Dec(0))
+ }, [uniquePools])
+
+ const pendingFees = useMemo(() => {
+ return uniquePools.reduce((sum, loan) => {
+ const feeTotalPaid = loan.pool.fees?.totalPaid ? loan.pool.fees.totalPaid.toDecimal() : 0
+ return sum.add(Dec(feeTotalPaid))
+ }, Dec(0))
+ }, [uniquePools])
+
+ const totalNAV = useMemo(() => {
+ return uniquePools.reduce((sum, loan) => {
+ const navTotal = loan.pool.nav?.total || '0'
+ const navAmount = new CurrencyBalance(navTotal, loan.pool.currency.decimals).toDecimal()
+ return sum.add(navAmount)
+ }, Dec(0))
+ }, [uniquePools])
+
+ return {
+ totalLoans,
+ totalAssets,
+ offchainAssets,
+ offchainReserve,
+ onchainReserve,
+ pendingFees,
+ totalNAV,
+ }
+}
+
+export function usePoolMetadataMap(pools: Pool[]) {
+ const metas = usePoolMetadataMulti(pools)
+ const poolMetadataMap = useMemo(() => {
+ const map = new Map()
+ pools.forEach((pool, index) => {
+ map.set(pool.id, metas[index]?.data)
+ })
+ return map
+ }, [pools, metas])
+ return poolMetadataMap
+}
+
+export function valuesToNftProperties(values: CreateAssetFormValues['attributes'], template: LoanTemplate) {
+ return Object.fromEntries(
+ template.sections.flatMap((section) =>
+ section.attributes
+ .map((key) => {
+ const attr = template.attributes[key]
+ if (!attr.public) return undefined as never
+ const value = values[key]
+ switch (attr.input.type) {
+ case 'date':
+ return [key, new Date(value).toISOString()]
+ case 'currency': {
+ return [
+ key,
+ attr.input.decimals ? CurrencyBalance.fromFloat(value, attr.input.decimals).toString() : String(value),
+ ]
+ }
+ case 'number':
+ return [
+ key,
+ attr.input.decimals ? CurrencyBalance.fromFloat(value, attr.input.decimals).toString() : String(value),
+ ]
+ default:
+ return [key, String(value)]
+ }
+ })
+ .filter(Boolean)
+ )
+ )
+}
diff --git a/centrifuge-app/src/components/DataTable.tsx b/centrifuge-app/src/components/DataTable.tsx
index a8f17a0587..c2970d23cd 100644
--- a/centrifuge-app/src/components/DataTable.tsx
+++ b/centrifuge-app/src/components/DataTable.tsx
@@ -463,21 +463,21 @@ export function FilterableTableHeader({
value={option}
onChange={handleChange}
checked={checked}
- label={label}
+ label={{label}}
extendedClickArea
/>
)
})}
-
+
{selectedOptions?.size === optionKeys.length ? (
- deselectAll()}>
+ deselectAll()}>
Deselect all
) : (
- selectAll()}>
+ selectAll()}>
Select all
)}
diff --git a/centrifuge-app/src/components/LoanLabel.tsx b/centrifuge-app/src/components/LoanLabel.tsx
index 615853891b..d3e26c1fda 100644
--- a/centrifuge-app/src/components/LoanLabel.tsx
+++ b/centrifuge-app/src/components/LoanLabel.tsx
@@ -11,38 +11,71 @@ interface Props {
export function getLoanLabelStatus(l: Loan | TinlakeLoan): [LabelStatus, string] {
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
- if (!l.status) return ['', '']
- if (l.status === 'Active' && (l as ActiveLoan).writeOffStatus) return ['critical', 'Write-off']
+ if (!l.status) {
+ return ['', '']
+ }
+
+ const status = l.status.toLowerCase()
+
+ const isActive = status === 'active'
+ const isCreated = status === 'created'
+ const isClosed = status === 'closed'
+ const hasMaturity = isActive && l.pricing.maturityDate
+ const isTinlakeLoan = 'riskGroup' in l
+ const isWriteOff = isActive && (l as ActiveLoan).writeOffStatus
+
+ // Highest priority: Write-off condition
+ if (isWriteOff) {
+ return ['critical', 'Write-off']
+ }
+
+ // Check for repaid conditions
const isExternalAssetRepaid =
- l.status === 'Active' && 'outstandingQuantity' in l.pricing && 'presentValue' in l && l.presentValue.isZero()
- if (l.status === 'Closed' || isExternalAssetRepaid) return ['ok', 'Repaid']
- if (
- l.status === 'Active' &&
- 'interestRate' in l.pricing &&
- l.pricing.interestRate?.gtn(0) &&
- l.totalBorrowed?.isZero()
- )
+ isActive && 'outstandingQuantity' in l.pricing && 'presentValue' in l && l.presentValue.isZero()
+ if (isClosed || isExternalAssetRepaid) {
+ return ['ok', 'Repaid']
+ }
+
+ // Active loan where interest exists and no amount has been borrowed
+ if (isActive && 'interestRate' in l.pricing && l.pricing.interestRate?.gtn(0) && l.totalBorrowed?.isZero()) {
return ['default', 'Ready']
- if (l.status === 'Created') return ['default', 'Created']
-
- if (l.status === 'Active' && l.pricing.maturityDate) {
- const isTinlakeLoan = 'riskGroup' in l
- if (isTinlakeLoan) return ['warning', 'Ongoing']
-
- const days = daysBetween(today, l.pricing.maturityDate)
- if (days === 0) return ['warning', 'Due today']
- if (days === 1) return ['warning', 'Due tomorrow']
- if (days > 1 && days <= 5) return ['warning', `Due in ${days} days`]
- if (days === -1) return ['critical', `Due ${Math.abs(days)} day ago`]
- if (days < -1) return ['critical', `Due ${Math.abs(days)} days ago`]
}
+
+ // Newly created loans are simply ongoing
+ if (isCreated) {
+ return ['warning', 'Ongoing']
+ }
+
+ // For active loans with a maturity date
+ if (hasMaturity) {
+ // For Tinlake-specific loans, always mark as ongoing regardless of maturity
+ if (isTinlakeLoan) {
+ return ['warning', 'Ongoing']
+ }
+
+ const days = daysBetween(today, l.pricing.maturityDate!)
+ if (days === 0) {
+ return ['critical', 'Due today']
+ }
+ if (days === 1) {
+ return ['warning', 'Due tomorrow']
+ }
+ if (days > 1 && days <= 5) {
+ return ['warning', `Due in ${days} days`]
+ }
+ if (days < 0) {
+ return ['critical', 'Overdue']
+ }
+ }
+
+ // Default label when no specific condition is met
return ['warning', 'Ongoing']
}
export function LoanLabel({ loan }: Props) {
const [status, text] = getLoanLabelStatus(loan)
const isCashAsset = 'valuationMethod' in loan.pricing && loan.pricing?.valuationMethod === 'cash'
- if (!status || isCashAsset) return null
+ if (!status || isCashAsset) return '-'
return {text}
}
diff --git a/centrifuge-app/src/components/LoanList.tsx b/centrifuge-app/src/components/LoanList.tsx
index e0ce284317..227a4485b8 100644
--- a/centrifuge-app/src/components/LoanList.tsx
+++ b/centrifuge-app/src/components/LoanList.tsx
@@ -380,7 +380,7 @@ export function AssetName({ loan }: { loan: Pick{getAmount(loan, pool, true)}
+ return {getAmount(loan, pool, true)}
}
diff --git a/centrifuge-app/src/components/Menu/DashboardMenu.tsx b/centrifuge-app/src/components/Menu/DashboardMenu.tsx
index ada86f7dc6..7e969c29f2 100644
--- a/centrifuge-app/src/components/Menu/DashboardMenu.tsx
+++ b/centrifuge-app/src/components/Menu/DashboardMenu.tsx
@@ -20,7 +20,7 @@ export function DashboardMenu() {
) : (
pages.map(({ href, label }) => (
-
+
{label}
diff --git a/centrifuge-app/src/components/PageSummary.tsx b/centrifuge-app/src/components/PageSummary.tsx
index edf769f8f7..4a83e38b0a 100644
--- a/centrifuge-app/src/components/PageSummary.tsx
+++ b/centrifuge-app/src/components/PageSummary.tsx
@@ -11,7 +11,7 @@ type Props = {
children?: React.ReactNode
}
-export function PageSummary({ data, children }: Props) {
+export function PageSummary({ data, children, ...props }: Props) {
const theme = useTheme()
return (
{data?.map(({ label, value, heading }, index) => (
diff --git a/centrifuge-app/src/components/PoolOverview/Cashflows.tsx b/centrifuge-app/src/components/PoolOverview/Cashflows.tsx
index bd123542fb..62b56df2a5 100644
--- a/centrifuge-app/src/components/PoolOverview/Cashflows.tsx
+++ b/centrifuge-app/src/components/PoolOverview/Cashflows.tsx
@@ -14,7 +14,7 @@ export const Cashflows = () => {
const { poolStates } = useDailyPoolStates(poolId) || {}
const pool = usePool(poolId)
- const { data: loans } = useLoans(poolId)
+ const { data: loans } = useLoans([poolId])
const firstOriginationDate = loans?.reduce((acc, cur) => {
if ('originationDate' in cur) {
diff --git a/centrifuge-app/src/components/Report/AssetTransactions.tsx b/centrifuge-app/src/components/Report/AssetTransactions.tsx
index 48cde87eef..a07357ed14 100644
--- a/centrifuge-app/src/components/Report/AssetTransactions.tsx
+++ b/centrifuge-app/src/components/Report/AssetTransactions.tsx
@@ -32,7 +32,7 @@ type Row = {
}
export function AssetTransactions({ pool }: { pool: Pool }) {
- const { startDate, endDate, setCsvData, txType, loan: loanId } = React.useContext(ReportContext)
+ const { startDate, endDate, setCsvData, loan: loanId } = React.useContext(ReportContext)
const transactions = useAssetTransactions(pool.id, new Date(startDate), new Date(endDate))
const explorer = useGetExplorerUrl()
const basePath = useBasePath()
@@ -163,7 +163,7 @@ export function AssetTransactions({ pool }: { pool: Pool }) {
return
}
- return data.length > 0 ? (
+ return data && data.length > 0 ? (
diff --git a/centrifuge-app/src/components/Report/DataFilter.tsx b/centrifuge-app/src/components/Report/DataFilter.tsx
index 02a1420d8a..5c061f8ccc 100644
--- a/centrifuge-app/src/components/Report/DataFilter.tsx
+++ b/centrifuge-app/src/components/Report/DataFilter.tsx
@@ -48,7 +48,7 @@ export function DataFilter({ poolId }: ReportFilterProps) {
const { data: domains } = useActiveDomains(pool.id)
const getNetworkName = useGetNetworkName()
- const { data: loans } = useLoans(pool.id) as { data: Loan[] | undefined | null; isLoading: boolean }
+ const { data: loans } = useLoans([pool.id]) as { data: Loan[] | undefined | null; isLoading: boolean }
const { showOracleTx } = useDebugFlags()
diff --git a/centrifuge-app/src/components/Tooltips.tsx b/centrifuge-app/src/components/Tooltips.tsx
index 0bca55ddcf..28ccfae58c 100644
--- a/centrifuge-app/src/components/Tooltips.tsx
+++ b/centrifuge-app/src/components/Tooltips.tsx
@@ -378,6 +378,30 @@ export const tooltipText = {
label: '',
body: 'You can directly whitelist the addresses that can invest in the pool.',
},
+ cashAsset: {
+ label: '',
+ body: 'Offchain funds held in a traditional bank account or custody account',
+ },
+ liquidAsset: {
+ label: '',
+ body: 'Identical assets that can be exchanged. E.g. stocks, bonds, commodities',
+ },
+ fundShares: {
+ label: '',
+ body: 'Invest in a portfolio of funds, rather than directly in individual securities or assets',
+ },
+ customAsset: {
+ label: '',
+ body: 'Unique assets that cannot be exchanged, have value specific to the asset. E.g. real estate, art, NFTs',
+ },
+ atPar: {
+ label: '',
+ body: 'Valuing the asset at its face value or nominal value, without accounting for any discounts, premiums, or adjustments for time value of money.',
+ },
+ discountedCashFlow: {
+ label: '',
+ body: 'Valuing the asset at its face value or nominal value, without accounting for any discounts, premiums, or adjustments for time value of money',
+ },
}
export type TooltipsProps = {
@@ -396,7 +420,7 @@ export function Tooltips({ type, label: labelOverride, size = 'sm', props, color
{labelOverride || label}
diff --git a/centrifuge-app/src/pages/Dashboard/AssetsPage.tsx b/centrifuge-app/src/pages/Dashboard/AssetsPage.tsx
index d0581a4335..b4f8053847 100644
--- a/centrifuge-app/src/pages/Dashboard/AssetsPage.tsx
+++ b/centrifuge-app/src/pages/Dashboard/AssetsPage.tsx
@@ -1,3 +1,96 @@
+import { Box, Checkbox, Text } from '@centrifuge/fabric'
+import { useEffect } from 'react'
+import { PoolCard } from '../../../src/components/Dashboard/PoolCard'
+import { PageSummary } from '../../../src/components/PageSummary'
+import { Spinner } from '../../../src/components/Spinner'
+import { Tooltips } from '../../../src/components/Tooltips'
+import { useSelectedPools } from '../../../src/utils/contexts/SelectedPoolsContext'
+import { formatBalance } from '../../../src/utils/formatting'
+import { useLoans } from '../../../src/utils/useLoans'
+import AssetsTable from '../../components/Dashboard/assets/AssetsTable'
+import { TransformedLoan, useLoanCalculations } from '../../components/Dashboard/assets/utils'
+
export default function AssetsPage() {
- return <>>
+ const { selectedPools, togglePoolSelection, setSelectedPools, pools = [] } = useSelectedPools()
+ const ids = pools.map((pool) => pool.id)
+ const { data: loans, isLoading } = useLoans(pools ? ids : [])
+
+ useEffect(() => {
+ if (selectedPools.length === 0 && pools.length > 0) {
+ setSelectedPools(pools.map((pool) => pool.id))
+ }
+ }, [pools.length, selectedPools.length, setSelectedPools, pools])
+
+ const poolMap = pools.reduce>((map, pool) => {
+ map[pool.id] = pool
+ return map
+ }, {})
+
+ const loansWithPool = loans?.map((loan) => ({
+ ...loan,
+ pool: poolMap[loan.poolId] || null,
+ }))
+
+ const filteredPools = loansWithPool?.filter((loan) => selectedPools.includes(loan.poolId)) ?? []
+
+ const { totalAssets, offchainReserve, onchainReserve, pendingFees, totalNAV } = useLoanCalculations(
+ filteredPools as TransformedLoan[]
+ )
+
+ const pageSummaryData: { label: React.ReactNode; value: React.ReactNode; heading?: boolean }[] = [
+ {
+ label: `Total NAV`,
+ value: `${formatBalance(totalNAV)} USDC`,
+ heading: true,
+ },
+ {
+ label: ,
+ value: {formatBalance(onchainReserve)} USDC,
+ heading: false,
+ },
+
+ {
+ label: ,
+ value: {formatBalance(offchainReserve)} USDC,
+ heading: false,
+ },
+ {
+ label: `Total Assets`,
+ value: {formatBalance(totalAssets)} USDC,
+ heading: false,
+ },
+ {
+ label: `Total pending fees (USDC)`,
+ value: `${pendingFees.isZero() ? '' : '-'}${formatBalance(pendingFees)} USDC`,
+ heading: false,
+ },
+ ]
+
+ if (isLoading || !loans || !pools.length) return
+
+ return (
+
+ Dashboard
+
+ {pools.map((pool, index) => (
+ togglePoolSelection(pool.id)}
+ onClick={(e) => e.stopPropagation()}
+ checked={selectedPools.includes(pool.id)}
+ />
+ }
+ onClick={() => togglePoolSelection(pool.id)}
+ />
+ ))}
+
+
+
+
+ )
}
diff --git a/centrifuge-app/src/pages/Dashboard/index.tsx b/centrifuge-app/src/pages/Dashboard/index.tsx
index f36dac3d12..eb03713f8a 100644
--- a/centrifuge-app/src/pages/Dashboard/index.tsx
+++ b/centrifuge-app/src/pages/Dashboard/index.tsx
@@ -1,4 +1,5 @@
import { Route, Routes } from 'react-router'
+import { SelectedPoolsProvider } from '../../../src/utils/contexts/SelectedPoolsContext'
import AccountsPage from './AccountsPage'
import AssetsPage from './AssetsPage'
import Dashboard from './Dashboard'
@@ -6,11 +7,13 @@ import InvestorsPage from './InvestorsPage'
export default function DashboardPage() {
return (
-
- } />
- } />
- } />
- } />
-
+
+
+ } />
+ } />
+ } />
+ } />
+
+
)
}
diff --git a/centrifuge-app/src/pages/IssuerPool/Assets/CreateLoan.tsx b/centrifuge-app/src/pages/IssuerPool/Assets/CreateLoan.tsx
index 5c6532046d..44cf068723 100644
--- a/centrifuge-app/src/pages/IssuerPool/Assets/CreateLoan.tsx
+++ b/centrifuge-app/src/pages/IssuerPool/Assets/CreateLoan.tsx
@@ -325,6 +325,8 @@ function IssuerCreateLoan() {
},
})
+ console.log(form.values)
+
const templateIds = poolMetadata?.loanTemplates?.map((s) => s.id) ?? []
const templateId = templateIds.at(-1)
const { data: templateMetadata } = useMetadata(templateId)
diff --git a/centrifuge-app/src/pages/IssuerPool/Investors/OnboardingSettings.tsx b/centrifuge-app/src/pages/IssuerPool/Investors/OnboardingSettings.tsx
index 260ddb7d86..be6d15c8aa 100644
--- a/centrifuge-app/src/pages/IssuerPool/Investors/OnboardingSettings.tsx
+++ b/centrifuge-app/src/pages/IssuerPool/Investors/OnboardingSettings.tsx
@@ -6,7 +6,6 @@ import {
Checkbox,
FileUpload,
IconMinusCircle,
- RadioButton,
SearchInput,
Shelf,
Stack,
@@ -300,7 +299,7 @@ export const OnboardingSettings = () => {
Onboarding provider
-
+ {/*
{
setUseExternalUrl(true)
}}
/>
-
+ */}
{useExternalUrl && (
l.id === source) as CreatedLoan | ActiveLoan
const displayCurrency = source === 'reserve' ? pool.currency.symbol : 'USD'
const [transactionSuccess, setTransactionSuccess] = React.useState(false)
diff --git a/centrifuge-app/src/pages/Loan/ExternalRepayForm.tsx b/centrifuge-app/src/pages/Loan/ExternalRepayForm.tsx
index 27c9c14aeb..4274fd80c4 100644
--- a/centrifuge-app/src/pages/Loan/ExternalRepayForm.tsx
+++ b/centrifuge-app/src/pages/Loan/ExternalRepayForm.tsx
@@ -49,7 +49,7 @@ export function ExternalRepayForm({
const account = useBorrower(loan.poolId, loan.id)
const poolFees = useChargePoolFees(loan.poolId, loan.id)
const muxRepay = useMuxRepay(loan.poolId, loan.id)
- const { data: loans } = useLoans(loan.poolId)
+ const { data: loans } = useLoans([loan.poolId])
const destinationLoan = loans?.find((l) => l.id === destination) as ActiveLoan
const displayCurrency = destination === 'reserve' ? pool.currency.symbol : 'USD'
const utils = useCentrifugeUtils()
diff --git a/centrifuge-app/src/pages/Loan/FinanceForm.tsx b/centrifuge-app/src/pages/Loan/FinanceForm.tsx
index 608eec4714..9d61b3097f 100644
--- a/centrifuge-app/src/pages/Loan/FinanceForm.tsx
+++ b/centrifuge-app/src/pages/Loan/FinanceForm.tsx
@@ -106,7 +106,7 @@ function InternalFinanceForm({
const account = useBorrower(loan.poolId, loan.id)
const api = useCentrifugeApi()
const poolFees = useChargePoolFees(loan.poolId, loan.id)
- const { data: loans } = useLoans(loan.poolId)
+ const { data: loans } = useLoans([loan.poolId])
const displayCurrency = source === 'reserve' ? pool.currency.symbol : 'USD'
const { current: availableFinancing } = useAvailableFinancing(loan.poolId, loan.id)
diff --git a/centrifuge-app/src/pages/Loan/RepayForm.tsx b/centrifuge-app/src/pages/Loan/RepayForm.tsx
index 58f1edc697..86dfa9fd1f 100644
--- a/centrifuge-app/src/pages/Loan/RepayForm.tsx
+++ b/centrifuge-app/src/pages/Loan/RepayForm.tsx
@@ -89,7 +89,7 @@ function InternalRepayForm({
const account = useBorrower(loan.poolId, loan.id)
const poolFees = useChargePoolFees(loan.poolId, loan.id)
const muxRepay = useMuxRepay(loan.poolId, loan.id)
- const { data: loans } = useLoans(loan.poolId)
+ const { data: loans } = useLoans([loan.poolId])
const api = useCentrifugeApi()
const destinationLoan = loans?.find((l) => l.id === destination) as Loan
const displayCurrency = destination === 'reserve' ? pool.currency.symbol : 'USD'
diff --git a/centrifuge-app/src/pages/Loan/SourceSelect.tsx b/centrifuge-app/src/pages/Loan/SourceSelect.tsx
index 89016c3a1b..71dc38957c 100644
--- a/centrifuge-app/src/pages/Loan/SourceSelect.tsx
+++ b/centrifuge-app/src/pages/Loan/SourceSelect.tsx
@@ -14,7 +14,7 @@ type SourceSelectProps = {
}
export function SourceSelect({ loan, value, onChange, action }: SourceSelectProps) {
- const { data: unfilteredLoans } = useLoans(loan.poolId)
+ const { data: unfilteredLoans } = useLoans([loan.poolId])
const account = useBorrower(loan.poolId, loan.id)
// acceptable options are active loans with cash valuation ONLY if connected account is the borrower
diff --git a/centrifuge-app/src/pages/Loan/index.tsx b/centrifuge-app/src/pages/Loan/index.tsx
index 2a2db6a618..c22fea5c30 100644
--- a/centrifuge-app/src/pages/Loan/index.tsx
+++ b/centrifuge-app/src/pages/Loan/index.tsx
@@ -9,7 +9,7 @@ import {
} from '@centrifuge/centrifuge-js'
import { BackButton, Box, Button, Card, Drawer, Grid, Shelf, Spinner, Stack, Text, truncate } from '@centrifuge/fabric'
import * as React from 'react'
-import { useParams } from 'react-router'
+import { useNavigate, useParams } from 'react-router'
import { AssetSummary } from '../../../src/components/AssetSummary'
import { SimpleLineChart } from '../../../src/components/Charts/SimpleLineChart'
import { LoanLabel, getLoanLabelStatus } from '../../../src/components/LoanLabel'
@@ -105,6 +105,7 @@ function Loan() {
const { pid: poolId, aid: loanId } = useParams<{ pid: string; aid: string }>()
if (!poolId || !loanId) throw new Error('Loan no found')
const basePath = useBasePath()
+ const navigate = useNavigate()
const isTinlakePool = poolId?.startsWith('0x')
const pool = usePool(poolId)
const loan = useLoan(poolId, loanId)
@@ -185,7 +186,7 @@ function Loan() {
return (
-
+
{loan && }
diff --git a/centrifuge-app/src/pages/Pool/Assets/index.tsx b/centrifuge-app/src/pages/Pool/Assets/index.tsx
index 0706971fb7..14b9b7f171 100644
--- a/centrifuge-app/src/pages/Pool/Assets/index.tsx
+++ b/centrifuge-app/src/pages/Pool/Assets/index.tsx
@@ -18,7 +18,7 @@ import { usePool } from '../../../utils/usePools'
import { PoolDetailHeader } from '../Header'
import { OffchainMenu } from './OffchainMenu'
-const StyledRouterTextLink = styled(RouterTextLink)`
+export const StyledRouterTextLink = styled(RouterTextLink)`
text-decoration: unset;
display: flex;
align-items: center;
@@ -44,7 +44,7 @@ export function PoolDetailAssets() {
if (!poolId) throw new Error('Pool not found')
const pool = usePool(poolId)
- const { data: loans } = useLoans(poolId)
+ const { data: loans } = useLoans([poolId])
const isTinlakePool = poolId.startsWith('0x')
const basePath = useBasePath()
const cashLoans = (loans ?? []).filter(
diff --git a/centrifuge-app/src/utils/contexts/SelectedPoolsContext.tsx b/centrifuge-app/src/utils/contexts/SelectedPoolsContext.tsx
new file mode 100644
index 0000000000..a80b84666f
--- /dev/null
+++ b/centrifuge-app/src/utils/contexts/SelectedPoolsContext.tsx
@@ -0,0 +1,48 @@
+import { Pool } from '@centrifuge/centrifuge-js'
+import React, { ReactNode, createContext, useContext, useState } from 'react'
+import { usePoolsThatAnyConnectedAddressHasPermissionsFor } from '../usePermissions'
+
+interface SelectedPoolsContextProps {
+ selectedPools: string[]
+ togglePoolSelection: (poolId: string) => void
+ setSelectedPools: React.Dispatch>
+ clearSelectedPools: () => void
+ pools: Pool[] | undefined
+}
+
+const SelectedPoolsContext = createContext(undefined)
+
+export const useSelectedPools = (): SelectedPoolsContextProps => {
+ const context = useContext(SelectedPoolsContext)
+ if (!context) {
+ throw new Error('useSelectedPools must be used within a SelectedPoolsProvider')
+ }
+ return context
+}
+
+interface SelectedPoolsProviderProps {
+ children: ReactNode
+}
+
+export const SelectedPoolsProvider = ({ children }: SelectedPoolsProviderProps) => {
+ const pools = usePoolsThatAnyConnectedAddressHasPermissionsFor()
+ const [selectedPools, setSelectedPools] = useState([])
+
+ const togglePoolSelection = (poolId: string) => {
+ setSelectedPools((prevSelected) =>
+ prevSelected.includes(poolId) ? prevSelected.filter((id) => id !== poolId) : [...prevSelected, poolId]
+ )
+ }
+
+ const clearSelectedPools = () => {
+ setSelectedPools([])
+ }
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/centrifuge-app/src/utils/createDownloadJson.ts b/centrifuge-app/src/utils/createDownloadJson.ts
new file mode 100644
index 0000000000..4950abb0eb
--- /dev/null
+++ b/centrifuge-app/src/utils/createDownloadJson.ts
@@ -0,0 +1,9 @@
+export function createDownloadJson(data: any, fileName: string) {
+ const jsonString = JSON.stringify(data, null, 2)
+
+ const blob = new Blob([jsonString], { type: 'application/json' })
+
+ const url = URL.createObjectURL(blob)
+
+ return { url, fileName, revoke: () => URL.revokeObjectURL(url) }
+}
diff --git a/centrifuge-app/src/utils/useAverageMaturity.ts b/centrifuge-app/src/utils/useAverageMaturity.ts
index 76ef24d653..9ced52b99a 100644
--- a/centrifuge-app/src/utils/useAverageMaturity.ts
+++ b/centrifuge-app/src/utils/useAverageMaturity.ts
@@ -5,9 +5,10 @@ import { formatAge } from './date'
import { useLoans } from './useLoans'
export const useAverageMaturity = (poolId: string) => {
- const { data: loans } = useLoans(poolId)
+ const { data: loans } = useLoans([poolId])
const avgMaturity = React.useMemo(() => {
+ if (!loans) return 0
const assets = (loans && [...loans].filter((asset) => asset.status === 'Active')) as ActiveLoan[]
const maturityPerAsset = assets.reduce((sum, asset) => {
if ('maturityDate' in asset.pricing && asset.pricing.maturityDate && asset.pricing.valuationMethod !== 'cash') {
diff --git a/centrifuge-app/src/utils/useLoans.ts b/centrifuge-app/src/utils/useLoans.ts
index 61536a9db3..9d2ce31584 100644
--- a/centrifuge-app/src/utils/useLoans.ts
+++ b/centrifuge-app/src/utils/useLoans.ts
@@ -2,20 +2,20 @@ import { useCentrifugeQuery } from '@centrifuge/centrifuge-react'
import { Dec } from './Decimal'
import { useTinlakeLoans } from './tinlake/useTinlakePools'
-export function useLoans(poolId: string) {
- const isTinlakePool = poolId?.startsWith('0x')
- const [centLoans, isLoading] = useCentrifugeQuery(['loans', poolId], (cent) => cent.pools.getLoans([poolId]), {
+export function useLoans(poolIds: string[]) {
+ const isTinlakePool = poolIds.length === 1 && poolIds[0]?.startsWith('0x')
+
+ const { data: tinlakeLoans, isLoading: isLoadingTinlake } = useTinlakeLoans(poolIds[0])
+
+ const [centLoans, isLoading] = useCentrifugeQuery(['loans', poolIds], (cent) => cent.pools.getLoans({ poolIds }), {
suspense: true,
enabled: !isTinlakePool,
})
-
- const { data: tinlakeLoans } = useTinlakeLoans(poolId)
-
- return { data: isTinlakePool ? tinlakeLoans : centLoans, isLoading }
+ return { data: isTinlakePool ? tinlakeLoans : centLoans, isLoading: isTinlakePool ? isLoadingTinlake : isLoading }
}
export function useLoan(poolId: string, assetId: string | undefined) {
- const { data: loans } = useLoans(poolId || '')
+ const { data: loans } = useLoans([poolId])
return loans?.find((loan) => loan.id === assetId)
}
diff --git a/centrifuge-app/src/utils/usePermissions.tsx b/centrifuge-app/src/utils/usePermissions.tsx
index c44ee12284..767f3939fd 100644
--- a/centrifuge-app/src/utils/usePermissions.tsx
+++ b/centrifuge-app/src/utils/usePermissions.tsx
@@ -71,6 +71,31 @@ export function usePoolsThatAnyConnectedAddressHasPermissionsFor() {
return filtered
}
+export const useFilterPoolsByUserRole = (roles: PoolRoles['roles'][0][]) => {
+ const {
+ substrate: { combinedAccounts, proxiesAreLoading },
+ } = useWallet()
+ const actingAddresses = [...new Set(combinedAccounts?.map((acc) => acc.actingAddress))]
+ const permissionsResult = useUserPermissionsMulti(actingAddresses, { enabled: !proxiesAreLoading })
+
+ const ids = new Set(
+ permissionsResult
+ ?.map((permissions) =>
+ Object.entries(permissions?.pools || {})
+ .filter(([poolId, rolesObj]) => {
+ const rolesArray = rolesObj.roles || []
+ return roles.some((role) => rolesArray.includes(role))
+ })
+ .map(([poolId]) => poolId)
+ )
+ .flat()
+ )
+ const pools = usePools(false)
+ const filtered = pools?.filter((p) => ids.has(p.id))
+
+ return filtered
+}
+
// Returns whether the connected address can borrow from a pool in principle
export function useCanBorrow(poolId: string) {
const [account] = useSuitableAccounts({ poolId, poolRole: ['Borrower'], proxyType: ['Borrow'] })
diff --git a/centrifuge-app/src/utils/usePools.ts b/centrifuge-app/src/utils/usePools.ts
index 4aa25dae9c..af71381e09 100644
--- a/centrifuge-app/src/utils/usePools.ts
+++ b/centrifuge-app/src/utils/usePools.ts
@@ -1,4 +1,4 @@
-import Centrifuge, { Loan, Pool, PoolMetadata } from '@centrifuge/centrifuge-js'
+import Centrifuge, { AssetSnapshot, Loan, Pool, PoolMetadata } from '@centrifuge/centrifuge-js'
import { useCentrifugeConsts, useCentrifugeQuery, useWallet } from '@centrifuge/centrifuge-react'
import BN from 'bn.js'
import { useEffect, useMemo } from 'react'
@@ -172,6 +172,25 @@ export function useAllPoolAssetSnapshots(poolId: string, date: string) {
return { data: result, isLoading }
}
+export function useAllPoolAssetSnapshotsMulti(pools: Pool[], date: string) {
+ return useCentrifugeQuery(
+ ['allAssetSnapshotsMulti', pools.map((p) => p.id), date],
+ (cent) =>
+ combineLatest(pools.map((pool) => cent.pools.getAllPoolAssetSnapshots([pool.id, new Date(date)]))).pipe(
+ map((snapshotsArray) => {
+ const result: Record = {}
+ pools.forEach((pool, index) => {
+ result[pool.id] = snapshotsArray[index]
+ })
+ return result
+ })
+ ),
+ {
+ enabled: !!date && pools.length > 0,
+ }
+ )
+}
+
export function usePoolFees(poolId: string) {
const [result] = useCentrifugeQuery(['poolFees', poolId], (cent) => cent.pools.getPoolFees([poolId]), {
enabled: !poolId.startsWith('0x'),
@@ -204,7 +223,7 @@ export function useOracleTransactions(from?: Date, to?: Date) {
export function useAverageAmount(poolId: string) {
const pool = usePool(poolId)
- const { data: loans } = useLoans(poolId)
+ const { data: loans } = useLoans([poolId])
if (!loans?.length || !pool) return new BN(0)
diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts
index e6da94c82a..a70f3a4292 100644
--- a/centrifuge-js/src/modules/pools.ts
+++ b/centrifuge-js/src/modules/pools.ts
@@ -904,7 +904,6 @@ export type AssetSnapshot = {
totalRepaidPrincipal: CurrencyBalance | undefined
totalRepaidUnscheduled: CurrencyBalance | undefined
unrealizedProfitAtMarketPrice: CurrencyBalance | undefined
- valuationMethod: string | undefined
}
export type AssetPoolSnapshot = {
@@ -3578,7 +3577,6 @@ export function getPoolsModule(inst: Centrifuge) {
totalRepaidPrincipal: transformVal(tx.totalRepaidPrincipal, currency.decimals),
totalRepaidUnscheduled: transformVal(tx.totalRepaidUnscheduled, currency.decimals),
unrealizedProfitAtMarketPrice: transformVal(tx.asset.unrealizedProfitAtMarketPrice, currency.decimals),
- valuationMethod: tx.asset.valuationMethod,
})) satisfies AssetSnapshot[]
})
)
@@ -4073,10 +4071,13 @@ export function getPoolsModule(inst: Centrifuge) {
)
}
- function getLoans(args: [poolId: string]) {
- const [poolId] = args
+ function getLoans(args: { poolIds: string[] }): Observable {
+ const { poolIds } = args
const $api = inst.getApi()
+ const sanitizedPoolIds = poolIds.map((id) => id.replace(/\D/g, ''))
+ const poolIdSet = new Set(sanitizedPoolIds)
+
const $events = inst.getEvents().pipe(
filter(({ api, events }) => {
const event = events.find(
@@ -4091,270 +4092,298 @@ export function getPoolsModule(inst: Centrifuge) {
api.events.loans.PortfolioValuationUpdated.is(event)
)
if (!event) return false
- const { poolId: eventPoolId } = (event.toHuman() as any).event.data
+ const eventData = (event.toHuman() as any)?.event?.data
+ const eventPoolId: string | undefined = eventData?.poolId
if (!eventPoolId) return true
- return eventPoolId.replace(/\D/g, '') === poolId
+ const sanitizedEventPoolId = eventPoolId.replace(/\D/g, '')
+ return poolIdSet.has(sanitizedEventPoolId)
})
)
return $api.pipe(
- switchMap(
- (api) => api.query.poolSystem.pool(poolId).pipe(take(1)),
- (api, poolValue) => ({ api, poolValue })
- ),
- switchMap(({ api, poolValue }) => {
- if (!poolValue.toPrimitive()) return of([])
- return combineLatest([
- api.query.loans.createdLoan.entries(poolId),
- api.query.loans.activeLoans(poolId),
- api.query.loans.closedLoan.entries(poolId),
- api.query.oraclePriceFeed.fedValues.entries(),
- api.query.ormlAssetRegistry.metadata((poolValue.toPrimitive() as any).currency),
- api.call.loansApi.portfolio(poolId), // TODO: remove loans.activeLoans and use values from this runtime call
- ]).pipe(take(1))
- }),
- map(([createdLoanValues, activeLoanValues, closedLoanValues, oracles, rawCurrency, rawPortfolio]) => {
- const currency = rawCurrency.toPrimitive() as AssetCurrencyData
-
- const oraclePrices: Record<
- string,
- {
- timestamp: number
- value: CurrencyBalance
- account: string
- }[]
- > = {}
- oracles.forEach((oracle) => {
- const [value, timestamp] = oracle[1].toPrimitive() as any
- const keys = oracle[0].toHuman() as any
- const isin = keys[1]?.Isin
- const account = keys[0].system?.Signed
- if (!isin || !account) return
- const entry = {
- timestamp,
- // Oracle prices always have 18 decimals on chain because they are used across pools
- // When financing they are converted to the right number of decimals
- value: new CurrencyBalance(value, 18),
- account: addressToHex(account),
- }
- if (oraclePrices[isin]) {
- oraclePrices[isin].push(entry)
- } else {
- oraclePrices[isin] = [entry]
- }
- })
- const activeLoansPortfolio: Record<
- string,
- {
- presentValue: CurrencyBalance
- outstandingPrincipal: CurrencyBalance
- outstandingInterest: CurrencyBalance
- currentPrice: CurrencyBalance
- }
- > = {}
+ switchMap((api) => {
+ // For each poolId, create an observable to fetch its loans
+ const poolObservables = poolIds.map((poolId) => {
+ return api.query.poolSystem.pool(poolId).pipe(
+ take(1),
+ switchMap((poolValue) => {
+ if (!poolValue.toPrimitive()) return of([] as Loan[])
+
+ return combineLatest([
+ api.query.loans.createdLoan.entries(poolId),
+ api.query.loans.activeLoans(poolId),
+ api.query.loans.closedLoan.entries(poolId),
+ api.query.oraclePriceFeed.fedValues.entries(),
+ api.query.ormlAssetRegistry.metadata((poolValue.toPrimitive() as any).currency),
+ api.call.loansApi.portfolio(poolId),
+ ]).pipe(
+ take(1),
+ map(([createdLoanValues, activeLoanValues, closedLoanValues, oracles, rawCurrency, rawPortfolio]) => {
+ const currency = rawCurrency.toPrimitive() as AssetCurrencyData
+
+ // Process oracle prices
+ const oraclePrices: Record<
+ string,
+ {
+ timestamp: number
+ value: CurrencyBalance
+ account: string
+ }[]
+ > = {}
+ oracles.forEach((oracle) => {
+ const [value, timestamp] = oracle[1].toPrimitive() as any
+ const keys = oracle[0].toHuman() as any
+ const isin = keys[1]?.Isin
+ const account = keys[0]?.system?.Signed
+ if (!isin || !account) return
+ const entry = {
+ timestamp,
+ // Oracle prices always have 18 decimals on chain because they are used across pools
+ // When financing they are converted to the right number of decimals
+ value: new CurrencyBalance(value, 18),
+ account: addressToHex(account),
+ }
+ if (oraclePrices[isin]) {
+ oraclePrices[isin].push(entry)
+ } else {
+ oraclePrices[isin] = [entry]
+ }
+ })
- ;(rawPortfolio as any).forEach(([key, value]: [Codec, Codec]) => {
- const data = value.toPrimitive() as any
- activeLoansPortfolio[String(key.toPrimitive())] = {
- presentValue: new CurrencyBalance(data.presentValue, currency.decimals),
- outstandingPrincipal: new CurrencyBalance(data.outstandingPrincipal, currency.decimals),
- outstandingInterest: new CurrencyBalance(data.outstandingInterest, currency.decimals),
- currentPrice: new CurrencyBalance(data.currentPrice ?? 0, currency.decimals),
- }
- })
+ // Process active loans portfolio
+ const activeLoansPortfolio: Record<
+ string,
+ {
+ presentValue: CurrencyBalance
+ outstandingPrincipal: CurrencyBalance
+ outstandingInterest: CurrencyBalance
+ currentPrice: CurrencyBalance
+ }
+ > = {}
+
+ ;(rawPortfolio as any).forEach(([key, value]: [Codec, Codec]) => {
+ const data = value.toPrimitive() as any
+ activeLoansPortfolio[String(key.toPrimitive())] = {
+ presentValue: new CurrencyBalance(data.presentValue, currency.decimals),
+ outstandingPrincipal: new CurrencyBalance(data.outstandingPrincipal, currency.decimals),
+ outstandingInterest: new CurrencyBalance(data.outstandingInterest, currency.decimals),
+ currentPrice: new CurrencyBalance(data.currentPrice ?? 0, currency.decimals),
+ }
+ })
- function getSharedLoanInfo(loan: CreatedLoanData | ActiveLoanData | ClosedLoanData) {
- const info = 'info' in loan ? loan.info : loan
- const [collectionId, nftId] = info.collateral
-
- // Active loans have additinal info layer
- const pricingInfo =
- 'info' in loan
- ? 'external' in loan.info.pricing
- ? loan.info.pricing.external
- : loan.info.pricing.internal
- : 'external' in loan.pricing
- ? loan.pricing.external.info
- : loan.pricing.internal.info
-
- const interestRate =
- 'info' in loan
- ? loan.info.interestRate.fixed.ratePerYear
- : 'external' in loan.pricing
- ? loan.pricing.external.interest.interestRate.fixed.ratePerYear
- : loan.pricing.internal.interest.interestRate.fixed.ratePerYear
-
- const discount =
- 'valuationMethod' in pricingInfo && 'discountedCashFlow' in pricingInfo.valuationMethod
- ? pricingInfo.valuationMethod.discountedCashFlow
- : undefined
- return {
- // Return the time the loans were fetched, in order to calculate a more accurate/up-to-date outstandingInterest
- // Mainly for when repaying interest, to repay as close to the correct amount of interest
- // Refetching before repaying would be another ideas, but less practical with substriptions
- fetchedAt: new Date(),
- asset: {
- collectionId: collectionId.toString(),
- nftId: nftId.toString(),
- },
- pricing:
- 'priceId' in pricingInfo
- ? {
- valuationMethod: 'oracle' as any,
- // If the max borrow quantity is larger than 10k, this is assumed to be "limitless"
- // TODO: replace by Option once data structure on chain changes
- maxBorrowAmount:
- 'noLimit' in pricingInfo.maxBorrowAmount
- ? null
- : new CurrencyBalance(pricingInfo.maxBorrowAmount.quantity, 18),
- maturityDate: !('none' in info.schedule.maturity)
- ? new Date(info.schedule.maturity.fixed.date * 1000).toISOString()
- : null,
- maturityExtensionDays: !('none' in info.schedule.maturity)
- ? info.schedule.maturity.fixed.extension / SEC_PER_DAY
- : null,
- priceId: pricingInfo.priceId,
- oracle: oraclePrices[
- 'isin' in pricingInfo.priceId
- ? pricingInfo.priceId?.isin
- : pricingInfo.priceId?.poolLoanId.join('-')
- ] || [
- {
- value: new CurrencyBalance(0, 18),
- timestamp: 0,
- account: '',
+ // Helper function to extract shared loan info
+ function getSharedLoanInfo(loan: CreatedLoanData | ActiveLoanData | ClosedLoanData) {
+ const info = 'info' in loan ? loan.info : loan
+ const [collectionId, nftId] = info.collateral
+
+ // Active loans have additional info layer
+ const pricingInfo =
+ 'info' in loan
+ ? 'external' in loan.info.pricing
+ ? loan.info.pricing.external
+ : loan.info.pricing.internal
+ : 'external' in loan.pricing
+ ? loan.pricing.external.info
+ : loan.pricing.internal.info
+
+ const interestRate =
+ 'info' in loan
+ ? loan.info.interestRate.fixed.ratePerYear
+ : 'external' in loan.pricing
+ ? loan.pricing.external.interest.interestRate.fixed.ratePerYear
+ : loan.pricing.internal.interest.interestRate.fixed.ratePerYear
+
+ const discount =
+ 'valuationMethod' in pricingInfo && 'discountedCashFlow' in pricingInfo.valuationMethod
+ ? pricingInfo.valuationMethod.discountedCashFlow
+ : undefined
+
+ return {
+ // Return the time the loans were fetched, in order to calculate a more accurate/up-to-date outstandingInterest
+ // Mainly for when repaying interest, to repay as close to the correct amount of interest
+ // Refetching before repaying would be another idea, but less practical with subscriptions
+ fetchedAt: new Date(),
+ asset: {
+ collectionId: collectionId.toString(),
+ nftId: nftId.toString(),
},
- ],
- outstandingQuantity:
- 'external' in info.pricing && 'outstandingQuantity' in info.pricing.external
- ? new CurrencyBalance(info.pricing.external.outstandingQuantity, 18)
- : new CurrencyBalance(0, 18),
- interestRate: new Rate(interestRate),
- notional: new CurrencyBalance(pricingInfo.notional, currency.decimals),
- maxPriceVariation: new Rate(pricingInfo.maxPriceVariation),
- withLinearPricing: pricingInfo.withLinearPricing,
+ pricing:
+ 'priceId' in pricingInfo
+ ? {
+ valuationMethod: 'oracle' as any,
+ // If the max borrow quantity is larger than 10k, this is assumed to be "limitless"
+ // TODO: replace by Option once data structure on chain changes
+ maxBorrowAmount:
+ 'noLimit' in pricingInfo.maxBorrowAmount
+ ? null
+ : new CurrencyBalance(pricingInfo.maxBorrowAmount.quantity, 18),
+ maturityDate:
+ !('none' in info.schedule.maturity) && info.schedule.maturity.fixed.date
+ ? new Date(info.schedule.maturity.fixed.date * 1000).toISOString()
+ : null,
+ maturityExtensionDays:
+ !('none' in info.schedule.maturity) && info.schedule.maturity.fixed.extension
+ ? info.schedule.maturity.fixed.extension / SEC_PER_DAY
+ : null,
+ priceId: pricingInfo.priceId,
+ oracle: oraclePrices[
+ 'isin' in pricingInfo.priceId
+ ? pricingInfo.priceId?.isin
+ : pricingInfo.priceId?.poolLoanId.join('-')
+ ] || [
+ {
+ value: new CurrencyBalance(0, 18),
+ timestamp: 0,
+ account: '',
+ },
+ ],
+ outstandingQuantity:
+ 'external' in info.pricing && 'outstandingQuantity' in info.pricing.external
+ ? new CurrencyBalance(info.pricing.external.outstandingQuantity, 18)
+ : new CurrencyBalance(0, 18),
+ interestRate: new Rate(interestRate),
+ notional: new CurrencyBalance(pricingInfo.notional, currency.decimals),
+ maxPriceVariation: new Rate(pricingInfo.maxPriceVariation),
+ withLinearPricing: pricingInfo.withLinearPricing,
+ }
+ : {
+ valuationMethod:
+ 'outstandingDebt' in pricingInfo.valuationMethod ||
+ 'cash' in pricingInfo.valuationMethod
+ ? Object.keys(pricingInfo.valuationMethod)[0]
+ : ('discountedCashFlow' as any),
+ maxBorrowAmount: Object.keys(pricingInfo.maxBorrowAmount)[0] as any,
+ value: new CurrencyBalance(pricingInfo.collateralValue, currency.decimals),
+ advanceRate: new Rate(Object.values(pricingInfo.maxBorrowAmount)[0].advanceRate),
+ probabilityOfDefault: discount?.probabilityOfDefault
+ ? new Rate(discount.probabilityOfDefault)
+ : undefined,
+ lossGivenDefault: discount?.lossGivenDefault
+ ? new Rate(discount.lossGivenDefault)
+ : undefined,
+ discountRate: discount?.discountRate
+ ? new Rate(discount.discountRate.fixed.ratePerYear)
+ : undefined,
+ interestRate: new Rate(interestRate),
+ maturityDate:
+ !('none' in info.schedule.maturity) && info.schedule.maturity.fixed.date
+ ? new Date(info.schedule.maturity.fixed.date * 1000).toISOString()
+ : null,
+ maturityExtensionDays:
+ !('none' in info.schedule.maturity) && info.schedule.maturity.fixed.extension
+ ? info.schedule.maturity.fixed.extension / SEC_PER_DAY
+ : null,
+ },
+ }
}
- : {
- valuationMethod:
- 'outstandingDebt' in pricingInfo.valuationMethod || 'cash' in pricingInfo.valuationMethod
- ? Object.keys(pricingInfo.valuationMethod)[0]
- : ('discountedCashFlow' as any),
- maxBorrowAmount: Object.keys(pricingInfo.maxBorrowAmount)[0] as any,
- value: new CurrencyBalance(pricingInfo.collateralValue, currency.decimals),
- advanceRate: new Rate(Object.values(pricingInfo.maxBorrowAmount)[0].advanceRate),
- probabilityOfDefault: discount?.probabilityOfDefault
- ? new Rate(discount.probabilityOfDefault)
- : undefined,
- lossGivenDefault: discount?.lossGivenDefault ? new Rate(discount.lossGivenDefault) : undefined,
- discountRate: discount?.discountRate
- ? new Rate(discount.discountRate.fixed.ratePerYear)
- : undefined,
- interestRate: new Rate(interestRate),
- maturityDate: !('none' in info.schedule.maturity)
- ? new Date(info.schedule.maturity.fixed.date * 1000).toISOString()
- : null,
- maturityExtensionDays: !('none' in info.schedule.maturity)
- ? info.schedule.maturity.fixed.extension / SEC_PER_DAY
- : null,
- },
- }
- }
- const createdLoans: CreatedLoan[] = (createdLoanValues as any[]).map(([key, value]) => {
- const loan = value.toPrimitive() as unknown as CreatedLoanData
- const nil = new CurrencyBalance(0, currency.decimals)
- return {
- ...getSharedLoanInfo(loan),
- id: formatLoanKey(key as StorageKey<[u32, u32]>),
- poolId,
- status: 'Created',
- borrower: addressToHex(loan.borrower),
- totalBorrowed: nil,
- totalRepaid: nil,
- outstandingDebt: nil,
- normalizedDebt: nil,
- }
- })
+ // Process created loans
+ const createdLoans: CreatedLoan[] = (createdLoanValues as any[]).map(([key, value]) => {
+ const loan = value.toPrimitive() as CreatedLoanData
+ const nil = new CurrencyBalance(0, currency.decimals)
+ return {
+ ...getSharedLoanInfo(loan),
+ id: formatLoanKey(key as StorageKey<[u32, u32]>),
+ poolId,
+ status: 'Created',
+ borrower: addressToHex(loan.borrower),
+ totalBorrowed: nil,
+ totalRepaid: nil,
+ outstandingDebt: nil,
+ normalizedDebt: nil,
+ }
+ })
- const activeLoans: ActiveLoan[] = (activeLoanValues.toPrimitive() as any[]).map(
- ([loanId, loan]: [number, ActiveLoanData]) => {
- const sharedInfo = getSharedLoanInfo(loan)
- const portfolio = activeLoansPortfolio[loanId.toString()]
- const penaltyRate =
- 'external' in loan.pricing
- ? loan.pricing.external.interest.penalty
- : loan.pricing.internal.interest.penalty
- const normalizedDebt =
- 'external' in loan.pricing
- ? loan.pricing.external.interest.normalizedAcc
- : loan.pricing.internal.interest.normalizedAcc
-
- const writeOffStatus = {
- penaltyInterestRate: new Rate(penaltyRate),
- percentage: new Rate(loan.writeOffPercentage),
- }
+ // Process active loans
+ const activeLoans: ActiveLoan[] = (activeLoanValues.toPrimitive() as any[]).map(
+ ([loanId, loan]: [number, ActiveLoanData]) => {
+ const sharedInfo = getSharedLoanInfo(loan)
+ const portfolio = activeLoansPortfolio[loanId.toString()]
+ const penaltyRate =
+ 'external' in loan.pricing
+ ? loan.pricing.external.interest.penalty
+ : loan.pricing.internal.interest.penalty
+ const normalizedDebt =
+ 'external' in loan.pricing
+ ? loan.pricing.external.interest.normalizedAcc
+ : loan.pricing.internal.interest.normalizedAcc
+
+ const writeOffStatus = {
+ penaltyInterestRate: new Rate(penaltyRate),
+ percentage: new Rate(loan.writeOffPercentage),
+ }
- const repaidPrincipal = new CurrencyBalance(loan.totalRepaid.principal, currency.decimals)
- const repaidInterest = new CurrencyBalance(loan.totalRepaid.interest, currency.decimals)
- const repaidUnscheduled = new CurrencyBalance(loan.totalRepaid.unscheduled, currency.decimals)
- const outstandingDebt = new CurrencyBalance(
- portfolio.outstandingInterest.add(portfolio.outstandingPrincipal),
- currency.decimals
- )
- return {
- ...sharedInfo,
- id: loanId.toString(),
- poolId,
- status: 'Active',
- borrower: addressToHex(loan.borrower),
- writeOffStatus: writeOffStatus.percentage.isZero() ? undefined : writeOffStatus,
- totalBorrowed: new CurrencyBalance(loan.totalBorrowed, currency.decimals),
- totalRepaid: new CurrencyBalance(
- repaidPrincipal.add(repaidInterest).add(repaidUnscheduled),
- currency.decimals
- ),
- repaid: {
- principal: repaidPrincipal,
- interest: repaidInterest,
- unscheduled: repaidUnscheduled,
- },
- originationDate: new Date(loan.originationDate * 1000).toISOString(),
- outstandingDebt,
- normalizedDebt: new CurrencyBalance(normalizedDebt, currency.decimals),
- outstandingPrincipal: portfolio.outstandingPrincipal,
- outstandingInterest: portfolio.outstandingInterest,
- presentValue: portfolio.presentValue,
- currentPrice: portfolio.currentPrice,
- }
- }
- )
+ const repaidPrincipal = new CurrencyBalance(loan.totalRepaid.principal, currency.decimals)
+ const repaidInterest = new CurrencyBalance(loan.totalRepaid.interest, currency.decimals)
+ const repaidUnscheduled = new CurrencyBalance(loan.totalRepaid.unscheduled, currency.decimals)
+ const outstandingDebt = new CurrencyBalance(
+ portfolio.outstandingInterest.add(portfolio.outstandingPrincipal),
+ currency.decimals
+ )
- const closedLoans: ClosedLoan[] = (closedLoanValues as any[]).map(([key, value]) => {
- const loan = value.toPrimitive() as unknown as ClosedLoanData
+ return {
+ ...sharedInfo,
+ id: loanId.toString(),
+ poolId,
+ status: 'Active',
+ borrower: addressToHex(loan.borrower),
+ writeOffStatus: writeOffStatus.percentage.isZero() ? undefined : writeOffStatus,
+ totalBorrowed: new CurrencyBalance(loan.totalBorrowed, currency.decimals),
+ totalRepaid: new CurrencyBalance(
+ repaidPrincipal.add(repaidInterest).add(repaidUnscheduled),
+ currency.decimals
+ ),
+ repaid: {
+ principal: repaidPrincipal,
+ interest: repaidInterest,
+ unscheduled: repaidUnscheduled,
+ },
+ originationDate: new Date(loan.originationDate * 1000).toISOString(),
+ outstandingDebt,
+ normalizedDebt: new CurrencyBalance(normalizedDebt, currency.decimals),
+ outstandingPrincipal: portfolio.outstandingPrincipal,
+ outstandingInterest: portfolio.outstandingInterest,
+ presentValue: portfolio.presentValue,
+ currentPrice: portfolio.currentPrice,
+ }
+ }
+ )
- const repaidPrincipal = new CurrencyBalance(loan.totalRepaid.principal, currency.decimals)
- const repaidInterest = new CurrencyBalance(loan.totalRepaid.interest, currency.decimals)
- const repaidUnscheduled = new CurrencyBalance(loan.totalRepaid.unscheduled, currency.decimals)
+ // Process closed loans
+ const closedLoans: ClosedLoan[] = (closedLoanValues as any[]).map(([key, value]) => {
+ const loan = value.toPrimitive() as ClosedLoanData
+
+ const repaidPrincipal = new CurrencyBalance(loan.totalRepaid.principal, currency.decimals)
+ const repaidInterest = new CurrencyBalance(loan.totalRepaid.interest, currency.decimals)
+ const repaidUnscheduled = new CurrencyBalance(loan.totalRepaid.unscheduled, currency.decimals)
+
+ return {
+ ...getSharedLoanInfo(loan),
+ id: formatLoanKey(key as StorageKey<[u32, u32]>),
+ poolId,
+ status: 'Closed',
+ totalBorrowed: new CurrencyBalance(loan.totalBorrowed, currency.decimals),
+ totalRepaid: new CurrencyBalance(
+ repaidPrincipal.add(repaidInterest).add(repaidUnscheduled),
+ currency.decimals
+ ),
+ repaid: {
+ principal: repaidPrincipal,
+ interest: repaidInterest,
+ unscheduled: repaidUnscheduled,
+ },
+ }
+ })
- return {
- ...getSharedLoanInfo(loan),
- id: formatLoanKey(key as StorageKey<[u32, u32]>),
- poolId,
- status: 'Closed',
- totalBorrowed: new CurrencyBalance(loan.totalBorrowed, currency.decimals),
- totalRepaid: new CurrencyBalance(
- repaidPrincipal.add(repaidInterest).add(repaidUnscheduled),
- currency.decimals
- ),
- repaid: {
- principal: repaidPrincipal,
- interest: repaidInterest,
- unscheduled: repaidUnscheduled,
- },
- }
+ // Combine all loans
+ return [...createdLoans, ...activeLoans, ...closedLoans] as Loan[]
+ })
+ )
+ })
+ )
})
- return [...createdLoans, ...activeLoans, ...closedLoans] as Loan[]
+ return combineLatest(poolObservables).pipe(map((loansPerPool) => loansPerPool.flat()))
}),
repeatWhen(() => $events)
)
diff --git a/fabric/src/components/Accordion/index.tsx b/fabric/src/components/Accordion/index.tsx
index a54e3ee653..321b3fc436 100644
--- a/fabric/src/components/Accordion/index.tsx
+++ b/fabric/src/components/Accordion/index.tsx
@@ -32,17 +32,7 @@ const Toggle = styled(Shelf)`
export function Accordion({ items, ...boxProps }: AccordionProps) {
return (
-
+
{items.map((entry, index) => (
0 ? 1 : 0} />
))}
@@ -58,6 +48,7 @@ function AccordionEntry({ title, body, ...boxProps }: AccordionProps['items'][nu
-
+
{body}
diff --git a/fabric/src/components/Button/BackButton.tsx b/fabric/src/components/Button/BackButton.tsx
index 5a4b7c0401..bc7ddfdd4b 100644
--- a/fabric/src/components/Button/BackButton.tsx
+++ b/fabric/src/components/Button/BackButton.tsx
@@ -31,17 +31,28 @@ export const BackButton = ({
label,
to,
width = '55%',
+ goBack,
+ ...props
}: {
align?: string
as?: React.ElementType
children?: ReactNode
label: string
- to: string
+ to?: string
width?: string
+ goBack?: boolean
}) => {
return (
- } variant="tertiary" />
+ }
+ variant="tertiary"
+ {...props}
+ goBack={goBack}
+ />
{label}
diff --git a/fabric/src/components/Button/VisualButton.tsx b/fabric/src/components/Button/VisualButton.tsx
index 93dcf4b469..29e506b680 100644
--- a/fabric/src/components/Button/VisualButton.tsx
+++ b/fabric/src/components/Button/VisualButton.tsx
@@ -17,7 +17,7 @@ const rotate = keyframes`
}
`
-type IconProps = {
+export type IconProps = {
size?: ResponsiveValue
}
diff --git a/fabric/src/components/Checkbox/index.tsx b/fabric/src/components/Checkbox/index.tsx
index 5585f954e4..290b99ea35 100644
--- a/fabric/src/components/Checkbox/index.tsx
+++ b/fabric/src/components/Checkbox/index.tsx
@@ -10,15 +10,22 @@ type CheckboxProps = React.InputHTMLAttributes & {
label?: string | React.ReactElement
errorMessage?: string
extendedClickArea?: boolean
+ variant?: 'primary' | 'secondary'
}
-export function Checkbox({ label, errorMessage, extendedClickArea, ...checkboxProps }: CheckboxProps) {
+export function Checkbox({
+ label,
+ errorMessage,
+ extendedClickArea,
+ variant = 'primary',
+ ...checkboxProps
+}: CheckboxProps) {
return (
-
+
{label && (
@@ -88,20 +95,21 @@ const StyledWrapper = styled(Flex)<{ $hasLabel: boolean }>`
}
`
-const StyledCheckbox = styled.input`
- width: 18px;
- height: 18px;
+const StyledCheckbox = styled.input<{ variant: 'primary' | 'secondary' }>`
+ width: 16px;
+ height: 16px;
appearance: none;
- border-radius: 2px;
- border: 1px solid ${({ theme }) => theme.colors.borderPrimary};
+ border-radius: 4px;
+ border: 1px solid
+ ${({ theme, variant }) => (variant === 'primary' ? theme.colors.borderPrimary : theme.colors.textPrimary)};
position: relative;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
- ${({ theme }) => `
+ ${({ theme, variant }) => `
&:checked {
- border-color: ${theme.colors.borderSecondary};
- background-color: ${theme.colors.textGold};
+ border-color: ${variant === 'primary' ? theme.colors.borderSecondary : theme.colors.textPrimary};
+ background-color: ${variant === 'primary' ? theme.colors.textGold : 'white'};
}
&:checked::after {
@@ -109,9 +117,9 @@ const StyledCheckbox = styled.input`
position: absolute;
top: 2px;
left: 5px;
- width: 6px;
- height: 10px;
- border: solid white;
+ width: 4px;
+ height: 8px;
+ border: solid ${variant === 'primary' ? 'white' : 'black'};
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
diff --git a/fabric/src/components/Drawer/index.tsx b/fabric/src/components/Drawer/index.tsx
index 30ac1d7219..1ddc3ec2f5 100644
--- a/fabric/src/components/Drawer/index.tsx
+++ b/fabric/src/components/Drawer/index.tsx
@@ -8,12 +8,14 @@ import { IconX } from '../../icon'
import { Box, BoxProps } from '../Box'
import { Button } from '../Button'
import { Stack } from '../Stack'
+import { Text } from '../Text'
export type DrawerProps = React.PropsWithChildren<{
isOpen: boolean
onClose: () => void
width?: string | number
innerPaddingTop?: number
+ title?: string
}> &
BoxProps
@@ -23,7 +25,7 @@ const DrawerCard = styled(Box)(
})
)
-function DrawerInner({ children, isOpen, onClose, width = 'drawer', ...props }: DrawerProps) {
+function DrawerInner({ children, isOpen, onClose, width = 'drawer', title, ...props }: DrawerProps) {
const ref = React.useRef(null)
const underlayRef = React.useRef(null)
const animation = React.useRef(undefined)
@@ -111,9 +113,16 @@ function DrawerInner({ children, isOpen, onClose, width = 'drawer', ...props }:
{...props}
>
-
-
+ {title ? (
+
+ {title}
+
+ ) : (
+
+
+ )}
{children}
diff --git a/fabric/src/components/FileUploadButton/index.tsx b/fabric/src/components/FileUploadButton/index.tsx
new file mode 100644
index 0000000000..5d46f43541
--- /dev/null
+++ b/fabric/src/components/FileUploadButton/index.tsx
@@ -0,0 +1,74 @@
+import React, { useRef } from 'react'
+import { Button, IconProps } from '../Button'
+
+export interface IconTextButtonProps extends React.ButtonHTMLAttributes {
+ icon?: React.ComponentType | React.ReactElement
+ text?: string
+ small?: boolean
+}
+
+export const IconTextButton: React.FC = ({ icon, text, small, ...props }) => {
+ return (
+
+ )
+}
+
+export interface FileUploadButtonProps extends Omit {
+ accept?: string
+ multiple?: boolean
+ maxFileSize?: number
+ allowedFileTypes?: string[]
+ onFileChange?: (files: File[]) => void
+}
+
+export const FileUploadButton: React.FC = ({
+ accept,
+ multiple = false,
+ maxFileSize,
+ allowedFileTypes,
+ onFileChange,
+ text,
+ small,
+ ...buttonProps
+}) => {
+ const inputRef = useRef(null)
+
+ const handleButtonClick = () => {
+ inputRef.current?.click()
+ }
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ if (e.target.files && onFileChange) {
+ const filesArray = Array.from(e.target.files)
+
+ const validFiles = filesArray.filter((file) => {
+ if (maxFileSize && file.size > maxFileSize) {
+ return false
+ }
+ if (allowedFileTypes && allowedFileTypes.length > 0 && !allowedFileTypes.includes(file.type)) {
+ return false
+ }
+ return true
+ })
+
+ onFileChange(validFiles)
+ }
+ e.target.value = ''
+ }
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/fabric/src/components/RadioButton/index.tsx b/fabric/src/components/RadioButton/index.tsx
index a0c1c63a2e..cce1586834 100644
--- a/fabric/src/components/RadioButton/index.tsx
+++ b/fabric/src/components/RadioButton/index.tsx
@@ -1,5 +1,6 @@
import * as React from 'react'
-import styled from 'styled-components'
+import styled, { useTheme } from 'styled-components'
+import { Box } from '../Box'
import { Flex } from '../Flex'
import { Shelf } from '../Shelf'
import { Stack } from '../Stack'
@@ -11,7 +12,56 @@ export type RadioButtonProps = React.InputHTMLAttributes & {
textStyle?: string
}
-export function RadioButton({ label, errorMessage, textStyle, ...radioProps }: RadioButtonProps) {
+export const RadioButton = ({
+ label,
+ disabled = false,
+ icon,
+ sublabel,
+ height,
+ styles,
+ border = false,
+ ...props
+}: {
+ name: string
+ sublabel?: string
+ icon?: React.ReactNode
+ height?: number
+ styles?: React.CSSProperties
+ label: string
+ value?: string | number
+ disabled?: boolean
+ onChange?: () => void
+ checked?: boolean
+ id?: string
+ border?: boolean
+}) => {
+ const theme = useTheme()
+
+ return (
+ // @ts-expect-error
+
+
+ {icon && {icon}}
+ {sublabel && (
+
+ {sublabel}
+
+ )}
+
+ )
+}
+
+export function RadioButtonInput({ label, errorMessage, textStyle, ...radioProps }: RadioButtonProps) {
return (