diff --git a/package-lock.json b/package-lock.json index 532fc97e..95ab2fc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.4", "license": "MIT", "dependencies": { - "@dfx.swiss/react": "^1.3.0-beta.270", + "@dfx.swiss/react": "^1.3.2-beta.0", "@dfx.swiss/react-components": "^1.3.0-beta.270", "@ledgerhq/hw-app-btc": "^6.24.1", "@ledgerhq/hw-app-eth": "^6.33.7", @@ -2631,15 +2631,24 @@ "@ethersproject/wordlists": "5.8.0" } }, - "node_modules/@dfx.swiss/react": { - "version": "1.3.0-beta.270", - "resolved": "https://registry.npmjs.org/@dfx.swiss/react/-/react-1.3.0-beta.270.tgz", - "integrity": "sha512-r81/MQXanHEWkAydl8u7X1jkl6a7t04bozHbkEXrYYKkdUTeXnDDHQ4Pvm6bDOUxkMWFz+L88vg+rupFB7T/ZA==", + "node_modules/@dfx.swiss/core": { + "version": "0.2.2-beta.0", + "resolved": "https://registry.npmjs.org/@dfx.swiss/core/-/core-0.2.2-beta.0.tgz", + "integrity": "sha512-yh6TR7xwPnhGGXCU4M9ydZbMbwSygrKPozhzJVSIs8A0iNh5ZZJynR/czEsO+GwQnfhcXTyBBCtnue2dJBlhxw==", "license": "MIT", "dependencies": { "ibantools": "^4.2.1", + "libphonenumber-js": "^1.10.39" + } + }, + "node_modules/@dfx.swiss/react": { + "version": "1.3.2-beta.0", + "resolved": "https://registry.npmjs.org/@dfx.swiss/react/-/react-1.3.2-beta.0.tgz", + "integrity": "sha512-FwS8AtwAGwne50rNh/h0RdBVIompkuba5WEoTEJbUsYT6BwPqkYTucVwlA6ThHiEr33Wm4wwtVt/Udnz0GX0rg==", + "license": "MIT", + "dependencies": { + "@dfx.swiss/core": "^0.2.2-beta.0", "jwt-decode": "^3.1.2", - "libphonenumber-js": "^1.10.39", "react": "^18.2.0", "typescript": "^4.9.3" } diff --git a/package.json b/package.json index 2b6078c8..387e0cb6 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "access": "public" }, "dependencies": { - "@dfx.swiss/react": "^1.3.0-beta.270", + "@dfx.swiss/react": "^1.3.2-beta.0", "@dfx.swiss/react-components": "^1.3.0-beta.270", "@ledgerhq/hw-app-btc": "^6.24.1", "@ledgerhq/hw-app-eth": "^6.33.7", diff --git a/src/App.tsx b/src/App.tsx index 0410e662..d9200ad9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -55,6 +55,8 @@ const EditMailScreen = lazy(() => import('./screens/edit-mail.screen')); const SafeScreen = lazy(() => import('./screens/safe.screen')); const TelegramSupportScreen = lazy(() => import('./screens/telegram-support.screen')); const ComplianceScreen = lazy(() => import('./screens/compliance.screen')); +const ComplianceBankTxScreen = lazy(() => import('./screens/compliance-bank-tx.screen')); +const ComplianceBankTxRecallScreen = lazy(() => import('./screens/compliance-bank-tx-recall.screen')); const ComplianceBankTxReturnScreen = lazy(() => import('./screens/compliance-bank-tx-return.screen')); const ComplianceKycFilesScreen = lazy(() => import('./screens/compliance-kyc-files.screen')); const ComplianceKycFilesDetailsScreen = lazy(() => import('./screens/compliance-kyc-files-details.screen')); @@ -65,8 +67,11 @@ const ComplianceSupportIssueScreen = lazy(() => import('./screens/compliance-sup const ComplianceRecommendationGraphScreen = lazy(() => import('./screens/compliance-recommendation-graph.screen')); const ComplianceCustodyOrdersScreen = lazy(() => import('./screens/compliance-custody-orders.screen')); const ComplianceMrosListScreen = lazy(() => import('./screens/compliance-mros-list.screen')); +const ComplianceMrosCreateScreen = lazy(() => import('./screens/compliance-mros-create.screen')); +const ComplianceMrosDetailScreen = lazy(() => import('./screens/compliance-mros-detail.screen')); const ComplianceRecallListScreen = lazy(() => import('./screens/compliance-recall-list.screen')); const ComplianceReviewScreen = lazy(() => import('./screens/compliance-review.screen')); +const CompliancePendingReviewsScreen = lazy(() => import('./screens/compliance-pending-reviews.screen')); const SupportDashboardScreen = lazy(() => import('./screens/support-dashboard.screen')); const SupportDashboardIssueScreen = lazy(() => import('./screens/support-dashboard-issue.screen')); const SupportDashboardCreateScreen = lazy(() => import('./screens/support-dashboard-create.screen')); @@ -367,6 +372,14 @@ export const Routes = [ path: 'compliance/recommendations/:id', element: withSuspense(), }, + { + path: 'compliance/bank-tx/:id', + element: withSuspense(), + }, + { + path: 'compliance/bank-tx/:id/recall', + element: withSuspense(), + }, { path: 'compliance/bank-tx/:id/return', element: withSuspense(), @@ -395,6 +408,14 @@ export const Routes = [ path: 'compliance/mros', element: withSuspense(), }, + { + path: 'compliance/mros/create', + element: withSuspense(), + }, + { + path: 'compliance/mros/:id', + element: withSuspense(), + }, { path: 'compliance/recalls', element: withSuspense(), @@ -403,6 +424,10 @@ export const Routes = [ path: 'compliance/user/:id/kyc', element: withSuspense(), }, + { + path: 'compliance/pending-reviews/:type/:name', + element: withSuspense(), + }, { path: 'sitemap', element: withSuspense(), diff --git a/src/components/compliance/recall-details-modal.tsx b/src/components/compliance/recall-details-modal.tsx new file mode 100644 index 00000000..2db5e839 --- /dev/null +++ b/src/components/compliance/recall-details-modal.tsx @@ -0,0 +1,37 @@ +import { StyledButton, StyledButtonColor, StyledButtonWidth, StyledVerticalStack } from '@dfx.swiss/react-components'; +import { Modal } from 'src/components/modal'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { RecallInfo } from 'src/hooks/compliance.hook'; +import { RecallDetails } from './recall-details'; + +interface RecallDetailsModalProps { + readonly isOpen: boolean; + readonly recall: RecallInfo | undefined; + readonly onClose: () => void; +} + +export function RecallDetailsModal({ isOpen, recall, onClose }: RecallDetailsModalProps): JSX.Element { + const { translate } = useSettingsContext(); + + if (!isOpen || !recall) return <>; + + return ( + +
+

+ {translate('screens/compliance', 'Recall')} +

+ + + + + +
+
+ ); +} diff --git a/src/components/compliance/recall-details.tsx b/src/components/compliance/recall-details.tsx new file mode 100644 index 00000000..73765e14 --- /dev/null +++ b/src/components/compliance/recall-details.tsx @@ -0,0 +1,17 @@ +import { RecallInfo } from 'src/hooks/compliance.hook'; +import { DetailRow, formatDateTime } from 'src/util/compliance-helpers'; + +export function RecallDetails({ recall }: { recall: RecallInfo }): JSX.Element { + return ( + + + + + + + + + +
+ ); +} diff --git a/src/components/compliance/recall-modal.tsx b/src/components/compliance/recall-modal.tsx new file mode 100644 index 00000000..d0d15eef --- /dev/null +++ b/src/components/compliance/recall-modal.tsx @@ -0,0 +1,155 @@ +import { Utils, Validations } from '@dfx.swiss/react'; +import { + Form, + StyledButton, + StyledButtonColor, + StyledButtonWidth, + StyledDropdown, + StyledInput, + StyledVerticalStack, +} from '@dfx.swiss/react-components'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { ErrorHint } from 'src/components/error-hint'; +import { Modal } from 'src/components/modal'; +import { useLayoutContext } from 'src/contexts/layout.context'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { RecallReason } from 'src/dto/recall.dto'; +import { useCompliance } from 'src/hooks/compliance.hook'; + +interface RecallModalProps { + readonly isOpen: boolean; + readonly bankTxId: number | undefined; + readonly onClose: () => void; + readonly onSuccess: () => void; +} + +interface RecallFormData { + reason: RecallReason; + comment: string; + fee: string; +} + +export function RecallModal({ isOpen, bankTxId, onClose, onSuccess }: RecallModalProps): JSX.Element { + const { translate, translateError } = useSettingsContext(); + const { createRecall } = useCompliance(); + const { rootRef } = useLayoutContext(); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(); + + const { + control, + handleSubmit, + formState: { isValid, errors }, + reset, + } = useForm({ + mode: 'onTouched', + defaultValues: { fee: '500', comment: 'n.a.' }, + }); + + useEffect(() => { + if (!isOpen) { + setError(undefined); + reset(); + } + }, [isOpen]); + + async function onSubmit(formData: RecallFormData): Promise { + if (!bankTxId) return; + + setIsSubmitting(true); + setError(undefined); + + try { + await createRecall({ + bankTxId, + sequence: 1, + reason: formData.reason, + comment: formData.comment, + fee: Number(formData.fee), + }); + onSuccess(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error'); + } finally { + setIsSubmitting(false); + } + } + + function handleClose(): void { + reset(); + setError(undefined); + onClose(); + } + + const rules = Utils.createRules({ + reason: Validations.Required, + comment: Validations.Required, + fee: [Validations.Required, Validations.Custom((v) => (isNaN(Number(v)) ? 'pattern' : true))], + }); + + if (!isOpen) return <>; + + return ( + +
+

+ {translate('screens/compliance', 'Recall erfassen')} +

+ +
+ + + rootRef={rootRef} + name="reason" + label={translate('screens/compliance', 'Reason')} + placeholder={translate('general/actions', 'Select') + '...'} + items={Object.values(RecallReason).filter((r) => r !== RecallReason.UNKNOWN)} + labelFunc={(item) => item} + full + smallLabel + /> + + + + + + {error && } + +
+ + +
+
+
+
+
+ ); +} diff --git a/src/components/compliance/transactions-tab.tsx b/src/components/compliance/transactions-tab.tsx index 96bff968..a069116f 100644 --- a/src/components/compliance/transactions-tab.tsx +++ b/src/components/compliance/transactions-tab.tsx @@ -2,8 +2,10 @@ import { Transaction, TransactionState, useTransaction } from '@dfx.swiss/react' import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; import { Fragment, useState } from 'react'; import { ChargebackModal } from 'src/components/compliance/chargeback-modal'; +import { RecallDetailsModal } from 'src/components/compliance/recall-details-modal'; +import { RecallModal } from 'src/components/compliance/recall-modal'; import { ConfirmDialog } from 'src/components/confirm-dialog'; -import { BankTxInfo, CryptoInputInfo, TransactionInfo, useCompliance } from 'src/hooks/compliance.hook'; +import { BankTxInfo, CryptoInputInfo, RecallInfo, TransactionInfo, useCompliance } from 'src/hooks/compliance.hook'; import { DetailRow, TransactionDetailRows, formatDate, statusBadge } from 'src/util/compliance-helpers'; interface TransactionsTableProps { @@ -44,6 +46,8 @@ export function TransactionsTable({ const [stopConfirmTxId, setStopConfirmTxId] = useState(); const [stopError, setStopError] = useState(); const [chargebackTxId, setChargebackTxId] = useState(); + const [recallBankTxId, setRecallBankTxId] = useState(); + const [viewingRecall, setViewingRecall] = useState(); async function confirmStop(): Promise { const txId = stopConfirmTxId; @@ -294,33 +298,31 @@ export function TransactionsTable({ return ( <> - {((tx.type === 'BuyCrypto' && !tx.isCompleted) || - ([ - TransactionState.FAILED, - TransactionState.CHECK_PENDING, - TransactionState.KYC_REQUIRED, - TransactionState.LIMIT_EXCEEDED, - TransactionState.UNASSIGNED, - ].includes(detail.state) && - !detail.chargebackAmount)) && ( -
- {tx.type === 'BuyCrypto' && !tx.isCompleted && ( - - )} - {[ + {(() => { + const canStop = tx.type === 'BuyCrypto' && !tx.isCompleted; + const canChargeback = + [ TransactionState.FAILED, TransactionState.CHECK_PENDING, TransactionState.KYC_REQUIRED, TransactionState.LIMIT_EXCEEDED, TransactionState.UNASSIGNED, - ].includes(detail.state) && - !detail.chargebackAmount && ( + ].includes(detail.state) && !detail.chargebackAmount; + const existingRecall = bankTx?.recall; + const canRecall = bankTx != null && !existingRecall; + if (!canStop && !canChargeback && !canRecall && !existingRecall) return null; + return ( +
+ {canStop && ( + + )} + {canChargeback && ( )} -
- )} + {canRecall && ( + + )} + {existingRecall && ( + + )} +
+ ); + })()} ); })() @@ -378,6 +397,17 @@ export function TransactionsTable({ onStopped?.(); }} /> + setRecallBankTxId(undefined)} + onSuccess={() => setRecallBankTxId(undefined)} + /> + setViewingRecall(undefined)} + /> ); } diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index d208c0ab..079e4468 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -225,6 +225,15 @@ function NavigationMenu({ setIsNavigationOpen, small = false }: NavigationMenuCo onClose={() => setIsNavigationOpen(false)} /> )} + {session?.role === UserRole.ADMIN && ( + setIsNavigationOpen(false)} + /> + )} )} diff --git a/src/dto/mros.dto.ts b/src/dto/mros.dto.ts index 3f20ea8e..daa1103f 100644 --- a/src/dto/mros.dto.ts +++ b/src/dto/mros.dto.ts @@ -5,13 +5,60 @@ export enum MrosStatus { CLOSED = 'Closed', } +export interface MrosPersonOverrides { + gender?: string; + middleName?: string; + birthPlace?: string; + profession?: string; + sourceOfWealth?: string; + canton?: string; + idDocIssueDate?: string; + idDocValidUntil?: string; + idDocIssuingCountryCode?: string; +} + export interface MrosListEntry { id: number; created: Date; updated: Date; status: MrosStatus; + reportCode?: string; submissionDate?: Date; authorityReference?: string; caseManager: string; + reason?: string; + action?: string; + indicators?: string; + personOverrides?: string; userData: { id: number }; + transactions?: { id: number }[]; } + +export interface CreateMrosDto { + userDataId: number; + status: MrosStatus; + submissionDate?: string; + authorityReference?: string; + caseManager: string; + reportCode?: string; + reason?: string; + action?: string; + indicators?: string[]; + personOverrides?: MrosPersonOverrides; + transactionIds?: number[]; +} + +export interface UpdateMrosDto { + status?: MrosStatus; + submissionDate?: string; + authorityReference?: string; + caseManager?: string; + reportCode?: string; + reason?: string; + action?: string; + indicators?: string[]; + personOverrides?: MrosPersonOverrides; + transactionIds?: number[]; +} + +export const DEFAULT_MROS_INDICATOR_CODES = ['0002M', '1004V', '2008G', '3004B', '3005B', '3007B']; diff --git a/src/dto/recall.dto.ts b/src/dto/recall.dto.ts index bedc8601..4280ea0b 100644 --- a/src/dto/recall.dto.ts +++ b/src/dto/recall.dto.ts @@ -20,3 +20,13 @@ export interface RecallListEntry { checkoutTx?: { id: number }; user?: { id: number }; } + +export interface CreateRecallDto { + bankTxId?: number; + checkoutTxId?: number; + sequence: number; + comment: string; + fee: number; + reason: RecallReason; + userId?: number; +} diff --git a/src/hooks/compliance.hook.ts b/src/hooks/compliance.hook.ts index 374e352c..39a4262b 100644 --- a/src/hooks/compliance.hook.ts +++ b/src/hooks/compliance.hook.ts @@ -8,9 +8,9 @@ import { ResponseType, useApi, } from '@dfx.swiss/react'; -import { MrosListEntry } from 'src/dto/mros.dto'; +import { CreateMrosDto, MrosListEntry, UpdateMrosDto } from 'src/dto/mros.dto'; import { CustodyOrderListEntry } from 'src/dto/order.dto'; -import { RecallListEntry } from 'src/dto/recall.dto'; +import { CreateRecallDto, RecallListEntry } from 'src/dto/recall.dto'; import { electronicFormatIBAN, isValidIBAN } from 'ibantools'; import { useMemo } from 'react'; import { downloadFile, downloadPdfFromString, filenameDateFormat } from 'src/util/utils'; @@ -92,6 +92,33 @@ export interface PendingOnboardingInfo { date: string; } +export enum PendingReviewType { + KYC_STEP = 'KycStep', + BANK_DATA = 'BankData', +} + +export enum PendingReviewStatus { + MANUAL_REVIEW = 'ManualReview', + INTERNAL_REVIEW = 'InternalReview', +} + +export interface PendingReviewSummaryEntry { + type: PendingReviewType; + name: string; + manualReview: number; + internalReview: number; +} + +export interface PendingReviewItem { + id: number; + userDataId: number; + userName?: string; + accountType?: string; + kycLevel?: number; + status: PendingReviewStatus; + date: string; +} + export interface BankTxSearchResult { id: number; transactionId?: number; @@ -101,6 +128,7 @@ export interface BankTxSearchResult { type: string; name?: string; iban?: string; + recall?: RecallInfo; } export interface BankTxInfo { @@ -113,6 +141,16 @@ export interface BankTxInfo { name?: string; iban?: string; remittanceInfo?: string; + recall?: RecallInfo; +} + +export interface RecallInfo { + id: number; + created: string; + sequence: number; + reason?: string; + comment: string; + fee: number; } export interface IpLogInfo { @@ -511,6 +549,26 @@ export function useCompliance() { }); } + async function getPendingReviews(): Promise { + return call({ + url: 'support/pending-reviews', + method: 'GET', + }); + } + + async function getPendingReviewItems( + type: PendingReviewType, + status: PendingReviewStatus, + name?: string, + ): Promise { + const query = new URLSearchParams({ type, status }); + if (name) query.set('name', name); + return call({ + url: `support/pending-reviews/items?${query.toString()}`, + method: 'GET', + }); + } + async function getMrosList(): Promise { return call({ url: 'mros', @@ -518,6 +576,29 @@ export function useCompliance() { }); } + async function getMrosById(id: number): Promise { + return call({ + url: `mros/${id}`, + method: 'GET', + }); + } + + async function createMros(dto: CreateMrosDto): Promise { + return call({ + url: 'mros', + method: 'POST', + data: dto, + }); + } + + async function updateMros(id: number, dto: UpdateMrosDto): Promise { + return call({ + url: `mros/${id}`, + method: 'PUT', + data: dto, + }); + } + async function getRecalls(): Promise { return call({ url: 'recall', @@ -525,6 +606,14 @@ export function useCompliance() { }); } + async function createRecall(dto: CreateRecallDto): Promise { + return call({ + url: 'recall', + method: 'POST', + data: dto, + }); + } + async function getRecommendationGraph(userDataId: number): Promise { return call({ url: `support/recommendation-graph/${userDataId}`, @@ -650,6 +739,8 @@ export function useCompliance() { search, getUserData, getPendingOnboardings, + getPendingReviews, + getPendingReviewItems, downloadUserFiles, checkUserFiles, getTransactionRefundData, @@ -663,7 +754,11 @@ export function useCompliance() { getCustodyOrders, approveCustodyOrder, getMrosList, + getMrosById, + createMros, + updateMros, getRecalls, + createRecall, updateKycStep, updateUserData, createLimitRequest, diff --git a/src/hooks/support-dashboard.hook.ts b/src/hooks/support-dashboard.hook.ts index de37f88c..0942e035 100644 --- a/src/hooks/support-dashboard.hook.ts +++ b/src/hooks/support-dashboard.hook.ts @@ -102,7 +102,7 @@ export function useSupportDashboard() { async function getIssueList(params?: { department?: string; - state?: string; + states?: string; type?: string; take?: number; skip?: number; @@ -119,6 +119,28 @@ export function useSupportDashboard() { }); } + async function getIssueCounts(): Promise> { + return call>({ + url: 'support/issue/counts', + method: 'GET', + }); + } + + async function getIssueActivity(since?: Date): Promise<{ count: number; latestAt?: string }> { + const query = since ? `?since=${encodeURIComponent(since.toISOString())}` : ''; + return call<{ count: number; latestAt?: string }>({ + url: `support/issue/activity${query}`, + method: 'GET', + }); + } + + async function getClerks(): Promise { + return call({ + url: 'support/issue/clerks', + method: 'GET', + }); + } + async function getIssueData(issueId: number): Promise { return call({ url: `support/issue/${issueId}/data`, @@ -198,6 +220,9 @@ export function useSupportDashboard() { return useMemo( () => ({ getIssueList, + getIssueCounts, + getIssueActivity, + getClerks, getIssueData, updateIssue, sendMessage, diff --git a/src/index.css b/src/index.css index d5e2aa1c..e7c0096e 100644 --- a/src/index.css +++ b/src/index.css @@ -72,16 +72,20 @@ input[type='number'] { @tailwind components; @tailwind utilities; -/* Scroll shadow — bottom only, visible when content overflows */ +/* Scroll shadow — top + bottom, each visible only when content overflows in that direction */ .scroll-shadow { background: + linear-gradient(white 30%, transparent) center top, linear-gradient(transparent, white 70%) center bottom, + radial-gradient(farthest-side at 50% 0, rgba(0, 0, 0, 0.12), transparent) center top, 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% 40px, + 100% 10px, 100% 10px; - background-attachment: local, scroll; + background-attachment: local, local, scroll, scroll; } /* Shadow DOM Animation */ diff --git a/src/screens/compliance-bank-tx-recall.screen.tsx b/src/screens/compliance-bank-tx-recall.screen.tsx new file mode 100644 index 00000000..b4a73f61 --- /dev/null +++ b/src/screens/compliance-bank-tx-recall.screen.tsx @@ -0,0 +1,143 @@ +import { Utils, Validations } from '@dfx.swiss/react'; +import { + Form, + StyledButton, + StyledButtonColor, + StyledButtonWidth, + StyledDropdown, + StyledInput, + StyledVerticalStack, +} from '@dfx.swiss/react-components'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useParams } from 'react-router-dom'; +import { ErrorHint } from 'src/components/error-hint'; +import { useLayoutContext } from 'src/contexts/layout.context'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { RecallReason } from 'src/dto/recall.dto'; +import { 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'; + +interface FormData { + reason: RecallReason; + comment: string; + fee: string; +} + +export default function ComplianceBankTxRecallScreen(): JSX.Element { + useComplianceGuard(); + + const { id } = useParams<{ id: string }>(); + const { translate, translateError } = useSettingsContext(); + const { createRecall } = useCompliance(); + const { navigate } = useNavigation(); + const { rootRef } = useLayoutContext(); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(); + const [success, setSuccess] = useState(false); + + const { + control, + handleSubmit, + formState: { isValid, errors }, + } = useForm({ + mode: 'onTouched', + defaultValues: { fee: '500', comment: 'n.a.' }, + }); + + useLayoutOptions({ title: translate('screens/compliance', 'Recall erfassen'), backButton: true }); + + async function onSubmit(formData: FormData) { + if (!id) return; + + setIsSubmitting(true); + setError(undefined); + + try { + await createRecall({ + bankTxId: +id, + sequence: 1, + reason: formData.reason, + comment: formData.comment, + fee: Number(formData.fee), + }); + setSuccess(true); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Unknown error'); + } finally { + setIsSubmitting(false); + } + } + + const rules = Utils.createRules({ + reason: Validations.Required, + comment: Validations.Required, + fee: [Validations.Required, Validations.Custom((v) => (isNaN(Number(v)) ? 'pattern' : true))], + }); + + if (success) { + return ( + +
+

+ {translate('screens/compliance', 'Recall created successfully')} +

+ navigate(-1)} width={StyledButtonWidth.MD} /> +
+
+ ); + } + + return ( +
+ + + rootRef={rootRef} + name="reason" + label={translate('screens/compliance', 'Reason')} + placeholder={translate('general/actions', 'Select') + '...'} + items={Object.values(RecallReason).filter((r) => r !== RecallReason.UNKNOWN)} + labelFunc={(item) => item} + full + smallLabel + /> + + + + + + {error && ( +
+ +
+ )} + + + + navigate(-1)} + width={StyledButtonWidth.FULL} + color={StyledButtonColor.WHITE} + /> +
+
+ ); +} diff --git a/src/screens/compliance-bank-tx.screen.tsx b/src/screens/compliance-bank-tx.screen.tsx new file mode 100644 index 00000000..dc599a1d --- /dev/null +++ b/src/screens/compliance-bank-tx.screen.tsx @@ -0,0 +1,84 @@ +import { StyledButton, StyledButtonColor, StyledButtonWidth, StyledVerticalStack } from '@dfx.swiss/react-components'; +import { useLocation, useParams } from 'react-router-dom'; +import { RecallDetails } from 'src/components/compliance/recall-details'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { BankTxSearchResult } 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'; +import { readCachedBankTx } from 'src/util/bank-tx-cache'; +import { BankTxUnassignedTypes, DetailRow } from 'src/util/compliance-helpers'; + +function loadBankTx(id?: string, state?: unknown): BankTxSearchResult | undefined { + const fromState = (state as { bankTx?: BankTxSearchResult } | null)?.bankTx; + if (fromState) return fromState; + return id ? readCachedBankTx(id) : undefined; +} + +export default function ComplianceBankTxScreen(): JSX.Element { + useComplianceGuard(); + + const { id } = useParams<{ id: string }>(); + const { translate } = useSettingsContext(); + const { navigate, goBack } = useNavigation(); + const location = useLocation(); + const bankTx = loadBankTx(id, location.state); + + useLayoutOptions({ title: 'Bank Transaction Details', backButton: true }); + + if (!bankTx) { + return ( + +

No data available. Please open from search.

+ +
+ ); + } + + return ( +
+
+

{translate('screens/compliance', 'Bank Transaction')}

+ + + + + + + + + + +
+
+ + {bankTx.recall ? ( +
+

{translate('screens/compliance', 'Recall')}

+ +
+ ) : ( + navigate(`compliance/bank-tx/${bankTx.id}/recall`)} + width={StyledButtonWidth.FULL} + color={StyledButtonColor.BLUE} + /> + )} + + {bankTx.transactionId && BankTxUnassignedTypes.includes(bankTx.type) && ( + navigate(`compliance/bank-tx/${bankTx.transactionId}/return`)} + width={StyledButtonWidth.FULL} + color={StyledButtonColor.BLUE} + /> + )} +
+ ); +} diff --git a/src/screens/compliance-mros-create.screen.tsx b/src/screens/compliance-mros-create.screen.tsx new file mode 100644 index 00000000..d8620a22 --- /dev/null +++ b/src/screens/compliance-mros-create.screen.tsx @@ -0,0 +1,166 @@ +import { Utils, Validations, useUser } from '@dfx.swiss/react'; +import { + Form, + StyledButton, + StyledButtonColor, + StyledButtonWidth, + StyledDropdown, + StyledInput, + StyledVerticalStack, +} from '@dfx.swiss/react-components'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { ErrorHint } from 'src/components/error-hint'; +import { useLayoutContext } from 'src/contexts/layout.context'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { MrosStatus } from 'src/dto/mros.dto'; +import { 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'; +import { todayAsString } from 'src/util/compliance-helpers'; + +interface FormData { + userDataId: string; + status: MrosStatus; + submissionDate: string; + authorityReference: string; +} + +export default function ComplianceMrosCreateScreen(): JSX.Element { + useComplianceGuard(); + + const { translate, translateError } = useSettingsContext(); + const { createMros } = useCompliance(); + const { getProfile } = useUser(); + const { navigate } = useNavigation(); + const { rootRef } = useLayoutContext(); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(); + const [success, setSuccess] = useState(false); + const [caseManager, setCaseManager] = useState(); + + const { + control, + handleSubmit, + formState: { isValid, errors }, + } = useForm({ + mode: 'onTouched', + defaultValues: { status: MrosStatus.DRAFT, submissionDate: todayAsString() }, + }); + + useEffect(() => { + getProfile() + .then((p) => setCaseManager([p?.firstName, p?.lastName].filter(Boolean).join(' '))) + .catch(() => setCaseManager('')); + }, []); + + useLayoutOptions({ title: translate('screens/compliance', 'MROS erfassen'), backButton: true }); + + async function onSubmit(formData: FormData): Promise { + if (!caseManager) return; + setIsSubmitting(true); + setError(undefined); + + try { + await createMros({ + userDataId: Number(formData.userDataId), + status: formData.status, + submissionDate: formData.submissionDate || undefined, + authorityReference: formData.authorityReference || undefined, + caseManager, + }); + setSuccess(true); + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error'); + } finally { + setIsSubmitting(false); + } + } + + const rules = Utils.createRules({ + userDataId: [Validations.Required, Validations.Custom((v) => (isNaN(Number(v)) ? 'pattern' : true))], + status: Validations.Required, + }); + + if (success) { + return ( + +
+

+ {translate('screens/compliance', 'MROS created successfully')} +

+ navigate(-1)} + width={StyledButtonWidth.MD} + /> +
+
+ ); + } + + return ( +
+ + + + + rootRef={rootRef} + name="status" + label={translate('screens/compliance', 'Status')} + placeholder={translate('general/actions', 'Select') + '...'} + items={Object.values(MrosStatus)} + labelFunc={(item) => item} + full + smallLabel + /> + + + + + + {error && ( +
+ +
+ )} + + + + navigate(-1)} + width={StyledButtonWidth.FULL} + color={StyledButtonColor.WHITE} + /> +
+
+ ); +} diff --git a/src/screens/compliance-mros-detail.screen.tsx b/src/screens/compliance-mros-detail.screen.tsx new file mode 100644 index 00000000..08f15065 --- /dev/null +++ b/src/screens/compliance-mros-detail.screen.tsx @@ -0,0 +1,350 @@ +import { Utils, Validations } from '@dfx.swiss/react'; +import { + Form, + SpinnerSize, + StyledButton, + StyledButtonColor, + StyledButtonWidth, + StyledDropdown, + StyledInput, + StyledLoadingSpinner, + StyledVerticalStack, +} from '@dfx.swiss/react-components'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useParams } from 'react-router-dom'; +import { ErrorHint } from 'src/components/error-hint'; +import { useLayoutContext } from 'src/contexts/layout.context'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { ComplianceUserData, TransactionInfo } from 'src/hooks/compliance.hook'; +import { DEFAULT_MROS_INDICATOR_CODES, MrosListEntry, MrosPersonOverrides, MrosStatus } from 'src/dto/mros.dto'; +import { 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'; +import { DetailRow, formatDateTime, mrosStatusBadge } from 'src/util/compliance-helpers'; + +interface FormData { + status: MrosStatus; + submissionDate: string; + authorityReference: string; + caseManager: string; + reason: string; + action: string; + gender: string; + middleName: string; + birthPlace: string; + profession: string; + sourceOfWealth: string; + canton: string; + idDocIssueDate: string; + idDocValidUntil: string; + idDocIssuingCountryCode: string; +} + +function parseIndicators(value?: string): string[] { + if (!value) return []; + try { + return JSON.parse(value) as string[]; + } catch { + return []; + } +} + +function parsePersonOverrides(value?: string): MrosPersonOverrides { + if (!value) return {}; + try { + return JSON.parse(value) as MrosPersonOverrides; + } catch { + return {}; + } +} + +export default function ComplianceMrosDetailScreen(): JSX.Element { + useComplianceGuard(); + + const { id } = useParams<{ id: string }>(); + const { translate, translateError } = useSettingsContext(); + const { getMrosById, updateMros, getUserData } = useCompliance(); + const { navigate } = useNavigation(); + const { rootRef } = useLayoutContext(); + + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(); + const [mros, setMros] = useState(); + const [userDataDetails, setUserDataDetails] = useState(); + + const [indicators, setIndicators] = useState([]); + const [transactionIds, setTransactionIds] = useState([]); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(); + const [saveSuccess, setSaveSuccess] = useState(false); + + const { + control, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ mode: 'onTouched' }); + + useLayoutOptions({ title: translate('screens/compliance', 'MROS Report'), backButton: true }); + + useEffect(() => { + if (!id) return; + setIsLoading(true); + setLoadError(undefined); + getMrosById(+id) + .then(async (entry) => { + setMros(entry); + const overrides = parsePersonOverrides(entry.personOverrides); + reset({ + status: entry.status, + submissionDate: entry.submissionDate ? String(entry.submissionDate).split('T')[0] : '', + authorityReference: entry.authorityReference ?? '', + caseManager: entry.caseManager, + reason: entry.reason ?? '', + action: entry.action ?? '', + gender: overrides.gender ?? '', + middleName: overrides.middleName ?? '', + birthPlace: overrides.birthPlace ?? '', + profession: overrides.profession ?? '', + sourceOfWealth: overrides.sourceOfWealth ?? '', + canton: overrides.canton ?? '', + idDocIssueDate: overrides.idDocIssueDate ?? '', + idDocValidUntil: overrides.idDocValidUntil ?? '', + idDocIssuingCountryCode: overrides.idDocIssuingCountryCode ?? '', + }); + setIndicators(parseIndicators(entry.indicators)); + setTransactionIds(entry.transactions?.map((t) => t.id) ?? []); + try { + const data = await getUserData(entry.userData.id); + setUserDataDetails(data); + } catch { + // non-fatal: transactions section just shows no options + } + }) + .catch((e: Error) => setLoadError(e.message)) + .finally(() => setIsLoading(false)); + }, [id]); + + async function onSubmit(formData: FormData): Promise { + if (!id) return; + setIsSaving(true); + setSaveError(undefined); + setSaveSuccess(false); + + const overrides: MrosPersonOverrides = { + gender: formData.gender || undefined, + middleName: formData.middleName || undefined, + birthPlace: formData.birthPlace || undefined, + profession: formData.profession || undefined, + sourceOfWealth: formData.sourceOfWealth || undefined, + canton: formData.canton || undefined, + idDocIssueDate: formData.idDocIssueDate || undefined, + idDocValidUntil: formData.idDocValidUntil || undefined, + idDocIssuingCountryCode: formData.idDocIssuingCountryCode || undefined, + }; + + try { + await updateMros(+id, { + status: formData.status, + submissionDate: formData.submissionDate || undefined, + authorityReference: formData.authorityReference || undefined, + caseManager: formData.caseManager, + reason: formData.reason || undefined, + action: formData.action || undefined, + indicators, + personOverrides: overrides, + transactionIds, + }); + setSaveSuccess(true); + // reload fresh state + const fresh = await getMrosById(+id); + setMros(fresh); + } catch (e) { + setSaveError(e instanceof Error ? e.message : 'Unknown error'); + } finally { + setIsSaving(false); + } + } + + const rules = Utils.createRules({ + status: Validations.Required, + caseManager: Validations.Required, + }); + + function toggleIndicator(code: string): void { + setIndicators((prev) => (prev.includes(code) ? prev.filter((c) => c !== code) : [...prev, code])); + } + + function toggleTransaction(txId: number): void { + setTransactionIds((prev) => (prev.includes(txId) ? prev.filter((id) => id !== txId) : [...prev, txId])); + } + + if (isLoading) return ; + if (loadError) return ; + if (!mros) return ; + + const availableCodes = Array.from(new Set([...DEFAULT_MROS_INDICATOR_CODES, ...indicators])); + const txOptions = userDataDetails?.transactions ?? []; + + return ( +
+
+

{translate('screens/compliance', 'Overview')}

+ + + + + + + + + + + +
Status:{mrosStatusBadge(mros.status)}
+
+ +
+ +
+

{translate('screens/compliance', 'Report')}

+ + + rootRef={rootRef} + name="status" + label={translate('screens/compliance', 'Status')} + placeholder={translate('general/actions', 'Select') + '...'} + items={Object.values(MrosStatus)} + labelFunc={(item) => item} + full + smallLabel + /> + + + + + + +
+ +
+ {availableCodes.map((code) => ( + + ))} +
+
+
+
+ +
+

{translate('screens/compliance', 'Person Overrides')}

+ + + + + + + + + + + +
+ +
+

{translate('screens/compliance', 'Transactions')}

+ {txOptions.length === 0 ? ( +

+ {translate('screens/compliance', 'No transactions available for this user.')} +

+ ) : ( +
+ {txOptions.map((tx: TransactionInfo) => ( + + ))} +
+ )} +
+ + {saveError && } + {saveSuccess && ( +

+ {translate('screens/compliance', 'MROS report saved successfully')} +

+ )} + + + navigate(-1)} + width={StyledButtonWidth.FULL} + color={StyledButtonColor.WHITE} + disabled={isSaving} + /> +
+
+
+ ); +} diff --git a/src/screens/compliance-mros-list.screen.tsx b/src/screens/compliance-mros-list.screen.tsx index 160c1dfb..8d1d52d2 100644 --- a/src/screens/compliance-mros-list.screen.tsx +++ b/src/screens/compliance-mros-list.screen.tsx @@ -2,23 +2,12 @@ import { useSessionContext } from '@dfx.swiss/react'; import { SpinnerSize, StyledLoadingSpinner, StyledVerticalStack } from '@dfx.swiss/react-components'; import { useCallback, useEffect, useState } from 'react'; import { ErrorHint } from 'src/components/error-hint'; -import { MrosListEntry, MrosStatus } from 'src/dto/mros.dto'; +import { MrosListEntry } from 'src/dto/mros.dto'; import { useCompliance } from 'src/hooks/compliance.hook'; import { useComplianceGuard } from 'src/hooks/guard.hook'; import { useLayoutOptions } from 'src/hooks/layout-config.hook'; - -const statusClasses: Record = { - [MrosStatus.DRAFT]: 'bg-dfxGray-400 text-dfxBlue-800', - [MrosStatus.SUBMITTED]: 'bg-dfxBlue-300/20 text-dfxBlue-400', - [MrosStatus.CONFIRMED]: 'bg-dfxGreen-100/20 text-dfxGreen-300', - [MrosStatus.CLOSED]: 'bg-dfxGray-700/20 text-dfxGray-800', -}; - -function statusBadge(status: MrosStatus): JSX.Element { - const classes = statusClasses[status] ?? 'bg-dfxGray-400 text-dfxBlue-800'; - - return {status}; -} +import { useNavigation } from 'src/hooks/navigation.hook'; +import { mrosStatusBadge } from 'src/util/compliance-helpers'; export default function ComplianceMrosListScreen(): JSX.Element { useComplianceGuard(); @@ -26,6 +15,7 @@ export default function ComplianceMrosListScreen(): JSX.Element { const { isLoggedIn } = useSessionContext(); const { getMrosList } = useCompliance(); + const { navigate } = useNavigation(); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(); @@ -82,12 +72,16 @@ export default function ComplianceMrosListScreen(): JSX.Element { {data.length > 0 ? ( data.map((entry) => ( - + navigate(`compliance/mros/${entry.id}`)} + > {entry.id} {formatDate(entry.created)} {formatDate(entry.updated)} {entry.userData.id} - {statusBadge(entry.status)} + {mrosStatusBadge(entry.status)} {formatDate(entry.submissionDate)} {entry.authorityReference ?? '-'} {entry.caseManager} diff --git a/src/screens/compliance-pending-reviews.screen.tsx b/src/screens/compliance-pending-reviews.screen.tsx new file mode 100644 index 00000000..6ab964fd --- /dev/null +++ b/src/screens/compliance-pending-reviews.screen.tsx @@ -0,0 +1,115 @@ +import { useSessionContext } from '@dfx.swiss/react'; +import { SpinnerSize, StyledLoadingSpinner, StyledVerticalStack } 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 { PendingReviewItem, PendingReviewStatus, PendingReviewType, 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 CompliancePendingReviewsScreen(): JSX.Element { + useComplianceGuard(); + + const { translate } = useSettingsContext(); + const { getPendingReviewItems } = useCompliance(); + const { navigate } = useNavigation(); + const { isLoggedIn } = useSessionContext(); + const { type, name } = useParams<{ type: PendingReviewType; name: string }>(); + const { search } = useLocation(); + + const status = + (new URLSearchParams(search).get('status') as PendingReviewStatus) ?? PendingReviewStatus.MANUAL_REVIEW; + + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(); + const [items, setItems] = useState([]); + + useEffect(() => { + if (!isLoggedIn || !type) return; + + const queryName = type === PendingReviewType.KYC_STEP ? name : undefined; + getPendingReviewItems(type, status, queryName) + .then(setItems) + .catch((e) => setError(e.message)) + .finally(() => setIsLoading(false)); + }, [isLoggedIn, type, name, status]); + + const title = + type === PendingReviewType.BANK_DATA + ? `${translate('screens/compliance', 'BankData')} – ${status}` + : `${name} – ${status}`; + + useLayoutOptions({ title }); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + +
+ + + + + + + + + + + + {items.length > 0 ? ( + items.map((item) => ( + navigate(`compliance/user/${item.userDataId}/kyc`)} + > + + + + + + + + )) + ) : ( + + + + )} + +
+ {translate('screens/compliance', 'ID')} + + {translate('screens/kyc', 'Account Type')} + + {translate('screens/kyc', 'Name')} + + {translate('screens/compliance', 'Kyc Level')} + + {translate('screens/compliance', 'Date')} + +
{item.userDataId}{item.accountType ?? '-'}{item.userName ?? '-'}{item.kycLevel ?? '-'} + {new Date(item.date).toLocaleDateString('de-CH')} + + +
+ {translate('screens/compliance', 'No entries found')} +
+
+
+ ); +} diff --git a/src/screens/compliance.screen.tsx b/src/screens/compliance.screen.tsx index 0e89891e..6cbae17e 100644 --- a/src/screens/compliance.screen.tsx +++ b/src/screens/compliance.screen.tsx @@ -11,7 +11,7 @@ import { StyledInput, StyledVerticalStack, } from '@dfx.swiss/react-components'; -import { useEffect, useState } from 'react'; +import { Fragment, useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useLocation } from 'react-router-dom'; import { ErrorHint } from 'src/components/error-hint'; @@ -20,12 +20,15 @@ import { BankTxSearchResult, ComplianceSearchResult, PendingOnboardingInfo, + PendingReviewStatus, + PendingReviewSummaryEntry, UserSearchResult, 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'; +import { cacheBankTx } from 'src/util/bank-tx-cache'; interface FormData { key: string; @@ -35,7 +38,7 @@ export default function ComplianceScreen(): JSX.Element { useComplianceGuard(); const { translate, translateError } = useSettingsContext(); - const { search, downloadUserFiles, getPendingOnboardings } = useCompliance(); + const { search, downloadUserFiles, getPendingOnboardings, getPendingReviews } = useCompliance(); const { navigate } = useNavigation(); const { search: query } = useLocation(); @@ -45,6 +48,7 @@ export default function ComplianceScreen(): JSX.Element { const [showInfo, setShowInfo] = useState(false); const [downloadingUserId, setDownloadingUserId] = useState(); const [pendingOnboardings, setPendingOnboardings] = useState([]); + const [pendingReviews, setPendingReviews] = useState([]); const paramSearch = new URLSearchParams(query).get('search') || undefined; @@ -52,6 +56,9 @@ export default function ComplianceScreen(): JSX.Element { getPendingOnboardings() .then(setPendingOnboardings) .catch(() => setPendingOnboardings([])); + getPendingReviews() + .then(setPendingReviews) + .catch(() => setPendingReviews([])); }, []); useEffect(() => { @@ -186,14 +193,15 @@ export default function ComplianceScreen(): JSX.Element { label: '', render: (b: BankTxSearchResult) => (
- {b.transactionId && ( - navigate(`compliance/bank-tx/${b.transactionId}/return`)} - /> - )} + { + cacheBankTx(b); + navigate(`compliance/bank-tx/${b.id}`); + }} + />
), }, @@ -310,9 +318,7 @@ export default function ComplianceScreen(): JSX.Element { {searchResult.bankTx.length > 0 && (
-

- {translate('screens/compliance', 'Unassigned Bank Transactions')} -

+

{translate('screens/compliance', 'Bank Transactions')}

@@ -401,6 +407,60 @@ export default function ComplianceScreen(): JSX.Element { )} + + {pendingReviews.length > 0 && ( +
+

+ {translate('screens/compliance', 'Pending Reviews')} ( + {pendingReviews.reduce((sum, r) => sum + r.manualReview + r.internalReview, 0)}) +

+
+
+ + + + + + + + + + {pendingReviews.map((r) => ( + + {[PendingReviewStatus.MANUAL_REVIEW, PendingReviewStatus.INTERNAL_REVIEW].map((s) => { + const isManual = s === PendingReviewStatus.MANUAL_REVIEW; + const count = isManual ? r.manualReview : r.internalReview; + if (count === 0) return null; + const valueCell = 'px-4 py-3 text-right text-sm text-dfxBlue-800 font-semibold'; + const emptyCell = 'px-4 py-3 text-right text-sm text-dfxGray-600'; + return ( + navigate(`compliance/pending-reviews/${r.type}/${r.name}?status=${s}`)} + > + + + + + + ); + })} + + ))} + +
+ {translate('screens/compliance', 'Type')} + + {translate('screens/kyc', 'Name')} + + {translate('screens/compliance', 'Manual Review')} + + {translate('screens/compliance', 'Internal Review')} +
{r.type}{r.name}{isManual ? count : '-'}{isManual ? '-' : count}
+
+
+ )} ); diff --git a/src/screens/kyc.screen.tsx b/src/screens/kyc.screen.tsx index fd8c23e7..ed6b69f0 100644 --- a/src/screens/kyc.screen.tsx +++ b/src/screens/kyc.screen.tsx @@ -257,7 +257,7 @@ export default function KycScreen(): JSX.Element { setIsLoading(true); navigate({ search: `?code=${e.switchToCode}` }); logout(); - } else if (e.statusCode === 403 && e.message?.includes('2FA')) { + } else if (e.code === 'TFA_REQUIRED') { setParams({ autoStart: 'true' }); navigate('/2fa', { setRedirect: true }); } else if (e.statusCode === 409 && e.message?.includes('exists')) { diff --git a/src/screens/sepa-manual.screen.tsx b/src/screens/sepa-manual.screen.tsx index 1aa1cd77..4bfd276e 100644 --- a/src/screens/sepa-manual.screen.tsx +++ b/src/screens/sepa-manual.screen.tsx @@ -18,6 +18,7 @@ import { ErrorHint } from 'src/components/error-hint'; import { useLayoutContext } from 'src/contexts/layout.context'; import { useLayoutOptions } from 'src/hooks/layout-config.hook'; import { buildCamt053Xml } from 'src/util/camt053-builder'; +import { todayAsString } from 'src/util/compliance-helpers'; import { useSettingsContext } from '../contexts/settings.context'; import { useAdminGuard } from '../hooks/guard.hook'; @@ -68,6 +69,8 @@ export default function SepaManualScreen(): JSX.Element { defaultValues: { currency: 'EUR', direction: CreditDebitIndicator.CRDT, + bookingDate: todayAsString(), + valueDate: todayAsString(), }, }); diff --git a/src/screens/support-dashboard-create.screen.tsx b/src/screens/support-dashboard-create.screen.tsx index 3e4fa92f..96282a8f 100644 --- a/src/screens/support-dashboard-create.screen.tsx +++ b/src/screens/support-dashboard-create.screen.tsx @@ -1,13 +1,14 @@ -import { SupportIssueReason, SupportIssueType, useAuthContext, useUserContext } from '@dfx.swiss/react'; +import { SupportIssueReason, SupportIssueType } from '@dfx.swiss/react'; import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { toBase64 } from 'src/util/utils'; import { ErrorHint } from 'src/components/error-hint'; import { useSettingsContext } from 'src/contexts/settings.context'; import { useSupportDashboardGuard } from 'src/hooks/guard.hook'; import { useLayoutOptions } from 'src/hooks/layout-config.hook'; import { useNavigation } from 'src/hooks/navigation.hook'; import { ASSIGNABLE_DEPARTMENTS, UserSearchResult, useSupportDashboard } from 'src/hooks/support-dashboard.hook'; +import { reasonLabel, typeLabel } from 'src/util/support-helpers'; +import { toBase64 } from 'src/util/utils'; const ISSUE_TYPES = Object.values(SupportIssueType); const ISSUE_REASONS = Object.values(SupportIssueReason); @@ -16,11 +17,11 @@ export default function SupportDashboardCreateScreen(): JSX.Element { useSupportDashboardGuard(); const { translate } = useSettingsContext(); - const { session } = useAuthContext(); - const { user } = useUserContext(); - const { createIssue, searchUsers } = useSupportDashboard(); + const { createIssue, searchUsers, getClerks } = useSupportDashboard(); const { navigate } = useNavigation(); + const [clerks, setClerks] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(); @@ -38,6 +39,16 @@ export default function SupportDashboardCreateScreen(): JSX.Element { const [reason, setReason] = useState(ISSUE_REASONS[0]); const [name, setName] = useState(''); const [department, setDepartment] = useState(ASSIGNABLE_DEPARTMENTS[0]); + const [clerk, setClerk] = useState(''); + + useEffect(() => { + getClerks() + .then((list) => { + setClerks(list); + setClerk((prev) => prev || list[0] || ''); + }) + .catch(() => undefined); + }, [getClerks]); const [message, setMessage] = useState(''); const [selectedFile, setSelectedFile] = useState(); const fileInputRef = useRef(null); @@ -106,14 +117,13 @@ export default function SupportDashboardCreateScreen(): JSX.Element { setError(undefined); try { - const author = user?.mail ?? session?.address ?? 'Support'; const fileData = selectedFile ? await toBase64(selectedFile) : undefined; await createIssue(selectedUser.id, { type, reason, name: name.trim(), department, - author, + author: clerk, message: message.trim(), file: fileData ?? undefined, fileName: selectedFile?.name, @@ -209,7 +219,7 @@ export default function SupportDashboardCreateScreen(): JSX.Element { > {ISSUE_TYPES.map((t) => ( ))} @@ -223,7 +233,7 @@ export default function SupportDashboardCreateScreen(): JSX.Element { > {ISSUE_REASONS.map((r) => ( ))} @@ -254,6 +264,20 @@ export default function SupportDashboardCreateScreen(): JSX.Element { + + + +