From b2e54f201ded60583a07b67d9fffba5691f45945 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:00:35 +0100 Subject: [PATCH] Refactor compliance user detail page and add new tabs (#1019) * Feat: refactor compliance user detail page and add new tabs Extract detail tabs into separate components, add support issues panel, IP logs panel, transactions tab with bank tx/crypto input details. Add support issue detail screen. Filter KycFileLog entries from KYC log tab. * Fix: resolve eslint errors in compliance components Wire up handleUidClick for transaction UID expand, replace non-null assertions with safe alternatives. * Refactor: extract generic DataTable component for detail tabs Replace repetitive table markup with a data-driven DataTable component that renders columns from array definitions. --- src/App.tsx | 5 + src/components/compliance/detail-tabs.tsx | 157 +++++ .../compliance/file-preview-panel.tsx | 31 + src/components/compliance/ip-logs-panel.tsx | 48 ++ src/components/compliance/kyc-files-panel.tsx | 47 ++ .../compliance/recommendation-panel.tsx | 47 ++ .../compliance/support-issues-panel.tsx | 65 ++ .../compliance/transactions-tab.tsx | 218 ++++++ src/components/compliance/user-data-panel.tsx | 166 +++++ src/hooks/compliance.hook.ts | 103 ++- src/index.css | 27 +- .../compliance-support-issue.screen.tsx | 202 ++++++ src/screens/compliance-user.screen.tsx | 621 +++--------------- src/util/compliance-helpers.tsx | 137 ++++ 14 files changed, 1326 insertions(+), 548 deletions(-) create mode 100644 src/components/compliance/detail-tabs.tsx create mode 100644 src/components/compliance/file-preview-panel.tsx create mode 100644 src/components/compliance/ip-logs-panel.tsx create mode 100644 src/components/compliance/kyc-files-panel.tsx create mode 100644 src/components/compliance/recommendation-panel.tsx create mode 100644 src/components/compliance/support-issues-panel.tsx create mode 100644 src/components/compliance/transactions-tab.tsx create mode 100644 src/components/compliance/user-data-panel.tsx create mode 100644 src/screens/compliance-support-issue.screen.tsx create mode 100644 src/util/compliance-helpers.tsx diff --git a/src/App.tsx b/src/App.tsx index e87510385..b97f17086 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -60,6 +60,7 @@ const ComplianceKycFilesDetailsScreen = lazy(() => import('./screens/compliance- const ComplianceKycStatsScreen = lazy(() => import('./screens/compliance-kyc-stats.screen')); const ComplianceTransactionListScreen = lazy(() => import('./screens/compliance-transaction-list.screen')); const ComplianceKycStepScreen = lazy(() => import('./screens/compliance-kyc-step.screen')); +const ComplianceSupportIssueScreen = lazy(() => import('./screens/compliance-support-issue.screen')); const ComplianceRecommendationGraphScreen = lazy(() => import('./screens/compliance-recommendation-graph.screen')); const ComplianceCustodyOrdersScreen = lazy(() => import('./screens/compliance-custody-orders.screen')); const RealunitScreen = lazy(() => import('./screens/realunit.screen')); @@ -346,6 +347,10 @@ export const Routes = [ path: 'compliance/user/:id/kyc-step/:stepId', element: withSuspense(), }, + { + path: 'compliance/user/:id/support-issue/:issueId', + element: withSuspense(), + }, { path: 'compliance/recommendations/:id', element: withSuspense(), diff --git a/src/components/compliance/detail-tabs.tsx b/src/components/compliance/detail-tabs.tsx new file mode 100644 index 000000000..0cbd3eee9 --- /dev/null +++ b/src/components/compliance/detail-tabs.tsx @@ -0,0 +1,157 @@ +import { ReactNode } from 'react'; +import { + BankDataInfo, + BuyRouteInfo, + KycLogInfo, + KycStepInfo, + SellRouteInfo, + UserInfo, +} from 'src/hooks/compliance.hook'; +import { boolBadge, formatDate, statusBadge } from 'src/util/compliance-helpers'; + +interface ColumnDef { + header: string; + align?: 'left' | 'center' | 'right'; + render: (item: T) => ReactNode; + className?: string; +} + +function DataTable({ + data, + columns, + emptyLabel, +}: { + data: T[]; + columns: ColumnDef[]; + emptyLabel: string; +}): JSX.Element { + return ( + + + + {columns.map((col) => ( + + ))} + + + + {data?.length > 0 ? ( + data.map((item) => ( + + {columns.map((col) => ( + + ))} + + )) + ) : ( + + + + )} + +
+ {col.header} +
+ {col.render(item)} +
+ {emptyLabel} +
+ ); +} + +const usersColumns: ColumnDef[] = [ + { header: 'ID', render: (u) => u.id }, + { header: 'Address', align: 'left', render: (u) => u.address, className: 'font-mono' }, + { header: 'Role', render: (u) => u.role }, + { header: 'Status', render: (u) => u.status }, + { header: 'Created', render: (u) => formatDate(u.created) }, +]; + +export function UsersTable({ users }: { users: UserInfo[] }): JSX.Element { + return ; +} + +const kycStepsColumns: ColumnDef[] = [ + { header: 'ID', render: (s) => s.id }, + { header: 'Name', align: 'left', render: (s) => s.name }, + { header: 'Type', align: 'left', render: (s) => s.type || '-' }, + { header: 'Status', render: (s) => statusBadge(s.status) }, + { header: 'Sequence', render: (s) => s.sequenceNumber }, + { + header: 'Comment', + align: 'left', + render: (s) => {s.comment || '-'}, + className: 'max-w-xs truncate', + }, + { header: 'Created', render: (s) => formatDate(s.created) }, +]; + +export function KycStepsTable({ kycSteps }: { kycSteps: KycStepInfo[] }): JSX.Element { + return ; +} + +const kycLogsColumns: ColumnDef[] = [ + { header: 'Date', render: (l) => formatDate(l.created) }, + { header: 'Type', align: 'left', render: (l) => l.type }, + { header: 'Comment', align: 'left', render: (l) => l.comment || '-' }, +]; + +export function KycLogsTable({ kycLogs }: { kycLogs: KycLogInfo[] }): JSX.Element { + return ; +} + +const bankDatasColumns: ColumnDef[] = [ + { header: 'ID', render: (b) => b.id }, + { header: 'IBAN', align: 'left', render: (b) => b.iban, className: 'font-mono' }, + { header: 'Name', align: 'left', render: (b) => b.name }, + { header: 'Type', render: (b) => b.type || '-' }, + { header: 'Status', render: (b) => (b.status ? statusBadge(b.status) : '-') }, + { header: 'Approved', render: (b) => boolBadge(b.approved) }, + { header: 'Manual', render: (b) => (b.manualApproved == null ? '-' : boolBadge(b.manualApproved)) }, + { header: 'Active', render: (b) => boolBadge(b.active) }, + { + header: 'Comment', + align: 'left', + render: (b) => {b.comment || '-'}, + className: 'max-w-xs truncate', + }, + { header: 'Created', render: (b) => formatDate(b.created) }, +]; + +export function BankDatasTable({ bankDatas }: { bankDatas: BankDataInfo[] }): JSX.Element { + return ; +} + +const buyRoutesColumns: ColumnDef[] = [ + { header: 'ID', render: (b) => b.id }, + { header: 'IBAN', align: 'left', render: (b) => b.iban || '-', className: 'font-mono' }, + { header: 'Bank Usage', render: (b) => b.bankUsage, className: 'font-mono' }, + { header: 'Asset', render: (b) => b.assetName }, + { header: 'Blockchain', align: 'left', render: (b) => b.blockchain }, + { header: 'Volume', align: 'right', render: (b) => b.volume?.toFixed(2) }, + { header: 'Active', render: (b) => boolBadge(b.active) }, + { header: 'Created', render: (b) => formatDate(b.created) }, +]; + +export function BuyRoutesTable({ buyRoutes }: { buyRoutes: BuyRouteInfo[] }): JSX.Element { + return ; +} + +const sellRoutesColumns: ColumnDef[] = [ + { header: 'ID', render: (s) => s.id }, + { header: 'IBAN', align: 'left', render: (s) => s.iban, className: 'font-mono' }, + { header: 'Fiat', render: (s) => s.fiatName || '-' }, + { header: 'Volume', align: 'right', render: (s) => s.volume?.toFixed(2) }, + { header: 'Active', render: (s) => boolBadge(s.active) }, + { header: 'Created', render: (s) => formatDate(s.created) }, +]; + +export function SellRoutesTable({ sellRoutes }: { sellRoutes: SellRouteInfo[] }): JSX.Element { + return ; +} diff --git a/src/components/compliance/file-preview-panel.tsx b/src/components/compliance/file-preview-panel.tsx new file mode 100644 index 000000000..f9cf52980 --- /dev/null +++ b/src/components/compliance/file-preview-panel.tsx @@ -0,0 +1,31 @@ +interface FilePreviewPanelProps { + preview?: { url: string; contentType: string; name: string }; + label: string; + onClose: () => void; +} + +export function FilePreviewPanel({ preview, label, onClose }: FilePreviewPanelProps): JSX.Element { + return ( +
+
+

{preview ? preview.name : label}

+ {preview && ( + + )} +
+
+ {preview ? ( + preview.contentType.includes('pdf') ? ( + + ) : ( + {preview.name} + ) + ) : ( +
Click a file to preview
+ )} +
+
+ ); +} diff --git a/src/components/compliance/ip-logs-panel.tsx b/src/components/compliance/ip-logs-panel.tsx new file mode 100644 index 000000000..e9b144776 --- /dev/null +++ b/src/components/compliance/ip-logs-panel.tsx @@ -0,0 +1,48 @@ +import { IpLogInfo } from 'src/hooks/compliance.hook'; +import { boolBadge, formatDateTimeShort } from 'src/util/compliance-helpers'; + +interface IpLogsPanelProps { + ipLogs: IpLogInfo[]; +} + +export function IpLogsPanel({ ipLogs }: IpLogsPanelProps): JSX.Element { + return ( +
+

IP Logs ({ipLogs?.length || 0})

+
+ {ipLogs?.length > 0 ? ( + + + + + + + + + + + {ipLogs.map((log) => ( + + + + + + + ))} + +
IP / CountryEndpointStatusDate
+ {log.ip} + {log.country && ({log.country})} + {log.url.replace('/v1/', '')}{boolBadge(log.result, 'Pass', 'Fail')} + {formatDateTimeShort(log.created)} +
+ ) : ( +
No IP logs
+ )} +
+
+ ); +} diff --git a/src/components/compliance/kyc-files-panel.tsx b/src/components/compliance/kyc-files-panel.tsx new file mode 100644 index 000000000..e803f8f64 --- /dev/null +++ b/src/components/compliance/kyc-files-panel.tsx @@ -0,0 +1,47 @@ +import { KycFile } from 'src/hooks/compliance.hook'; + +interface KycFilesPanelProps { + kycFiles: KycFile[]; + label: string; + onOpenFile: (file: KycFile) => void; +} + +export function KycFilesPanel({ kycFiles, label, onOpenFile }: KycFilesPanelProps): JSX.Element { + return ( +
+

+ {label} ({kycFiles?.length || 0}) +

+
+ {kycFiles?.length > 0 ? ( + + + + + + + + + + {kycFiles.map((file) => ( + onOpenFile(file)} + > + + + + + ))} + +
IDNameType
{file.id} + {file.name} + {file.type}
+ ) : ( +
No KYC files
+ )} +
+
+ ); +} diff --git a/src/components/compliance/recommendation-panel.tsx b/src/components/compliance/recommendation-panel.tsx new file mode 100644 index 000000000..b1a8efdac --- /dev/null +++ b/src/components/compliance/recommendation-panel.tsx @@ -0,0 +1,47 @@ +import { NavigateFunction } from 'react-router-dom'; +import { KycStepInfo } from 'src/hooks/compliance.hook'; +import { formatDate, statusBadge } from 'src/util/compliance-helpers'; + +interface RecommendationPanelProps { + kycSteps: KycStepInfo[]; + userDataId: string; + navigate: NavigateFunction; +} + +export function RecommendationPanel({ kycSteps, userDataId, navigate }: RecommendationPanelProps): JSX.Element { + const recommendations = kycSteps?.filter((s) => s.name === 'Recommendation') || []; + + return ( +
+

Recommendation ({recommendations.length})

+
+ {recommendations.length > 0 ? ( + + + + + + + + + {recommendations.map((step) => ( + navigate(`/compliance/user/${userDataId}/kyc-step/${step.id}`, { state: { step } })} + > + + + + ))} + +
StatusCreated
{statusBadge(step.status)} + {formatDate(step.created)} +
+ ) : ( +
No recommendation
+ )} +
+
+ ); +} diff --git a/src/components/compliance/support-issues-panel.tsx b/src/components/compliance/support-issues-panel.tsx new file mode 100644 index 000000000..36b4bd337 --- /dev/null +++ b/src/components/compliance/support-issues-panel.tsx @@ -0,0 +1,65 @@ +import { NavigateFunction } from 'react-router-dom'; +import { SupportIssueInfo } from 'src/hooks/compliance.hook'; +import { formatDate, statusBadge } from 'src/util/compliance-helpers'; + +interface SupportIssuesPanelProps { + supportIssues: SupportIssueInfo[]; + userDataId: string; + navigate: NavigateFunction; +} + +export function SupportIssuesPanel({ supportIssues, userDataId, navigate }: SupportIssuesPanelProps): JSX.Element { + return ( +
+

Support Issues ({supportIssues?.length || 0})

+
+ {supportIssues?.length > 0 ? ( + + + + + + + + + + + + {supportIssues.map((issue) => ( + + navigate(`/compliance/user/${userDataId}/support-issue/${issue.id}`, { state: { issue } }) + } + > + + + + + + + ))} + +
TypeReasonStateMsgsDate
+ {issue.type} + {issue.limitRequest && ( + + ({issue.limitRequest.limit.toLocaleString()} CHF + {issue.limitRequest.decision && ` - ${issue.limitRequest.decision}`}) + + )} + + {issue.reason} + {statusBadge(issue.state)} + {issue.messages.length} + + {formatDate(issue.created)} +
+ ) : ( +
No support issues
+ )} +
+
+ ); +} diff --git a/src/components/compliance/transactions-tab.tsx b/src/components/compliance/transactions-tab.tsx new file mode 100644 index 000000000..3e8e9b904 --- /dev/null +++ b/src/components/compliance/transactions-tab.tsx @@ -0,0 +1,218 @@ +import { Transaction, useTransaction } from '@dfx.swiss/react'; +import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; +import { Fragment, useState } from 'react'; +import { BankTxInfo, CryptoInputInfo, TransactionInfo } from 'src/hooks/compliance.hook'; +import { DetailRow, TransactionDetailRows, formatDate, statusBadge } from 'src/util/compliance-helpers'; + +interface TransactionsTableProps { + transactions: TransactionInfo[]; + bankTxs: BankTxInfo[]; + cryptoInputs: CryptoInputInfo[]; + expandedBankTxId?: number; + expandedCryptoInputId?: number; + expandedTxUid?: string; + onExpandBankTx: (id: number | undefined) => void; + onExpandCryptoInput: (id: number | undefined) => void; + onExpandTxUid: (uid: string | undefined) => void; +} + +export function TransactionsTable({ + transactions, + bankTxs, + cryptoInputs, + expandedBankTxId, + expandedCryptoInputId, + expandedTxUid, + onExpandBankTx, + onExpandCryptoInput, + onExpandTxUid, +}: TransactionsTableProps): JSX.Element { + const { getTransactionByUid } = useTransaction(); + const [txDetailCache, setTxDetailCache] = useState>(new Map()); + const [txDetailLoading, setTxDetailLoading] = useState(); + const [txDetailError, setTxDetailError] = useState(); + + function handleUidClick(uid: string): void { + if (expandedTxUid === uid) { + onExpandTxUid(undefined); + return; + } + + onExpandTxUid(uid); + setTxDetailError(undefined); + + if (txDetailCache.has(uid)) return; + + setTxDetailLoading(uid); + getTransactionByUid(uid) + .then((detail) => { + setTxDetailCache((prev) => new Map(prev).set(uid, detail)); + }) + .catch((e: unknown) => { + setTxDetailError(e instanceof Error ? e.message : 'Failed to load transaction details'); + }) + .finally(() => setTxDetailLoading(undefined)); + } + const bankTxByTxId = new Map( + bankTxs + ?.filter((b): b is BankTxInfo & { transactionId: number } => b.transactionId != null) + .map((b) => [b.transactionId, b]), + ); + const cryptoInputByTxId = new Map( + cryptoInputs + ?.filter((c): c is CryptoInputInfo & { transactionId: number } => c.transactionId != null) + .map((c) => [c.transactionId, c]), + ); + + return ( + + + + + + + + + + + + + + + + + {transactions?.length > 0 ? ( + transactions.map((tx) => { + const bankTx = bankTxByTxId.get(tx.id); + const cryptoInput = cryptoInputByTxId.get(tx.id); + const isBankTxExpanded = expandedBankTxId === bankTx?.id; + const isCryptoExpanded = expandedCryptoInputId === cryptoInput?.id; + + return ( + + + + + + + + + + + + + + + {isBankTxExpanded && bankTx && ( + + + + )} + + {isCryptoExpanded && cryptoInput && ( + + + + )} + + {expandedTxUid === tx.uid && ( + + + + )} + + ); + }) + ) : ( + + + + )} + +
IDUIDTypeSourceAmount (CHF)AML CheckChargebackBank TXCrypto InCreated
{tx.id} handleUidClick(tx.uid)} + > + {tx.uid} + {tx.type || '-'}{tx.sourceType}{tx.amountInChf?.toFixed(2) || '-'}{tx.amlCheck ? statusBadge(tx.amlCheck) : '-'} + {tx.chargebackDate ? ( + + {formatDate(tx.chargebackDate)} + {tx.amlReason && ( + <> +
+ {tx.amlReason} + + )} +
+ ) : ( + '-' + )} +
+ {bankTx ? ( + + ) : ( + '-' + )} + + {cryptoInput ? ( + + ) : ( + '-' + )} + {formatDate(tx.created)}
+ + + + + + + +
+
+ + + + + + + + + + +
+
+ {txDetailLoading === tx.uid ? ( + + ) : txDetailError && !txDetailCache.has(tx.uid) ? ( +

{txDetailError}

+ ) : txDetailCache.has(tx.uid) ? ( + + ) : null} +
+ No transactions +
+ ); +} diff --git a/src/components/compliance/user-data-panel.tsx b/src/components/compliance/user-data-panel.tsx new file mode 100644 index 000000000..5f31cddf8 --- /dev/null +++ b/src/components/compliance/user-data-panel.tsx @@ -0,0 +1,166 @@ +import { useState } from 'react'; +import { formatDate, formatDateTime } from 'src/util/compliance-helpers'; + +interface UserDataPanelProps { + userData: Record; + keyLabel: string; + valueLabel: string; + titleLabel: string; +} + +const fieldOrder = [ + 'id', + 'firstname', + 'accountType', + 'kycStatus', + 'kycLevel', + 'kycType', + 'kycHash', + 'mail', + 'phone', + 'street', + 'zip', + 'country', + 'nationality', + 'language', + 'birthday', + 'status', + 'riskStatus', + 'highRisk', + 'pep', + 'amlAccountType', + 'amlListAddedDate', + 'amlListExpiredDate', + 'amlListStatus', + 'bankDatas', + 'bankTransactionVerification', + 'depositLimit', + 'hasBankTx', + 'hasIpRisk', + 'identificationType', + 'isTrustedReferrer', + 'phoneCallStatus', + 'postAmlCheck', + 'tradeApprovalDate', + 'verifiedName', + 'wallet', + 'currency', + 'buyVolume', + 'monthlyBuyVolume', + 'annualBuyVolume', + 'sellVolume', + 'monthlySellVolume', + 'annualSellVolume', + 'cryptoVolume', + 'monthlyCryptoVolume', + 'annualCryptoVolume', + 'created', + 'updated', +]; + +const combinedFields: Record = { + firstname: 'surname', + street: 'houseNumber', + zip: 'location', +}; + +const hiddenFields = [...Object.values(combinedFields), 'apiFilterCT', 'apiKeyCT']; + +function formatValue(key: string, value: unknown): string { + if (key.endsWith('Date') && typeof value === 'string' && value.includes('T')) { + return formatDate(value); + } + if ((key === 'created' || key === 'updated') && typeof value === 'string' && value.includes('T')) { + return formatDateTime(value); + } + if (key === 'birthday' && typeof value === 'string' && value.includes('T')) { + return formatDate(value); + } + if (Array.isArray(value)) { + return value.length > 0 ? value.map((i: Record) => i.name || i.iban || i.id).join(', ') : '-'; + } + if (value && typeof value === 'object') { + const obj = value as Record; + const displayName = obj.name || obj.symbol || obj.displayName; + return displayName ? `${displayName} (${obj.id})` : String(obj.id); + } + return value?.toString() || '-'; +} + +export function UserDataPanel({ userData, keyLabel, valueLabel, titleLabel }: UserDataPanelProps): JSX.Element { + const [showAll, setShowAll] = useState(false); + + const allEntries = Object.entries(userData) + .filter(([key]) => !hiddenFields.includes(key)) + .sort(([a], [b]) => { + const indexA = fieldOrder.indexOf(a); + const indexB = fieldOrder.indexOf(b); + if (indexA === -1 && indexB === -1) return a.localeCompare(b); + if (indexA === -1) return 1; + if (indexB === -1) return -1; + return indexA - indexB; + }); + + const visibleEntries = showAll + ? allEntries + : allEntries.filter(([key]) => fieldOrder.includes(key) || combinedFields[key]); + + const hiddenCount = + allEntries.length - allEntries.filter(([key]) => fieldOrder.includes(key) || combinedFields[key]).length; + + return ( +
+

+ {titleLabel} ({Object.keys(userData).length}) +

+
+ + + + + + + + + {visibleEntries.map(([key, value]) => { + const valueString = formatValue(key, value); + const secondaryField = combinedFields[key]; + + if (secondaryField) { + const secondaryValue = userData[secondaryField] || ''; + const combinedValue = [valueString, secondaryValue].filter(Boolean).join(' ') || '-'; + return ( + + + + + ); + } + + return ( + + + + + ); + })} + {hiddenCount > 0 && ( + + + + )} + +
{keyLabel}{valueLabel}
+ {key} / {secondaryField} + {combinedValue}
{key}{valueString}
+ +
+
+
+ ); +} diff --git a/src/hooks/compliance.hook.ts b/src/hooks/compliance.hook.ts index c5fb8cb90..6d699667f 100644 --- a/src/hooks/compliance.hook.ts +++ b/src/hooks/compliance.hook.ts @@ -22,7 +22,7 @@ export interface RefundBankDetails { } export interface TransactionRefundData { - expiryDate: Date; + expiryDate: string; fee: RefundFeeData; refundAmount: number; refundAsset: Asset | Fiat; @@ -82,12 +82,81 @@ export interface BankTxSearchResult { iban?: string; } +export interface BankTxInfo { + id: number; + transactionId?: number; + accountServiceRef: string; + amount: number; + currency: string; + type: string; + name?: string; + iban?: string; + remittanceInfo?: string; +} + +export interface IpLogInfo { + id: number; + ip: string; + country?: string; + url: string; + result: boolean; + created: string; +} + +export interface SupportMessageInfo { + author: string; + message?: string; + created: string; +} + +export interface LimitRequestInfo { + limit: number; + acceptedLimit?: number; + decision?: string; + fundOrigin: string; +} + +export interface SupportIssueInfo { + id: number; + uid: string; + type: string; + state: string; + reason: string; + name: string; + clerk?: string; + department?: string; + information?: string; + messages: SupportMessageInfo[]; + transaction?: Pick; + limitRequest?: LimitRequestInfo; + created: string; +} + +export interface CryptoInputInfo { + id: number; + transactionId?: number; + inTxId: string; + inTxExplorerUrl?: string; + status?: string; + amount: number; + assetName?: string; + blockchain?: string; + senderAddresses?: string; + returnTxId?: string; + returnTxExplorerUrl?: string; + purpose?: string; +} + export interface ComplianceUserData { - userData: object; + userData: Record; kycFiles: KycFile[]; kycSteps: KycStepInfo[]; kycLogs: KycLogInfo[]; transactions: TransactionInfo[]; + bankTxs: BankTxInfo[]; + cryptoInputs: CryptoInputInfo[]; + ipLogs: IpLogInfo[]; + supportIssues: SupportIssueInfo[]; users: UserInfo[]; bankDatas: BankDataInfo[]; buyRoutes: BuyRouteInfo[]; @@ -100,7 +169,7 @@ export interface RecommendationGraphNode { surname?: string; kycStatus?: string; kycLevel?: number; - tradeApprovalDate?: Date; + tradeApprovalDate?: string; } export interface RecommendationGraphEdge { @@ -110,8 +179,8 @@ export interface RecommendationGraphEdge { method: string; type: string; isConfirmed?: boolean; - confirmationDate?: Date; - created: Date; + confirmationDate?: string; + created: string; } export interface RecommendationGraph { @@ -130,8 +199,8 @@ export interface RecommendationEntry { id: number; recommended: RecommendationUserInfo; isConfirmed?: boolean; - confirmationDate?: Date; - created: Date; + confirmationDate?: string; + created: string; } export interface KycStepInfo { @@ -145,14 +214,14 @@ export interface KycStepInfo { recommender?: RecommendationUserInfo; recommended?: RecommendationUserInfo; allRecommendations?: RecommendationEntry[]; - created: Date; + created: string; } export interface KycLogInfo { id: number; type: string; comment?: string; - created: Date; + created: string; } export interface UserInfo { @@ -160,7 +229,7 @@ export interface UserInfo { address: string; role: string; status: string; - created: Date; + created: string; } export interface TransactionInfo { @@ -170,23 +239,33 @@ export interface TransactionInfo { sourceType: string; amountInChf?: number; amlCheck?: string; - created: Date; + chargebackDate?: string; + amlReason?: string; + created: string; } export interface BankDataInfo { id: number; iban: string; name: string; + type?: string; + status?: string; approved: boolean; + manualApproved?: boolean; + active: boolean; + comment?: string; + created: string; } export interface BuyRouteInfo { id: number; + iban?: string; bankUsage: string; assetName: string; blockchain: string; volume: number; active: boolean; + created: string; } export interface SellRouteInfo { @@ -194,6 +273,8 @@ export interface SellRouteInfo { iban: string; fiatName?: string; volume: number; + active: boolean; + created: string; } export interface KycFile { diff --git a/src/index.css b/src/index.css index 03499fba7..d5e2aa1c9 100644 --- a/src/index.css +++ b/src/index.css @@ -22,8 +22,19 @@ input[type='number'] { @apply text-white; @apply bg-white; - font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, - 'Open Sans', 'Helvetica Neue', sans-serif; + font-family: + 'Inter', + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Oxygen, + Ubuntu, + Cantarell, + 'Open Sans', + 'Helvetica Neue', + sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; background-color: #fff; @@ -61,6 +72,18 @@ input[type='number'] { @tailwind components; @tailwind utilities; +/* Scroll shadow — bottom only, visible when content overflows */ +.scroll-shadow { + background: + linear-gradient(transparent, white 70%) center bottom, + radial-gradient(farthest-side at 50% 100%, rgba(0, 0, 0, 0.12), transparent) center bottom; + background-repeat: no-repeat; + background-size: + 100% 40px, + 100% 10px; + background-attachment: local, scroll; +} + /* Shadow DOM Animation */ @keyframes spinners-react-circular { 0% { diff --git a/src/screens/compliance-support-issue.screen.tsx b/src/screens/compliance-support-issue.screen.tsx new file mode 100644 index 000000000..17bae5302 --- /dev/null +++ b/src/screens/compliance-support-issue.screen.tsx @@ -0,0 +1,202 @@ +import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; +import { useEffect, useState } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; +import { ErrorHint } from 'src/components/error-hint'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { SupportIssueInfo, useCompliance } from 'src/hooks/compliance.hook'; +import { useComplianceGuard } from 'src/hooks/guard.hook'; +import { formatDateTime, statusBadge } from 'src/util/compliance-helpers'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; + +export default function ComplianceSupportIssueScreen(): JSX.Element { + useComplianceGuard(); + + const { translate } = useSettingsContext(); + const { id: userDataId, issueId } = useParams(); + const { getUserData } = useCompliance(); + const location = useLocation(); + const passedIssue = (location.state as { issue?: SupportIssueInfo } | null)?.issue; + + const [isLoading, setIsLoading] = useState(!passedIssue); + const [error, setError] = useState(); + const [issue, setIssue] = useState(passedIssue); + + useLayoutOptions({ title: translate('screens/compliance', 'Support Issue'), backButton: true, noMaxWidth: true }); + + useEffect(() => { + if (issue) return; + + let cancelled = false; + if (userDataId && issueId) { + setIsLoading(true); + getUserData(+userDataId) + .then((data) => { + if (cancelled) return; + const found = data.supportIssues?.find((s) => s.id === +issueId); + if (found) setIssue(found); + else setError('Support issue not found'); + }) + .catch((e: unknown) => !cancelled && setError(e instanceof Error ? e.message : 'Unknown error')) + .finally(() => !cancelled && setIsLoading(false)); + } else { + setError('Missing parameters'); + setIsLoading(false); + } + return () => { + cancelled = true; + }; + }, [userDataId, issueId]); + + if (error) return ; + if (isLoading || !issue) return ; + + return ( +
+ {/* Header Panels */} +
+ {/* Issue Details — always shown */} +
+

Issue Details

+ + + + + + + + + + + + + + + + + + + + + + + {issue.clerk && ( + + + + + )} + {issue.department && ( + + + + + )} + + + + + +
UID:{issue.uid}
Name:{issue.name}
Type:{issue.type}
Reason:{issue.reason}
State:{statusBadge(issue.state)}
Clerk:{issue.clerk}
Department:{issue.department}
Created:{formatDateTime(issue.created)}
+
+ + {/* Transaction — shown for TransactionIssue */} + {issue.transaction && ( +
+

Related Transaction

+ + + + + + + + + + + + + + + + + + + + + + + {issue.transaction.amlCheck && ( + + + + + )} + +
ID:{issue.transaction.id}
UID:{issue.transaction.uid}
Type:{issue.transaction.type || '-'}
Source:{issue.transaction.sourceType}
Amount (CHF):{issue.transaction.amountInChf?.toFixed(2) || '-'}
AML Check:{statusBadge(issue.transaction.amlCheck)}
+
+ )} + + {/* Limit Request — shown for LimitRequest */} + {issue.limitRequest && ( +
+

Limit Request

+ + + + + + + {issue.limitRequest.acceptedLimit != null && ( + + + + + )} + + + + + {issue.limitRequest.decision && ( + + + + + )} + +
Requested Limit:{issue.limitRequest.limit.toLocaleString()} CHF
Accepted Limit:{issue.limitRequest.acceptedLimit.toLocaleString()} CHF
Fund Origin:{issue.limitRequest.fundOrigin}
Decision:{statusBadge(issue.limitRequest.decision)}
+
+ )} + + {/* Additional Info — shown when information field is set */} + {issue.information && ( +
+

Additional Info

+
{issue.information}
+
+ )} +
+ + {/* Messages */} +
+

Messages ({issue.messages.length})

+
+ {issue.messages.map((msg, idx) => ( +
+
+ + {msg.author} + + {formatDateTime(msg.created)} +
+
{msg.message || '-'}
+
+ ))} +
+
+
+ ); +} diff --git a/src/screens/compliance-user.screen.tsx b/src/screens/compliance-user.screen.tsx index de13eab1f..1ae7540e8 100644 --- a/src/screens/compliance-user.screen.tsx +++ b/src/screens/compliance-user.screen.tsx @@ -1,21 +1,25 @@ -import { ApiError, useKyc } from '@dfx.swiss/react'; +import { useKyc } from '@dfx.swiss/react'; import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; +import { + BankDatasTable, + BuyRoutesTable, + KycLogsTable, + KycStepsTable, + SellRoutesTable, + UsersTable, +} from 'src/components/compliance/detail-tabs'; +import { FilePreviewPanel } from 'src/components/compliance/file-preview-panel'; +import { IpLogsPanel } from 'src/components/compliance/ip-logs-panel'; +import { KycFilesPanel } from 'src/components/compliance/kyc-files-panel'; +import { RecommendationPanel } from 'src/components/compliance/recommendation-panel'; +import { SupportIssuesPanel } from 'src/components/compliance/support-issues-panel'; +import { TransactionsTable } from 'src/components/compliance/transactions-tab'; +import { UserDataPanel } from 'src/components/compliance/user-data-panel'; import { ErrorHint } from 'src/components/error-hint'; import { useSettingsContext } from 'src/contexts/settings.context'; -import { - BankDataInfo, - BuyRouteInfo, - ComplianceUserData, - KycFile, - KycLogInfo, - KycStepInfo, - SellRouteInfo, - TransactionInfo, - UserInfo, - useCompliance, -} from 'src/hooks/compliance.hook'; +import { ComplianceUserData, KycFile, useCompliance } from 'src/hooks/compliance.hook'; import { useComplianceGuard } from 'src/hooks/guard.hook'; import { useLayoutOptions } from 'src/hooks/layout-config.hook'; @@ -27,42 +31,6 @@ interface TabConfig { count: number; } -// Define the display order for user data fields -const fieldOrder = [ - 'id', - 'firstname', // includes surname - 'accountType', - 'kycStatus', - 'kycLevel', - 'kycType', - 'kycHash', - 'mail', - 'phone', - 'street', // includes houseNumber - 'zip', // includes location - 'country', - 'nationality', - 'language', - 'birthday', - 'status', - 'riskStatus', - 'bankTransactionVerification', - 'wallet', - 'currency', - 'created', - 'updated', -]; - -// Fields that are combined with another field (primary -> secondary) -const combinedFields: Record = { - firstname: 'surname', - street: 'houseNumber', - zip: 'location', -}; - -// Hidden fields are the secondary fields from combinedFields -const hiddenFields = Object.values(combinedFields); - export default function ComplianceUserScreen(): JSX.Element { useComplianceGuard(); @@ -77,6 +45,27 @@ export default function ComplianceUserScreen(): JSX.Element { const [data, setData] = useState(); const [preview, setPreview] = useState<{ url: string; contentType: string; name: string }>(); const [activeTab, setActiveTab] = useState('transactions'); + const [expandedBankTxId, setExpandedBankTxId] = useState(); + const [expandedCryptoInputId, setExpandedCryptoInputId] = useState(); + const [expandedTxUid, setExpandedTxUid] = useState(); + + function handleExpandBankTx(id: number | undefined): void { + setExpandedBankTxId(id); + setExpandedCryptoInputId(undefined); + setExpandedTxUid(undefined); + } + + function handleExpandCryptoInput(id: number | undefined): void { + setExpandedCryptoInputId(id); + setExpandedBankTxId(undefined); + setExpandedTxUid(undefined); + } + + function handleExpandTxUid(uid: string | undefined): void { + setExpandedTxUid(uid); + setExpandedBankTxId(undefined); + setExpandedCryptoInputId(undefined); + } async function openFile(file: KycFile): Promise { try { @@ -90,22 +79,18 @@ export default function ComplianceUserScreen(): JSX.Element { const url = URL.createObjectURL(blob); setPreview({ url, contentType, name: file.name }); - } catch (e: any) { - setError(e.message ?? 'Error loading file'); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Error loading file'); } } - function closePreview(): void { - setPreview(undefined); - } - useEffect(() => { let cancelled = false; if (userDataId) { setIsLoading(true); getUserData(+userDataId) .then((d) => !cancelled && setData(d)) - .catch((e: ApiError) => !cancelled && setError(e.message ?? 'Unknown error')) + .catch((e: unknown) => !cancelled && setError(e instanceof Error ? e.message : 'Unknown error')) .finally(() => !cancelled && setIsLoading(false)); } else { setError('No ID provided'); @@ -141,201 +126,39 @@ export default function ComplianceUserScreen(): JSX.Element { ) : (
- {/* Top Section: User Data | KYC Files | File Preview */} + {/* Top Section: User Data | Middle Panels | File Preview */}
- {/* Left: User Data */} -
-

- {translate('screens/compliance', 'User Data')} ({Object.keys(data.userData).length}) -

-
- - - - - - - - - {Object.entries(data.userData) - .filter(([key]) => !hiddenFields.includes(key)) - .sort(([a], [b]) => { - const indexA = fieldOrder.indexOf(a); - const indexB = fieldOrder.indexOf(b); - if (indexA === -1 && indexB === -1) return a.localeCompare(b); - if (indexA === -1) return 1; - if (indexB === -1) return -1; - return indexA - indexB; - }) - .map(([key, value]) => { - let valueString = value?.toString() || '-'; - - if (Array.isArray(value)) { - valueString = value.length > 0 ? value.map((i) => i.name || i.iban || i.id).join(', ') : '-'; - } else if (value && typeof value === 'object') { - // Show name/symbol if available, otherwise just ID - const displayName = value.name || value.symbol || value.displayName; - valueString = displayName ? `${displayName} (${value.id})` : String(value.id); - } - - // Handle combined fields - const secondaryField = combinedFields[key]; - if (secondaryField) { - const secondaryValue = (data.userData as Record)[secondaryField] || ''; - const combinedValue = [valueString, secondaryValue].filter(Boolean).join(' ') || '-'; - return ( - - - - - ); - } - - return ( - - - - - ); - })} - -
- {translate('screens/compliance', 'Key')} - - {translate('screens/compliance', 'Value')} -
- {key} / {secondaryField} - {combinedValue}
{key}{valueString}
-
+ + +
+ + + +
- {/* Middle: KYC Steps + KYC Files */} -
- {/* Recommendation Steps */} - {(() => { - const recommendations = data.kycSteps?.filter((s) => s.name === 'Recommendation') || []; - return ( -
-

Recommendation ({recommendations.length})

-
- {recommendations.length > 0 ? ( - - - - - - - - - {recommendations.map((step: KycStepInfo) => ( - - navigate(`/compliance/user/${userDataId}/kyc-step/${step.id}`, { state: { step } }) - } - > - - - - ))} - -
StatusCreated
- - {step.status} - - - {new Date(step.created).toLocaleDateString()} -
- ) : ( -
No recommendation
- )} -
-
- ); - })()} - - {/* KYC Files */} -
-

- {translate('screens/compliance', 'KYC Files')} ({data.kycFiles?.length || 0}) -

-
- {data.kycFiles?.length > 0 ? ( - - - - - - - - - - {data.kycFiles.map((file: KycFile) => ( - openFile(file)} - > - - - - - ))} - -
IDNameType
{file.id} - {file.name} - {file.type}
- ) : ( -
No KYC files
- )} -
-
-
- - {/* Right: File Preview */} -
-
-

- {preview ? preview.name : translate('screens/compliance', 'File Preview')} -

- {preview && ( - - )} -
-
- {preview ? ( - preview.contentType.includes('pdf') ? ( - - ) : ( - {preview.name} - ) - ) : ( -
Click a file to preview
- )} -
-
+ setPreview(undefined)} + />
{/* Bottom Section: Tabs */}
- {/* Tab Headers */}
{tabs.map((tab) => (
diff --git a/src/util/compliance-helpers.tsx b/src/util/compliance-helpers.tsx new file mode 100644 index 000000000..265e5d235 --- /dev/null +++ b/src/util/compliance-helpers.tsx @@ -0,0 +1,137 @@ +import { Transaction } from '@dfx.swiss/react'; + +export function DetailRow({ + label, + value, + url, + mono, +}: { + label: string; + value?: string | number | null; + url?: string | null; + mono?: boolean; +}): JSX.Element | null { + if (value == null || value === '') return null; + + return ( + + {label}: + + {url ? ( + + {value} + + ) : ( + value + )} + + + ); +} + +export function TransactionDetailRows({ tx }: { tx: Transaction }): JSX.Element { + return ( + + + + + + {tx.reason && } + + + {tx.state !== 'Returned' && ( + + )} + + + {tx.fees && ( + <> + + + + + + + + )} + {tx.chargebackAmount != null && ( + <> + + + + + + )} + +
+ ); +} + +const statusColors: Record = { + Completed: 'bg-dfxGray-300 text-dfxGreen-100', + Pass: 'bg-dfxGray-300 text-dfxGreen-100', + Accepted: 'bg-dfxGray-300 text-dfxGreen-100', + Yes: 'bg-dfxGray-300 text-dfxGreen-100', + Failed: 'bg-dfxGray-300 text-primary-red', + Fail: 'bg-dfxGray-300 text-primary-red', + Rejected: 'bg-dfxGray-300 text-primary-red', + No: 'bg-dfxGray-300 text-primary-red', + Created: 'bg-dfxGray-300 text-dfxBlue-300', +}; + +const fallbackColor = 'bg-dfxGray-300 text-dfxBlue-800'; + +export function statusBadge(status: string): JSX.Element { + const classes = statusColors[status] ?? fallbackColor; + return {status}; +} + +export function boolBadge(value: boolean, trueLabel = 'Yes', falseLabel = 'No'): JSX.Element { + return statusBadge(value ? trueLabel : falseLabel); +} + +export function formatDate(value: string): string { + return new Date(value).toLocaleDateString(); +} + +export function formatDateTime(value: string): string { + return new Date(value).toLocaleString(); +} + +export function formatDateTimeShort(value: string): string { + return new Date(value).toLocaleString([], { + day: '2-digit', + month: '2-digit', + year: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +}