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