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) => ( + ({ label: pool?.meta?.pool?.name, value: pool.id }))} + onChange={(event) => { + const selectedPool = poolsMetadata.find((pool) => pool.id === event.target.value) + form.setFieldValue('selectedPool', selectedPool) + form.setFieldValue('uploadedTemplates', selectedPool?.meta?.loanTemplates || []) + setPid(selectedPool?.id ?? '') + }} + /> + )} + + + {type === 'create-asset' && } + {type === 'upload-template' && ( + + )} + + + + + + ) +} 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) => ( + form.setFieldValue('oracleSource', event.target.value, false)} + errorMessage={meta.touched && meta.error ? meta.error : undefined} + options={[ + { value: 'isin', label: 'ISIN' }, + { value: 'assetSpecific', label: 'Asset specific' }, + ]} + /> + )} + + {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) => ( + form.setFieldValue('maturity', event.target.value, false)} + errorMessage={meta.touched && meta.error ? meta.error : undefined} + options={[ + { value: 'fixed', label: 'Fixed' }, + { value: 'fixedWithExtension', label: 'Fixed with extension period' }, + values.customType !== 'discountedCashFlow' ? { value: 'none', label: 'Open-end' } : (null as never), + ].filter(Boolean)} + /> + )} + + {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) => ( - { - const val = event.target.value - form.setFieldValue('pricing.valuationMethod', val, false) - if (val === 'cash') { - form.setFieldValue('pricing.maturity', 'none') - } - }} - errorMessage={meta.touched && meta.error ? meta.error : undefined} - options={[ - { value: 'discountedCashFlow', label: 'Non-fungible asset - DCF' }, - { value: 'outstandingDebt', label: 'Non-fungible asset - at par' }, - { value: 'oracle', label: 'Fungible asset - external pricing' }, - { value: 'cash', label: 'Cash' }, - ]} - placeholder="Choose asset type" - /> - )} - - - - {form.values.pricing.valuationMethod === 'cash' ? null : ( - - - - )} - {form.values.pricing.valuationMethod !== 'cash' && - templateMetadata?.sections?.map((section) => ( - templateMetadata?.attributes?.[key]?.public) ? 'Public' : 'Private' - } - key={section.name} - > - - {section.attributes?.map((key) => { - const attr = templateMetadata?.attributes?.[key] - if (!attr) return null - const name = `attributes.${key}` - return - })} - - - ))} - - {form.values.pricing.valuationMethod !== 'cash' && - (templateMetadata?.options?.image || templateMetadata?.options?.description) && ( - - - {templateMetadata.options.image && ( - - {({ field, meta, form }: FieldProps) => ( - { - form.setFieldTouched('image', true, false) - form.setFieldValue('image', file) - }} - requirements="JPG/PNG/SVG, max 1MB" - label="Asset image" - errorMessage={meta.touched ? meta.error : undefined} - /> - )} - - )} - {templateMetadata.options.description && ( - - )} - - - )} - - - - - {errorMessage && {errorMessage}} - - - - - - - - ) -} - -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) => ( - form.setFieldValue('pricing.maxBorrowAmount', event.target.value, false)} - errorMessage={meta.touched && meta.error ? meta.error : undefined} - options={[ - { value: 'upToTotalBorrowed', label: 'Up to total borrowed' }, - { value: 'upToOutstandingDebt', label: 'Up to outstanding debt' }, - ]} - placeholder="Choose borrow restriction" - /> - )} - - - - {({ 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) => ( -