From 6a548d0d332dd3d8aed732526d54002e7442e0c0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:28:54 +0100 Subject: [PATCH 1/7] 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. --- src/App.tsx | 5 + src/hooks/compliance.hook.ts | 20 ++ .../compliance-transaction-list.screen.tsx | 173 ++++++++++++++++++ 3 files changed, 198 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..a8e706c6 100644 --- a/src/hooks/compliance.hook.ts +++ b/src/hooks/compliance.hook.ts @@ -152,6 +152,18 @@ export interface KycFile { uid: string; } +export interface TransactionListEntry { + id: number; + type?: string; + accountId?: number; + name?: string; + domicile?: string; + eventDate?: string; + assets?: string; + amountInChf?: number; + highRisk?: boolean; +} + export interface KycFileListEntry { kycFileId: number; id: number; @@ -259,6 +271,13 @@ export function useCompliance() { }); } + async function getTransactionList(): Promise { + return call({ + url: 'support/transactionList', + method: 'GET', + }); + } + return useMemo( () => ({ search, @@ -269,6 +288,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..8a1bce91 --- /dev/null +++ b/src/screens/compliance-transaction-list.screen.tsx @@ -0,0 +1,173 @@ +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([]); + + 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', 'Transaktionsdatum', 'Assets', 'CHF Value', 'TMER']; + const rows = data.map((entry) => [ + entry.id, + entry.type ?? '', + entry.accountId ?? '', + entry.name ?? '', + entry.domicile ?? '', + formatDate(entry.eventDate), + entry.assets ?? '', + formatChf(entry.amountInChf), + entry.highRisk ? 'Ja' : 'Nein', + ]); + + const csvContent = [headers, ...rows].map((row) => row.map((cell) => `"${cell}"`).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; + + getTransactionList() + .then(setData) + .catch((e) => setError(e.message)) + .finally(() => setIsLoading(false)); + }, [isLoggedIn]); + + useLayoutOptions({ title: translate('screens/compliance', 'Transaction List'), noMaxWidth: true }); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + +
+
+ + {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', 'Transaktionsdatum')} + + {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.eventDate)}{entry.assets ?? '-'}{formatChf(entry.amountInChf)} + {entry.highRisk ? 'Ja' : 'Nein'} +
+ {translate('screens/compliance', 'No entries found')} +
+
+
+ ); +} From 6391004b868fcee4f46dc290c78f40d48efb7033 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:16:28 +0100 Subject: [PATCH 2/7] 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. --- src/screens/compliance-transaction-list.screen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/screens/compliance-transaction-list.screen.tsx b/src/screens/compliance-transaction-list.screen.tsx index 8a1bce91..f20e7b8b 100644 --- a/src/screens/compliance-transaction-list.screen.tsx +++ b/src/screens/compliance-transaction-list.screen.tsx @@ -74,7 +74,7 @@ export default function ComplianceTransactionListScreen(): JSX.Element { .then(setData) .catch((e) => setError(e.message)) .finally(() => setIsLoading(false)); - }, [isLoggedIn]); + }, [isLoggedIn, getTransactionList]); useLayoutOptions({ title: translate('screens/compliance', 'Transaction List'), noMaxWidth: true }); @@ -142,7 +142,7 @@ export default function ComplianceTransactionListScreen(): JSX.Element { data.map((entry) => ( entry.accountId && navigate(`compliance/user/${entry.accountId}`)} > {entry.id} From 4969889ef5c3d10b6989eff9382e2b17bf021f81 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:12:44 +0100 Subject: [PATCH 3/7] 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 --- src/hooks/compliance.hook.ts | 2 + .../compliance-transaction-list.screen.tsx | 136 +++++++++++++++++- 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/src/hooks/compliance.hook.ts b/src/hooks/compliance.hook.ts index a8e706c6..62efb160 100644 --- a/src/hooks/compliance.hook.ts +++ b/src/hooks/compliance.hook.ts @@ -158,7 +158,9 @@ export interface TransactionListEntry { accountId?: number; name?: string; domicile?: string; + created?: string; eventDate?: string; + outputDate?: string; assets?: string; amountInChf?: number; highRisk?: boolean; diff --git a/src/screens/compliance-transaction-list.screen.tsx b/src/screens/compliance-transaction-list.screen.tsx index f20e7b8b..2faabe30 100644 --- a/src/screens/compliance-transaction-list.screen.tsx +++ b/src/screens/compliance-transaction-list.screen.tsx @@ -8,7 +8,7 @@ import { StyledLoadingSpinner, StyledVerticalStack, } from '@dfx.swiss/react-components'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, 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'; @@ -28,6 +28,14 @@ export default function ComplianceTransactionListScreen(): JSX.Element { 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', { @@ -42,15 +50,57 @@ export default function ComplianceTransactionListScreen(): JSX.Element { return Math.round(value).toLocaleString('de-CH'); } + const filteredData = useMemo(() => { + const cFrom = createdFrom ? new Date(createdFrom) : null; + const cTo = createdTo ? new Date(createdTo) : null; + if (cTo) cTo.setHours(23, 59, 59, 999); + + const oFrom = outputFrom ? new Date(outputFrom) : null; + const oTo = outputTo ? new Date(outputTo) : null; + if (oTo) oTo.setHours(23, 59, 59, 999); + + return data.filter((entry) => { + if (cFrom || cTo) { + const entryDate = entry.created ? new Date(entry.created) : null; + if (!entryDate) return false; + if (cFrom && entryDate < cFrom) return false; + if (cTo && entryDate > cTo) return false; + } + + if (oFrom || oTo) { + const entryDate = entry.outputDate ? new Date(entry.outputDate) : null; + if (!entryDate) return false; + if (oFrom && entryDate < oFrom) return false; + if (oTo && entryDate > oTo) return false; + } + + return true; + }); + }, [data, createdFrom, createdTo, outputFrom, outputTo]); + function exportCsv() { - const headers = ['Id', 'Type', 'AccountId', 'Name', 'Domizil', 'Transaktionsdatum', 'Assets', 'CHF Value', 'TMER']; - const rows = data.map((entry) => [ + const headers = [ + 'Id', + 'Type', + 'AccountId', + 'Name', + 'Domizil', + 'Created', + 'Transaktionsdatum', + 'Output Datum', + 'Assets', + 'CHF Value', + 'TMER', + ]; + const rows = filteredData.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', @@ -89,15 +139,79 @@ export default function ComplianceTransactionListScreen(): JSX.Element { return (
+
+ + setCreatedFrom(e.target.value)} + /> +
+ +
+ + setCreatedTo(e.target.value)} + /> +
+ +
+ + setOutputFrom(e.target.value)} + /> +
+ +
+ + setOutputTo(e.target.value)} + /> +
+ +
+   + +
+
+ {translate('screens/compliance', 'Showing')} {filteredData.length} {translate('screens/compliance', 'of')}{' '} {data.length} {translate('screens/compliance', 'entries')} @@ -123,9 +237,15 @@ export default function ComplianceTransactionListScreen(): JSX.Element { {translate('screens/compliance', 'Domizil')} + + {translate('screens/compliance', 'Created')} + {translate('screens/compliance', 'Transaktionsdatum')} + + {translate('screens/compliance', 'Output Datum')} + {translate('screens/compliance', 'Assets')} @@ -138,8 +258,8 @@ export default function ComplianceTransactionListScreen(): JSX.Element { - {data.length > 0 ? ( - data.map((entry) => ( + {filteredData.length > 0 ? ( + filteredData.map((entry) => ( {entry.accountId ?? '-'} {entry.name ?? '-'} {entry.domicile ?? '-'} + {formatDate(entry.created)} {formatDate(entry.eventDate)} + {formatDate(entry.outputDate)} {entry.assets ?? '-'} {formatChf(entry.amountInChf)} @@ -160,7 +282,7 @@ export default function ComplianceTransactionListScreen(): JSX.Element { )) ) : ( - + {translate('screens/compliance', 'No entries found')} From cd6e62f65e9ce443a8994c4cb3f100baf5f59244 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 6 Feb 2026 00:19:53 +0100 Subject: [PATCH 4/7] 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. --- src/hooks/compliance.hook.ts | 14 ++++++++++++-- src/screens/compliance-transaction-list.screen.tsx | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/hooks/compliance.hook.ts b/src/hooks/compliance.hook.ts index 62efb160..c544da35 100644 --- a/src/hooks/compliance.hook.ts +++ b/src/hooks/compliance.hook.ts @@ -273,9 +273,19 @@ export function useCompliance() { }); } - async function getTransactionList(): Promise { + 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!)}`); + const queryString = queryParts.length ? `?${queryParts.join('&')}` : ''; + return call({ - url: 'support/transactionList', + url: `support/transactionList${queryString}`, method: 'GET', }); } diff --git a/src/screens/compliance-transaction-list.screen.tsx b/src/screens/compliance-transaction-list.screen.tsx index 2faabe30..7f0621cc 100644 --- a/src/screens/compliance-transaction-list.screen.tsx +++ b/src/screens/compliance-transaction-list.screen.tsx @@ -120,11 +120,21 @@ export default function ComplianceTransactionListScreen(): JSX.Element { useEffect(() => { if (!isLoggedIn) return; - getTransactionList() + 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]); + }, [isLoggedIn, getTransactionList, createdFrom, createdTo, outputFrom, outputTo]); useLayoutOptions({ title: translate('screens/compliance', 'Transaction List'), noMaxWidth: true }); From 89d6cb3dee9d06275684c6b5804b5ada747c5787 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 6 Feb 2026 00:50:45 +0100 Subject: [PATCH 5/7] Fix ESLint non-null assertion warning in getTransactionList --- src/hooks/compliance.hook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/compliance.hook.ts b/src/hooks/compliance.hook.ts index c544da35..4ececadb 100644 --- a/src/hooks/compliance.hook.ts +++ b/src/hooks/compliance.hook.ts @@ -281,7 +281,7 @@ export function useCompliance() { }): Promise { const queryParts = Object.entries(params ?? {}) .filter(([, v]) => v) - .map(([k, v]) => `${k}=${encodeURIComponent(v!)}`); + .map(([k, v]) => `${k}=${encodeURIComponent(v as string)}`); const queryString = queryParts.length ? `?${queryParts.join('&')}` : ''; return call({ From 5849ffd6650b8fcc34aa03c2f607af1c3c262754 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 6 Feb 2026 01:00:25 +0100 Subject: [PATCH 6/7] 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. --- .../compliance-transaction-list.screen.tsx | 39 +++---------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/src/screens/compliance-transaction-list.screen.tsx b/src/screens/compliance-transaction-list.screen.tsx index 7f0621cc..f48a144c 100644 --- a/src/screens/compliance-transaction-list.screen.tsx +++ b/src/screens/compliance-transaction-list.screen.tsx @@ -8,7 +8,7 @@ import { StyledLoadingSpinner, StyledVerticalStack, } from '@dfx.swiss/react-components'; -import { useEffect, useMemo, useState } from 'react'; +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'; @@ -50,34 +50,6 @@ export default function ComplianceTransactionListScreen(): JSX.Element { return Math.round(value).toLocaleString('de-CH'); } - const filteredData = useMemo(() => { - const cFrom = createdFrom ? new Date(createdFrom) : null; - const cTo = createdTo ? new Date(createdTo) : null; - if (cTo) cTo.setHours(23, 59, 59, 999); - - const oFrom = outputFrom ? new Date(outputFrom) : null; - const oTo = outputTo ? new Date(outputTo) : null; - if (oTo) oTo.setHours(23, 59, 59, 999); - - return data.filter((entry) => { - if (cFrom || cTo) { - const entryDate = entry.created ? new Date(entry.created) : null; - if (!entryDate) return false; - if (cFrom && entryDate < cFrom) return false; - if (cTo && entryDate > cTo) return false; - } - - if (oFrom || oTo) { - const entryDate = entry.outputDate ? new Date(entry.outputDate) : null; - if (!entryDate) return false; - if (oFrom && entryDate < oFrom) return false; - if (oTo && entryDate > oTo) return false; - } - - return true; - }); - }, [data, createdFrom, createdTo, outputFrom, outputTo]); - function exportCsv() { const headers = [ 'Id', @@ -92,7 +64,7 @@ export default function ComplianceTransactionListScreen(): JSX.Element { 'CHF Value', 'TMER', ]; - const rows = filteredData.map((entry) => [ + const rows = data.map((entry) => [ entry.id, entry.type ?? '', entry.accountId ?? '', @@ -214,14 +186,13 @@ export default function ComplianceTransactionListScreen(): JSX.Element {
- {translate('screens/compliance', 'Showing')} {filteredData.length} {translate('screens/compliance', 'of')}{' '} {data.length} {translate('screens/compliance', 'entries')} @@ -268,8 +239,8 @@ export default function ComplianceTransactionListScreen(): JSX.Element { - {filteredData.length > 0 ? ( - filteredData.map((entry) => ( + {data.length > 0 ? ( + data.map((entry) => ( Date: Fri, 6 Feb 2026 01:09:57 +0100 Subject: [PATCH 7/7] Escape double quotes in CSV export Prevents malformed CSV output when cell values contain double quote characters. --- src/screens/compliance-transaction-list.screen.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/screens/compliance-transaction-list.screen.tsx b/src/screens/compliance-transaction-list.screen.tsx index f48a144c..4c8fb76d 100644 --- a/src/screens/compliance-transaction-list.screen.tsx +++ b/src/screens/compliance-transaction-list.screen.tsx @@ -78,7 +78,9 @@ export default function ComplianceTransactionListScreen(): JSX.Element { entry.highRisk ? 'Ja' : 'Nein', ]); - const csvContent = [headers, ...rows].map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n'); + 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);