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/Assets/AssetTemplateSection.tsx b/centrifuge-app/src/components/Dashboard/Assets/AssetTemplateSection.tsx
new file mode 100644
index 0000000000..e2bbfcaaad
--- /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 { combine, max, min, positiveNumber, required } from '../../../utils/validation'
+import { FieldWithErrorMessage } from '../../FieldWithErrorMessage'
+
+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..b3d0efa5b7
--- /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 { TransformedLoan, usePoolMetadataMap } from '../utils'
+import { CreateAssetsDrawer } from './CreateAssetsDrawer'
+
+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..be6d5355b5
--- /dev/null
+++ b/centrifuge-app/src/components/Dashboard/Assets/CreateAssetForm.tsx
@@ -0,0 +1,246 @@
+import {
+ Accordion,
+ Box,
+ Button,
+ 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 { validate } from '../../../pages/IssuerCreatePool/validate'
+import { LoanTemplate } from '../../../types'
+import { useMetadata } from '../../../utils/useMetadata'
+import { useSuitableAccounts } from '../../../utils/usePermissions'
+import { FieldWithErrorMessage } from '../../FieldWithErrorMessage'
+import { Tooltips, tooltipText } from '../../Tooltips'
+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) => (
+ }
+ placement="left"
+ />
+ }
+ onChange={() => form.setFieldValue('assetType', asset.id)}
+ checked={form.values.assetType === asset.id}
+ styles={{ padding: '0px 8px', margin: '8px 0px' }}
+ border
+ disabled={!canCreateAssets}
+ />
+ ))}
+
+
+
+ {({ field, form }: FieldProps) => (
+ {
+ form.setFieldValue('assetName', event.target.value)
+ }}
+ />
+ )}
+
+
+
+ {hasTemplates && canCreateAssets && form.values.assetType !== 'cash' && (
+ <>
+
+ {form.values.assetType === 'custom' && (
+
+ setSelectedTabIndex(index)}>
+
+
+ }
+ />
+ }
+ type="button"
+ variant="tertiary"
+ onClick={() => {
+ form.setFieldValue('customType', 'atPar', false)
+ }}
+ small
+ >
+ At par
+
+
+
+
+ }
+ />
+ }
+ >
+ 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 && (
+
+
+
+ )}
+
+
+
+
+ >
+ )}
+
+ )
+}
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..41821ed315
--- /dev/null
+++ b/centrifuge-app/src/components/Dashboard/Assets/CreateAssetsDrawer.tsx
@@ -0,0 +1,323 @@
+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 { LoanTemplate } from '../../../types'
+import { getFileDataURI } from '../../../utils/getFileDataURI'
+import { useMetadata } from '../../../utils/useMetadata'
+import { useFilterPoolsByUserRole, usePoolAccess, useSuitableAccounts } from '../../../utils/usePermissions'
+import { LoadBoundary } from '../../LoadBoundary'
+import { usePoolMetadataMap, valuesToNftProperties } from '../utils'
+import { CreateAssetsForm } from './CreateAssetForm'
+import { FooterActionButtons } from './FooterActionButtons'
+import { UploadAssetTemplateForm } from './UploadAssetTemplateForm'
+
+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 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/DashboardTable.tsx b/centrifuge-app/src/components/Dashboard/Assets/DashboardTable.tsx
new file mode 100644
index 0000000000..8dfed84d32
--- /dev/null
+++ b/centrifuge-app/src/components/Dashboard/Assets/DashboardTable.tsx
@@ -0,0 +1,103 @@
+import { CurrencyBalance, Pool, Token } from '@centrifuge/centrifuge-js'
+import { useCentrifuge } from '@centrifuge/centrifuge-react'
+import { Box, Button, Divider, Grid, IconSettings, IconUsers, Text } from '@centrifuge/fabric'
+import Decimal from 'decimal.js-light'
+import { useMemo } from 'react'
+import { useTheme } from 'styled-components'
+import { Dec } from '../../../utils/Decimal'
+import { formatBalance } from '../../../utils/formatting'
+import { DataTable, SortableTableHeader } from '../../DataTable'
+import { calculateApyPerToken, useGetPoolsMetadata } from '../utils'
+
+export type Row = {
+ poolIcon: string
+ poolName: string
+ trancheToken: string
+ apy: string
+ navPerToken: CurrencyBalance
+ valueLocked: Decimal
+ poolId: string
+}
+
+export function DashboardTable({ filteredPools }: { filteredPools: Pool[] }) {
+ const theme = useTheme()
+ const cent = useCentrifuge()
+ const pools = useGetPoolsMetadata(filteredPools || [])
+
+ const data = useMemo(() => {
+ return pools.flatMap((pool) =>
+ pool.tranches.map((token: Token) => ({
+ poolIcon: cent.metadata.parseMetadataUrl(pool.meta?.pool?.icon?.uri),
+ poolName: pool.meta?.pool?.name,
+ trancheToken: token.currency.displayName,
+ apy: calculateApyPerToken(token, pool),
+ navPerToken: token.tokenPrice,
+ valueLocked: token?.tokenPrice ? token.totalIssuance.toDecimal().mul(token.tokenPrice.toDecimal()) : Dec(0),
+ poolId: pool.id,
+ }))
+ )
+ }, [pools])
+
+ const columns = useMemo(() => {
+ return [
+ {
+ header: 'Pool',
+ align: 'left',
+ cell: ({ poolName, poolIcon }: Row) => {
+ return (
+
+ {poolIcon && }
+
+ {poolName}
+
+
+ )
+ },
+ },
+ {
+ header: 'Tranche',
+ sortKey: 'tranchetoken',
+ cell: ({ trancheToken }: Row) => {trancheToken},
+ },
+ {
+ header: ,
+ sortKey: 'apy',
+ cell: ({ apy }: Row) => {apy},
+ },
+ {
+ header: ,
+ sortKey: 'valueLocked',
+ cell: ({ valueLocked }: Row) => {valueLocked ? formatBalance(valueLocked) : '-'},
+ },
+ {
+ header: ,
+ sortKey: 'navPerToken',
+ cell: ({ navPerToken }: Row) => {navPerToken ? formatBalance(navPerToken) : '-'},
+ },
+ ]
+ }, [pools])
+
+ if (!pools.length) return No data available
+
+ return (
+
+
+
+
+
+ `/pools/${row.poolId}`}
+ />
+
+
+ )
+}
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..3e421a2713
--- /dev/null
+++ b/centrifuge-app/src/components/Dashboard/Assets/FooterActionButtons.tsx
@@ -0,0 +1,167 @@
+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 '../../../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 canCreateAssets =
+ useSuitableAccounts({ poolId: pool?.id, poolRole: ['Borrower', 'PoolAdmin'], proxyType: ['Borrow', 'PoolAdmin'] })
+ .length > 0
+
+ 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..8b9e20dece
--- /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 { validate } from '../../../pages/IssuerCreatePool/validate'
+import { combine, max, nonNegativeNumber, positiveNumber, required } from '../../../utils/validation'
+import { FieldWithErrorMessage } from '../../FieldWithErrorMessage'
+import { Tooltips } from '../../Tooltips'
+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="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..67bb3a98b1
--- /dev/null
+++ b/centrifuge-app/src/components/Dashboard/Assets/UploadAssetTemplateForm.tsx
@@ -0,0 +1,179 @@
+import { useCentrifuge } from '@centrifuge/centrifuge-react'
+import { AnchorButton, Box, FileUpload, Grid, IconDownload, IconFile, IconWarning, Text } from '@centrifuge/fabric'
+import { useFormikContext } from 'formik'
+import { useMemo, useState } from 'react'
+import { lastValueFrom } from 'rxjs'
+import { LoanTemplate } from 'src/types'
+import { useTheme } from 'styled-components'
+import { createDownloadJson } from '../../../utils/createDownloadJson'
+import { useMetadataMulti } from '../../../utils/useMetadata'
+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 [errorMessage, setErrorMessage] = useState('')
+
+ 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 ? (
+ {
+ if (!file) return
+
+ // Check if file size exceeds 5MB (5 * 1024 * 1024 bytes)
+ const maxSizeInBytes = 5 * 1024 * 1024
+ if (file.size > maxSizeInBytes) {
+ setErrorMessage('File size exceeds the 5MB limit.')
+ return
+ }
+ 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)
+ }}
+ small
+ file={null}
+ />
+ ) : (
+
+
+
+ Only pool admins can upload asset templates.
+
+
+ )}
+
+
+ )
+}
diff --git a/centrifuge-app/src/components/Dashboard/PoolSelector.tsx b/centrifuge-app/src/components/Dashboard/PoolSelector.tsx
new file mode 100644
index 0000000000..23b5978381
--- /dev/null
+++ b/centrifuge-app/src/components/Dashboard/PoolSelector.tsx
@@ -0,0 +1,73 @@
+import { Pool } from '@centrifuge/centrifuge-js'
+import { useCentrifuge } from '@centrifuge/centrifuge-react'
+import { Box, Checkbox, Shelf, Text, Thumbnail } from '@centrifuge/fabric'
+import { useTheme } from 'styled-components'
+import { useSelectedPools } from '../../utils/contexts/SelectedPoolsContext'
+import { usePoolMetadata } from '../../utils/usePools'
+
+export const PoolSelector = ({ multiple = true }: { multiple?: boolean }) => {
+ const { pools, selectedPools } = useSelectedPools(multiple)
+ const theme = useTheme()
+ return (
+
+ {pools?.map((pool) => (
+
+ ))}
+
+ )
+}
+
+const PoolSelect = ({ pool, active, multiple }: { pool: Pool; active: boolean; multiple: boolean }) => {
+ const cent = useCentrifuge()
+ const { togglePoolSelection, selectedPools, clearSelectedPools } = useSelectedPools(multiple)
+ const { data: poolMetadata } = usePoolMetadata(pool)
+ const theme = useTheme()
+ const poolUri = poolMetadata?.pool?.icon?.uri
+ ? cent.metadata.parseMetadataUrl(poolMetadata?.pool?.icon?.uri)
+ : undefined
+ return (
+ {
+ e.stopPropagation()
+ if (!multiple) {
+ clearSelectedPools()
+ togglePoolSelection(pool.id)
+ }
+ }}
+ >
+
+ {poolUri ? (
+
+ ) : (
+
+ )}
+
+ {poolMetadata?.pool?.name}
+
+
+
+ {multiple ? (
+ togglePoolSelection(pool.id)}
+ onClick={(e) => e.stopPropagation()}
+ checked={selectedPools.includes(pool.id)}
+ />
+ ) : null}
+
+
+ )
+}
diff --git a/centrifuge-app/src/components/Dashboard/utils.ts b/centrifuge-app/src/components/Dashboard/utils.ts
new file mode 100644
index 0000000000..64b14b03ae
--- /dev/null
+++ b/centrifuge-app/src/components/Dashboard/utils.ts
@@ -0,0 +1,139 @@
+import { CurrencyBalance, Loan, Pool } from '@centrifuge/centrifuge-js'
+import { useMemo } from 'react'
+import { LoanTemplate } from '../../types'
+import { Dec } from '../../utils/Decimal'
+import { usePoolMetadataMulti } from '../../utils/usePools'
+import { getAmount } from '../LoanList'
+import { CreateAssetFormValues } from './Assets/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/InvestRedeem/RedeemForm.tsx b/centrifuge-app/src/components/InvestRedeem/RedeemForm.tsx
index dccf00fb96..9c711039fd 100644
--- a/centrifuge-app/src/components/InvestRedeem/RedeemForm.tsx
+++ b/centrifuge-app/src/components/InvestRedeem/RedeemForm.tsx
@@ -31,8 +31,6 @@ export function RedeemForm({ autoFocus }: RedeemFormProps) {
const pendingRedeem = state.order?.remainingRedeemToken ?? Dec(0)
const maxRedeemTokens = state.trancheBalanceWithPending
- const maxRedeemCurrency = maxRedeemTokens.mul(state.tokenPrice)
- const tokenSymbol = state.trancheCurrency?.symbol
hooks.useActionSucceeded((action) => {
if (action === 'approveTrancheToken') {
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/Onboarding/FileUpload.tsx b/centrifuge-app/src/components/Onboarding/FileUpload.tsx
index 7400a80a2e..1c24da404c 100644
--- a/centrifuge-app/src/components/Onboarding/FileUpload.tsx
+++ b/centrifuge-app/src/components/Onboarding/FileUpload.tsx
@@ -3,7 +3,7 @@ import {
Button,
Flex,
IconAlertCircle,
- IconFileText,
+ IconFile,
IconX,
Shelf,
Spinner,
@@ -167,7 +167,7 @@ export function FileUpload({
) : errorMessage ? (
) : (
-
+
)}
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
new file mode 100644
index 0000000000..62b56df2a5
--- /dev/null
+++ b/centrifuge-app/src/components/PoolOverview/Cashflows.tsx
@@ -0,0 +1,96 @@
+import { CurrencyBalance } from '@centrifuge/centrifuge-js'
+import { AnchorButton, IconDownload, Shelf, Stack, Text } from '@centrifuge/fabric'
+import { useParams } from 'react-router'
+import { formatDate } from '../../utils/date'
+import { formatBalance } from '../../utils/formatting'
+import { getCSVDownloadUrl } from '../../utils/getCSVDownloadUrl'
+import { useLoans } from '../../utils/useLoans'
+import { useDailyPoolStates, usePool } from '../../utils/usePools'
+import { CashflowsChart } from '../Charts/CashflowsChart'
+
+export const Cashflows = () => {
+ const { pid: poolId } = useParams<{ pid: string }>()
+ if (!poolId) throw new Error('Pool not found')
+
+ const { poolStates } = useDailyPoolStates(poolId) || {}
+ const pool = usePool(poolId)
+ const { data: loans } = useLoans([poolId])
+
+ const firstOriginationDate = loans?.reduce((acc, cur) => {
+ if ('originationDate' in cur) {
+ if (!acc) return cur.originationDate
+ return acc < cur.originationDate ? acc : cur.originationDate
+ }
+ return acc
+ }, '')
+
+ const truncatedPoolStates = poolStates?.filter((poolState) => {
+ if (firstOriginationDate) {
+ return new Date(poolState.timestamp) >= new Date(firstOriginationDate)
+ }
+ return true
+ })
+
+ const csvData = truncatedPoolStates?.map((poolState) => {
+ return {
+ Date: `"${formatDate(poolState.timestamp, {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ })}"`,
+ Purchases: poolState.sumBorrowedAmountByPeriod
+ ? `"${formatBalance(
+ new CurrencyBalance(poolState.sumBorrowedAmountByPeriod, pool.currency.decimals).toDecimal().toNumber(),
+ 'USD',
+ 2,
+ 2
+ )}"`
+ : '-',
+ 'Principal repayments': poolState.sumPrincipalRepaidAmountByPeriod
+ ? `"${formatBalance(
+ new CurrencyBalance(poolState.sumPrincipalRepaidAmountByPeriod, pool.currency.decimals)
+ .toDecimal()
+ .toNumber(),
+ 'USD',
+ 2,
+ 2
+ )}"`
+ : '-',
+ Interest: poolState.sumInterestRepaidAmountByPeriod
+ ? `"${formatBalance(
+ new CurrencyBalance(poolState.sumInterestRepaidAmountByPeriod, pool.currency.decimals)
+ .toDecimal()
+ .toNumber(),
+ 'USD',
+ 2,
+ 2
+ )}"`
+ : '-',
+ }
+ })
+
+ const csvUrl = csvData?.length ? getCSVDownloadUrl(csvData) : ''
+
+ return (
+
+
+
+ Cashflows
+
+ {csvUrl && (
+
+ Download
+
+ )}
+
+
+
+ )
+}
diff --git a/centrifuge-app/src/components/Report/AssetTransactions.tsx b/centrifuge-app/src/components/Report/AssetTransactions.tsx
index 31a4b628f6..fe676efe35 100644
--- a/centrifuge-app/src/components/Report/AssetTransactions.tsx
+++ b/centrifuge-app/src/components/Report/AssetTransactions.tsx
@@ -173,7 +173,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/BalanceSheet.tsx b/centrifuge-app/src/components/Report/BalanceSheet.tsx
index 386db37134..e0e0b27201 100644
--- a/centrifuge-app/src/components/Report/BalanceSheet.tsx
+++ b/centrifuge-app/src/components/Report/BalanceSheet.tsx
@@ -111,7 +111,7 @@ export function BalanceSheet({ pool }: { pool: Pool }) {
formatter: (v: any) => (v ? formatBalance(v, 2, currency) : ''),
},
]
- }, [pool.currency.displayName, poolStates])
+ }, [currency, poolStates])
const trancheRecords: Row[] = React.useMemo(() => {
return [
@@ -167,7 +167,7 @@ export function BalanceSheet({ pool }: { pool: Pool }) {
formatter: (v: any) => (v ? formatBalance(v, 2, currency) : ''),
},
]
- }, [poolStates, pool])
+ }, [poolStates, pool, currency])
const headers = columns.slice(0, -1).map(({ header }) => header)
diff --git a/centrifuge-app/src/components/Report/CashflowStatement.tsx b/centrifuge-app/src/components/Report/CashflowStatement.tsx
index 4aba2e9e89..d51f48a09b 100644
--- a/centrifuge-app/src/components/Report/CashflowStatement.tsx
+++ b/centrifuge-app/src/components/Report/CashflowStatement.tsx
@@ -28,7 +28,7 @@ export function CashflowStatement({ pool }: { pool: Pool }) {
React.useEffect(() => {
setReportData(data)
- }, [data])
+ }, [data, setReportData])
const columns = React.useMemo(() => {
if (!data.length && !isLoading) {
@@ -69,7 +69,7 @@ export function CashflowStatement({ pool }: { pool: Pool }) {
cell: () => ,
width: '1fr',
})
- }, [data, groupBy])
+ }, [data, groupBy, isLoading])
const grossCashflowRecords: Row[] = React.useMemo(() => {
return [
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/Report/FeeTransactions.tsx b/centrifuge-app/src/components/Report/FeeTransactions.tsx
index abe94b1a21..eb4ceb6b73 100644
--- a/centrifuge-app/src/components/Report/FeeTransactions.tsx
+++ b/centrifuge-app/src/components/Report/FeeTransactions.tsx
@@ -79,7 +79,7 @@ export function FeeTransactions({ pool }: { pool: Pool }) {
],
heading: false,
}))
- }, [transactions, txType, poolMetadata, pool.currency.symbol])
+ }, [transactions, poolMetadata, pool.currency.symbol])
const columns = columnConfig
.map((col, index) => ({
diff --git a/centrifuge-app/src/components/Report/ProfitAndLoss.tsx b/centrifuge-app/src/components/Report/ProfitAndLoss.tsx
index 2fcd9550d0..4e993ed2e2 100644
--- a/centrifuge-app/src/components/Report/ProfitAndLoss.tsx
+++ b/centrifuge-app/src/components/Report/ProfitAndLoss.tsx
@@ -213,7 +213,7 @@ export function ProfitAndLoss({ pool }: { pool: Pool }) {
})
return rows
- }, [data, pool.currency.displayName])
+ }, [data, pool.currency.displayName, pool.currency.decimals])
const totalProfitRecords: Row[] = React.useMemo(() => {
return [
@@ -224,7 +224,7 @@ export function ProfitAndLoss({ pool }: { pool: Pool }) {
formatter: (v: any) => `${formatBalance(v, 2, pool.currency.displayName)}`,
},
]
- }, [data, poolMetadata?.pool?.asset.class, pool.currency.displayName])
+ }, [data, pool.currency.displayName])
const headers = columns.slice(0, -1).map(({ header }) => header)
diff --git a/centrifuge-app/src/components/Root.tsx b/centrifuge-app/src/components/Root.tsx
index e23cbae974..fad8fc4a15 100644
--- a/centrifuge-app/src/components/Root.tsx
+++ b/centrifuge-app/src/components/Root.tsx
@@ -61,7 +61,6 @@ const CollectionsPage = React.lazy(() => import('../pages/Collections'))
const InvestmentDisclaimerPage = React.lazy(() => import('../pages/InvestmentDisclaimer'))
const IssuerCreatePoolPage = React.lazy(() => import('../pages/IssuerCreatePool'))
const IssuerPoolPage = React.lazy(() => import('../pages/IssuerPool'))
-const IssuerCreateLoanPage = React.lazy(() => import('../pages/IssuerPool/Assets/CreateLoan'))
const LoanPage = React.lazy(() => import('../pages/Loan'))
const MintNFTPage = React.lazy(() => import('../pages/MintNFT'))
const MultisigApprovalPage = React.lazy(() => import('../pages/MultisigApproval'))
@@ -125,11 +124,6 @@ const router = createHashRouter([
element: ,
handle: { component: PoolTransactionsPage },
},
- {
- path: '/issuer/:pid/assets/create',
- element: ,
- handle: { component: IssuerCreateLoanPage },
- },
{ path: '/portfolio', element: , handle: { component: PortfolioPage } },
{ path: '/prime/:dao', element: , handle: { component: PrimeDetailPage } },
{ path: '/prime', element: , handle: { component: PrimePage } },
diff --git a/centrifuge-app/src/components/Tooltips.tsx b/centrifuge-app/src/components/Tooltips.tsx
index 0bca55ddcf..d52470f5a3 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 = {
@@ -386,17 +410,30 @@ export type TooltipsProps = {
props?: any
size?: 'med' | 'sm' | 'xs'
color?: string
+ placement?: 'top' | 'bottom' | 'left' | 'right'
} & Partial>
-export function Tooltips({ type, label: labelOverride, size = 'sm', props, color, ...textProps }: TooltipsProps) {
+export function Tooltips({
+ type,
+ label: labelOverride,
+ size = 'sm',
+ props,
+ color,
+ placement,
+ ...textProps
+}: TooltipsProps) {
const { label, body } = type ? tooltipText[type] : { label: labelOverride, body: textProps.body }
return (
-
+
{typeof label === 'string' ? (
{labelOverride || label}
diff --git a/centrifuge-app/src/pages/Dashboard/AssetsPage.tsx b/centrifuge-app/src/pages/Dashboard/AssetsPage.tsx
index d0581a4335..2c8524fc2d 100644
--- a/centrifuge-app/src/pages/Dashboard/AssetsPage.tsx
+++ b/centrifuge-app/src/pages/Dashboard/AssetsPage.tsx
@@ -1,3 +1,84 @@
+import { Box, Text } from '@centrifuge/fabric'
+import { useEffect } from 'react'
+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 { PoolSelector } from '../../components/Dashboard/PoolSelector'
+import { TransformedLoan, useLoanCalculations } from '../../components/Dashboard/utils'
+
export default function AssetsPage() {
- return <>>
+ const { selectedPools, setSelectedPools, pools = [] } = useSelectedPools()
+ const ids = pools.map((pool) => pool.id)
+ const { data: loans, isLoading } = useLoans(pools ? ids : [])
+
+ // TODO - replace with Sophia's code once merged
+ 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 (!pools.length || !loans) return No data available
+
+ if (isLoading) return
+
+ return (
+
+ Dashboard
+
+
+
+
+
+
+ )
}
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
deleted file mode 100644
index 5c6532046d..0000000000
--- a/centrifuge-app/src/pages/IssuerPool/Assets/CreateLoan.tsx
+++ /dev/null
@@ -1,520 +0,0 @@
-import { CurrencyBalance, LoanInfoInput, Price, Rate } from '@centrifuge/centrifuge-js'
-import { NFTMetadataInput } from '@centrifuge/centrifuge-js/dist/modules/nfts'
-import {
- formatBalance,
- useBalances,
- useCentrifuge,
- useCentrifugeApi,
- useCentrifugeConsts,
- useCentrifugeTransaction,
- wrapProxyCallsForAccount,
-} from '@centrifuge/centrifuge-react'
-import {
- Box,
- Button,
- CurrencyInput,
- DateInput,
- Grid,
- ImageUpload,
- NumberInput,
- Select,
- Shelf,
- Stack,
- Text,
- TextAreaInput,
- TextInput,
-} from '@centrifuge/fabric'
-import BN from 'bn.js'
-import { Field, FieldProps, Form, FormikProvider, useFormik } from 'formik'
-import * as React from 'react'
-import { Navigate, useNavigate, useParams } from 'react-router'
-import { firstValueFrom, lastValueFrom, switchMap } from 'rxjs'
-import { FieldWithErrorMessage } from '../../../components/FieldWithErrorMessage'
-import { PageHeader } from '../../../components/PageHeader'
-import { PageSection } from '../../../components/PageSection'
-import { RouterLinkButton } from '../../../components/RouterLinkButton'
-import { LoanTemplate, LoanTemplateAttribute } from '../../../types'
-import { getFileDataURI } from '../../../utils/getFileDataURI'
-import { useFocusInvalidInput } from '../../../utils/useFocusInvalidInput'
-import { useMetadata } from '../../../utils/useMetadata'
-import { usePoolAccess, useSuitableAccounts } from '../../../utils/usePermissions'
-import { usePool, usePoolMetadata } from '../../../utils/usePools'
-import { combine, max, maxLength, min, positiveNumber, required } from '../../../utils/validation'
-import { validate } from '../../IssuerCreatePool/validate'
-import { PricingInput } from './PricingInput'
-
-export default function IssuerCreateLoanPage() {
- return
-}
-
-export type CreateLoanFormValues = {
- image: File | null
- description: string
- assetName: string
- attributes: Record
- pricing: {
- valuationMethod: 'discountedCashFlow' | 'outstandingDebt' | 'oracle' | 'cash'
- maxBorrowAmount: 'upToTotalBorrowed' | 'upToOutstandingDebt'
- maturity: 'fixed' | 'none' | 'fixedWithExtension'
- value: number | ''
- maturityDate: string
- maturityExtensionDays: number
- advanceRate: number | ''
- interestRate: number | ''
- probabilityOfDefault: number | ''
- lossGivenDefault: number | ''
- discountRate: number | ''
- maxBorrowQuantity: number | ''
- isin: string
- notional: number | ''
- withLinearPricing: boolean
- oracleSource: 'isin' | 'assetSpecific'
- }
-}
-
-type TemplateFieldProps = LoanTemplateAttribute & { name: string }
-
-function TemplateField({ label, name, input }: TemplateFieldProps) {
- 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 (
-
- )
- }
- }
-}
-
-// 'integer' | 'decimal' | 'string' | 'bytes' | 'timestamp' | 'monetary'
-
-function IssuerCreateLoan() {
- const { pid } = useParams<{ pid: string }>()
- if (!pid) throw new Error('Pool not found')
- const pool = usePool(pid)
- const [redirect, setRedirect] = React.useState()
- const navigate = useNavigate()
- const centrifuge = useCentrifuge()
-
- const {
- loans: { loanDeposit },
- chainSymbol,
- } = useCentrifugeConsts()
- const api = useCentrifugeApi()
- 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 balances = useBalances(account?.actingAddress)
-
- const { data: poolMetadata } = usePoolMetadata(pool)
-
- 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: '',
- assetName: '',
- attributes: {},
- pricing: {
- 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, { setSubmitting }) => {
- if (!collateralCollectionId || !account || !templateMetadata) return
- const { decimals } = pool.currency
- let pricingInfo: LoanInfoInput
- if (values.pricing.valuationMethod === 'cash') {
- pricingInfo = {
- valuationMethod: values.pricing.valuationMethod,
- 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: values.pricing.maturity !== 'none' ? new Date(values.pricing.maturityDate) : null,
- }
- } else if (values.pricing.valuationMethod === 'oracle') {
- const loanId = await firstValueFrom(centrifuge.pools.getNextLoanId([pid]))
- pricingInfo = {
- valuationMethod: values.pricing.valuationMethod,
- maxPriceVariation: Rate.fromPercent(9999),
- maxBorrowAmount: values.pricing.maxBorrowQuantity ? Price.fromFloat(values.pricing.maxBorrowQuantity) : null,
- priceId:
- values.pricing.oracleSource === 'isin'
- ? { isin: values.pricing.isin }
- : { poolLoanId: [pid, loanId.toString()] satisfies [string, string] },
- maturityDate: values.pricing.maturity !== 'none' ? new Date(values.pricing.maturityDate) : null,
- interestRate: Rate.fromPercent(values.pricing.notional === 0 ? 0 : values.pricing.interestRate),
- notional: CurrencyBalance.fromFloat(values.pricing.notional, decimals),
- withLinearPricing: values.pricing.withLinearPricing,
- }
- } else if (values.pricing.valuationMethod === 'outstandingDebt') {
- pricingInfo = {
- valuationMethod: values.pricing.valuationMethod,
- maxBorrowAmount: values.pricing.maxBorrowAmount,
- value: CurrencyBalance.fromFloat(values.pricing.value, decimals),
- maturityDate: values.pricing.maturity !== 'none' ? new Date(values.pricing.maturityDate) : null,
- maturityExtensionDays:
- values.pricing.maturity === 'fixedWithExtension' ? values.pricing.maturityExtensionDays : null,
- advanceRate: Rate.fromPercent(values.pricing.advanceRate),
- interestRate: Rate.fromPercent(values.pricing.interestRate),
- }
- } else {
- pricingInfo = {
- valuationMethod: values.pricing.valuationMethod,
- maxBorrowAmount: values.pricing.maxBorrowAmount,
- value: CurrencyBalance.fromFloat(values.pricing.value, decimals),
- maturityDate: values.pricing.maturity !== 'none' ? new Date(values.pricing.maturityDate) : null,
- maturityExtensionDays:
- values.pricing.maturity === 'fixedWithExtension' ? values.pricing.maturityExtensionDays : null,
- advanceRate: Rate.fromPercent(values.pricing.advanceRate),
- interestRate: Rate.fromPercent(values.pricing.interestRate),
- probabilityOfDefault: Rate.fromPercent(values.pricing.probabilityOfDefault || 0),
- lossGivenDefault: Rate.fromPercent(values.pricing.lossGivenDefault || 0),
- discountRate: Rate.fromPercent(values.pricing.discountRate || 0),
- }
- }
-
- const properties =
- values.pricing.valuationMethod === 'cash'
- ? {}
- : { ...(valuesToNftProperties(values.attributes, templateMetadata 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',
- })
- setSubmitting(false)
- },
- })
-
- const templateIds = poolMetadata?.loanTemplates?.map((s) => s.id) ?? []
- const templateId = templateIds.at(-1)
- const { data: templateMetadata } = useMetadata(templateId)
-
- const formRef = React.useRef(null)
- useFocusInvalidInput(form, formRef)
-
- React.useEffect(() => {
- if (form.values.pricing.maturity === 'none' && form.values.pricing.valuationMethod === 'discountedCashFlow') {
- form.setFieldValue('pricing.maturity', 'fixed', false)
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [form.values])
-
- if (redirect) {
- return
- }
-
- const isPending = isTxLoading || form.isSubmitting
-
- const balanceDec = balances?.native.balance.toDecimal()
- const balanceLow = balanceDec?.lt(loanDeposit.toDecimal())
-
- const errorMessage = balanceLow ? `The AO account needs at least ${formatBalance(loanDeposit, chainSymbol, 1)}` : null
-
- return (
-
-
-
- )
-}
-
-function valuesToNftProperties(values: CreateLoanFormValues['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/pages/IssuerPool/Assets/PricingInput.tsx b/centrifuge-app/src/pages/IssuerPool/Assets/PricingInput.tsx
deleted file mode 100644
index f0a7b7d7c5..0000000000
--- a/centrifuge-app/src/pages/IssuerPool/Assets/PricingInput.tsx
+++ /dev/null
@@ -1,214 +0,0 @@
-import {
- Checkbox,
- CurrencyInput,
- DateInput,
- Grid,
- NumberInput,
- Select,
- Stack,
- Text,
- TextInput,
-} from '@centrifuge/fabric'
-import { Field, FieldProps, useFormikContext } from 'formik'
-import { FieldWithErrorMessage } from '../../../components/FieldWithErrorMessage'
-import { Tooltips } from '../../../components/Tooltips'
-import { usePool } from '../../../utils/usePools'
-import { combine, max, nonNegativeNumber, positiveNumber, required } from '../../../utils/validation'
-import { validate } from '../../IssuerCreatePool/validate'
-import { CreateLoanFormValues } from './CreateLoan'
-
-export function PricingInput({ poolId }: { poolId: string }) {
- const { values } = useFormikContext()
- const pool = usePool(poolId)
- return (
-
- {values.pricing.valuationMethod === 'oracle' && (
- <>
-
- {({ field, meta, form }: FieldProps) => (
-
- {values.pricing.oracleSource === 'isin' && (
- }
- placeholder="010101010000"
- name="pricing.isin"
- validate={validate.isin}
- />
- )}
-
- {({ field, meta, form }: FieldProps) => (
- }
- placeholder="0.00"
- errorMessage={meta.touched ? meta.error : undefined}
- onChange={(value) => {
- form.setFieldValue('pricing.notional', value)
- if (value === 0) {
- form.setFieldValue('pricing.interestRate', 0)
- }
- }}
- currency={pool.currency.symbol}
- />
- )}
-
-
-
- {({ field, meta }: FieldProps) => (
- With linear pricing?}
- {...field}
- />
- )}
-
-
- >
- )}
-
- {(values.pricing.valuationMethod === 'discountedCashFlow' ||
- values.pricing.valuationMethod === 'outstandingDebt') && (
- <>
-
- {({ field, meta, form }: FieldProps) => (
-
-
-
- {({ field, meta, form }: FieldProps) => (
- form.setFieldValue('pricing.value', value)}
- />
- )}
-
- >
- )}
- {values.pricing.valuationMethod !== 'cash' && (
- }
- placeholder="0.00"
- symbol="%"
- disabled={Number(values.pricing.notional) <= 0}
- name="pricing.interestRate"
- validate={combine(required(), nonNegativeNumber(), max(100))}
- />
- )}
-
-
- {({ field, meta, form }: FieldProps) => (
-
- {values.pricing.maturity.startsWith('fixed') && (
-
- )}
- {values.pricing.maturity === 'fixedWithExtension' && (
- }
- placeholder={0}
- symbol="days"
- name="pricing.maturityExtensionDays"
- validate={validate.maturityExtensionDays}
- />
- )}
-
- {(values.pricing.valuationMethod === 'discountedCashFlow' ||
- values.pricing.valuationMethod === 'outstandingDebt') && (
- <>
- }
- placeholder="0.00"
- symbol="%"
- name="pricing.advanceRate"
- validate={validate.advanceRate}
- />
- >
- )}
- {values.pricing.valuationMethod === 'discountedCashFlow' && (
- <>
- }
- placeholder="0.00"
- symbol="%"
- name="pricing.probabilityOfDefault"
- validate={validate.probabilityOfDefault}
- />
- }
- placeholder="0.00"
- symbol="%"
- name="pricing.lossGivenDefault"
- validate={validate.lossGivenDefault}
- />
- }
- placeholder="0.00"
- symbol="%"
- name="pricing.discountRate"
- validate={validate.discountRate}
- />
- >
- )}
-
- )
-}
diff --git a/centrifuge-app/src/pages/IssuerPool/Configuration/CreateLoanTemplate.tsx b/centrifuge-app/src/pages/IssuerPool/Configuration/CreateLoanTemplate.tsx
deleted file mode 100644
index b68d539996..0000000000
--- a/centrifuge-app/src/pages/IssuerPool/Configuration/CreateLoanTemplate.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-import { PoolMetadata } from '@centrifuge/centrifuge-js'
-import { useCentrifuge, useCentrifugeTransaction } from '@centrifuge/centrifuge-react'
-import { Box, Button, Shelf, TextAreaInput } from '@centrifuge/fabric'
-import { Form, FormikErrors, FormikProvider, setIn, useFormik } from 'formik'
-import * as React from 'react'
-import { Navigate, useNavigate, useParams } from 'react-router'
-import { lastValueFrom } from 'rxjs'
-import { FieldWithErrorMessage } from '../../../components/FieldWithErrorMessage'
-import { PageHeader } from '../../../components/PageHeader'
-import { PageSection } from '../../../components/PageSection'
-import { LoanTemplate } from '../../../types'
-import { useMetadata, usePrefetchMetadata } from '../../../utils/useMetadata'
-import { useSuitableAccounts } from '../../../utils/usePermissions'
-import { usePool, usePoolMetadata } from '../../../utils/usePools'
-import { isValidJsonString } from '../../../utils/validation'
-
-const initialSchemaJSON = `{
- "options": {
- "description": true,
- "image": true
- },
- "attributes": {
- "key1": {
- "label": "string value",
- "type": {
- "primitive": "string",
- "statistics": "categorical",
- "constructor": "String"
- },
- "input": {
- "type": "text"
- },
- "output": null,
- "public": true
- },
- "key2": {
- "label": "number value",
- "type": {
- "primitive": "number",
- "statistics": "categorical",
- "constructor": "Number"
- },
- "input": {
- "type": "number",
- "unit": "%",
- "min": 0,
- "max": 100
- },
- "output": null,
- "public": true
- },
- "key3": {
- "label": "a date",
- "type": {
- "primitive": "string",
- "statistics": "continuous",
- "constructor": "Date"
- },
- "input": {
- "type": "date"
- },
- "output": null,
- "public": true
- },
- "key4": {
- "label": "a currency value",
- "type": {
- "primitive": "string",
- "statistics": "continuous",
- "constructor": "Number"
- },
- "input": {
- "type": "currency",
- "symbol": "USD"
- },
- "output": null,
- "public": true
- },
- "key5": {
- "label": "A or B",
- "type": {
- "primitive": "string",
- "statistics": "categorical",
- "constructor": "String"
- },
- "input": {
- "type": "single-select",
- "options": ["A", "B"]
- },
- "output": null,
- "public": true
- }
- },
- "sections": [
- {
- "name": "A section",
- "attributes": [
- "key1",
- "key3",
- "key4",
- "key5"
- ]
- },
- {
- "name": "Another section",
- "attributes": [
- "key2"
- ]
- }
- ]
-}`
-
-export function IssuerPoolCreateLoanTemplatePage() {
- return
-}
-
-export function CreateLoanTemplate() {
- const { pid: poolId } = useParams<{ pid: string }>()
-
- if (!poolId) throw new Error('Pool not found')
-
- const pool = usePool(poolId)
- const { data: poolMetadata } = usePoolMetadata(pool)
- const navigate = useNavigate()
- const prefetchMetadata = usePrefetchMetadata()
- const [redirect, setRedirect] = React.useState('')
- const cent = useCentrifuge()
- const { data: lastTemplateVersion } = useMetadata(poolMetadata?.loanTemplates?.at(-1)?.id)
- const [account] = useSuitableAccounts({ poolId, poolRole: ['PoolAdmin'] })
-
- const { execute: updateConfigTx, isLoading } = useCentrifugeTransaction(
- 'Create asset template',
- (cent) => cent.pools.setMetadata,
- {
- onSuccess: () => {
- setRedirect(`/issuer/${poolId}/configuration`)
- },
- }
- )
-
- const form = useFormik({
- initialValues: {
- metadata: initialSchemaJSON,
- },
- validate: (values) => {
- let errors: FormikErrors = {}
- if (!isValidJsonString(values.metadata)) {
- errors = setIn(errors, `metadata`, 'Must be a valid JSON string')
- } else {
- const obj: Partial = JSON.parse(values.metadata)
- const allSameVisibility = obj.sections?.every((section) =>
- section.attributes.every((key) => {
- const isPublic = obj.attributes?.[section.attributes[0]]?.public
- const attr = obj.attributes?.[key]
- return !!attr?.public === !!isPublic
- })
- )
- if (!allSameVisibility) {
- errors = setIn(errors, `metadata`, 'Attributes in a section must all be public or all be not public')
- }
- }
- return errors
- },
- onSubmit: async (values, { setSubmitting }) => {
- const templateMetadataHash = await lastValueFrom(cent.metadata.pinJson(JSON.parse(values.metadata)))
- const newPoolMetadata = {
- ...(poolMetadata as PoolMetadata),
- loanTemplates: [
- ...(poolMetadata?.loanTemplates ?? []),
- {
- id: templateMetadataHash.ipfsHash,
- createdAt: new Date().toISOString(),
- },
- ],
- }
-
- prefetchMetadata(templateMetadataHash.ipfsHash)
-
- updateConfigTx([poolId, newPoolMetadata], { account })
- setSubmitting(false)
- },
- })
-
- React.useEffect(() => {
- if (!lastTemplateVersion) return
- form.resetForm()
- form.setValues({ metadata: JSON.stringify(lastTemplateVersion, null, 2) }, false)
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [lastTemplateVersion])
-
- if (!poolMetadata || (poolMetadata.loanTemplates?.[0] && !lastTemplateVersion)) return null
-
- const isUpdating = !!poolMetadata.loanTemplates?.[0]
-
- if (redirect) {
- ;
- }
-
- return (
-
-
-
- )
-}
diff --git a/centrifuge-app/src/pages/IssuerPool/Configuration/LoanTemplates.tsx b/centrifuge-app/src/pages/IssuerPool/Configuration/LoanTemplates.tsx
deleted file mode 100644
index 92694b13a3..0000000000
--- a/centrifuge-app/src/pages/IssuerPool/Configuration/LoanTemplates.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import { IconChevronRight, Text } from '@centrifuge/fabric'
-import { useParams } from 'react-router'
-import { DataTable } from '../../../components/DataTable'
-import { PageSection } from '../../../components/PageSection'
-import { RouterLinkButton } from '../../../components/RouterLinkButton'
-import { LoanTemplate } from '../../../types'
-import { formatDate } from '../../../utils/date'
-import { useMetadataMulti } from '../../../utils/useMetadata'
-import { usePool, usePoolMetadata } from '../../../utils/usePools'
-
-type Row = {
- name: string
- createdAt: Date | null
- id: string
-}
-
-export function LoanTemplates() {
- const { pid: poolId } = useParams<{ pid: string }>()
-
- if (!poolId) throw new Error('Pool not found')
-
- const pool = usePool(poolId)
- const { data: poolMetadata } = usePoolMetadata(pool)
-
- const templateIds = poolMetadata?.loanTemplates?.map((s) => s.id) ?? []
- const templateMetadata = useMetadataMulti(templateIds)
-
- const tableData = templateIds.map((id, i) => {
- const meta = templateMetadata[i].data
- const metaMeta = poolMetadata?.loanTemplates?.[i]
- return {
- name: meta?.name ?? `Version ${i + 1}`,
- createdAt: metaMeta?.createdAt ? new Date(metaMeta?.createdAt) : null,
- id,
- }
- })
-
- return (
-
- {tableData.length ? 'Update template' : 'Create'}
-
- }
- >
- `/issuer/${poolId}/configuration/view-asset-template/${row.id}`}
- columns={[
- {
- align: 'left',
- header: 'Asset template',
- cell: (row: Row) => (
-
- {row.name}
-
- ),
- width: '3fr',
- },
- {
- header: 'Created',
- cell: (row: Row) => row.createdAt && formatDate(row.createdAt),
- },
- {
- header: '',
- cell: () => ,
- width: '72px',
- },
- ]}
- />
-
- )
-}
diff --git a/centrifuge-app/src/pages/IssuerPool/Configuration/ViewLoanTemplate.tsx b/centrifuge-app/src/pages/IssuerPool/Configuration/ViewLoanTemplate.tsx
deleted file mode 100644
index e5b5c3c557..0000000000
--- a/centrifuge-app/src/pages/IssuerPool/Configuration/ViewLoanTemplate.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Box, Text } from '@centrifuge/fabric'
-import { useParams } from 'react-router'
-import { PageHeader } from '../../../components/PageHeader'
-import { useMetadata } from '../../../utils/useMetadata'
-import { usePool, usePoolMetadata } from '../../../utils/usePools'
-
-export function IssuerPoolViewLoanTemplatePage() {
- return
-}
-
-export function ViewLoanTemplate() {
- const { pid: poolId, sid: templateId } = useParams<{ pid: string; sid: string }>()
- if (!poolId || !templateId) throw new Error('Template not found')
- const pool = usePool(poolId)
- const { data: poolMetadata } = usePoolMetadata(pool)
- const { data: templateData } = useMetadata(`ipfs://${templateId}`)
-
- return (
- <>
-
-
-
- {JSON.stringify(templateData, null, 2)}
-
-
- >
- )
-}
diff --git a/centrifuge-app/src/pages/IssuerPool/Configuration/index.tsx b/centrifuge-app/src/pages/IssuerPool/Configuration/index.tsx
index c6c61dff80..f15abc1f5a 100644
--- a/centrifuge-app/src/pages/IssuerPool/Configuration/index.tsx
+++ b/centrifuge-app/src/pages/IssuerPool/Configuration/index.tsx
@@ -8,7 +8,6 @@ import { OnboardingSettings } from '../Investors/OnboardingSettings'
import { Details } from './Details'
import { EpochAndTranches } from './EpochAndTranches'
import { Issuer } from './Issuer'
-import { LoanTemplates } from './LoanTemplates'
import { PoolConfig } from './PoolConfig'
export function IssuerPoolConfigurationPage() {
@@ -38,7 +37,6 @@ function IssuerPoolConfiguration() {
-
{isPoolAdmin && }
{editPoolConfig && }
>
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 && (
} />
- } />
- } />
} />
} />
} />
diff --git a/centrifuge-app/src/pages/Loan/ExternalFinanceForm.tsx b/centrifuge-app/src/pages/Loan/ExternalFinanceForm.tsx
index 94ce921032..c201db1497 100644
--- a/centrifuge-app/src/pages/Loan/ExternalFinanceForm.tsx
+++ b/centrifuge-app/src/pages/Loan/ExternalFinanceForm.tsx
@@ -77,7 +77,7 @@ export function ExternalFinanceForm({
const account = useBorrower(loan.poolId, loan.id)
const poolFees = useChargePoolFees(loan.poolId, loan.id)
const api = useCentrifugeApi()
- const { data: loans } = useLoans(loan.poolId)
+ const { data: loans } = useLoans([loan.poolId])
const sourceLoan = loans?.find((l) => 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..4a7318364f 100644
--- a/centrifuge-app/src/pages/Loan/index.tsx
+++ b/centrifuge-app/src/pages/Loan/index.tsx
@@ -26,7 +26,6 @@ import { nftMetadataSchema } from '../../schemas'
import { LoanTemplate } from '../../types'
import { copyToClipboard } from '../../utils/copyToClipboard'
import { formatBalance, truncateText } from '../../utils/formatting'
-import { useBasePath } from '../../utils/useBasePath'
import { useLoan } from '../../utils/useLoans'
import { useMetadata } from '../../utils/useMetadata'
import { useCentNFT } from '../../utils/useNFTs'
@@ -104,7 +103,6 @@ function ActionButtons({ loan }: { loan: LoanType }) {
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 isTinlakePool = poolId?.startsWith('0x')
const pool = usePool(poolId)
const loan = useLoan(poolId, loanId)
@@ -185,7 +183,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..a604f8abc5 100644
--- a/centrifuge-app/src/pages/Pool/Assets/index.tsx
+++ b/centrifuge-app/src/pages/Pool/Assets/index.tsx
@@ -1,5 +1,5 @@
import { CurrencyBalance, Loan } from '@centrifuge/centrifuge-js'
-import { Box, IconChevronRight, IconPlus, Shelf, Text } from '@centrifuge/fabric'
+import { Box, IconChevronRight, Shelf, Text } from '@centrifuge/fabric'
import * as React from 'react'
import { useParams } from 'react-router'
import styled from 'styled-components'
@@ -8,17 +8,15 @@ import { useBasePath } from '../../../../src/utils/useBasePath'
import { LoadBoundary } from '../../../components/LoadBoundary'
import { LoanList, getAmount } from '../../../components/LoanList'
import { PageSummary } from '../../../components/PageSummary'
-import { RouterLinkButton } from '../../../components/RouterLinkButton'
import { Tooltips } from '../../../components/Tooltips'
import { Dec } from '../../../utils/Decimal'
import { formatBalance } from '../../../utils/formatting'
import { useLoans } from '../../../utils/useLoans'
-import { useSuitableAccounts } from '../../../utils/usePermissions'
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 +42,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(
@@ -57,7 +55,6 @@ export function PoolDetailAssets() {
return (
No assets have been originated yet
-
)
}
@@ -124,22 +121,10 @@ export function PoolDetailAssets() {
return (
<>
-
-
-
+
>
)
}
-
-function CreateAssetButton({ poolId }: { poolId: string }) {
- const canCreateAssets = useSuitableAccounts({ poolId, poolRole: ['Borrower'], proxyType: ['Borrow'] }).length > 0
-
- return canCreateAssets ? (
- }>
- Create assets
-
- ) : null
-}
diff --git a/centrifuge-app/src/utils/contexts/SelectedPoolsContext.tsx b/centrifuge-app/src/utils/contexts/SelectedPoolsContext.tsx
new file mode 100644
index 0000000000..86be30fbac
--- /dev/null
+++ b/centrifuge-app/src/utils/contexts/SelectedPoolsContext.tsx
@@ -0,0 +1,55 @@
+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 = (defaultSelectAll: boolean = false): SelectedPoolsContextProps => {
+ const context = useContext(SelectedPoolsContext)
+ if (!context) {
+ throw new Error('useSelectedPools must be used within a SelectedPoolsProvider')
+ }
+
+ React.useEffect(() => {
+ if (defaultSelectAll && context.pools?.length && context.selectedPools.length === 0) {
+ context.setSelectedPools(context.pools.map((pool) => pool.id))
+ }
+ }, [defaultSelectAll, context.pools])
+
+ 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/tinlake/useTinlakePools.ts b/centrifuge-app/src/utils/tinlake/useTinlakePools.ts
index 1fe722cea4..1ed6c3cbad 100644
--- a/centrifuge-app/src/utils/tinlake/useTinlakePools.ts
+++ b/centrifuge-app/src/utils/tinlake/useTinlakePools.ts
@@ -150,12 +150,11 @@ export function useTinlakePools(suspense = false) {
)
}
export function useTinlakeLoans(poolId: string) {
- const tinlakePools = useTinlakePools(poolId.startsWith('0x'))
-
- const pool = tinlakePools?.data?.pools?.find((p) => p.id.toLowerCase() === poolId.toLowerCase())
+ const tinlakePools = useTinlakePools(poolId?.startsWith('0x'))
+ const pool = tinlakePools?.data?.pools?.find((p) => p?.id?.toLowerCase() === poolId?.toLowerCase())
return useQuery(
- ['tinlakePoolLoans', poolId.toLowerCase()],
+ ['tinlakePoolLoans', poolId?.toLowerCase()],
async () => {
const loans = await getTinlakeLoans(poolId)
const writeOffPercentages = await getWriteOffPercentages(pool!, loans)
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..4d0f039062 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,12 @@ 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 poolIdSet = new Set(poolIds)
+
const $events = inst.getEvents().pipe(
filter(({ api, events }) => {
const event = events.find(
@@ -4091,270 +4091,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..577e6948af 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,30 +95,29 @@ 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 {
content: '';
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/FileUpload/index.tsx b/fabric/src/components/FileUpload/index.tsx
index 83f755b88e..18435dc98f 100644
--- a/fabric/src/components/FileUpload/index.tsx
+++ b/fabric/src/components/FileUpload/index.tsx
@@ -79,6 +79,7 @@ export function FileUpload({
id,
fileTypeText,
small,
+ accept,
...inputProps
}: FileUploadProps) {
const defaultId = React.useId()
@@ -177,7 +178,7 @@ export function FileUpload({
{' '}
- {(curFile && typeof curFile !== 'string' && curFile.name) || 'Click to upload'}
+ {(curFile && typeof curFile !== 'string' && curFile.name) || placeholder || 'Click to upload'}
{curFile && typeof curFile !== 'string' && curFile.name ? (
- {(curFile && typeof curFile !== 'string' && curFile.name) || 'Click to upload'}
+ {(curFile && typeof curFile !== 'string' && curFile.name) || placeholder || 'Click to upload'}
{curFile && typeof curFile !== 'string' && curFile.name ? (
''
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 (