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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
23
apps/web/src/components/LoadingSpinner.tsx
Normal file
23
apps/web/src/components/LoadingSpinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user