From a9b7de155fe3e452a01113ee515148184b1d3605 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 6 Feb 2026 01:22:41 +0100 Subject: [PATCH] Add compliance transaction list screen (#950) * Add compliance transaction list screen Add new screen to display all transactions for compliance review with table view, date/CHF formatting and CSV export functionality. Includes route, API hook and TransactionListEntry interface. * Fix cursor-pointer and useEffect dependency in transaction list Make cursor-pointer conditional on entry.accountId being present and add getTransactionList to useEffect dependency array. * Add date range filters for created and output date - Add separate date filters for Created and Output Datum - Default filter range: last 3 days - Add Created and Output Datum columns to table and CSV export - Filter bar with reset button and filtered count display * Pass date filters to transaction list API and reload on change Send createdFrom/createdTo/outputFrom/outputTo as query params. useEffect now re-fetches when any filter value changes. * Fix ESLint non-null assertion warning in getTransactionList * Remove redundant client-side date filtering Date filtering is already handled server-side via query params. Remove the duplicate useMemo filter and use API response directly. * Escape double quotes in CSV export Prevents malformed CSV output when cell values contain double quote characters. --- src/App.tsx | 5 + src/hooks/compliance.hook.ts | 32 ++ .../compliance-transaction-list.screen.tsx | 278 ++++++++++++++++++ 3 files changed, 315 insertions(+) create mode 100644 src/screens/compliance-transaction-list.screen.tsx diff --git a/src/App.tsx b/src/App.tsx index bc5337c9..1efceba9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,6 +58,7 @@ const ComplianceBankTxReturnScreen = lazy(() => import('./screens/compliance-ban const ComplianceKycFilesScreen = lazy(() => import('./screens/compliance-kyc-files.screen')); const ComplianceKycFilesDetailsScreen = lazy(() => import('./screens/compliance-kyc-files-details.screen')); const ComplianceKycStatsScreen = lazy(() => import('./screens/compliance-kyc-stats.screen')); +const ComplianceTransactionListScreen = lazy(() => import('./screens/compliance-transaction-list.screen')); const RealunitScreen = lazy(() => import('./screens/realunit.screen')); const RealunitUserScreen = lazy(() => import('./screens/realunit-user.screen')); const PersonalIbanScreen = lazy(() => import('./screens/personal-iban.screen')); @@ -343,6 +344,10 @@ export const Routes = [ path: 'compliance/kyc-stats', element: withSuspense(), }, + { + path: 'compliance/transactions', + element: withSuspense(), + }, { path: 'realunit', element: ( diff --git a/src/hooks/compliance.hook.ts b/src/hooks/compliance.hook.ts index e30b63f6..4ececadb 100644 --- a/src/hooks/compliance.hook.ts +++ b/src/hooks/compliance.hook.ts @@ -152,6 +152,20 @@ export interface KycFile { uid: string; } +export interface TransactionListEntry { + id: number; + type?: string; + accountId?: number; + name?: string; + domicile?: string; + created?: string; + eventDate?: string; + outputDate?: string; + assets?: string; + amountInChf?: number; + highRisk?: boolean; +} + export interface KycFileListEntry { kycFileId: number; id: number; @@ -259,6 +273,23 @@ export function useCompliance() { }); } + async function getTransactionList(params?: { + createdFrom?: string; + createdTo?: string; + outputFrom?: string; + outputTo?: string; + }): Promise { + const queryParts = Object.entries(params ?? {}) + .filter(([, v]) => v) + .map(([k, v]) => `${k}=${encodeURIComponent(v as string)}`); + const queryString = queryParts.length ? `?${queryParts.join('&')}` : ''; + + return call({ + url: `support/transactionList${queryString}`, + method: 'GET', + }); + } + return useMemo( () => ({ search, @@ -269,6 +300,7 @@ export function useCompliance() { processTransactionRefund, getKycFileList, getKycFileStats, + getTransactionList, }), [call], ); diff --git a/src/screens/compliance-transaction-list.screen.tsx b/src/screens/compliance-transaction-list.screen.tsx new file mode 100644 index 00000000..4c8fb76d --- /dev/null +++ b/src/screens/compliance-transaction-list.screen.tsx @@ -0,0 +1,278 @@ +import { useSessionContext } from '@dfx.swiss/react'; +import { + DfxIcon, + IconColor, + IconSize, + IconVariant, + SpinnerSize, + StyledLoadingSpinner, + StyledVerticalStack, +} from '@dfx.swiss/react-components'; +import { useEffect, useState } from 'react'; +import { ErrorHint } from 'src/components/error-hint'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { TransactionListEntry, useCompliance } from 'src/hooks/compliance.hook'; +import { useComplianceGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; +import { useNavigation } from 'src/hooks/navigation.hook'; + +export default function ComplianceTransactionListScreen(): JSX.Element { + useComplianceGuard(); + + const { translate } = useSettingsContext(); + const { getTransactionList } = useCompliance(); + const { navigate } = useNavigation(); + const { isLoggedIn } = useSessionContext(); + + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(); + const [data, setData] = useState([]); + + // Filter state + const today = new Date().toISOString().split('T')[0]; + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + const [createdFrom, setCreatedFrom] = useState(threeDaysAgo); + const [createdTo, setCreatedTo] = useState(today); + const [outputFrom, setOutputFrom] = useState(threeDaysAgo); + const [outputTo, setOutputTo] = useState(today); + + function formatDate(dateString?: string): string { + if (!dateString) return '-'; + return new Date(dateString).toLocaleDateString('de-CH', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + } + + function formatChf(value?: number): string { + if (value == null) return '-'; + return Math.round(value).toLocaleString('de-CH'); + } + + function exportCsv() { + const headers = [ + 'Id', + 'Type', + 'AccountId', + 'Name', + 'Domizil', + 'Created', + 'Transaktionsdatum', + 'Output Datum', + 'Assets', + 'CHF Value', + 'TMER', + ]; + const rows = data.map((entry) => [ + entry.id, + entry.type ?? '', + entry.accountId ?? '', + entry.name ?? '', + entry.domicile ?? '', + formatDate(entry.created), + formatDate(entry.eventDate), + formatDate(entry.outputDate), + entry.assets ?? '', + formatChf(entry.amountInChf), + entry.highRisk ? 'Ja' : 'Nein', + ]); + + const csvContent = [headers, ...rows] + .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')) + .join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `transaction-list-${new Date().toISOString().split('T')[0]}.csv`; + link.click(); + URL.revokeObjectURL(url); + } + + useEffect(() => { + if (!isLoggedIn) return; + + setIsLoading(true); + setError(undefined); + + const params = { + createdFrom: createdFrom || undefined, + createdTo: createdTo || undefined, + outputFrom: outputFrom || undefined, + outputTo: outputTo || undefined, + }; + + getTransactionList(params) + .then(setData) + .catch((e) => setError(e.message)) + .finally(() => setIsLoading(false)); + }, [isLoggedIn, getTransactionList, createdFrom, createdTo, outputFrom, outputTo]); + + useLayoutOptions({ title: translate('screens/compliance', 'Transaction List'), noMaxWidth: true }); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + +
+
+ + setCreatedFrom(e.target.value)} + /> +
+ +
+ + setCreatedTo(e.target.value)} + /> +
+ +
+ + setOutputFrom(e.target.value)} + /> +
+ +
+ + setOutputTo(e.target.value)} + /> +
+ +
+   + +
+ +
+ + {data.length} {translate('screens/compliance', 'entries')} + + +
+
+ +
+ + + + + + + + + + + + + + + + + + {data.length > 0 ? ( + data.map((entry) => ( + entry.accountId && navigate(`compliance/user/${entry.accountId}`)} + > + + + + + + + + + + + + + )) + ) : ( + + + + )} + +
+ {translate('screens/compliance', 'Id')} + + {translate('screens/compliance', 'Type')} + + {translate('screens/compliance', 'AccountId')} + + {translate('screens/compliance', 'Name')} + + {translate('screens/compliance', 'Domizil')} + + {translate('screens/compliance', 'Created')} + + {translate('screens/compliance', 'Transaktionsdatum')} + + {translate('screens/compliance', 'Output Datum')} + + {translate('screens/compliance', 'Assets')} + + {translate('screens/compliance', 'CHF Value')} + + {translate('screens/compliance', 'TMER')} +
{entry.id}{entry.type ?? '-'}{entry.accountId ?? '-'}{entry.name ?? '-'}{entry.domicile ?? '-'}{formatDate(entry.created)}{formatDate(entry.eventDate)}{formatDate(entry.outputDate)}{entry.assets ?? '-'}{formatChf(entry.amountInChf)} + {entry.highRisk ? 'Ja' : 'Nein'} +
+ {translate('screens/compliance', 'No entries found')} +
+
+
+ ); +}