17 KiB
17 KiB
Frontend Integration Guide
Bank Transaction CSV Import UI
Where to Add the UI
Create a new page or add to an existing bank management section:
- Suggested location:
/src/app/(dashboard)/finance/treasury/bank-transactions/import - Or: Add as a tab in
/finance/treasurypage
Component Structure
'use client';
import { useState } from 'react';
import { Upload, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
interface ParsedTransaction {
transactionDate: string;
description: string;
transactionType: 'DEBIT' | 'CREDIT';
amount: number;
}
export default function BankTransactionImportPage() {
const [file, setFile] = useState<File | null>(null);
const [parsing, setParsing] = useState(false);
const [parseError, setParseError] = useState<string | null>(null);
const [parsedTransactions, setParsedTransactions] = useState<ParsedTransaction[]>([]);
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [bankAccountId, setBankAccountId] = useState('');
// Step 1: Parse CSV
const handleFileParse = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (!selectedFile) return;
setFile(selectedFile);
setParsing(true);
setParseError(null);
try {
const formData = new FormData();
formData.append('file', selectedFile);
const response = await fetch('/api/bank-transactions/parse-csv', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json();
setParseError(error.error || 'Failed to parse CSV');
setParsedTransactions([]);
return;
}
const data = await response.json();
setParsedTransactions(data.transactions);
} catch (error) {
setParseError(
error instanceof Error ? error.message : 'Error parsing CSV file'
);
setParsedTransactions([]);
} finally {
setParsing(false);
}
};
// Step 2: Import transactions
const handleImport = async () => {
if (!bankAccountId) {
setImportError('Please select a bank account');
return;
}
if (parsedTransactions.length === 0) {
setImportError('No transactions to import');
return;
}
setImporting(true);
setImportError(null);
try {
const response = await fetch('/api/bank-transactions/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bankAccountId,
transactions: parsedTransactions,
}),
});
if (!response.ok) {
const error = await response.json();
setImportError(error.error || 'Failed to import transactions');
return;
}
const data = await response.json();
// Success - reset form and show success message
setFile(null);
setParsedTransactions([]);
setBankAccountId('');
// Show success toast/notification
console.log(`Successfully imported ${data.imported} transactions`);
} catch (error) {
setImportError(
error instanceof Error ? error.message : 'Error importing transactions'
);
} finally {
setImporting(false);
}
};
return (
<div className="space-y-6">
{/* File Upload Section */}
<div className="bg-white rounded-lg shadow-sm p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
Upload Bank Transactions
</h2>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Select CSV File
</label>
<div className="relative">
<input
type="file"
accept=".csv"
onChange={handleFileParse}
disabled={parsing}
className="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4 file:rounded-lg
file:border-0 file:text-sm file:font-semibold
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100"
/>
</div>
<p className="text-xs text-gray-500 mt-2">
Supported formats: CSV with columns for Date, Description, and Amount
(or Debit/Credit)
</p>
</div>
{parsing && (
<div className="flex items-center gap-2 text-blue-600 mb-4">
<Loader2 size={18} className="animate-spin" />
Parsing CSV...
</div>
)}
{parseError && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg mb-4">
<p className="font-medium">Error parsing CSV</p>
<p className="text-sm">{parseError}</p>
</div>
)}
</div>
{/* Preview Section */}
{parsedTransactions.length > 0 && (
<div className="bg-white rounded-lg shadow-sm p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
Preview: {parsedTransactions.length} Transactions
</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-4 py-3 text-left font-semibold text-gray-700">
Date
</th>
<th className="px-4 py-3 text-left font-semibold text-gray-700">
Description
</th>
<th className="px-4 py-3 text-center font-semibold text-gray-700">
Type
</th>
<th className="px-4 py-3 text-right font-semibold text-gray-700">
Amount
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{parsedTransactions.map((tx, idx) => (
<tr key={idx} className="hover:bg-gray-50">
<td className="px-4 py-3 text-gray-900">{tx.transactionDate}</td>
<td className="px-4 py-3 text-gray-700">
{tx.description.length > 50
? tx.description.substring(0, 50) + '...'
: tx.description}
</td>
<td className="px-4 py-3 text-center">
<span
className={`px-2 py-1 rounded text-xs font-semibold ${
tx.transactionType === 'DEBIT'
? 'bg-red-100 text-red-800'
: 'bg-green-100 text-green-800'
}`}
>
{tx.transactionType}
</span>
</td>
<td className="px-4 py-3 text-right font-medium text-gray-900">
${tx.amount.toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Bank Account Selection */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Bank Account *
</label>
<select
value={bankAccountId}
onChange={(e) => setBankAccountId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Choose a bank account...</option>
<option value="account-id-1">Checking - 4567</option>
<option value="account-id-2">Savings - 8901</option>
{/* Fetch these from /api/bank-accounts */}
</select>
</div>
{importError && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg mb-4">
<p className="font-medium">Import Error</p>
<p className="text-sm">{importError}</p>
</div>
)}
<div className="flex gap-3">
<button
onClick={() => {
setFile(null);
setParsedTransactions([]);
setParseError(null);
}}
className="px-4 py-2 bg-gray-200 text-gray-800 font-semibold rounded-lg
hover:bg-gray-300 transition-colors"
>
Clear
</button>
<button
onClick={handleImport}
disabled={importing || !bankAccountId}
className="px-4 py-2 bg-blue-600 text-white font-semibold rounded-lg
hover:bg-blue-700 transition-colors disabled:opacity-50
flex items-center gap-2"
>
{importing && <Loader2 size={16} className="animate-spin" />}
{importing ? 'Importing...' : `Import ${parsedTransactions.length} Transactions`}
</button>
</div>
</div>
)}
</div>
);
}
Payment Recording UI
Already Implemented
The payment recording modal is already built into the AR Invoice Detail page:
Location: /src/app/(dashboard)/finance/ar/invoice/[invoiceId]/page.tsx
How to Use:
- Navigate to any AR invoice
- Click "Record Payment" button in Actions section
- Fill in the payment form:
- Amount (required)
- Date (required)
- Method (required: ACH, WIRE, CHECK, etc.)
- Click "Record Payment"
- Page refreshes to show updated invoice status
Direct Navigation: Users can also navigate directly to the payment form:
/finance/ar/invoice/{invoiceId}?action=payment
This auto-opens the payment modal.
Bank Account Selection Component
Reusable Component for Bank Account Dropdown
import { useState, useEffect } from 'react';
interface BankAccount {
id: string;
accountName: string;
accountNumberMasked: string;
bankName: string;
currentBalance: number;
}
interface BankAccountSelectProps {
value: string;
onChange: (value: string) => void;
required?: boolean;
disabled?: boolean;
}
export function BankAccountSelect({
value,
onChange,
required = false,
disabled = false,
}: BankAccountSelectProps) {
const [accounts, setAccounts] = useState<BankAccount[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchAccounts = async () => {
try {
const response = await fetch('/api/bank-accounts');
if (response.ok) {
const data = await response.json();
setAccounts(data);
}
} catch (error) {
console.error('Failed to fetch bank accounts', error);
} finally {
setLoading(false);
}
};
fetchAccounts();
}, []);
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled || loading}
className="w-full px-3 py-2 border border-gray-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-blue-500
disabled:bg-gray-100 disabled:cursor-not-allowed"
>
<option value="">
{loading ? 'Loading accounts...' : 'Select a bank account...'}
</option>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.accountName} • {account.accountNumberMasked} •{' '}
{account.bankName}
</option>
))}
</select>
);
}
Form Validation Helpers
Utility Functions for Form Validation
export function validateAmount(value: string | number): string | null {
const amount = parseFloat(String(value));
if (isNaN(amount)) {
return 'Amount must be a valid number';
}
if (amount <= 0) {
return 'Amount must be greater than 0';
}
return null;
}
export function validateDate(value: string): string | null {
const date = new Date(value);
if (isNaN(date.getTime())) {
return 'Invalid date format';
}
// Don't allow future dates
if (date > new Date()) {
return 'Date cannot be in the future';
}
return null;
}
export function validateFileSize(
file: File,
maxSizeMB: number = 10
): string | null {
const maxBytes = maxSizeMB * 1024 * 1024;
if (file.size > maxBytes) {
return `File size must be less than ${maxSizeMB}MB`;
}
return null;
}
export function validateCSVFile(file: File): string | null {
if (!file.name.endsWith('.csv')) {
return 'File must be a CSV file (.csv)';
}
const sizeError = validateFileSize(file, 10);
if (sizeError) return sizeError;
return null;
}
API Integration Patterns
Fetch Bank Accounts
async function fetchBankAccounts() {
const response = await fetch('/api/bank-accounts');
if (!response.ok) throw new Error('Failed to fetch accounts');
return response.json();
}
Parse CSV
async function parseCSV(file: File) {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/bank-transactions/parse-csv', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
return response.json();
}
Import Transactions
async function importTransactions(
bankAccountId: string,
transactions: any[]
) {
const response = await fetch('/api/bank-transactions/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bankAccountId,
transactions,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
return response.json();
}
Record Payment
async function recordPayment(
customerId: string,
invoiceId: string,
paymentData: {
paymentDate: string;
paymentMethod: string;
paymentAmount: number;
}
) {
const response = await fetch('/api/payments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customerId,
invoiceId,
...paymentData,
paymentAllocations: [
{
invoiceId,
allocatedAmount: paymentData.paymentAmount,
},
],
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
return response.json();
}
Toast/Notification Integration
Add success/error notifications to your pages:
import { useState } from 'react';
function useNotification() {
const [notification, setNotification] = useState<{
type: 'success' | 'error' | 'info';
message: string;
} | null>(null);
return {
notification,
showSuccess: (message: string) => {
setNotification({ type: 'success', message });
setTimeout(() => setNotification(null), 3000);
},
showError: (message: string) => {
setNotification({ type: 'error', message });
setTimeout(() => setNotification(null), 5000);
},
showInfo: (message: string) => {
setNotification({ type: 'info', message });
setTimeout(() => setNotification(null), 3000);
},
};
}
// Usage in component
const { notification, showSuccess } = useNotification();
const handleImport = async () => {
try {
const result = await importTransactions(bankAccountId, transactions);
showSuccess(`Successfully imported ${result.imported} transactions`);
} catch (error) {
showError(error instanceof Error ? error.message : 'Import failed');
}
};
Testing Checklist
- CSV upload accepts .csv files
- CSV parse shows correct transaction count
- CSV preview shows transactions with correct types and amounts
- Bank account dropdown populates from API
- Import button is disabled until account is selected
- Import shows loading state during submission
- Import success message appears
- Payment form validates amount > 0
- Payment form validates date not in future
- Payment method dropdown has all options
- Payment submit shows loading state
- Payment success refreshes invoice
- Error messages display with helpful text
- Modal closes on cancel or success
- Query parameter ?action=payment opens modal
Performance Considerations
- Batch Imports: All transactions imported in single API call
- File Size: Limit CSV files to 10MB (handles 50k+ transactions)
- Preview Table: Paginate if more than 1000 transactions
- Debouncing: Debounce file input changes
- Caching: Cache bank accounts list with SWR or React Query
Accessibility Notes
- Form labels properly associated with inputs
- Error messages clearly displayed in red
- Loading states show spinner with text
- Keyboard navigation: Tab through form
- Screen reader support: ARIA labels on all inputs