Implement UI components and quick wins

- Complete Dashboard page with statistics, recent activity, compliance status
- Complete Transactions page with form, validation, E&O uplift display
- Complete Treasury page with account management
- Complete Reports page with BCB report generation and export
- Add LoadingSpinner component
- Add ErrorBoundary component
- Add Toast notification system
- Add comprehensive input validation
- Add error handling utilities
- Add basic unit tests structure
- Fix XML exporter TypeScript errors
- All quick wins completed
This commit is contained in:
defiQUG
2026-01-23 16:32:41 -08:00
parent adb2b3620b
commit 7558268f9d
20 changed files with 2135 additions and 28 deletions

View File

@@ -1,4 +1,5 @@
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import { ErrorBoundary } from './components/ErrorBoundary';
import DashboardPage from './pages/DashboardPage';
import TransactionsPage from './pages/TransactionsPage';
import TreasuryPage from './pages/TreasuryPage';
@@ -6,8 +7,9 @@ import ReportsPage from './pages/ReportsPage';
function App() {
return (
<BrowserRouter>
<div className="min-h-screen bg-gray-50">
<ErrorBoundary>
<BrowserRouter>
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
@@ -56,8 +58,9 @@ function App() {
<Route path="/reports" element={<ReportsPage />} />
</Routes>
</main>
</div>
</BrowserRouter>
</div>
</BrowserRouter>
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
text?: string;
}
export default function LoadingSpinner({ size = 'md', text }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12',
};
return (
<div className="flex flex-col items-center justify-center p-4">
<div
className={`${sizeClasses[size]} animate-spin rounded-full border-4 border-gray-200 border-t-blue-600`}
/>
{text && <p className="mt-2 text-sm text-gray-600">{text}</p>}
</div>
);
}

View File

@@ -10,3 +10,18 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}

View File

@@ -1,10 +1,191 @@
import React from 'react';
import React, { useState } from 'react';
import { useTransactionStore } from '../stores/transactionStore';
import { generateBCBReport, exportBCBReportToCSV } from '@brazil-swift-ops/audit';
import type { BCBReport } from '@brazil-swift-ops/types';
export default function ReportsPage() {
const { transactions, results } = useTransactionStore();
const [report, setReport] = useState<BCBReport | null>(null);
const [dateRange, setDateRange] = useState({
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0],
});
const [isGenerating, setIsGenerating] = useState(false);
const handleGenerateReport = () => {
setIsGenerating(true);
try {
const filteredTransactions = transactions.filter((txn) => {
const txnDate = txn.createdAt.toISOString().split('T')[0];
return txnDate >= dateRange.start && txnDate <= dateRange.end;
});
const filteredResults = filteredTransactions
.map((txn) => results.get(txn.id))
.filter((r): r is NonNullable<typeof r> => r !== undefined);
const generatedReport = generateBCBReport(
filteredTransactions,
filteredResults,
'periodic',
new Date(dateRange.start),
new Date(dateRange.end)
);
setReport(generatedReport);
} catch (error) {
console.error('Error generating report:', error);
} finally {
setIsGenerating(false);
}
};
const handleExportJSON = () => {
if (!report) return;
const blob = new Blob([report.data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `BCB_Report_${report.reportId}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleExportCSV = () => {
if (!report) return;
const csv = exportBCBReportToCSV(report);
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `BCB_Report_${report.reportId}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="px-4 py-6 sm:px-0">
<h1 className="text-2xl font-bold mb-4">ReportsPage</h1>
<p className="text-gray-600">ReportsPage interface</p>
<h1 className="text-2xl font-bold mb-6">Reports</h1>
{/* Report Generation */}
<div className="bg-white rounded-lg shadow mb-6">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold">Generate BCB Report</h2>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">End Date</label>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
</div>
<button
onClick={handleGenerateReport}
disabled={isGenerating}
className="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isGenerating ? 'Generating...' : 'Generate Report'}
</button>
</div>
</div>
{/* Report Summary */}
{report && (
<div className="bg-white rounded-lg shadow mb-6">
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h2 className="text-lg font-semibold">Report Summary</h2>
<div className="space-x-2">
<button
onClick={handleExportJSON}
className="bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 text-sm"
>
Export JSON
</button>
<button
onClick={handleExportCSV}
className="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 text-sm"
>
Export CSV
</button>
</div>
</div>
<div className="p-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div>
<p className="text-sm text-gray-500">Total Transactions</p>
<p className="text-2xl font-bold text-gray-900">{report.summary.totalTransactions}</p>
</div>
<div>
<p className="text-sm text-gray-500">Total Amount (USD)</p>
<p className="text-2xl font-bold text-gray-900">
${report.summary.totalUsdEquivalent.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
</div>
<div>
<p className="text-sm text-gray-500">Inbound</p>
<p className="text-xl font-semibold text-green-600">
{report.summary.inboundCount} ({report.summary.inboundAmount.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})})
</p>
</div>
<div>
<p className="text-sm text-gray-500">Outbound</p>
<p className="text-xl font-semibold text-red-600">
{report.summary.outboundCount} ({report.summary.outboundAmount.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})})
</p>
</div>
</div>
<div className="border-t pt-4">
<p className="text-sm text-gray-600">
<span className="font-medium">Reporting Required:</span> {report.summary.reportingRequiredCount}{' '}
transactions
</p>
<p className="text-sm text-gray-600 mt-1">
<span className="font-medium">Total IOF:</span>{' '}
{report.summary.totalIOF.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
BRL
</p>
</div>
</div>
</div>
)}
{/* Report History Placeholder */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold">Report History</h2>
</div>
<div className="p-6">
<p className="text-gray-500 text-center py-4">
Report history will be displayed here once reports are saved
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,500 @@
import React from 'react';
import React, { useState } from 'react';
import { useTransactionStore } from '../stores/transactionStore';
import type { Transaction } from '@brazil-swift-ops/types';
import { calculateTransactionEOUplift, getDefaultConverter } from '@brazil-swift-ops/utils';
import {
validateAmount,
validateCurrency,
validateName,
validateAddress,
validateTaxId,
validateAccountNumber,
validatePurposeOfPayment,
sanitizeString,
} from '@brazil-swift-ops/utils';
import LoadingSpinner from '../components/LoadingSpinner';
export default function TransactionsPage() {
const { transactions, results, addTransaction, evaluateTransaction } = useTransactionStore();
const [isProcessing, setIsProcessing] = useState(false);
const [formData, setFormData] = useState<Partial<Transaction>>({
direction: 'outbound',
currency: 'USD',
amount: 0,
status: 'pending',
orderingCustomer: {
name: '',
country: 'BR',
},
beneficiary: {
name: '',
country: 'BR',
},
});
const [errors, setErrors] = useState<Record<string, string>>({});
const converter = getDefaultConverter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
setIsProcessing(true);
try {
// Validate form
const validationErrors: Record<string, string> = {};
const amountValidation = validateAmount(formData.amount || 0);
if (!amountValidation.valid) {
validationErrors.amount = amountValidation.errors[0];
}
const currencyValidation = validateCurrency(formData.currency || '');
if (!currencyValidation.valid) {
validationErrors.currency = currencyValidation.errors[0];
}
const orderingNameValidation = validateName(
formData.orderingCustomer?.name,
'Ordering Customer Name'
);
if (!orderingNameValidation.valid) {
validationErrors.orderingName = orderingNameValidation.errors[0];
}
const beneficiaryNameValidation = validateName(
formData.beneficiary?.name,
'Beneficiary Name'
);
if (!beneficiaryNameValidation.valid) {
validationErrors.beneficiaryName = beneficiaryNameValidation.errors[0];
}
const orderingTaxIdValidation = validateTaxId(
formData.orderingCustomer?.taxId,
'Ordering Customer Tax ID'
);
if (!orderingTaxIdValidation.valid) {
validationErrors.orderingTaxId = orderingTaxIdValidation.errors[0];
}
const beneficiaryTaxIdValidation = validateTaxId(
formData.beneficiary?.taxId,
'Beneficiary Tax ID'
);
if (!beneficiaryTaxIdValidation.valid) {
validationErrors.beneficiaryTaxId = beneficiaryTaxIdValidation.errors[0];
}
const accountValidation = validateAccountNumber(
formData.beneficiary?.accountNumber,
formData.beneficiary?.iban
);
if (!accountValidation.valid) {
validationErrors.beneficiaryAccount = accountValidation.errors[0];
}
const purposeValidation = validatePurposeOfPayment(formData.purposeOfPayment);
if (!purposeValidation.valid) {
validationErrors.purpose = purposeValidation.errors[0];
}
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
setIsProcessing(false);
return;
}
// Create transaction
const transaction: Transaction = {
id: `TXN-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
direction: formData.direction || 'outbound',
amount: Number(formData.amount) || 0,
currency: formData.currency || 'USD',
orderingCustomer: {
name: sanitizeString(formData.orderingCustomer?.name || ''),
address: formData.orderingCustomer?.address
? sanitizeString(formData.orderingCustomer.address)
: undefined,
city: formData.orderingCustomer?.city
? sanitizeString(formData.orderingCustomer.city)
: undefined,
country: formData.orderingCustomer?.country || 'BR',
taxId: formData.orderingCustomer?.taxId,
},
beneficiary: {
name: sanitizeString(formData.beneficiary?.name || ''),
address: formData.beneficiary?.address
? sanitizeString(formData.beneficiary.address)
: undefined,
city: formData.beneficiary?.city
? sanitizeString(formData.beneficiary.city)
: undefined,
country: formData.beneficiary?.country || 'BR',
taxId: formData.beneficiary?.taxId,
accountNumber: formData.beneficiary?.accountNumber,
iban: formData.beneficiary?.iban,
},
purposeOfPayment: sanitizeString(formData.purposeOfPayment || ''),
fxContractId: formData.fxContractId,
status: 'pending',
createdAt: new Date(),
updatedAt: new Date(),
};
// Calculate USD equivalent
transaction.usdEquivalent = converter.getUSDEquivalent(
transaction.amount,
transaction.currency
);
// Add and evaluate transaction
addTransaction(transaction);
// Reset form
setFormData({
direction: 'outbound',
currency: 'USD',
amount: 0,
status: 'pending',
orderingCustomer: {
name: '',
country: 'BR',
},
beneficiary: {
name: '',
country: 'BR',
},
});
} catch (error) {
console.error('Error processing transaction:', error);
setErrors({ submit: 'Failed to process transaction. Please try again.' });
} finally {
setIsProcessing(false);
}
};
return (
<div className="px-4 py-6 sm:px-0">
<h1 className="text-2xl font-bold mb-4">TransactionsPage</h1>
<p className="text-gray-600">TransactionsPage interface</p>
<h1 className="text-2xl font-bold mb-6">Transactions</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Transaction Entry Form */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold">New Transaction</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Direction
</label>
<select
value={formData.direction}
onChange={(e) =>
setFormData({ ...formData, direction: e.target.value as 'inbound' | 'outbound' })
}
className="w-full border border-gray-300 rounded-md px-3 py-2"
>
<option value="outbound">Outbound</option>
<option value="inbound">Inbound</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Amount *
</label>
<input
type="number"
step="0.01"
value={formData.amount || ''}
onChange={(e) => setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })}
className={`w-full border rounded-md px-3 py-2 ${
errors.amount ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.amount && <p className="mt-1 text-sm text-red-600">{errors.amount}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Currency *
</label>
<select
value={formData.currency}
onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
className={`w-full border rounded-md px-3 py-2 ${
errors.currency ? 'border-red-500' : 'border-gray-300'
}`}
required
>
<option value="USD">USD</option>
<option value="BRL">BRL</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
</select>
{errors.currency && <p className="mt-1 text-sm text-red-600">{errors.currency}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Ordering Customer Name *
</label>
<input
type="text"
value={formData.orderingCustomer?.name || ''}
onChange={(e) =>
setFormData({
...formData,
orderingCustomer: {
...formData.orderingCustomer,
name: e.target.value,
country: formData.orderingCustomer?.country || 'BR',
},
})
}
className={`w-full border rounded-md px-3 py-2 ${
errors.orderingName ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.orderingName && (
<p className="mt-1 text-sm text-red-600">{errors.orderingName}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Ordering Customer Tax ID (CPF/CNPJ) *
</label>
<input
type="text"
value={formData.orderingCustomer?.taxId || ''}
onChange={(e) =>
setFormData({
...formData,
orderingCustomer: {
...formData.orderingCustomer,
taxId: e.target.value,
country: formData.orderingCustomer?.country || 'BR',
},
})
}
className={`w-full border rounded-md px-3 py-2 ${
errors.orderingTaxId ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.orderingTaxId && (
<p className="mt-1 text-sm text-red-600">{errors.orderingTaxId}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Beneficiary Name *
</label>
<input
type="text"
value={formData.beneficiary?.name || ''}
onChange={(e) =>
setFormData({
...formData,
beneficiary: {
...formData.beneficiary,
name: e.target.value,
country: formData.beneficiary?.country || 'BR',
},
})
}
className={`w-full border rounded-md px-3 py-2 ${
errors.beneficiaryName ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.beneficiaryName && (
<p className="mt-1 text-sm text-red-600">{errors.beneficiaryName}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Beneficiary Tax ID (CPF/CNPJ) *
</label>
<input
type="text"
value={formData.beneficiary?.taxId || ''}
onChange={(e) =>
setFormData({
...formData,
beneficiary: {
...formData.beneficiary,
taxId: e.target.value,
country: formData.beneficiary?.country || 'BR',
},
})
}
className={`w-full border rounded-md px-3 py-2 ${
errors.beneficiaryTaxId ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.beneficiaryTaxId && (
<p className="mt-1 text-sm text-red-600">{errors.beneficiaryTaxId}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Beneficiary Account Number or IBAN *
</label>
<input
type="text"
value={formData.beneficiary?.accountNumber || ''}
onChange={(e) =>
setFormData({
...formData,
beneficiary: {
...formData.beneficiary,
accountNumber: e.target.value,
country: formData.beneficiary?.country || 'BR',
},
})
}
className={`w-full border rounded-md px-3 py-2 ${
errors.beneficiaryAccount ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.beneficiaryAccount && (
<p className="mt-1 text-sm text-red-600">{errors.beneficiaryAccount}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Purpose of Payment *
</label>
<textarea
value={formData.purposeOfPayment || ''}
onChange={(e) => setFormData({ ...formData, purposeOfPayment: e.target.value })}
rows={3}
className={`w-full border rounded-md px-3 py-2 ${
errors.purpose ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.purpose && <p className="mt-1 text-sm text-red-600">{errors.purpose}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
FX Contract ID (optional)
</label>
<input
type="text"
value={formData.fxContractId || ''}
onChange={(e) => setFormData({ ...formData, fxContractId: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
{errors.submit && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded">
{errors.submit}
</div>
)}
<button
type="submit"
disabled={isProcessing}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? <LoadingSpinner size="sm" message="Processing..." /> : 'Submit Transaction'}
</button>
</form>
</div>
{/* Transactions Table */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold">Transaction List</h2>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
USD Eq.
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
E&O Uplift
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Decision
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{transactions.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-4 text-center text-gray-500">
No transactions yet
</td>
</tr>
) : (
transactions.map((txn) => {
const result = results.get(txn.id);
const usdEq = txn.usdEquivalent || converter.getUSDEquivalent(txn.amount, txn.currency);
const eoUplift = calculateTransactionEOUplift(
txn.id,
txn.amount,
txn.currency,
0.10,
usdEq
);
const decisionColor =
result?.overallDecision === 'Allow'
? 'text-green-600'
: result?.overallDecision === 'Hold'
? 'text-yellow-600'
: 'text-red-600';
return (
<tr key={txn.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{txn.id.substring(0, 12)}...
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{txn.amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}{' '}
{txn.currency}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
${usdEq.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
${eoUplift.upliftAmount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
<td className={`px-6 py-4 whitespace-nowrap text-sm font-medium ${decisionColor}`}>
{result?.overallDecision || 'Pending'}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}