UNPKG

ecfr-navigator

Version:

A lightweight, reusable Vue 3 component with Pinia integration for navigating hierarchical eCFR-style content in existing Vue applications.

627 lines (566 loc) 17.7 kB
import { ref, computed, reactive, watch } from 'vue'; import { useFormValidation } from './useFormValidation.js'; /** * Complete loan application flow management * Handles multi-step forms, progress tracking, and data persistence */ export function useLoanFlow() { // Flow configuration const steps = ref([ { id: 'personal', title: 'Personal Information', description: 'Basic contact and identity details', icon: 'fas fa-user', required: true }, { id: 'employment', title: 'Employment Details', description: 'Current employment and income information', icon: 'fas fa-briefcase', required: true }, { id: 'financial', title: 'Financial Profile', description: 'Assets, debts, and financial situation', icon: 'fas fa-chart-line', required: true }, { id: 'loan', title: 'Loan Configuration', description: 'Loan amount, purpose, and terms', icon: 'fas fa-money-bill-wave', required: true }, { id: 'documents', title: 'Document Upload', description: 'Required verification documents', icon: 'fas fa-file-upload', required: false }, { id: 'review', title: 'Review & Submit', description: 'Final review before submission', icon: 'fas fa-check-circle', required: true } ]); const currentStep = ref(0); const completedSteps = ref(new Set()); const stepErrors = ref(new Set()); // Application data structure const initialData = { personal: { firstName: '', lastName: '', middleName: '', suffix: '', ssn: '', dateOfBirth: '', email: '', phone: '', alternatePhone: '', maritalStatus: '', dependents: 0 }, address: { street: '', apartment: '', city: '', state: '', zipCode: '', residencyType: '', monthsAtAddress: 0, previousAddress: { street: '', city: '', state: '', zipCode: '' } }, employment: { status: '', employer: '', jobTitle: '', workPhone: '', supervisor: '', monthsEmployed: 0, grossMonthlyIncome: 0, otherIncome: 0, otherIncomeSource: '', previousEmployer: { name: '', monthsEmployed: 0 } }, financial: { bankName: '', accountType: '', monthlyRent: 0, monthlyDebtPayments: 0, creditScore: 0, assets: { checking: 0, savings: 0, investments: 0, realEstate: 0, vehicle: 0, other: 0 }, debts: { creditCards: 0, autoLoans: 0, studentLoans: 0, mortgage: 0, other: 0 } }, loan: { amount: 15000, purpose: '', purposeDetails: '', term: 48, preferredPaymentDate: '', collateral: false, collateralDescription: '', coBorrower: false }, coBorrower: { firstName: '', lastName: '', ssn: '', phone: '', relationship: '', income: 0 }, documents: { idVerification: [], incomeVerification: [], addressVerification: [], bankStatements: [], additional: [] }, agreements: { terms: false, privacy: false, creditCheck: false, electronicSignature: false, marketing: false }, metadata: { applicationId: '', startedAt: new Date(), lastSaved: null, referralSource: '', campaignId: '', ipAddress: '', userAgent: '' } }; // Form validation setup const { formData, setValidationRules, setAsyncValidators, validateField, validateForm, setFieldValue, touchField, getFieldError, hasFieldError, isValid, hasErrors, handleSubmit, validators, resetForm } = useFormValidation(initialData); // Validation rules for each step const setupValidationRules = () => { setValidationRules({ // Personal Information 'personal.firstName': [ validators.required(), validators.minLength(2), validators.maxLength(50), validators.pattern(/^[A-Za-z\s'-]+$/, 'Only letters, spaces, hyphens, and apostrophes allowed') ], 'personal.lastName': [ validators.required(), validators.minLength(2), validators.maxLength(50), validators.pattern(/^[A-Za-z\s'-]+$/, 'Only letters, spaces, hyphens, and apostrophes allowed') ], 'personal.ssn': [ validators.required(), validators.ssn() ], 'personal.dateOfBirth': [ validators.required(), validators.custom((value) => { if (!value) return 'Date of birth is required'; const today = new Date(); const birthDate = new Date(value); const age = (today - birthDate) / (365.25 * 24 * 60 * 60 * 1000); if (age < 18) return 'You must be at least 18 years old'; if (age > 100) return 'Please enter a valid date of birth'; return true; }) ], 'personal.email': [ validators.required(), validators.email() ], 'personal.phone': [ validators.required(), validators.phone() ], 'personal.maritalStatus': [ validators.required() ], // Address Information 'address.street': [ validators.required(), validators.minLength(5), validators.maxLength(100) ], 'address.city': [ validators.required(), validators.minLength(2), validators.maxLength(50), validators.pattern(/^[A-Za-z\s'-]+$/, 'Only letters, spaces, hyphens, and apostrophes allowed') ], 'address.state': [ validators.required() ], 'address.zipCode': [ validators.required(), validators.pattern(/^\d{5}(-\d{4})?$/, 'Please enter a valid ZIP code') ], 'address.residencyType': [ validators.required() ], 'address.monthsAtAddress': [ validators.required(), validators.minValue(0) ], // Employment Information 'employment.status': [ validators.required() ], 'employment.employer': [ validators.required(), validators.minLength(2), validators.maxLength(100) ], 'employment.jobTitle': [ validators.required(), validators.minLength(2), validators.maxLength(100) ], 'employment.monthsEmployed': [ validators.required(), validators.minValue(0) ], 'employment.grossMonthlyIncome': [ validators.required(), validators.minValue(1000, 'Monthly income must be at least $1,000') ], // Financial Information 'financial.bankName': [ validators.required(), validators.minLength(2) ], 'financial.accountType': [ validators.required() ], 'financial.monthlyRent': [ validators.required(), validators.minValue(0) ], 'financial.monthlyDebtPayments': [ validators.required(), validators.minValue(0) ], // Loan Information 'loan.amount': [ validators.required(), validators.minValue(1000, 'Minimum loan amount is $1,000'), validators.maxValue(50000, 'Maximum loan amount is $50,000') ], 'loan.purpose': [ validators.required() ], 'loan.term': [ validators.required(), validators.minValue(12), validators.maxValue(84) ], // Agreements 'agreements.terms': [ validators.custom((value) => value === true || 'You must accept the terms and conditions') ], 'agreements.privacy': [ validators.custom((value) => value === true || 'You must accept the privacy policy') ], 'agreements.creditCheck': [ validators.custom((value) => value === true || 'You must authorize the credit check') ], 'agreements.electronicSignature': [ validators.custom((value) => value === true || 'You must consent to electronic signature') ] }); // Async validators for real-time validation setAsyncValidators({ 'personal.email': async (email) => { if (!email) return true; // Simulate email uniqueness check await new Promise(resolve => setTimeout(resolve, 300)); if (email.includes('test@blocked.com')) { return 'This email address is not allowed'; } return true; }, 'personal.ssn': async (ssn) => { if (!ssn) return true; // Simulate SSN validation await new Promise(resolve => setTimeout(resolve, 500)); if (ssn === '123-45-6789') { return 'This SSN is invalid'; } return true; } }); }; // Initialize validation rules setupValidationRules(); // Step navigation const canNavigateToStep = (stepIndex) => { if (stepIndex === 0) return true; return completedSteps.value.has(stepIndex - 1) || completedSteps.value.has(stepIndex); }; const goToStep = (stepIndex) => { if (canNavigateToStep(stepIndex)) { currentStep.value = stepIndex; } }; const nextStep = async () => { const isStepValid = await validateCurrentStep(); if (isStepValid) { completedSteps.value.add(currentStep.value); stepErrors.value.delete(currentStep.value); if (currentStep.value < steps.value.length - 1) { currentStep.value++; } await saveProgress(); } else { stepErrors.value.add(currentStep.value); } }; const previousStep = () => { if (currentStep.value > 0) { currentStep.value--; } }; // Step validation const validateCurrentStep = async () => { const step = steps.value[currentStep.value]; const fieldsToValidate = getStepFields(step.id); const validationPromises = fieldsToValidate.map(field => validateField(field)); const results = await Promise.all(validationPromises); return results.every(result => result === true); }; const getStepFields = (stepId) => { const fieldMap = { personal: [ 'personal.firstName', 'personal.lastName', 'personal.ssn', 'personal.dateOfBirth', 'personal.email', 'personal.phone', 'personal.maritalStatus', 'address.street', 'address.city', 'address.state', 'address.zipCode', 'address.residencyType', 'address.monthsAtAddress' ], employment: [ 'employment.status', 'employment.employer', 'employment.jobTitle', 'employment.monthsEmployed', 'employment.grossMonthlyIncome' ], financial: [ 'financial.bankName', 'financial.accountType', 'financial.monthlyRent', 'financial.monthlyDebtPayments' ], loan: [ 'loan.amount', 'loan.purpose', 'loan.term' ], documents: [], // Documents are handled separately review: [ 'agreements.terms', 'agreements.privacy', 'agreements.creditCheck', 'agreements.electronicSignature' ] }; return fieldMap[stepId] || []; }; // Progress calculation const progressPercentage = computed(() => { return Math.round((completedSteps.value.size / steps.value.length) * 100); }); const currentStepInfo = computed(() => { return steps.value[currentStep.value]; }); const isFirstStep = computed(() => currentStep.value === 0); const isLastStep = computed(() => currentStep.value === steps.value.length - 1); // Auto-save functionality const saveProgress = async () => { try { formData.metadata.lastSaved = new Date(); // Simulate API call to save progress const savedData = { applicationId: formData.metadata.applicationId || generateApplicationId(), currentStep: currentStep.value, completedSteps: Array.from(completedSteps.value), formData: JSON.parse(JSON.stringify(formData)), timestamp: new Date().toISOString() }; // Save to localStorage as backup localStorage.setItem('loanApplicationProgress', JSON.stringify(savedData)); return savedData; } catch (error) { console.error('Error saving progress:', error); throw error; } }; // Load saved progress const loadProgress = () => { try { const saved = localStorage.getItem('loanApplicationProgress'); if (saved) { const savedData = JSON.parse(saved); // Restore form data Object.assign(formData, savedData.formData); // Restore progress currentStep.value = savedData.currentStep || 0; completedSteps.value = new Set(savedData.completedSteps || []); return true; } } catch (error) { console.error('Error loading progress:', error); } return false; }; // Application submission const submitApplication = async () => { const isFormValid = await validateForm(); if (!isFormValid) { // Find first step with errors and navigate to it const firstErrorStep = steps.value.findIndex(step => { const stepFields = getStepFields(step.id); return stepFields.some(field => hasFieldError(field)); }); if (firstErrorStep !== -1) { currentStep.value = firstErrorStep; } return { success: false, errors: 'Please correct all errors before submitting' }; } return handleSubmit(async (data) => { // Simulate API submission await new Promise(resolve => setTimeout(resolve, 2000)); // Clear saved progress on successful submission localStorage.removeItem('loanApplicationProgress'); return { applicationId: data.metadata.applicationId, submittedAt: new Date().toISOString(), status: 'submitted', estimatedDecisionTime: '24-48 hours' }; }); }; // Utility functions const generateApplicationId = () => { return 'APP-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9).toUpperCase(); }; const calculateDebtToIncome = computed(() => { const monthlyIncome = formData.employment.grossMonthlyIncome + formData.employment.otherIncome; const monthlyDebt = formData.financial.monthlyDebtPayments; if (monthlyIncome === 0) return 0; return Math.round((monthlyDebt / monthlyIncome) * 100); }); const estimatedMonthlyPayment = computed(() => { const principal = formData.loan.amount; const termMonths = formData.loan.term; const estimatedRate = getEstimatedInterestRate(); if (principal && termMonths && estimatedRate) { const monthlyRate = estimatedRate / 12; const payment = principal * (monthlyRate * Math.pow(1 + monthlyRate, termMonths)) / (Math.pow(1 + monthlyRate, termMonths) - 1); return Math.round(payment * 100) / 100; } return 0; }); const getEstimatedInterestRate = () => { const creditScore = formData.financial.creditScore || 650; const debtToIncome = calculateDebtToIncome.value; let baseRate = 0.15; // 15% // Adjust based on credit score if (creditScore >= 750) baseRate -= 0.03; else if (creditScore >= 700) baseRate -= 0.02; else if (creditScore >= 650) baseRate -= 0.01; else if (creditScore < 600) baseRate += 0.03; // Adjust based on debt-to-income ratio if (debtToIncome > 40) baseRate += 0.02; else if (debtToIncome < 20) baseRate -= 0.01; return Math.max(0.08, Math.min(0.25, baseRate)); // Cap between 8% and 25% }; // Auto-save on data changes watch(() => formData, () => { // Debounced auto-save clearTimeout(window.autoSaveTimeout); window.autoSaveTimeout = setTimeout(() => { saveProgress(); }, 1000); }, { deep: true }); // Initialize application ID if (!formData.metadata.applicationId) { formData.metadata.applicationId = generateApplicationId(); } return { // State steps: computed(() => steps.value), currentStep: computed(() => currentStep.value), currentStepInfo, completedSteps: computed(() => completedSteps.value), stepErrors: computed(() => stepErrors.value), progressPercentage, isFirstStep, isLastStep, // Form data and validation formData, setFieldValue, touchField, getFieldError, hasFieldError, isValid, hasErrors, // Navigation canNavigateToStep, goToStep, nextStep, previousStep, // Validation validateCurrentStep, validateForm, // Persistence saveProgress, loadProgress, // Submission submitApplication, // Calculations calculateDebtToIncome, estimatedMonthlyPayment, getEstimatedInterestRate, // Utilities resetForm, generateApplicationId }; }