Files
NeoCom/FRONTEND_INTEGRATION_GUIDE.md
2026-04-09 20:36:10 -07:00

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/treasury page

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:

  1. Navigate to any AR invoice
  2. Click "Record Payment" button in Actions section
  3. Fill in the payment form:
    • Amount (required)
    • Date (required)
    • Method (required: ACH, WIRE, CHECK, etc.)
  4. Click "Record Payment"
  5. 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

  1. Batch Imports: All transactions imported in single API call
  2. File Size: Limit CSV files to 10MB (handles 50k+ transactions)
  3. Preview Table: Paginate if more than 1000 transactions
  4. Debouncing: Debounce file input changes
  5. 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