diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..118463c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,39 @@ +# Paycheck Planner Copilot Instructions + +## PR Review Expectations + +When asked to review a PR, pull request diff, or changeset, default to a PR review mindset focused on merge risk, regressions, and missing validation. This project is an Electron + React + TypeScript desktop app built with Vite, so reviews should favor focused root-cause fixes that preserve existing architecture, shared primitives, and established UX patterns unless the change explicitly justifies something broader. + +- Prioritize findings over summaries. +- Focus first on bugs, behavioral regressions, data loss risks, invalid file acceptance, persistence issues, and missing or insufficient tests. +- Call out platform-specific risks for Electron, especially file handling, keyboard shortcuts, native integrations, and packaging/signing behavior. +- Identify when a change duplicates logic that should live in a shared utility, hook, service, or component. +- Flag changes that bypass existing shared UI primitives such as `Modal`, `Button`, `Toast`, and other shared form or layout components without a clear reason. +- Prefer fixes at the service or state-management boundary when the PR only patches symptoms in the UI. +- Flag styling changes that drift from the current visual language or introduce broad CSS selectors instead of component-scoped selectors. +- Flag globally scoped CSS that risks collisions across screens. +- Treat file operations as safety-critical. Call out regressions in moved-file relink behavior, acceptance of non-plan files such as settings exports, or save flows that can target stale or missing file paths. +- Preserve the existing two-tier keyboard shortcut model: Electron/global shortcuts for app-level reliability and React capture-phase listeners as renderer fallback. Treat bubbling-only handlers for critical shortcuts as a review risk. +- Treat missing test coverage for changed `src/services` or `src/utils` files as a review finding unless there is a strong reason not to add tests. +- Treat a new file in `src/services` or `src/utils` without a corresponding `*.test.ts` file as a review finding. +- Treat changed logic in an existing `src/services` or `src/utils` file without corresponding updates to its existing tests as a review finding. +- Check that new behavior added to existing service or util modules is explicitly exercised by tests, not only covered indirectly by unrelated suites. +- Prefer mock-based tests for services that touch Electron APIs, file system behavior, or keychain behavior. +- Flag dependency or build changes in the package.json or Vite config that are not clearly justified by the PR description or that introduce new risks without clear mitigation. +- If a PR includes a dependency update, check the changelog for that dependency for any breaking changes or new risks that should be called out in the review. + +## Review Response Format + +- Present findings first, ordered by severity. +- Include concrete file references whenever possible. +- Keep summaries brief and secondary. +- When it makes sense, give a brief explanation of the root cause or reasoning behind a finding, but avoid long-winded explanations when the issue is straightforward. +- For each finding, if possible, suggest a specific fix or improvement rather than just flagging the issue. +- If no issues are found, state that explicitly and mention any residual risks or validation gaps. + +## Collaboration Style + +- Be direct, concise, and technical. +- Do useful work without unnecessary back-and-forth when the task is clear. +- If there are unrelated local changes, do not revert them unless explicitly asked. +- If user changes conflict with the current task, stop and ask before overwriting them. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index c7c7301..0000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,25 +0,0 @@ -## Summary -- Briefly describe what changed and why. - -## Type of Change -- [ ] Bug fix -- [ ] New feature -- [ ] Refactor/cleanup -- [ ] Documentation update -- [ ] Build/release/config update - -## User-Facing Impact -- [ ] This PR changes user-facing behavior -- [ ] If user-facing behavior changed, I updated `Features.md` - -## Docs Checklist -- [ ] I reviewed `README.md` and updated it for major functionality/architecture changes (if applicable) -- [ ] I updated `app_updates/APP_UPDATES.md` for shipped/planned status changes (if applicable) - -## Validation -- [ ] `npm run lint` -- [ ] `npx tsc --noEmit` -- [ ] Manual smoke test completed - -## Notes -- Add any reviewer notes, screenshots, or follow-up items. diff --git a/app_updates/APP_MVP.md b/app_updates/APP_MVP.md deleted file mode 100644 index df987a3..0000000 --- a/app_updates/APP_MVP.md +++ /dev/null @@ -1,64 +0,0 @@ -# MVP Specification - -## Proposed App Name -**Primary recommendation:** **Paycheck Planner** - -Why this name: -- Clearly communicates paycheck-to-expense flow -- Sounds practical and desktop-friendly -- Broad enough to support future features without rebranding - -Other good options: -- **PayMap** -- **NetNest** -- **SpendPath** - -## MVP Goal -Help a user plan where each paycheck goes, from gross pay to remaining spendable amount, with optional local encryption and year-based planning. - -## Minimum Features (Must-Have) -1. **New/Open Home Screen** - - Create new plan - - Open existing plan - - Show recently opened plans - -2. **Setup Wizard for New Plan** - - Year selection - - Pay input: yearly salary or hourly wage + hours/pay period - - Pay frequency (weekly, bi-weekly, monthly) - - Optional pre-tax deductions (401k, benefits) - -3. **Core Breakdown Engine** - - Calculate gross → pre-tax deductions → estimated taxes (user entered for now) → net pay - - Show values in bi-weekly, monthly, and yearly formats - - Let user allocate net pay across user-defined accounts (Checking, Savings, etc.) - - Show the breakdown in a pleasing UI where they can also see the amount visually shrinking as it goes down to the final net amount lef - -4. **Bills and Line Items** - - Add/edit/delete bill or expense line items - - Frequencies: weekly, monthly, yearly, custom - - Assign each line item to a selected account - -5. **Three MVP Views** - - **Key Metrics:** totals for income, bills, savings, remaining - - **Pay Breakdown:** gross-to-net flow - - **Bills:** recurring items and account assignment - -6. **Local Save + Optional Encryption** - - Save/load plan files locally - - User chooses encrypted or unencrypted on setup - - If encrypted, key stored securely in local keychain - -7. **Year-Based Plan + Copy Forward** - - Plans are tied to a year - - Duplicate prior year plan into a new year - -## Not MVP (Defer) -- Fully automatic location-aware tax calculations -- Custom user-defined view builder -- Full audit history and deleted-item restore - -## Lightweight MVP Additions That Improve UX (Still Minimal) -- **Unsaved changes indicator** in the window title/footer -- **Auto-save every N seconds** with manual save still available -- **First-run sample plan** to reduce setup friction \ No newline at end of file diff --git a/app_updates/APP_UPDATES.md b/app_updates/APP_UPDATES.md deleted file mode 100644 index d036ba7..0000000 --- a/app_updates/APP_UPDATES.md +++ /dev/null @@ -1,84 +0,0 @@ -# App Updates - -## Feature Enhancements - All Completed -(Additional features moved into Github Project view) - -- [x] **App Settings Panel** - Create a dedicated Settings interface accessible via `Cmd+,` / `Ctrl+,` (or through the menu bar) with app-wide configuration options: - - Theme preference (Light, Dark, or System default) - - Additional settings to be determined as needed - -- [x] **Dynamic Currency Support** - Implement multi-currency support across the entire application: - - Allow users to set a currency per plan - - Display the correct currency symbol throughout the plan (replacing hardcoded `$` signs) - - Enable currency configuration within the Pay Settings editor - -- [x] **Enhanced Modal Navigation** - Improve UX for windows with Cancel buttons: - - Implement Esc key handling to close modals and dialogs without requiring a Cancel button click - -- [x] **Account Configuration** - Move account management from the Bills section to the initial setup flow: - - Allow users to create and configure multiple accounts during setup - - Remove the Account creation from the Bill editor to streamline the bill management process - - Accounts should be app-wide entities that can be associated with bills and pay breakdowns, rather than being tied to specific bills - - Update the UI to reflect this change, ensuring that users can easily manage their accounts and associate them with their financial plans - -- [x] **Improve Welcome page** - Refine the welcome screen to better guide new users: - - Keep the Create new and Open existing, but below them have the recently opened plans (with file names and last opened dates) for quick access - - Remove the blurbs about the app features from the welcome screen and move them to the About section (accessible from the menu bar or settings) to keep the welcome screen focused on getting users to create or open a plan - -- [x] **Encryption enhancements** - Make encryption more baked into the plan creation via the Wizard, and more flexible overall: - - In the Wizard, they should be able to choose whether they want to enable encryption for the plan they're creating, and if so, guide them through the encryption setup right there in the Wizard flow (instead of having it as a separate step after the plan is created). - - The encryption key they choose should be saved securely in their local keychain (using a package that is available on all platforms) instead of just in localStorage, for better security and convenience. This way it is easier for users to manage their keys and they don't have to worry about losing them as much. - - If they want, they should be able to turn that encryption off individually for the plans (for sharing the file or other reasons). - - On the plan page header, display the encryption status as a clickable badge that opens encryption settings for quick access - -- [x] **Pay Breakdown Improvements** - - - Enable detailed fund allocation per account (investment goals, savings targets, expense distribution, etc.) after Net Pay is calculated - - Define account priority and funding order after taxes and other deductions are taken from the Gross Pay (e.g., Investment → Savings → Checking) - - Allow users to specify how they want their Net Pay distributed across accounts and categories - - After all allocations are set, show a summary of the final breakdown of how the Gross Pay is distributed across all accounts and categories, including taxes, deductions, and final amounts allocated to each account - - Show a "On Pay Day" summary of the total amounts that will be deposited into each account and category based on the configured breakdown - - Show a final "All that remains" summary of the total amount left after all deductions and allocations, which can be used for discretionary spending or additional savings/investments - - For the pay settings, we want the footer to be stickied for the cancel and save buttons, so the user doesn't have to scroll down to save their changes after configuring the pay breakdown. When selecting cancel as well, it should back out into the prior screen instead of just closing the modal, since it is more of a "cancel and go back" action rather than just "cancel and stay on the same screen". - -- [x] **Account Settings Fixes** - There a some improvements we need for the account settings view: - - The modal is not large enough for the content, making the account list impossible to read on a smaller window. We should make the modal larger and more responsive to accommodate the content. - - We should add a delete button for accounts, and when an account is deleted, we should prompt the user to choose what to do with any bills or pay breakdowns that are currently using that account (e.g., reassign to another account, delete them, etc.) to prevent orphaned data and ensure a smooth user experience. - -- [x] **Tab Management** - Allow for more customization of certain tabs - - Have a [+] button next to the last tab that allows users to add less commonly used tabs - - The Benefits and Taxes should be put behind this [+] button by default since they are less commonly used, but users can choose to add them as main tabs if they want - - Future state could allow users to create custom tabs for different categories of bills or accounts - - Allow user to hide tabs that they don't use often to declutter the interface, and also allow them to rearrange the order of the tabs based on their preferences - - They should be able to re-add hidden tabs from the [+] button, and also drag and drop to rearrange the order of the tabs - - Key Metrics and Pay Breakdown should always be the first two tabs and cannot be hidden since they are the core of the app, but the user can rearrange the order of the other tabs as they see fit - -- [x] **Currency Conversions** - When a user changes the currency for a plan, offer to convert all existing amounts to the new currency using an exchange rate (with an option to skip conversion and just change the symbol) - - For now, just allow user to give the exchange rate manually, but in the future we can look into integrating with a currency exchange API to get real-time rates for more accurate conversions. - -- [x] **More Tab options** - Add more tabs for some new categories, including: - - Loans tab for managing any debts or loans, with details like interest rates, payment or loan amortization schedules, etc. - - This could be for a home Mortgage, student loans, car loans, or any other type of debt the user wants to track easily - -- [x] **Custom Tab Display Modes** - Allow users to show tabs on the left, right, top, or bottom of the screen based on their preference for better accessibility and workflow customization - - This would involve making the tab component more flexible to support different orientations and placements around the main content area - - Users could choose to have tabs on the left side for easier vertical navigation, or on the top for a more traditional layout, etc. - - For tabs on the left or right in a sidebar, we could also allow users to choose between icons only (for a more compact view) or icons with labels for easier identification, with a pleasant animation for expanding/collapsing the sidebar if they choose the icons-only view. - - By default we could show icons with labels for better discoverability, but allow users to switch to icons-only if they prefer a more minimalist interface. - This preference should be saved and persist across sessions. - - When moving tabs manually to rearrange them, there should be a clear indicator to the user of where the tab will be placed when they drop it, and the tab order should update immediately to reflect the change for a smooth and intuitive user experience. - -- [x] **Glossary of Terms** - Add a glossary or tooltip explanations for more complicated financial terms used in the app (e.g., Gross Pay, Net Pay, Deductions, Allocations, etc.) to help users understand the concepts and calculations better - - This could be implemented as tooltips that appear when hovering over certain terms, or as a dedicated glossary section in the app where users can look up definitions and explanations of key financial terms used in the app - - We should add a Help menu in the menu bar that links to the glossary and other helpful resources for users who want to learn more about financial planning concepts, with easy searching and navigation to find the information they need. - -- [x] **Feedback System** - Implement a way for users to easily provide feedback, report bugs, or request features directly from the app to facilitate communication and continuous improvement - - This could be a simple form in the app that allows users to submit their feedback, which would then be sent to our support email or stored in a database for review - - Button in the bottom left of the footer (before Last Saved) that opens a feedback form modal where users can enter their feedback, report bugs, or request features - - The form should have fields for the user's email (optional), a subject line, and a message box for them to describe their feedback in detail - - If it was a bug they encountered, allow user to select an option to automatically include relevant logs to help us diagnose the issue more effectively - - If they have a new feature suggestion, allow them to categorize it (e.g., UI improvement, new feature, performance issue, etc.) to help us prioritize and organize the feedback we receive - - Also allow them to include a screenshot if they want to illustrate their feedback visually, which can be especially helpful for UI-related suggestions or bug reports - -- [x] **Unit tests** - Add unit tests for critical components and functions to improve reliability and catch bugs early - - Focus on testing the core logic of the application, such as the pay breakdown calculations, encryption/decryption functions, and file storage operations - - Use a testing framework like Jest to write and run the tests - - Set up a test suite that can be run as part of the development process to ensure that new changes don't break existing functionality diff --git a/app_updates/Shared Logic Refactor.md b/app_updates/Shared Logic Refactor.md deleted file mode 100644 index 28d7050..0000000 --- a/app_updates/Shared Logic Refactor.md +++ /dev/null @@ -1,370 +0,0 @@ -# Shared Logic Refactor - -## Goal -Reduce duplication, improve consistency, and lower bug risk by extracting reusable logic into shared `utils`, `services`, hooks, and shared UI patterns. - -## How To Use This File -- Mark an item complete only when its **Done Criteria** are fully met. -- For any task that changes/adds `src/services` or `src/utils`, add/update tests in the same task. -- Keep each item scope-limited; do not bundle multiple large refactors into one PR. - -## Prioritized Checklist (Most Important -> Least Important) - -### 1. Centralize Financial Calculation Engine -**Priority:** Critical - -- [ ] Implement `src/services/budgetCalculations.ts` as the single source of truth for paycheck/tax/net/allocation math. -- [ ] Migrate `BudgetContext.calculatePaycheckBreakdown` to use shared functions. -- [ ] Migrate `PayBreakdown`, `KeyMetrics`, and `pdfExport` to use the same shared functions. -- [ ] Add parity tests proving all consumers return identical numbers for the same fixture. - -**Problem:** Core pay/tax/net/leftover math is duplicated across multiple layers. - -**Current duplication evidence:** -- `src/contexts/BudgetContext.tsx` (`calculatePaycheckBreakdown`) -- `src/components/PayBreakdown/PayBreakdown.tsx` (yearly/display breakdown math) -- `src/components/KeyMetrics/KeyMetrics.tsx` (annual/monthly metric math) -- `src/services/pdfExport.ts` (its own gross/tax/net math) - -**Refactor target:** -- Add `src/services/budgetCalculations.ts` with pure functions: - - `calculatePayBreakdownPerPaycheck` - - `calculateAnnualizedSummary` - - `calculateDisplaySummary(displayMode)` - - `calculateAllocationTotals` - -**Done Criteria:** -- No component/service performs independent pay/tax/net math outside shared calculator functions. -- `pdfExport` values match `PayBreakdown`/`KeyMetrics` outputs for test fixtures. -- New/updated tests exist for `budgetCalculations` and all touched services/utils. - -**Why first:** This removes the highest bug surface area and guarantees all screens/export use identical numbers. - ---- - -### 2. Unified Missing/Moved File Relink Flow -**Priority:** Critical - -- [ ] Extract shared relink state/decision logic to `src/hooks/useFileRelinkFlow.ts` (or equivalent service + hook). -- [ ] Keep Welcome and PlanDashboard modal UIs, but wire both to the shared flow. -- [ ] Ensure cancel behavior is identical across entry points (clear stale path policy, no stale saves). -- [ ] Add tests for `success`, `cancelled`, `mismatch`, and `invalid` paths. - -**Problem:** Similar relink state + modal handling exists in multiple components. - -**Current duplication evidence:** -- `src/components/WelcomeScreen/WelcomeScreen.tsx` -- `src/components/PlanDashboard/PlanDashboard.tsx` -- Shared behavior depends on `FileStorageService.relinkMovedBudgetFile` - -**Refactor target:** -- Add `src/hooks/useFileRelinkFlow.ts` (or a small service + hook pair) -- Keep modal visuals local if desired, but centralize decision/state logic: - - open/cancel/retry - - invalid/mismatch message handling - - stale path cleanup policy - -**Why second:** Prevents regressions in file safety logic and keeps save/load behavior consistent. - -**Done Criteria:** -- Welcome and PlanDashboard use a common relink flow contract. -- Saving cannot proceed to stale paths from any trigger (`button`, menu, keyboard shortcut). -- Shared tests cover all status outcomes. - ---- - -### 3. File Path / Plan Name Helpers -**Priority:** High - -- [ ] Create `src/utils/filePath.ts` for all filename/path parsing. -- [ ] Replace all ad-hoc basename/split logic in `BudgetContext` and `FileStorageService`. -- [ ] Add unit tests for separators, extensions, whitespace, and empty input edge cases. - -**Problem:** Path-to-name parsing logic is duplicated in context/service and ad-hoc splits exist. - -**Current duplication evidence:** -- `src/contexts/BudgetContext.tsx` (`derivePlanNameFromFilePath`) -- `src/services/fileStorage.ts` (`derivePlanNameFromFilePath` and repeated basename extraction) - -**Refactor target:** -- Add `src/utils/filePath.ts`: - - `getBaseFileName(path)` - - `getPlanNameFromPath(path)` - - `stripFileExtension(name)` - -**Why third:** Low risk and immediately improves consistency. - -**Done Criteria:** -- Only shared helpers are used for plan-name derivation and basename extraction. -- No direct `split(/[\\/])` basename parsing remains in business logic. - ---- - -### 4. Shared Dialog Strategy (Replace scattered `alert` / `confirm`) -**Priority:** High - -- [ ] Introduce shared app dialogs (`ConfirmDialog`, `ErrorDialog`) and optional `useAppDialogs` helper. -- [ ] Replace highest-impact `alert`/`confirm` calls first (PlanDashboard, Settings, SetupWizard, Welcome). -- [ ] Standardize button wording (`Cancel`, `Confirm`, `Retry`) and error presentation. - -**Problem:** Browser dialogs are still scattered and inconsistent for UX/testing. - -**Current duplication evidence:** -- `PlanDashboard`, `Settings`, `WelcomeScreen`, `SetupWizard`, `EncryptionSetup`, `PlanTabs`, and manager components. - -**Refactor target:** -- Add shared modal-based helpers: - - `ConfirmDialog` - - `ErrorDialog` - - optional `useAppDialogs` - -**Why fourth:** Better UX consistency and testability. - -**Done Criteria:** -- Critical flows no longer rely on native `window.alert`/`window.confirm`. -- Dialog interaction behavior is consistent and keyboard-accessible. - ---- - -### 5. Manager CRUD Form Patterns -> Shared Hooks -**Priority:** High - -- [ ] Extract reusable hooks for modal entity editing and field error state. -- [ ] Migrate at least two manager components first (recommended: Bills + Loans), then expand. -- [ ] Preserve existing UX/validation messages while reducing duplicate handlers. - -**Problem:** Bills/Loans/Savings/Benefits managers all implement similar modal-form CRUD state and validation patterns. - -**Current duplication evidence:** -- Add/edit modal toggles and `editingX` patterns -- `handleAdd*`, `handleEdit*`, `handleSave*`, `handleDelete*` duplication - -**Refactor target:** -- Add hooks: - - `useModalEntityEditor()` - - `useFieldErrors()` - - `useDeleteConfirmation()` - -**Why fifth:** Large maintainability win, but higher refactor complexity than utility-only changes. - -**Done Criteria:** -- Repeated `handleAdd/Edit/Save/Delete` scaffolding is reduced substantially across managers. -- No behavior regressions in add/edit/delete flows. - ---- - -### 6. Display Mode Conversion Utilities -**Priority:** Medium-High - -- [ ] Add `src/utils/displayAmounts.ts` and migrate manager-level `toDisplayAmount`/`fromDisplayAmount` helpers. -- [ ] Keep pay frequency + display mode conversion rules centralized. - -**Problem:** Local `toDisplayAmount` helpers repeated across managers. - -**Current duplication evidence:** -- `BillsManager`, `SavingsManager`, `PayBreakdown`, and related components. - -**Refactor target:** -- Add `src/utils/displayAmounts.ts`: - - `toDisplayAmount(perPaycheck, paychecksPerYear, mode)` - - `fromDisplayAmount(value, paychecksPerYear, mode)` - -**Why now:** Reduces small arithmetic drift and simplifies components. - -**Done Criteria:** -- All display amount conversions route through one shared utility API. - ---- - -### 7. Suggested Leftover Logic -**Priority:** Medium - -- [ ] Move suggestion formula to `src/utils/paySuggestions.ts`. -- [ ] Use it in both SetupWizard and PaySettingsModal. -- [ ] Add tests for rounding/minimum-floor behavior. - -**Problem:** Same suggestion formula exists in setup and pay settings modal. - -**Current duplication evidence:** -- `SetupWizard` -- `PaySettingsModal` - -**Refactor target:** -- Add `src/utils/paySuggestions.ts`: - **Done Criteria:** - - Setup and modal produce identical suggestion values for the same inputs. - - `getSuggestedLeftoverPerPaycheck(grossPerPaycheck)` - - `formatSuggestedLeftover(...)` if needed - ---- - -### 8. Retirement Yearly Limit/Auto-Calc Math -**Priority:** Medium - -- [ ] Extract yearly-limit and auto-calc math to `src/utils/retirementMath.ts`. -- [ ] Replace duplicated logic in SavingsManager and BenefitsManager. -- [ ] Add tests for match/no-match and percentage/amount cap variants. - -**Problem:** Similar yearly-limit and auto-calc logic appears in multiple managers. - -**Current duplication evidence:** -- `SavingsManager` -- `BenefitsManager` - -**Refactor target:** -- Add `src/utils/retirementMath.ts`: - **Done Criteria:** - - One shared implementation powers both manager UIs. - - `calculateYearlyRetirementContribution` - - `checkYearlyLimitExceeded` - - `autoCalculateContributionForYearlyLimit` - ---- - -### 9. Budget Currency Conversion as Service -**Priority:** Medium - -- [ ] Move `convertBudgetAmounts` + rounding logic from `PaySettingsModal` to `src/services/budgetCurrencyConversion.ts`. -- [ ] Add tests covering major numeric fields and excluded percentage fields. - -**Problem:** Deep budget amount conversion currently lives in component-level modal logic. - -**Current duplication evidence:** -- `PaySettingsModal` (`convertBudgetAmounts`) - -**Refactor target:** -- Add `src/services/budgetCurrencyConversion.ts`: - **Done Criteria:** - - Component only orchestrates UI; conversion logic lives in service with tests. - - `convertBudgetAmounts(data, rate)` - - `roundCurrency` helper - ---- - -### 10. Account Grouping and Totals Helpers -**Priority:** Medium - -- [ ] Add `src/utils/accountGrouping.ts` helpers for repeated reducers/grouping. -- [ ] Migrate Bills/Loans/Savings grouping and subtotal logic to shared helpers. - -**Problem:** Multiple account-grouping reducers and subtotal patterns exist in managers. - -**Current duplication evidence:** -- `BillsManager`, `LoansManager`, `SavingsManager` - -**Refactor target:** -- Add `src/utils/accountGrouping.ts`: - **Done Criteria:** - - Manager components use shared grouping functions for account-based list construction. - - `groupByAccountId` - - `buildAccountRows` - - `sumByFrequency` - ---- - -### 11. Shared `ViewMode` Type -**Priority:** Medium-Low - -- [ ] Add `src/types/viewMode.ts`. -- [ ] Replace local `'paycheck' | 'monthly' | 'yearly'` declarations with imported type. - -**Problem:** `'paycheck' | 'monthly' | 'yearly'` is redeclared in many places. - -**Refactor target:** -- Add `src/types/viewMode.ts` and import everywhere. - -**Done Criteria:** -- No local re-declarations of `ViewMode` union remain in components. - ---- - -### 12. Encryption Setup Flow Consolidation -**Priority:** Medium-Low - -- [ ] Extract reusable encryption setup/save behavior into service/hook. -- [ ] Remove duplicated key validation/saving code across SetupWizard, EncryptionSetup, PlanDashboard. - -**Problem:** Encryption setup/save behavior is distributed across SetupWizard, EncryptionSetup, and PlanDashboard handlers. - -**Refactor target:** -- Add `src/services/encryptionSetupService.ts` or `useEncryptionSetupFlow(planId)`. - -**Done Criteria:** -- One flow controls key generation, validation, save, and error messaging. - ---- - -### 13. Shared Path Display Styling Utility -**Priority:** Low - -- [ ] Create one shared CSS utility/class for relink path/code blocks. -- [ ] Replace duplicated modal path classes in Welcome and PlanDashboard. - -**Problem:** Similar relink modal path styling classes exist in multiple component CSS files. - -**Refactor target:** -- Add one shared utility class for code/path blocks in a shared stylesheet. - -**Done Criteria:** -- Path display styling is defined once and reused. - ---- - -### 14. Remove/Archive Backup and Unused Artifacts -**Priority:** Low (Hygiene) - -- [ ] Remove or archive `*.backup` files outside `src`. -- [ ] Confirm whether `BenefitsManager` is intentionally unused; either wire it or remove it. -- [ ] Ensure search/indexing and maintenance docs no longer reference stale backups. - -**Problem:** `*.backup` files and likely unused component artifacts increase confusion. - -**Current candidates:** -- `src/components/BenefitsManager/*.backup` -- `src/components/LoansManager/*.backup` -- `src/components/shared/Button/*.backup` - -**Refactor target:** -- Remove from active tree (or move to an archive folder outside `src`). - -**Done Criteria:** -- Active source tree has no backup artifacts that can be confused with runtime code. - -## Phased Execution Plan - -### Phase 1 (Low Risk, High ROI) -- [ ] Shared file-path utils (`filePath.ts`) -- [ ] Shared `ViewMode` type -- [ ] Suggested leftover utility -- [ ] Display amount utility - -### Phase 2 (Core correctness) -- [ ] `budgetCalculations` service -- [ ] Migrate `PayBreakdown`, `KeyMetrics`, `pdfExport`, and context calculations to shared engine -- [ ] Add regression tests for parity across all consumers - -### Phase 3 (Workflow consistency) -- [ ] File relink hook/service extraction -- [ ] Dialog strategy (`ConfirmDialog`/`ErrorDialog`) -- [ ] Replace high-impact `alert`/`confirm` usage - -### Phase 4 (Form architecture) -- [ ] Manager CRUD hooks -- [ ] Retirement math extraction -- [ ] Account grouping helpers - -### Phase 5 (Cleanup) -- [ ] Currency conversion service extraction -- [ ] CSS/path styling utility extraction -- [ ] Remove backup artifacts - -## Test Strategy Requirements -- [ ] Any changed existing `src/services` or `src/utils` module includes updated tests. -- [ ] Any new `src/services/*.ts` or `src/utils/*.ts` module includes a matching `*.test.ts`. -- [ ] Parity tests added where math is migrated from components/context to shared service. - -## Success Metrics -- [ ] Reduced duplicated logic blocks in manager/components/services. -- [ ] Financial outputs match across UI and PDF export for the same input data. -- [ ] Fewer direct `alert`/`confirm` calls in app components. -- [ ] All new/refactored services/utils covered by unit tests. diff --git a/electron/main.ts b/electron/main.ts index 8ac8493..773cd22 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -8,6 +8,7 @@ import * as fs from 'fs/promises'; import { watch, type FSWatcher } from 'fs'; import { fileURLToPath } from 'url'; import { createRequire } from 'module'; +import { MENU_EVENTS, menuChannel, type MenuEventName } from '../src/constants/events'; import { FEEDBACK_FORM_ENTRY_IDS, FEEDBACK_FORM_URL } from './constants'; // Create require function for ES modules @@ -211,6 +212,10 @@ const getOrCreateBudgetFileWatchState = (windowId: number): BudgetFileWatchState return created; }; +const sendMenuEvent = (window: BrowserWindow, event: MenuEventName, arg?: unknown) => { + window.webContents.send(menuChannel(event), arg); +}; + const stopBudgetFileWatch = (windowId: number) => { const state = budgetFileWatchByWindowId.get(windowId); if (!state) return; @@ -345,7 +350,7 @@ function dispatchExternalBudgetOpen(filePath: string) { } const sendOpenFileEvent = () => { - targetWindow.webContents.send('menu:open-budget-file', filePath); + sendMenuEvent(targetWindow, MENU_EVENTS.openBudgetFile, filePath); pendingExternalBudgetFilePath = null; targetWindow.show(); targetWindow.focus(); @@ -784,7 +789,7 @@ function createApplicationMenu() { click: () => { const targetWindow = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; if (targetWindow) { - targetWindow.webContents.send('menu:open-about'); + sendMenuEvent(targetWindow, MENU_EVENTS.openAbout); } }, }, @@ -795,7 +800,7 @@ function createApplicationMenu() { click: () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { - focusedWindow.webContents.send('menu:open-settings'); + sendMenuEvent(focusedWindow, MENU_EVENTS.openSettings); } }, }, @@ -837,7 +842,7 @@ function createApplicationMenu() { click: () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { - focusedWindow.webContents.send('menu:new-budget'); + sendMenuEvent(focusedWindow, MENU_EVENTS.newBudget); } }, }, @@ -854,7 +859,7 @@ function createApplicationMenu() { click: () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { - focusedWindow.webContents.send('menu:open-budget'); + sendMenuEvent(focusedWindow, MENU_EVENTS.openBudget); } }, }, @@ -864,7 +869,7 @@ function createApplicationMenu() { click: () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { - focusedWindow.webContents.send('menu:save-plan'); + sendMenuEvent(focusedWindow, MENU_EVENTS.savePlan); } }, }, @@ -912,7 +917,7 @@ function createApplicationMenu() { click: () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { - focusedWindow.webContents.send('menu:open-accounts'); + sendMenuEvent(focusedWindow, MENU_EVENTS.openAccounts); } }, }, @@ -922,7 +927,7 @@ function createApplicationMenu() { click: () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { - focusedWindow.webContents.send('menu:open-pay-options'); + sendMenuEvent(focusedWindow, MENU_EVENTS.openPayOptions); } }, }, @@ -942,7 +947,7 @@ function createApplicationMenu() { click: () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { - focusedWindow.webContents.send('menu:set-tab-position', 'top'); + sendMenuEvent(focusedWindow, MENU_EVENTS.setTabPosition, 'top'); } }, }, @@ -952,7 +957,7 @@ function createApplicationMenu() { click: () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { - focusedWindow.webContents.send('menu:set-tab-position', 'bottom'); + sendMenuEvent(focusedWindow, MENU_EVENTS.setTabPosition, 'bottom'); } }, }, @@ -962,7 +967,7 @@ function createApplicationMenu() { click: () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { - focusedWindow.webContents.send('menu:set-tab-position', 'left'); + sendMenuEvent(focusedWindow, MENU_EVENTS.setTabPosition, 'left'); } }, }, @@ -972,7 +977,7 @@ function createApplicationMenu() { click: () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { - focusedWindow.webContents.send('menu:set-tab-position', 'right'); + sendMenuEvent(focusedWindow, MENU_EVENTS.setTabPosition, 'right'); } }, }, @@ -984,7 +989,7 @@ function createApplicationMenu() { click: () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { - focusedWindow.webContents.send('menu:toggle-tab-display-mode'); + sendMenuEvent(focusedWindow, MENU_EVENTS.toggleTabDisplayMode); } }, }, @@ -1005,7 +1010,7 @@ function createApplicationMenu() { click: () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { - focusedWindow.webContents.send('menu:open-settings'); + sendMenuEvent(focusedWindow, MENU_EVENTS.openSettings); } }, }] : []), @@ -1066,7 +1071,7 @@ function createApplicationMenu() { click: () => { const targetWindow = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; if (targetWindow) { - targetWindow.webContents.send('menu:open-glossary'); + sendMenuEvent(targetWindow, MENU_EVENTS.openGlossary); } }, }, @@ -1077,7 +1082,7 @@ function createApplicationMenu() { click: () => { const targetWindow = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; if (targetWindow) { - targetWindow.webContents.send('menu:open-keyboard-shortcuts'); + sendMenuEvent(targetWindow, MENU_EVENTS.openKeyboardShortcuts); } }, }, @@ -1099,7 +1104,7 @@ function registerGlobalShortcuts() { globalShortcut.register(settingsShortcut, () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { - focusedWindow.webContents.send('menu:open-settings'); + sendMenuEvent(focusedWindow, MENU_EVENTS.openSettings); debug(`Settings shortcut triggered via globalShortcut (${settingsShortcut})`); } }); @@ -1108,7 +1113,7 @@ function registerGlobalShortcuts() { globalShortcut.register(backShortcut, () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { - focusedWindow.webContents.send('menu:history-back'); + sendMenuEvent(focusedWindow, MENU_EVENTS.historyBack); debug(`Back shortcut triggered via globalShortcut (${backShortcut})`); } }); @@ -1117,7 +1122,7 @@ function registerGlobalShortcuts() { globalShortcut.register(forwardShortcut, () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { - focusedWindow.webContents.send('menu:history-forward'); + sendMenuEvent(focusedWindow, MENU_EVENTS.historyForward); debug(`Forward shortcut triggered via globalShortcut (${forwardShortcut})`); } }); @@ -1126,7 +1131,7 @@ function registerGlobalShortcuts() { globalShortcut.register(homeShortcut, () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { - focusedWindow.webContents.send('menu:history-home'); + sendMenuEvent(focusedWindow, MENU_EVENTS.historyHome); debug(`Home shortcut triggered via globalShortcut (${homeShortcut})`); } }); diff --git a/electron/preload.ts b/electron/preload.ts index 7b20608..4ccb9b8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -3,6 +3,8 @@ // It's like a controlled doorway - only certain things can pass through import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; +import type { MenuEventName } from '../src/constants/events'; +import { menuChannel } from '../src/constants/events'; console.log('[PRELOAD] Preload script starting...'); @@ -97,10 +99,10 @@ contextBridge.exposeInMainWorld('electronAPI', { // Takes: event name and callback function // Returns: () => unsubscribe function to remove listener onMenuEvent: ( - event: 'new-budget' | 'open-budget' | 'open-budget-file' | 'change-encryption' | 'save-plan' | 'open-settings' | 'open-about' | 'open-glossary' | 'open-keyboard-shortcuts' | 'open-pay-options' | 'open-accounts' | 'set-tab-position' | 'toggle-tab-display-mode' | 'history-back' | 'history-forward' | 'history-home', + event: MenuEventName, callback: (arg?: unknown) => void ) => { - const channel = `menu:${event}`; + const channel = menuChannel(event); const listener = (_event: IpcRendererEvent, arg?: unknown) => callback(arg); ipcRenderer.on(channel, listener); return () => ipcRenderer.removeListener(channel, listener); diff --git a/src/App.tsx b/src/App.tsx index 92519a6..f177ef1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,15 @@ // Main App component - decides whether to show setup, welcome screen, or dashboard import { useState, useEffect } from 'react' +import { APP_CUSTOM_EVENTS, MENU_EVENTS } from './constants/events' import { useBudget } from './contexts/BudgetContext' import { useGlobalKeyboardShortcuts } from './hooks' -import EncryptionSetup from './components/EncryptionSetup' -import WelcomeScreen from './components/WelcomeScreen' +import EncryptionSetup from './components/views/EncryptionSetup' +import WelcomeScreen from './components/views/WelcomeScreen' import PlanDashboard from './components/PlanDashboard' -import Settings from './components/Settings' -import About from './components/About' -import Glossary from './components/Glossary' -import KeyboardShortcutsModal from './components/KeyboardShortcutsModal' +import SettingsModal from './components/modals/SettingsModal' +import AboutModal from './components/modals/AboutModal' +import GlossaryModal from './components/modals/GlossaryModal' +import KeyboardShortcutsModal from './components/modals/KeyboardShortcutsModal' import './App.css' function App() { @@ -51,7 +52,7 @@ function App() { useEffect(() => { if (!window.electronAPI?.onMenuEvent) return - const unsubscribe = window.electronAPI.onMenuEvent('open-settings', () => { + const unsubscribe = window.electronAPI.onMenuEvent(MENU_EVENTS.openSettings, () => { setShowSettings(true) }) @@ -62,7 +63,7 @@ function App() { useEffect(() => { if (!window.electronAPI?.onMenuEvent) return - const unsubscribe = window.electronAPI.onMenuEvent('open-about', () => { + const unsubscribe = window.electronAPI.onMenuEvent(MENU_EVENTS.openAbout, () => { setShowAbout(true) }) @@ -73,7 +74,7 @@ function App() { useEffect(() => { if (!window.electronAPI?.onMenuEvent) return - const unsubscribe = window.electronAPI.onMenuEvent('open-glossary', () => { + const unsubscribe = window.electronAPI.onMenuEvent(MENU_EVENTS.openGlossary, () => { setInitialGlossaryTermId(null) setShowGlossary(true) }) @@ -84,7 +85,7 @@ function App() { useEffect(() => { if (!window.electronAPI?.onMenuEvent) return - const unsubscribe = window.electronAPI.onMenuEvent('open-keyboard-shortcuts', () => { + const unsubscribe = window.electronAPI.onMenuEvent(MENU_EVENTS.openKeyboardShortcuts, () => { setShowKeyboardShortcuts(true) }) @@ -95,7 +96,7 @@ function App() { useEffect(() => { if (!window.electronAPI?.onMenuEvent) return - const unsubscribe = window.electronAPI.onMenuEvent('open-budget-file', (arg) => { + const unsubscribe = window.electronAPI.onMenuEvent(MENU_EVENTS.openBudgetFile, (arg) => { if (typeof arg === 'string' && arg.trim()) { loadBudget(arg) } @@ -114,8 +115,8 @@ function App() { setShowGlossary(true) } - window.addEventListener('app:open-glossary', handleOpenGlossary as EventListener) - return () => window.removeEventListener('app:open-glossary', handleOpenGlossary as EventListener) + window.addEventListener(APP_CUSTOM_EVENTS.openGlossary, handleOpenGlossary as EventListener) + return () => window.removeEventListener(APP_CUSTOM_EVENTS.openGlossary, handleOpenGlossary as EventListener) }, []) // Expose a save hook for Electron close confirmation flow @@ -186,11 +187,11 @@ function App() { ) : ( <> - setShowSettings(false)} /> + setShowSettings(false)} /> )} - setShowAbout(false)} /> - setShowAbout(false)} /> + { diff --git a/src/README.md b/src/README.md index 2409d37..e4afaae 100644 --- a/src/README.md +++ b/src/README.md @@ -194,23 +194,23 @@ Renderer Process (React + Context + Hooks) ├─ Hooks │ └─ useGlobalKeyboardShortcuts.ts (cross-platform keyboard shortcuts) └─ Components - ├─ SetupWizard/ (onboarding flow) - ├─ WelcomeScreen/ (new/open/recent/demo entry points) + ├─ views/SetupWizard/ (onboarding flow) + ├─ views/WelcomeScreen/ (new/open/recent/demo entry points) ├─ PlanDashboard/ (main dashboard shell + PlanTabs for tab management) - ├─ KeyMetrics/ (high-level summary) - ├─ PayBreakdown/ (gross-to-net paycheck detail) - ├─ BillsManager/ (recurring expenses) - ├─ BenefitsManager/ (health, FSA/HSA, life, disability, commuter) - ├─ LoansManager/ (debt tracking) - ├─ AccountsManager/ (checking, savings, etc.) - ├─ TaxBreakdown/ (tax withholding detail) - ├─ Settings/ (theme, glossary tooltips config) - ├─ About/ (version, credits, license) - ├─ Glossary/ (searchable financial terms reference) - ├─ ExportModal/ (PDF export with password protection) - ├─ PaySettingsModal/ (edit pay settings) + ├─ tabViews/KeyMetrics/ (high-level summary) + ├─ tabViews/PayBreakdown/ (gross-to-net paycheck detail) + ├─ tabViews/BillsManager/ (recurring expenses) + ├─ tabViews/LoansManager/ (debt tracking) + ├─ tabViews/SavingsManager/ (savings and retirement planning) + ├─ tabViews/TaxBreakdown/ (tax withholding detail) + ├─ modals/AccountsModal/ (checking, savings, etc.) + ├─ modals/SettingsModal/ (theme, glossary tooltips config) + ├─ modals/AboutModal/ (version, credits, license) + ├─ modals/GlossaryModal/ (searchable financial terms reference) + ├─ modals/ExportModal/ (PDF export with password protection) + ├─ modals/PaySettingsModal/ (edit pay settings) ├─ EncryptionSetup/ (encryption configuration) - └─ shared/ (Button, Modal, Card, Input, Toggle, PillToggle, etc.) + └─ _shared/ (Button, Modal, Card, Input, Toggle, PillToggle, etc.) Services ├─ fileStorage.ts (save/load, encryption envelope, recent files, migrations, settings persistence) @@ -255,26 +255,29 @@ paycheck-planner/ │ └── constants.ts (app constants) ├── src/ │ ├── components/ -│ │ ├── SetupWizard/ (onboarding flow) -│ │ ├── WelcomeScreen/ (entry point selection) +│ │ ├── views/ +│ │ │ ├── SetupWizard/ (onboarding flow) +│ │ │ └── WelcomeScreen/ (entry point selection) │ │ ├── PlanDashboard/ │ │ │ ├── PlanTabs/ (tab management) │ │ │ ├── PlanDashboard.tsx (main shell) │ │ │ └── PlanDashboard.css -│ │ ├── KeyMetrics/ (summary dashboard) -│ │ ├── PayBreakdown/ (gross-to-net detail) -│ │ ├── BillsManager/ (recurring expenses) -│ │ ├── BenefitsManager/ (health, FSA/HSA, etc.) -│ │ ├── LoansManager/ (debt tracking) -│ │ ├── AccountsManager/ (accounts CRUD) -│ │ ├── TaxBreakdown/ (tax detail) -│ │ ├── Settings/ (app preferences) -│ │ ├── About/ (version/license) -│ │ ├── Glossary/ (terms reference) -│ │ ├── ExportModal/ (PDF export) -│ │ ├── PaySettingsModal/ (pay settings editor) +│ │ ├── tabViews/ +│ │ │ ├── KeyMetrics/ (summary dashboard) +│ │ │ ├── PayBreakdown/ (gross-to-net detail) +│ │ │ ├── BillsManager/ (recurring expenses) +│ │ │ ├── LoansManager/ (debt tracking) +│ │ │ ├── SavingsManager/ (savings + retirement) +│ │ │ └── TaxBreakdown/ (tax detail) +│ │ ├── modals/ +│ │ │ ├── AccountsModal/ (accounts CRUD) +│ │ │ ├── SettingsModal/ (app preferences) +│ │ │ ├── AboutModal/ (version/license) +│ │ │ ├── GlossaryModal/ (terms reference) +│ │ │ ├── ExportModal/ (PDF export) +│ │ │ └── PaySettingsModal/ (pay settings editor) │ │ ├── EncryptionSetup/ (encryption config) -│ │ └── shared/ (reusable UI components) +│ │ └── _shared/ (reusable UI components) │ ├── contexts/ │ │ ├── BudgetContext.tsx (budget state management) │ │ └── ThemeContext.tsx (theme management) diff --git a/src/components/About/index.ts b/src/components/About/index.ts deleted file mode 100644 index 1ef71b8..0000000 --- a/src/components/About/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './About'; diff --git a/src/components/AccountsManager/index.ts b/src/components/AccountsManager/index.ts deleted file mode 100644 index 708e821..0000000 --- a/src/components/AccountsManager/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './AccountsManager'; diff --git a/src/components/BenefitsManager/BenefitsManager.css b/src/components/BenefitsManager/BenefitsManager.css deleted file mode 100644 index 8ec4753..0000000 --- a/src/components/BenefitsManager/BenefitsManager.css +++ /dev/null @@ -1,339 +0,0 @@ -.benefits-manager { - display: flex; - flex-direction: column; - gap: 1.5rem; - padding: 1.5rem; - line-height: 1.6; - overflow-y: auto; - height: 100%; -} - -.benefits-section { - padding: 1rem; - border-radius: 12px; - border: 1px solid var(--border-color); -} - -/* Section Headers */ -.section-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - margin-bottom: 0.5rem; - flex-wrap: wrap; -} - -.section-header > div:first-child { - display: flex; - flex-direction: column; -} - -.section-header h2 { - font-size: 1.25rem; - font-weight: 600; - margin: 0 0 0.25rem 0; - color: var(--text-primary); -} - -.section-header p { - font-size: 0.875rem; - color: var(--text-secondary); - margin: 0 0 1rem 0; -} - -.section-header button { - margin-top: 0.5rem; -} - -.section-total { - display: flex; - flex-direction: row; - align-items: center; - gap: 1rem; -} - -.section-total > button { - flex-shrink: 0; -} - -.section-total > div { - display: flex; - flex-direction: column; - align-items: flex-end; - text-align: right; -} - -.section-total-label { - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0.5px; - text-transform: uppercase; - color: var(--text-secondary); -} - -.section-total-amount { - font-size: 1rem; - font-weight: 700; - color: var(--text-primary); -} - -.field-error { - border-color: var(--error-color) !important; -} - -.input-with-prefix input.field-error { - border-color: var(--error-color) !important; -} - -/* Empty State */ -.empty-state { - text-align: center; - padding: 2rem; - border: 2px dashed var(--border-color); - border-radius: 0.5rem; - background-color: var(--background-secondary); -} - -.empty-icon { - font-size: 2.5rem; - margin-bottom: 0.75rem; -} - -.empty-state h3 { - font-size: 1.125rem; - font-weight: 600; - margin: 0 0 0.5rem 0; - color: var(--text-primary); -} - -.empty-state p { - color: var(--text-secondary); - margin: 0; - font-size: 0.875rem; -} - -/* Benefits List */ -.benefits-list { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.benefit-item { - display: grid; - grid-template-columns: 1fr auto auto; - align-items: center; - gap: 1rem; -} - -.benefit-info { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.benefit-info h4 { - font-size: 0.95rem; - font-weight: 600; - margin: 0; - color: var(--text-primary); -} - -.benefit-type { - display: inline-block; - width: fit-content; - font-size: 0.75rem; - font-weight: 600; - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.benefit-type.pre-tax { - background-color: rgba(34, 197, 94, 0.15); - color: rgb(34, 197, 94); -} - -.benefit-type.post-tax { - background-color: rgba(168, 85, 247, 0.15); - color: rgb(168, 85, 247); -} - -.benefit-type.from-account { - background-color: rgba(59, 130, 246, 0.15); - color: rgb(59, 130, 246); -} - -.benefit-amount { - text-align: right; - display: flex; - flex-direction: row; - gap: 1rem; -} - -.benefit-amount .amount-display { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.25rem; -} - -.benefit-amount .amount-label { - font-size: 0.75rem; - color: var(--text-secondary); - font-weight: 500; -} - -.benefit-amount .amount { - font-size: 0.95rem; - font-weight: 600; - color: var(--text-primary); -} - -.benefit-actions { - display: flex; - gap: 0.5rem; -} - -.benefit-actions button { - padding: 0.5rem; - min-width: auto; - background: transparent; - border: 1px solid var(--border-color); - color: var(--text-secondary); - cursor: pointer; - border-radius: 0.25rem; - font-size: 1rem; - transition: all 0.2s; -} - -.benefit-actions button:hover { - background-color: var(--background-secondary); - border-color: var(--text-secondary); - color: var(--text-primary); -} - -/* Retirement List */ -.retirement-list { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.retirement-item { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; -} - -.retirement-info { - flex: 1; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.retirement-info h4 { - font-size: 0.95rem; - font-weight: 600; - margin: 0; - color: var(--text-primary); -} - -.retirement-details { - display: flex; - flex-direction: column; - gap: 0.375rem; -} - -.detail { - display: flex; - justify-content: space-between; - font-size: 0.875rem; - color: var(--text-secondary); -} - -.detail .label { - font-weight: 500; - color: var(--text-secondary); -} - -.detail .value { - color: var(--text-primary); - font-weight: 500; -} - -.retirement-actions { - display: flex; - gap: 0.5rem; - flex-shrink: 0; -} - -.retirement-actions button { - padding: 0.5rem; - min-width: auto; - background: transparent; - border: 1px solid var(--border-color); - color: var(--text-secondary); - cursor: pointer; - border-radius: 0.25rem; - font-size: 1rem; - transition: all 0.2s; -} - -.retirement-actions button:hover { - background-color: var(--background-secondary); - border-color: var(--text-secondary); - color: var(--text-primary); -} - -/* Responsive */ -@media (max-width: 768px) { - .benefits-manager { - padding: 1rem; - gap: 1.5rem; - } - - .section-header { - flex-direction: column; - align-items: stretch; - } - - .section-total { - align-items: flex-start; - text-align: left; - } - - .benefit-item { - grid-template-columns: 1fr; - gap: 0.5rem; - } - - .benefit-info { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - } - - .benefit-amount { - text-align: left; - } - - .benefit-actions { - grid-column: 1; - justify-content: flex-start; - } - - .retirement-item { - flex-direction: column; - } - - .retirement-actions { - width: 100%; - justify-content: flex-end; - } -} diff --git a/src/components/BenefitsManager/BenefitsManager.css.backup b/src/components/BenefitsManager/BenefitsManager.css.backup deleted file mode 100644 index 8ec4753..0000000 --- a/src/components/BenefitsManager/BenefitsManager.css.backup +++ /dev/null @@ -1,339 +0,0 @@ -.benefits-manager { - display: flex; - flex-direction: column; - gap: 1.5rem; - padding: 1.5rem; - line-height: 1.6; - overflow-y: auto; - height: 100%; -} - -.benefits-section { - padding: 1rem; - border-radius: 12px; - border: 1px solid var(--border-color); -} - -/* Section Headers */ -.section-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - margin-bottom: 0.5rem; - flex-wrap: wrap; -} - -.section-header > div:first-child { - display: flex; - flex-direction: column; -} - -.section-header h2 { - font-size: 1.25rem; - font-weight: 600; - margin: 0 0 0.25rem 0; - color: var(--text-primary); -} - -.section-header p { - font-size: 0.875rem; - color: var(--text-secondary); - margin: 0 0 1rem 0; -} - -.section-header button { - margin-top: 0.5rem; -} - -.section-total { - display: flex; - flex-direction: row; - align-items: center; - gap: 1rem; -} - -.section-total > button { - flex-shrink: 0; -} - -.section-total > div { - display: flex; - flex-direction: column; - align-items: flex-end; - text-align: right; -} - -.section-total-label { - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0.5px; - text-transform: uppercase; - color: var(--text-secondary); -} - -.section-total-amount { - font-size: 1rem; - font-weight: 700; - color: var(--text-primary); -} - -.field-error { - border-color: var(--error-color) !important; -} - -.input-with-prefix input.field-error { - border-color: var(--error-color) !important; -} - -/* Empty State */ -.empty-state { - text-align: center; - padding: 2rem; - border: 2px dashed var(--border-color); - border-radius: 0.5rem; - background-color: var(--background-secondary); -} - -.empty-icon { - font-size: 2.5rem; - margin-bottom: 0.75rem; -} - -.empty-state h3 { - font-size: 1.125rem; - font-weight: 600; - margin: 0 0 0.5rem 0; - color: var(--text-primary); -} - -.empty-state p { - color: var(--text-secondary); - margin: 0; - font-size: 0.875rem; -} - -/* Benefits List */ -.benefits-list { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.benefit-item { - display: grid; - grid-template-columns: 1fr auto auto; - align-items: center; - gap: 1rem; -} - -.benefit-info { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.benefit-info h4 { - font-size: 0.95rem; - font-weight: 600; - margin: 0; - color: var(--text-primary); -} - -.benefit-type { - display: inline-block; - width: fit-content; - font-size: 0.75rem; - font-weight: 600; - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.benefit-type.pre-tax { - background-color: rgba(34, 197, 94, 0.15); - color: rgb(34, 197, 94); -} - -.benefit-type.post-tax { - background-color: rgba(168, 85, 247, 0.15); - color: rgb(168, 85, 247); -} - -.benefit-type.from-account { - background-color: rgba(59, 130, 246, 0.15); - color: rgb(59, 130, 246); -} - -.benefit-amount { - text-align: right; - display: flex; - flex-direction: row; - gap: 1rem; -} - -.benefit-amount .amount-display { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.25rem; -} - -.benefit-amount .amount-label { - font-size: 0.75rem; - color: var(--text-secondary); - font-weight: 500; -} - -.benefit-amount .amount { - font-size: 0.95rem; - font-weight: 600; - color: var(--text-primary); -} - -.benefit-actions { - display: flex; - gap: 0.5rem; -} - -.benefit-actions button { - padding: 0.5rem; - min-width: auto; - background: transparent; - border: 1px solid var(--border-color); - color: var(--text-secondary); - cursor: pointer; - border-radius: 0.25rem; - font-size: 1rem; - transition: all 0.2s; -} - -.benefit-actions button:hover { - background-color: var(--background-secondary); - border-color: var(--text-secondary); - color: var(--text-primary); -} - -/* Retirement List */ -.retirement-list { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.retirement-item { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; -} - -.retirement-info { - flex: 1; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.retirement-info h4 { - font-size: 0.95rem; - font-weight: 600; - margin: 0; - color: var(--text-primary); -} - -.retirement-details { - display: flex; - flex-direction: column; - gap: 0.375rem; -} - -.detail { - display: flex; - justify-content: space-between; - font-size: 0.875rem; - color: var(--text-secondary); -} - -.detail .label { - font-weight: 500; - color: var(--text-secondary); -} - -.detail .value { - color: var(--text-primary); - font-weight: 500; -} - -.retirement-actions { - display: flex; - gap: 0.5rem; - flex-shrink: 0; -} - -.retirement-actions button { - padding: 0.5rem; - min-width: auto; - background: transparent; - border: 1px solid var(--border-color); - color: var(--text-secondary); - cursor: pointer; - border-radius: 0.25rem; - font-size: 1rem; - transition: all 0.2s; -} - -.retirement-actions button:hover { - background-color: var(--background-secondary); - border-color: var(--text-secondary); - color: var(--text-primary); -} - -/* Responsive */ -@media (max-width: 768px) { - .benefits-manager { - padding: 1rem; - gap: 1.5rem; - } - - .section-header { - flex-direction: column; - align-items: stretch; - } - - .section-total { - align-items: flex-start; - text-align: left; - } - - .benefit-item { - grid-template-columns: 1fr; - gap: 0.5rem; - } - - .benefit-info { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - } - - .benefit-amount { - text-align: left; - } - - .benefit-actions { - grid-column: 1; - justify-content: flex-start; - } - - .retirement-item { - flex-direction: column; - } - - .retirement-actions { - width: 100%; - justify-content: flex-end; - } -} diff --git a/src/components/BenefitsManager/BenefitsManager.tsx b/src/components/BenefitsManager/BenefitsManager.tsx deleted file mode 100644 index e6a0146..0000000 --- a/src/components/BenefitsManager/BenefitsManager.tsx +++ /dev/null @@ -1,972 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { useBudget } from '../../contexts/BudgetContext'; -import type { Benefit, RetirementElection } from '../../types/auth'; -import { formatWithSymbol, getCurrencySymbol } from '../../utils/currency'; -import { getPaychecksPerYear, convertToDisplayMode, getDisplayModeLabel, calculateGrossPayPerPaycheck } from '../../utils/payPeriod'; -import { getDefaultAccountIcon } from '../../utils/accountDefaults'; -import { getRetirementPlanDisplayLabel, RETIREMENT_PLAN_OPTIONS } from '../../utils/retirement'; -import { Modal, Button, FormGroup, InputWithPrefix, RadioGroup, SectionItemCard, Alert, ViewModeSelector, PageHeader } from '../shared'; -import { GlossaryTerm } from '../Glossary'; -import './BenefitsManager.css'; - -interface BenefitsManagerProps { - shouldScrollToRetirement?: boolean; - onScrollToRetirementComplete?: () => void; - displayMode?: 'paycheck' | 'monthly' | 'yearly'; - onDisplayModeChange?: (mode: 'paycheck' | 'monthly' | 'yearly') => void; -} - -type BenefitFieldErrors = { - name?: string; - amount?: string; - sourceAccountId?: string; -}; - -type RetirementFieldErrors = { - employeeAmount?: string; - sourceAccountId?: string; - employerMatchCap?: string; - yearlyLimit?: string; - customLabel?: string; -}; - -const BenefitsManager: React.FC = ({ - shouldScrollToRetirement, - onScrollToRetirementComplete, - displayMode = 'paycheck', - onDisplayModeChange, -}) => { - const { budgetData, addBenefit, updateBenefit, deleteBenefit, addRetirementElection, updateRetirementElection, deleteRetirementElection, calculateRetirementContributions } = useBudget(); - const [showAddBenefit, setShowAddBenefit] = useState(false); - const [editingBenefit, setEditingBenefit] = useState(null); - const [showAddRetirement, setShowAddRetirement] = useState(false); - const [editingRetirement, setEditingRetirement] = useState(null); - const scrollCompletedRef = useRef(false); - - // Benefit form state - const [benefitName, setBenefitName] = useState(''); - const [benefitAmount, setBenefitAmount] = useState(''); - const [benefitIsPercentage, setBenefitIsPercentage] = useState(false); - const [benefitIsTaxable, setBenefitIsTaxable] = useState(false); - const [benefitSource, setBenefitSource] = useState<'paycheck' | 'account'>('paycheck'); - const [benefitSourceAccountId, setBenefitSourceAccountId] = useState(''); - - // Retirement form state - const [retirementType, setRetirementType] = useState('401k'); - const [retirementCustomLabel, setRetirementCustomLabel] = useState(''); - const [employeeAmount, setEmployeeAmount] = useState(''); - const [employeeIsPercentage, setEmployeeIsPercentage] = useState(true); - const [retirementSource, setRetirementSource] = useState<'paycheck' | 'account'>('paycheck'); - const [retirementSourceAccountId, setRetirementSourceAccountId] = useState(''); - const [retirementIsPreTax, setRetirementIsPreTax] = useState(true); - const [employerMatchOption, setEmployerMatchOption] = useState<'no-match' | 'has-match'>('no-match'); - const [employerMatchCap, setEmployerMatchCap] = useState(''); - const [employerMatchCapIsPercentage, setEmployerMatchCapIsPercentage] = useState(true); - const [yearlyLimit, setYearlyLimit] = useState(''); - const [benefitFieldErrors, setBenefitFieldErrors] = useState({}); - const [retirementFieldErrors, setRetirementFieldErrors] = useState({}); - const [retirementFormMessage, setRetirementFormMessage] = useState<{ type: 'warning' | 'error'; message: string } | null>(null); - - // Scroll to retirement section when shouldScrollToRetirement is true - useEffect(() => { - if (shouldScrollToRetirement && !scrollCompletedRef.current) { - const element = document.getElementById('retirement-section'); - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'start' }); - scrollCompletedRef.current = true; - onScrollToRetirementComplete?.(); - } - } - - // Reset the ref when shouldScrollToRetirement becomes false - if (!shouldScrollToRetirement) { - scrollCompletedRef.current = false; - } - }, [shouldScrollToRetirement, onScrollToRetirementComplete]); - - if (!budgetData) return null; - - const currency = budgetData.settings?.currency || 'USD'; - - const paychecksPerYear = getPaychecksPerYear(budgetData.paySettings.payFrequency); - - // Calculate gross pay per paycheck - const getGrossPayPerPaycheck = (): number => { - return calculateGrossPayPerPaycheck(budgetData.paySettings); - }; - - // Convert per-paycheck amount to display mode - const toDisplayAmount = (perPaycheckAmount: number): number => { - return convertToDisplayMode(perPaycheckAmount, paychecksPerYear, displayMode); - }; - - // Calculate yearly retirement contribution (employee + employer) - const calculateYearlyRetirementContribution = ( - employeeContribAmount: number, - isPercentage: boolean, - hasEmployerMatch: boolean, - employerCapAmount: number, - employerCapIsPercentage: boolean - ): number => { - const grossPayPerPaycheck = getGrossPayPerPaycheck(); - - // Calculate contribution per paycheck - let employeePerPaycheck = 0; - if (isPercentage) { - employeePerPaycheck = (grossPayPerPaycheck * employeeContribAmount) / 100; - } else { - employeePerPaycheck = employeeContribAmount; - } - - // Calculate employer match per paycheck (if enabled) - let employerPerPaycheck = 0; - if (hasEmployerMatch) { - const employeePercentage = isPercentage - ? employeeContribAmount - : (employeePerPaycheck / grossPayPerPaycheck) * 100; - - if (employerCapIsPercentage) { - const matchPercentage = Math.min(employeePercentage, employerCapAmount); - employerPerPaycheck = (grossPayPerPaycheck * matchPercentage) / 100; - } else { - employerPerPaycheck = Math.min(employeePerPaycheck, employerCapAmount); - } - } - - return (employeePerPaycheck + employerPerPaycheck) * paychecksPerYear; - }; - - // Check if current contribution would exceed yearly limit - const checkYearlyLimitExceeded = ( - limit: number, - employeeAmount: number, - isPercentage: boolean, - hasEmployerMatch: boolean, - employerCapAmount: number, - employerCapIsPercentage: boolean - ): { exceeded: boolean; total: number; overage: number } => { - const yearly = calculateYearlyRetirementContribution( - employeeAmount, - isPercentage, - hasEmployerMatch, - employerCapAmount, - employerCapIsPercentage - ); - return { - exceeded: yearly > limit, - total: yearly, - overage: Math.max(0, yearly - limit), - }; - }; - - // Auto-calculate contribution to hit yearly limit exactly - const handleAutoCalculateYearlyAmount = () => { - if (!yearlyLimit || parseFloat(yearlyLimit) <= 0) { - setRetirementFormMessage({ - type: 'warning', - message: 'Please enter a yearly limit first', - }); - return; - } - - const grossPayPerPaycheck = getGrossPayPerPaycheck(); - const yearlyLimitAmount = parseFloat(yearlyLimit); - - // If employer match is enabled, we need to account for it - if (employerMatchOption === 'has-match') { - const employerCapAmount = parseFloat(employerMatchCap) || 0; - - // We need to solve: (employeePerPaycheck + employerPerPaycheck) * paychecksPerYear = yearlyLimit - // This is iterative since employer match depends on employee amount - - let employeePerPaycheck = yearlyLimitAmount / paychecksPerYear * 0.9; // Start with 90% guess - let iterations = 0; - const maxIterations = 10; - - while (iterations < maxIterations) { - const employeePercentage = employeeIsPercentage - ? (employeePerPaycheck / grossPayPerPaycheck) * 100 - : (employeePerPaycheck / grossPayPerPaycheck) * 100; - - let employerPerPaycheck = 0; - if (employerMatchCapIsPercentage) { - const matchPercentage = Math.min(employeePercentage, employerCapAmount); - employerPerPaycheck = (grossPayPerPaycheck * matchPercentage) / 100; - } else { - employerPerPaycheck = Math.min(employeePerPaycheck, employerCapAmount); - } - - const totalPerPaycheck = employeePerPaycheck + employerPerPaycheck; - const totalYearly = totalPerPaycheck * paychecksPerYear; - - if (Math.abs(totalYearly - yearlyLimitAmount) < 0.01) { - // Close enough - if (employeeIsPercentage) { - setEmployeeAmount(((employeePerPaycheck / grossPayPerPaycheck) * 100).toFixed(2)); - } else { - setEmployeeAmount(employeePerPaycheck.toFixed(2)); - } - setRetirementFormMessage(null); - return; - } - - employeePerPaycheck = employeePerPaycheck * (yearlyLimitAmount / totalYearly); - iterations++; - } - } else { - // No employer match, simple calculation - const employeePerPaycheck = yearlyLimitAmount / paychecksPerYear; - if (employeeIsPercentage) { - setEmployeeAmount(((employeePerPaycheck / grossPayPerPaycheck) * 100).toFixed(2)); - } else { - setEmployeeAmount(employeePerPaycheck.toFixed(2)); - } - setRetirementFormMessage(null); - } - }; - - const grossPayPerPaycheck = getGrossPayPerPaycheck(); - - const getBenefitPerPaycheck = (benefit: Benefit): number => { - if (benefit.isPercentage) { - return (grossPayPerPaycheck * benefit.amount) / 100; - } - return benefit.amount; - }; - - const sortedBenefits = [...budgetData.benefits].sort( - (a, b) => getBenefitPerPaycheck(b) - getBenefitPerPaycheck(a) - ); - - const benefitsTotalPerPaycheck = sortedBenefits.reduce( - (sum, benefit) => sum + getBenefitPerPaycheck(benefit), - 0 - ); - - const sortedRetirement = [...(budgetData.retirement || [])].sort((a, b) => { - const aTotal = calculateRetirementContributions(a).employeeAmount + calculateRetirementContributions(a).employerAmount; - const bTotal = calculateRetirementContributions(b).employeeAmount + calculateRetirementContributions(b).employerAmount; - return bTotal - aTotal; - }); - - const retirementTotalPerPaycheck = sortedRetirement.reduce((sum, election) => { - const { employeeAmount, employerAmount } = calculateRetirementContributions(election); - return sum + employeeAmount + employerAmount; - }, 0); - - // Benefit handlers - const handleAddBenefit = () => { - setEditingBenefit(null); - setBenefitName(''); - setBenefitAmount(''); - setBenefitIsPercentage(false); - setBenefitIsTaxable(false); - setBenefitSource('paycheck'); - setBenefitSourceAccountId(''); - setBenefitFieldErrors({}); - setShowAddBenefit(true); - }; - - const handleEditBenefit = (benefit: Benefit) => { - setEditingBenefit(benefit); - setBenefitName(benefit.name); - setBenefitAmount(benefit.amount.toString()); - setBenefitIsPercentage(benefit.isPercentage || false); - setBenefitSource(benefit.deductionSource || 'paycheck'); - setBenefitSourceAccountId(benefit.sourceAccountId || ''); - setBenefitIsTaxable((benefit.deductionSource || 'paycheck') === 'account' ? true : benefit.isTaxable); - setBenefitFieldErrors({}); - setShowAddBenefit(true); - }; - - const handleSaveBenefit = () => { - const trimmedBenefitName = benefitName.trim(); - const parsedBenefitAmount = parseFloat(benefitAmount); - const isAccountSource = benefitSource === 'account'; - const errors: BenefitFieldErrors = {}; - - if (!trimmedBenefitName) { - errors.name = 'Benefit name is required.'; - } - - if (!Number.isFinite(parsedBenefitAmount) || parsedBenefitAmount < 0) { - errors.amount = 'Please enter a valid benefit amount.'; - } - - if (isAccountSource && !benefitSourceAccountId) { - errors.sourceAccountId = 'Please select an account for this benefit deduction source.'; - } - - if (Object.keys(errors).length > 0) { - setBenefitFieldErrors(errors); - return; - } - - const benefitData = { - name: trimmedBenefitName, - amount: parsedBenefitAmount, - isTaxable: isAccountSource ? true : benefitIsTaxable, - isPercentage: benefitIsPercentage, - deductionSource: benefitSource, - sourceAccountId: isAccountSource ? benefitSourceAccountId : undefined, - }; - - if (editingBenefit) { - updateBenefit(editingBenefit.id, benefitData); - } else { - addBenefit(benefitData); - } - - setShowAddBenefit(false); - setEditingBenefit(null); - setBenefitFieldErrors({}); - }; - - const handleDeleteBenefit = (id: string) => { - if (confirm('Are you sure you want to delete this benefit?')) { - deleteBenefit(id); - } - }; - - // Retirement handlers - const handleAddRetirement = () => { - setEditingRetirement(null); - setRetirementType('401k'); - setRetirementCustomLabel(''); - setEmployeeAmount(''); - setEmployeeIsPercentage(true); - setRetirementSource('paycheck'); - setRetirementSourceAccountId(''); - setRetirementIsPreTax(true); - setEmployerMatchOption('no-match'); - setEmployerMatchCap(''); - setEmployerMatchCapIsPercentage(true); - setYearlyLimit(''); - setRetirementFieldErrors({}); - setRetirementFormMessage(null); - setShowAddRetirement(true); - }; - - const handleEditRetirement = (election: RetirementElection) => { - setEditingRetirement(election); - setRetirementType(election.type); - setRetirementCustomLabel(election.customLabel || ''); - setEmployeeAmount(election.employeeContribution.toString()); - setEmployeeIsPercentage(election.employeeContributionIsPercentage); - setRetirementSource(election.deductionSource || 'paycheck'); - setRetirementSourceAccountId(election.sourceAccountId || ''); - setRetirementIsPreTax(election.isPreTax !== false); - setEmployerMatchOption(election.hasEmployerMatch ? 'has-match' : 'no-match'); - setEmployerMatchCap((election.employerMatchCap || 0).toString()); - setEmployerMatchCapIsPercentage(election.employerMatchCapIsPercentage); - setYearlyLimit((election.yearlyLimit || '').toString()); - setRetirementFieldErrors({}); - setRetirementFormMessage(null); - setShowAddRetirement(true); - }; - - const handleSaveRetirement = () => { - const hasEmployerMatch = employerMatchOption === 'has-match'; - const parsedEmployeeContribution = parseFloat(employeeAmount); - const parsedMatchCap = parseFloat(employerMatchCap); - const isAccountSource = retirementSource === 'account'; - const parsedYearlyLimit = yearlyLimit ? parseFloat(yearlyLimit) : undefined; - const errors: RetirementFieldErrors = {}; - - if (!Number.isFinite(parsedEmployeeContribution) || parsedEmployeeContribution < 0) { - errors.employeeAmount = 'Please enter a valid contribution amount.'; - } - - if (retirementType === 'other' && !retirementCustomLabel.trim()) { - errors.customLabel = 'Please enter a custom plan name for "Other" retirement type.'; - } - - if (isAccountSource && !retirementSourceAccountId) { - errors.sourceAccountId = 'Please select an account for this retirement deduction source.'; - } - - if (hasEmployerMatch && (!Number.isFinite(parsedMatchCap) || parsedMatchCap < 0)) { - errors.employerMatchCap = 'Please enter a valid employer match cap.'; - } - - if (yearlyLimit && (!Number.isFinite(parsedYearlyLimit) || (parsedYearlyLimit ?? 0) <= 0)) { - errors.yearlyLimit = 'Please enter a valid yearly limit greater than zero.'; - } - - if (Object.keys(errors).length > 0) { - setRetirementFieldErrors(errors); - setRetirementFormMessage(null); - return; - } - - // Check if yearly limit would be exceeded - if (parsedYearlyLimit && parsedYearlyLimit > 0) { - const check = checkYearlyLimitExceeded( - parsedYearlyLimit, - parsedEmployeeContribution, - employeeIsPercentage, - hasEmployerMatch, - parsedMatchCap || 0, - employerMatchCapIsPercentage - ); - - if (check.exceeded) { - setRetirementFormMessage({ - type: 'error', - message: `This contribution would exceed your yearly limit by ${formatWithSymbol(check.overage, currency, { minimumFractionDigits: 2 })}. Total would be ${formatWithSymbol(check.total, currency, { minimumFractionDigits: 2 })} vs limit of ${formatWithSymbol(parsedYearlyLimit, currency, { minimumFractionDigits: 2 })}. Use "Auto-Calculate" to adjust or reduce the amount.`, - }); - return; // Prevent saving - } - } - - const retirementData = { - type: retirementType, - customLabel: retirementType === 'other' ? retirementCustomLabel.trim() : undefined, - employeeContribution: parsedEmployeeContribution, - employeeContributionIsPercentage: employeeIsPercentage, - isPreTax: isAccountSource ? false : retirementIsPreTax, - deductionSource: retirementSource, - sourceAccountId: isAccountSource ? retirementSourceAccountId : undefined, - hasEmployerMatch: hasEmployerMatch, - employerMatchCap: hasEmployerMatch ? (isNaN(parsedMatchCap) ? 0 : parsedMatchCap) : 0, - employerMatchCapIsPercentage: hasEmployerMatch ? employerMatchCapIsPercentage : false, - yearlyLimit: parsedYearlyLimit, - }; - - if (editingRetirement) { - updateRetirementElection(editingRetirement.id, retirementData); - } else { - addRetirementElection(retirementData); - } - - setShowAddRetirement(false); - setEditingRetirement(null); - setRetirementFieldErrors({}); - setRetirementFormMessage(null); - }; - - const handleDeleteRetirement = (id: string) => { - if (confirm('Are you sure you want to delete this retirement election?')) { - deleteRetirementElection(id); - } - }; - - return ( -
- {})} />} - /> - - {/* Benefits Section */} -
-
-
-

Benefits Elections

-

Health insurance, FSA, HSA, and other benefit deductions

-
-
-
- Total {getDisplayModeLabel(displayMode)} - - {formatWithSymbol(toDisplayAmount(benefitsTotalPerPaycheck), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - -
- -
-
- - {budgetData.benefits.length === 0 ? ( -
-
🏥
-

No Benefits Yet

-

Add your benefit elections to get started

-
- ) : ( -
- {sortedBenefits.map(benefit => { - const accountName = benefit.deductionSource === 'account' - ? budgetData.accounts.find(acc => acc.id === benefit.sourceAccountId)?.name - : null; - const benefitPerPaycheck = getBenefitPerPaycheck(benefit); - const benefitInDisplayMode = toDisplayAmount(benefitPerPaycheck); - - return ( - -
-

{benefit.name}

- - {benefit.deductionSource === 'account' ? `From ${accountName}` : (benefit.isTaxable ? 'Post-Tax' : 'Pre-Tax')} - -
-
-
- Per Paycheck: - - {benefit.isPercentage ? `${benefit.amount}%` : formatWithSymbol(benefitPerPaycheck, currency, { minimumFractionDigits: 2 })} - -
- {displayMode !== 'paycheck' && ( -
- {getDisplayModeLabel(displayMode)}: - - {formatWithSymbol(benefitInDisplayMode, currency, { minimumFractionDigits: 2 })} - -
- )} -
-
- - -
-
- ); - })} -
- )} -
- - {/* Retirement Section */} -
-
-
-

Retirement Elections

-

401k, 403b, IRA, and other retirement plan contributions

-
-
-
- Total {getDisplayModeLabel(displayMode)} - - {formatWithSymbol(toDisplayAmount(retirementTotalPerPaycheck), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - -
- -
-
- - {!budgetData.retirement || budgetData.retirement.length === 0 ? ( -
-
🏦
-

No Retirement Plans Yet

-

Add your retirement plan elections to get started

-
- ) : ( -
- {sortedRetirement.map(retirement => { - const { employeeAmount, employerAmount } = calculateRetirementContributions(retirement); - const totalPerPaycheck = employeeAmount + employerAmount; - const totalInDisplayMode = toDisplayAmount(totalPerPaycheck); - const displayLabel = getRetirementPlanDisplayLabel(retirement); - - return ( - -
-

{displayLabel}

-
-
- Your Contribution: - - {formatWithSymbol(employeeAmount || 0, currency, { minimumFractionDigits: 2 })} per paycheck - {retirement.employeeContributionIsPercentage && ` (${retirement.employeeContribution}%)`} - -
- {retirement.hasEmployerMatch && ( -
- Employer Match: - - {formatWithSymbol(employerAmount || 0, currency, { minimumFractionDigits: 2 })} per paycheck - (up to {retirement.employerMatchCapIsPercentage ? `${retirement.employerMatchCap || 0}%` : formatWithSymbol(retirement.employerMatchCap || 0, currency, { minimumFractionDigits: 2 })}) - -
- )} - {displayMode !== 'paycheck' && ( -
- Total {getDisplayModeLabel(displayMode)}: - - {formatWithSymbol(totalInDisplayMode, currency, { minimumFractionDigits: 2 })} - -
- )} - {retirement.yearlyLimit && ( -
- Yearly Limit: - - {formatWithSymbol(retirement.yearlyLimit, currency, { minimumFractionDigits: 2 })} max per year - -
- )} -
-
-
- - -
-
- ); - })} -
- )} -
- - {/* Add/Edit Benefit Modal */} - { - setShowAddBenefit(false); - setBenefitFieldErrors({}); - }} - header={editingBenefit ? 'Edit Benefit' : 'Add Benefit'} - footer={ - <> - - - - } - > - - { - setBenefitName(e.target.value); - if (benefitFieldErrors.name) { - setBenefitFieldErrors((prev) => ({ ...prev, name: undefined })); - } - }} - placeholder="e.g., Health Insurance, FSA" - required - /> - - - - - - -
-
- - { - setBenefitAmount(e.target.value); - if (benefitFieldErrors.amount) { - setBenefitFieldErrors((prev) => ({ ...prev, amount: undefined })); - } - }} - placeholder={benefitIsPercentage ? '0' : '0.00'} - step={benefitIsPercentage ? '0.1' : '0.01'} - min="0" - required - /> - -
-
- - - -
-
- - {benefitSource === 'paycheck' ? ( - - setBenefitIsTaxable(value === 'post-tax')} - layout="column" - options={[ - { value: 'pre-tax', label: 'Pre-Tax', description: 'Reduces taxable income' }, - { value: 'post-tax', label: 'Post-Tax', description: 'Deducted after taxes' }, - ]} - /> - - ) : ( -

- Account-based deductions are treated as post-tax and will be grouped under the selected account in Pay Breakdown. -

- )} -
- - {/* Add/Edit Retirement Modal */} - { - setShowAddRetirement(false); - setRetirementFieldErrors({}); - setRetirementFormMessage(null); - }} - header={editingRetirement ? 'Edit Retirement Plan' : 'Add Retirement Plan'} - footer={ - <> - - - - } - > - Plan Type} required> - - - - {retirementType === 'other' && ( - - { - setRetirementCustomLabel(e.target.value); - if (retirementFieldErrors.customLabel) { - setRetirementFieldErrors((prev) => ({ ...prev, customLabel: undefined })); - } - }} - placeholder="e.g., 457(b), Solo 401(k), SIMPLE IRA" - required - /> - - )} - -
-

Your Contribution

- - - - - -
-
- - { - setEmployeeAmount(e.target.value); - if (retirementFieldErrors.employeeAmount) { - setRetirementFieldErrors((prev) => ({ ...prev, employeeAmount: undefined })); - } - }} - placeholder={employeeIsPercentage ? '0' : '0.00'} - step={employeeIsPercentage ? '0.1' : '0.01'} - min="0" - required - /> - -
-
- - - -
-
- - {retirementSource === 'paycheck' && ( -
- Tax Treatment}> - setRetirementIsPreTax(value === 'pre-tax')} - layout="column" - options={[ - { value: 'pre-tax', label: 'Pre-Tax', description: 'Reduces taxable income' }, - { value: 'post-tax', label: 'Post-Tax', description: 'Deducted after taxes' }, - ]} - /> - -
- )} - - {retirementFormMessage && ( - - {retirementFormMessage.message} - - )} - - {retirementSource === 'account' && ( -

- Account-based contributions are treated as post-tax and will be grouped under the selected account in Pay Breakdown. -

- )} - -
-

Yearly Limit (Optional)

- Maximum Yearly Contribution} error={retirementFieldErrors.yearlyLimit}> - { - setYearlyLimit(e.target.value); - if (retirementFieldErrors.yearlyLimit) { - setRetirementFieldErrors((prev) => ({ ...prev, yearlyLimit: undefined })); - } - }} - placeholder="0.00" - step="0.01" - min="0" - /> - - - {yearlyLimit && parseFloat(yearlyLimit) > 0 && ( -
- -
- )} - - {yearlyLimit && parseFloat(yearlyLimit) > 0 && ( -

- Set a yearly contribution limit and we'll help you auto-calculate the perfect per-paycheck amount to reach it without going over (includes employer match if enabled). -

- )} -
-
- -
-

Employer Match (Optional)

- - Employer Match Availability}> - setEmployerMatchOption(value as 'no-match' | 'has-match')} - layout="column" - options={[ - { value: 'no-match', label: 'Employer does not offer match' }, - { value: 'has-match', label: 'Employer offers match' }, - ]} - /> - - - {employerMatchOption === 'has-match' && ( - <> -
-
- Match Cap} required error={retirementFieldErrors.employerMatchCap}> - { - setEmployerMatchCap(e.target.value); - if (retirementFieldErrors.employerMatchCap) { - setRetirementFieldErrors((prev) => ({ ...prev, employerMatchCap: undefined })); - } - }} - placeholder={employerMatchCapIsPercentage ? '6' : '0.00'} - step={employerMatchCapIsPercentage ? '0.1' : '0.01'} - min="0" - required - /> - -
-
- Cap Type}> - - -
-
-

- Employer will match your contribution up to this cap. For example, if cap is 6% and you contribute 10%, employer matches 6%. -

- - )} -
-
-
- ); -}; - -export default BenefitsManager; diff --git a/src/components/BenefitsManager/BenefitsManager.tsx.backup b/src/components/BenefitsManager/BenefitsManager.tsx.backup deleted file mode 100644 index 56fc89a..0000000 --- a/src/components/BenefitsManager/BenefitsManager.tsx.backup +++ /dev/null @@ -1,976 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { useBudget } from '../../contexts/BudgetContext'; -import type { Benefit, RetirementElection } from '../../types/auth'; -import { formatWithSymbol, getCurrencySymbol } from '../../utils/currency'; -import { getPaychecksPerYear, convertToDisplayMode, getDisplayModeLabel, calculateGrossPayPerPaycheck } from '../../utils/payPeriod'; -import { getDefaultAccountIcon } from '../../utils/accountDefaults'; -import { Modal, Button, FormGroup, InputWithPrefix, RadioGroup, SectionItemCard, Alert, ViewModeSelector, PageHeader } from '../shared'; -import { GlossaryTerm } from '../Glossary'; -import './BenefitsManager.css'; - -interface BenefitsManagerProps { - shouldScrollToRetirement?: boolean; - onScrollToRetirementComplete?: () => void; - displayMode?: 'paycheck' | 'monthly' | 'yearly'; - onDisplayModeChange?: (mode: 'paycheck' | 'monthly' | 'yearly') => void; -} - -type BenefitFieldErrors = { - name?: string; - amount?: string; - sourceAccountId?: string; -}; - -type RetirementFieldErrors = { - employeeAmount?: string; - sourceAccountId?: string; - employerMatchCap?: string; - yearlyLimit?: string; - customLabel?: string; -}; - -const BenefitsManager: React.FC = ({ - shouldScrollToRetirement, - onScrollToRetirementComplete, - displayMode = 'paycheck', - onDisplayModeChange, -}) => { - const { budgetData, addBenefit, updateBenefit, deleteBenefit, addRetirementElection, updateRetirementElection, deleteRetirementElection, calculateRetirementContributions } = useBudget(); - const [showAddBenefit, setShowAddBenefit] = useState(false); - const [editingBenefit, setEditingBenefit] = useState(null); - const [showAddRetirement, setShowAddRetirement] = useState(false); - const [editingRetirement, setEditingRetirement] = useState(null); - const scrollCompletedRef = useRef(false); - - // Benefit form state - const [benefitName, setBenefitName] = useState(''); - const [benefitAmount, setBenefitAmount] = useState(''); - const [benefitIsPercentage, setBenefitIsPercentage] = useState(false); - const [benefitIsTaxable, setBenefitIsTaxable] = useState(false); - const [benefitSource, setBenefitSource] = useState<'paycheck' | 'account'>('paycheck'); - const [benefitSourceAccountId, setBenefitSourceAccountId] = useState(''); - - // Retirement form state - const [retirementType, setRetirementType] = useState<'401k' | '403b' | 'roth-ira' | 'traditional-ira' | 'pension' | 'other'>('401k'); - const [retirementCustomLabel, setRetirementCustomLabel] = useState(''); - const [employeeAmount, setEmployeeAmount] = useState(''); - const [employeeIsPercentage, setEmployeeIsPercentage] = useState(true); - const [retirementSource, setRetirementSource] = useState<'paycheck' | 'account'>('paycheck'); - const [retirementSourceAccountId, setRetirementSourceAccountId] = useState(''); - const [retirementIsPreTax, setRetirementIsPreTax] = useState(true); - const [employerMatchOption, setEmployerMatchOption] = useState<'no-match' | 'has-match'>('no-match'); - const [employerMatchCap, setEmployerMatchCap] = useState(''); - const [employerMatchCapIsPercentage, setEmployerMatchCapIsPercentage] = useState(true); - const [yearlyLimit, setYearlyLimit] = useState(''); - const [benefitFieldErrors, setBenefitFieldErrors] = useState({}); - const [retirementFieldErrors, setRetirementFieldErrors] = useState({}); - const [retirementFormMessage, setRetirementFormMessage] = useState<{ type: 'warning' | 'error'; message: string } | null>(null); - - // Scroll to retirement section when shouldScrollToRetirement is true - useEffect(() => { - if (shouldScrollToRetirement && !scrollCompletedRef.current) { - const element = document.getElementById('retirement-section'); - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'start' }); - scrollCompletedRef.current = true; - onScrollToRetirementComplete?.(); - } - } - - // Reset the ref when shouldScrollToRetirement becomes false - if (!shouldScrollToRetirement) { - scrollCompletedRef.current = false; - } - }, [shouldScrollToRetirement, onScrollToRetirementComplete]); - - if (!budgetData) return null; - - const currency = budgetData.settings?.currency || 'USD'; - - const paychecksPerYear = getPaychecksPerYear(budgetData.paySettings.payFrequency); - - // Calculate gross pay per paycheck - const getGrossPayPerPaycheck = (): number => { - return calculateGrossPayPerPaycheck(budgetData.paySettings); - }; - - // Convert per-paycheck amount to display mode - const toDisplayAmount = (perPaycheckAmount: number): number => { - return convertToDisplayMode(perPaycheckAmount, paychecksPerYear, displayMode); - }; - - // Calculate yearly retirement contribution (employee + employer) - const calculateYearlyRetirementContribution = ( - employeeContribAmount: number, - isPercentage: boolean, - hasEmployerMatch: boolean, - employerCapAmount: number, - employerCapIsPercentage: boolean - ): number => { - const grossPayPerPaycheck = getGrossPayPerPaycheck(); - - // Calculate contribution per paycheck - let employeePerPaycheck = 0; - if (isPercentage) { - employeePerPaycheck = (grossPayPerPaycheck * employeeContribAmount) / 100; - } else { - employeePerPaycheck = employeeContribAmount; - } - - // Calculate employer match per paycheck (if enabled) - let employerPerPaycheck = 0; - if (hasEmployerMatch) { - const employeePercentage = isPercentage - ? employeeContribAmount - : (employeePerPaycheck / grossPayPerPaycheck) * 100; - - if (employerCapIsPercentage) { - const matchPercentage = Math.min(employeePercentage, employerCapAmount); - employerPerPaycheck = (grossPayPerPaycheck * matchPercentage) / 100; - } else { - employerPerPaycheck = Math.min(employeePerPaycheck, employerCapAmount); - } - } - - return (employeePerPaycheck + employerPerPaycheck) * paychecksPerYear; - }; - - // Check if current contribution would exceed yearly limit - const checkYearlyLimitExceeded = ( - limit: number, - employeeAmount: number, - isPercentage: boolean, - hasEmployerMatch: boolean, - employerCapAmount: number, - employerCapIsPercentage: boolean - ): { exceeded: boolean; total: number; overage: number } => { - const yearly = calculateYearlyRetirementContribution( - employeeAmount, - isPercentage, - hasEmployerMatch, - employerCapAmount, - employerCapIsPercentage - ); - return { - exceeded: yearly > limit, - total: yearly, - overage: Math.max(0, yearly - limit), - }; - }; - - // Auto-calculate contribution to hit yearly limit exactly - const handleAutoCalculateYearlyAmount = () => { - if (!yearlyLimit || parseFloat(yearlyLimit) <= 0) { - setRetirementFormMessage({ - type: 'warning', - message: 'Please enter a yearly limit first', - }); - return; - } - - const grossPayPerPaycheck = getGrossPayPerPaycheck(); - const yearlyLimitAmount = parseFloat(yearlyLimit); - - // If employer match is enabled, we need to account for it - if (employerMatchOption === 'has-match') { - const employerCapAmount = parseFloat(employerMatchCap) || 0; - - // We need to solve: (employeePerPaycheck + employerPerPaycheck) * paychecksPerYear = yearlyLimit - // This is iterative since employer match depends on employee amount - - let employeePerPaycheck = yearlyLimitAmount / paychecksPerYear * 0.9; // Start with 90% guess - let iterations = 0; - const maxIterations = 10; - - while (iterations < maxIterations) { - const employeePercentage = employeeIsPercentage - ? (employeePerPaycheck / grossPayPerPaycheck) * 100 - : (employeePerPaycheck / grossPayPerPaycheck) * 100; - - let employerPerPaycheck = 0; - if (employerMatchCapIsPercentage) { - const matchPercentage = Math.min(employeePercentage, employerCapAmount); - employerPerPaycheck = (grossPayPerPaycheck * matchPercentage) / 100; - } else { - employerPerPaycheck = Math.min(employeePerPaycheck, employerCapAmount); - } - - const totalPerPaycheck = employeePerPaycheck + employerPerPaycheck; - const totalYearly = totalPerPaycheck * paychecksPerYear; - - if (Math.abs(totalYearly - yearlyLimitAmount) < 0.01) { - // Close enough - if (employeeIsPercentage) { - setEmployeeAmount(((employeePerPaycheck / grossPayPerPaycheck) * 100).toFixed(2)); - } else { - setEmployeeAmount(employeePerPaycheck.toFixed(2)); - } - setRetirementFormMessage(null); - return; - } - - employeePerPaycheck = employeePerPaycheck * (yearlyLimitAmount / totalYearly); - iterations++; - } - } else { - // No employer match, simple calculation - const employeePerPaycheck = yearlyLimitAmount / paychecksPerYear; - if (employeeIsPercentage) { - setEmployeeAmount(((employeePerPaycheck / grossPayPerPaycheck) * 100).toFixed(2)); - } else { - setEmployeeAmount(employeePerPaycheck.toFixed(2)); - } - setRetirementFormMessage(null); - } - }; - - const grossPayPerPaycheck = getGrossPayPerPaycheck(); - - const getBenefitPerPaycheck = (benefit: Benefit): number => { - if (benefit.isPercentage) { - return (grossPayPerPaycheck * benefit.amount) / 100; - } - return benefit.amount; - }; - - const sortedBenefits = [...budgetData.benefits].sort( - (a, b) => getBenefitPerPaycheck(b) - getBenefitPerPaycheck(a) - ); - - const benefitsTotalPerPaycheck = sortedBenefits.reduce( - (sum, benefit) => sum + getBenefitPerPaycheck(benefit), - 0 - ); - - const sortedRetirement = [...(budgetData.retirement || [])].sort((a, b) => { - const aTotal = calculateRetirementContributions(a).employeeAmount + calculateRetirementContributions(a).employerAmount; - const bTotal = calculateRetirementContributions(b).employeeAmount + calculateRetirementContributions(b).employerAmount; - return bTotal - aTotal; - }); - - const retirementTotalPerPaycheck = sortedRetirement.reduce((sum, election) => { - const { employeeAmount, employerAmount } = calculateRetirementContributions(election); - return sum + employeeAmount + employerAmount; - }, 0); - - // Benefit handlers - const handleAddBenefit = () => { - setEditingBenefit(null); - setBenefitName(''); - setBenefitAmount(''); - setBenefitIsPercentage(false); - setBenefitIsTaxable(false); - setBenefitSource('paycheck'); - setBenefitSourceAccountId(''); - setBenefitFieldErrors({}); - setShowAddBenefit(true); - }; - - const handleEditBenefit = (benefit: Benefit) => { - setEditingBenefit(benefit); - setBenefitName(benefit.name); - setBenefitAmount(benefit.amount.toString()); - setBenefitIsPercentage(benefit.isPercentage || false); - setBenefitSource(benefit.deductionSource || 'paycheck'); - setBenefitSourceAccountId(benefit.sourceAccountId || ''); - setBenefitIsTaxable((benefit.deductionSource || 'paycheck') === 'account' ? true : benefit.isTaxable); - setBenefitFieldErrors({}); - setShowAddBenefit(true); - }; - - const handleSaveBenefit = () => { - const trimmedBenefitName = benefitName.trim(); - const parsedBenefitAmount = parseFloat(benefitAmount); - const isAccountSource = benefitSource === 'account'; - const errors: BenefitFieldErrors = {}; - - if (!trimmedBenefitName) { - errors.name = 'Benefit name is required.'; - } - - if (!Number.isFinite(parsedBenefitAmount) || parsedBenefitAmount < 0) { - errors.amount = 'Please enter a valid benefit amount.'; - } - - if (isAccountSource && !benefitSourceAccountId) { - errors.sourceAccountId = 'Please select an account for this benefit deduction source.'; - } - - if (Object.keys(errors).length > 0) { - setBenefitFieldErrors(errors); - return; - } - - const benefitData = { - name: trimmedBenefitName, - amount: parsedBenefitAmount, - isTaxable: isAccountSource ? true : benefitIsTaxable, - isPercentage: benefitIsPercentage, - deductionSource: benefitSource, - sourceAccountId: isAccountSource ? benefitSourceAccountId : undefined, - }; - - if (editingBenefit) { - updateBenefit(editingBenefit.id, benefitData); - } else { - addBenefit(benefitData); - } - - setShowAddBenefit(false); - setEditingBenefit(null); - setBenefitFieldErrors({}); - }; - - const handleDeleteBenefit = (id: string) => { - if (confirm('Are you sure you want to delete this benefit?')) { - deleteBenefit(id); - } - }; - - // Retirement handlers - const handleAddRetirement = () => { - setEditingRetirement(null); - setRetirementType('401k'); - setRetirementCustomLabel(''); - setEmployeeAmount(''); - setEmployeeIsPercentage(true); - setRetirementSource('paycheck'); - setRetirementSourceAccountId(''); - setRetirementIsPreTax(true); - setEmployerMatchOption('no-match'); - setEmployerMatchCap(''); - setEmployerMatchCapIsPercentage(true); - setYearlyLimit(''); - setRetirementFieldErrors({}); - setRetirementFormMessage(null); - setShowAddRetirement(true); - }; - - const handleEditRetirement = (election: RetirementElection) => { - setEditingRetirement(election); - setRetirementType(election.type); - setRetirementCustomLabel(election.customLabel || ''); - setEmployeeAmount(election.employeeContribution.toString()); - setEmployeeIsPercentage(election.employeeContributionIsPercentage); - setRetirementSource(election.deductionSource || 'paycheck'); - setRetirementSourceAccountId(election.sourceAccountId || ''); - setRetirementIsPreTax(election.isPreTax !== false); - setEmployerMatchOption(election.hasEmployerMatch ? 'has-match' : 'no-match'); - setEmployerMatchCap((election.employerMatchCap || 0).toString()); - setEmployerMatchCapIsPercentage(election.employerMatchCapIsPercentage); - setYearlyLimit((election.yearlyLimit || '').toString()); - setRetirementFieldErrors({}); - setRetirementFormMessage(null); - setShowAddRetirement(true); - }; - - const handleSaveRetirement = () => { - const hasEmployerMatch = employerMatchOption === 'has-match'; - const parsedEmployeeContribution = parseFloat(employeeAmount); - const parsedMatchCap = parseFloat(employerMatchCap); - const isAccountSource = retirementSource === 'account'; - const parsedYearlyLimit = yearlyLimit ? parseFloat(yearlyLimit) : undefined; - const errors: RetirementFieldErrors = {}; - - if (!Number.isFinite(parsedEmployeeContribution) || parsedEmployeeContribution < 0) { - errors.employeeAmount = 'Please enter a valid contribution amount.'; - } - - if (retirementType === 'other' && !retirementCustomLabel.trim()) { - errors.customLabel = 'Please enter a custom plan name for "Other" retirement type.'; - } - - if (isAccountSource && !retirementSourceAccountId) { - errors.sourceAccountId = 'Please select an account for this retirement deduction source.'; - } - - if (hasEmployerMatch && (!Number.isFinite(parsedMatchCap) || parsedMatchCap < 0)) { - errors.employerMatchCap = 'Please enter a valid employer match cap.'; - } - - if (yearlyLimit && (!Number.isFinite(parsedYearlyLimit) || (parsedYearlyLimit ?? 0) <= 0)) { - errors.yearlyLimit = 'Please enter a valid yearly limit greater than zero.'; - } - - if (Object.keys(errors).length > 0) { - setRetirementFieldErrors(errors); - setRetirementFormMessage(null); - return; - } - - // Check if yearly limit would be exceeded - if (parsedYearlyLimit && parsedYearlyLimit > 0) { - const check = checkYearlyLimitExceeded( - parsedYearlyLimit, - parsedEmployeeContribution, - employeeIsPercentage, - hasEmployerMatch, - parsedMatchCap || 0, - employerMatchCapIsPercentage - ); - - if (check.exceeded) { - setRetirementFormMessage({ - type: 'error', - message: `This contribution would exceed your yearly limit by ${formatWithSymbol(check.overage, currency, { minimumFractionDigits: 2 })}. Total would be ${formatWithSymbol(check.total, currency, { minimumFractionDigits: 2 })} vs limit of ${formatWithSymbol(parsedYearlyLimit, currency, { minimumFractionDigits: 2 })}. Use "Auto-Calculate" to adjust or reduce the amount.`, - }); - return; // Prevent saving - } - } - - const retirementData = { - type: retirementType, - customLabel: retirementType === 'other' ? retirementCustomLabel.trim() : undefined, - employeeContribution: parsedEmployeeContribution, - employeeContributionIsPercentage: employeeIsPercentage, - isPreTax: isAccountSource ? false : retirementIsPreTax, - deductionSource: retirementSource, - sourceAccountId: isAccountSource ? retirementSourceAccountId : undefined, - hasEmployerMatch: hasEmployerMatch, - employerMatchCap: hasEmployerMatch ? (isNaN(parsedMatchCap) ? 0 : parsedMatchCap) : 0, - employerMatchCapIsPercentage: hasEmployerMatch ? employerMatchCapIsPercentage : false, - yearlyLimit: parsedYearlyLimit, - }; - - if (editingRetirement) { - updateRetirementElection(editingRetirement.id, retirementData); - } else { - addRetirementElection(retirementData); - } - - setShowAddRetirement(false); - setEditingRetirement(null); - setRetirementFieldErrors({}); - setRetirementFormMessage(null); - }; - - const handleDeleteRetirement = (id: string) => { - if (confirm('Are you sure you want to delete this retirement election?')) { - deleteRetirementElection(id); - } - }; - - return ( -
- {})} />} - /> - - {/* Benefits Section */} -
-
-
-

Benefits Elections

-

Health insurance, FSA, HSA, and other benefit deductions

-
-
-
- Total {getDisplayModeLabel(displayMode)} - - {formatWithSymbol(toDisplayAmount(benefitsTotalPerPaycheck), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - -
- -
-
- - {budgetData.benefits.length === 0 ? ( -
-
🏥
-

No Benefits Yet

-

Add your benefit elections to get started

-
- ) : ( -
- {sortedBenefits.map(benefit => { - const accountName = benefit.deductionSource === 'account' - ? budgetData.accounts.find(acc => acc.id === benefit.sourceAccountId)?.name - : null; - const benefitPerPaycheck = getBenefitPerPaycheck(benefit); - const benefitInDisplayMode = toDisplayAmount(benefitPerPaycheck); - - return ( - -
-

{benefit.name}

- - {benefit.deductionSource === 'account' ? `From ${accountName}` : (benefit.isTaxable ? 'Post-Tax' : 'Pre-Tax')} - -
-
-
- Per Paycheck: - - {benefit.isPercentage ? `${benefit.amount}%` : formatWithSymbol(benefitPerPaycheck, currency, { minimumFractionDigits: 2 })} - -
- {displayMode !== 'paycheck' && ( -
- {getDisplayModeLabel(displayMode)}: - - {formatWithSymbol(benefitInDisplayMode, currency, { minimumFractionDigits: 2 })} - -
- )} -
-
- - -
-
- ); - })} -
- )} -
- - {/* Retirement Section */} -
-
-
-

Retirement Elections

-

401k, 403b, IRA, and other retirement plan contributions

-
-
-
- Total {getDisplayModeLabel(displayMode)} - - {formatWithSymbol(toDisplayAmount(retirementTotalPerPaycheck), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - -
- -
-
- - {!budgetData.retirement || budgetData.retirement.length === 0 ? ( -
-
🏦
-

No Retirement Plans Yet

-

Add your retirement plan elections to get started

-
- ) : ( -
- {sortedRetirement.map(retirement => { - const { employeeAmount, employerAmount } = calculateRetirementContributions(retirement); - const totalPerPaycheck = employeeAmount + employerAmount; - const totalInDisplayMode = toDisplayAmount(totalPerPaycheck); - const displayLabel = retirement.type === 'other' && retirement.customLabel - ? retirement.customLabel - : retirement.type.toUpperCase(); - - return ( - -
-

{displayLabel}

-
-
- Your Contribution: - - {formatWithSymbol(employeeAmount || 0, currency, { minimumFractionDigits: 2 })} per paycheck - {retirement.employeeContributionIsPercentage && ` (${retirement.employeeContribution}%)`} - -
- {retirement.hasEmployerMatch && ( -
- Employer Match: - - {formatWithSymbol(employerAmount || 0, currency, { minimumFractionDigits: 2 })} per paycheck - (up to {retirement.employerMatchCapIsPercentage ? `${retirement.employerMatchCap || 0}%` : formatWithSymbol(retirement.employerMatchCap || 0, currency, { minimumFractionDigits: 2 })}) - -
- )} - {displayMode !== 'paycheck' && ( -
- Total {getDisplayModeLabel(displayMode)}: - - {formatWithSymbol(totalInDisplayMode, currency, { minimumFractionDigits: 2 })} - -
- )} - {retirement.yearlyLimit && ( -
- Yearly Limit: - - {formatWithSymbol(retirement.yearlyLimit, currency, { minimumFractionDigits: 2 })} max per year - -
- )} -
-
-
- - -
-
- ); - })} -
- )} -
- - {/* Add/Edit Benefit Modal */} - { - setShowAddBenefit(false); - setBenefitFieldErrors({}); - }} - header={editingBenefit ? 'Edit Benefit' : 'Add Benefit'} - footer={ - <> - - - - } - > - - { - setBenefitName(e.target.value); - if (benefitFieldErrors.name) { - setBenefitFieldErrors((prev) => ({ ...prev, name: undefined })); - } - }} - placeholder="e.g., Health Insurance, FSA" - required - /> - - - - - - -
-
- - { - setBenefitAmount(e.target.value); - if (benefitFieldErrors.amount) { - setBenefitFieldErrors((prev) => ({ ...prev, amount: undefined })); - } - }} - placeholder={benefitIsPercentage ? '0' : '0.00'} - step={benefitIsPercentage ? '0.1' : '0.01'} - min="0" - required - /> - -
-
- - - -
-
- - {benefitSource === 'paycheck' ? ( - - setBenefitIsTaxable(value === 'post-tax')} - layout="column" - options={[ - { value: 'pre-tax', label: 'Pre-Tax', description: 'Reduces taxable income' }, - { value: 'post-tax', label: 'Post-Tax', description: 'Deducted after taxes' }, - ]} - /> - - ) : ( -

- Account-based deductions are treated as post-tax and will be grouped under the selected account in Pay Breakdown. -

- )} -
- - {/* Add/Edit Retirement Modal */} - { - setShowAddRetirement(false); - setRetirementFieldErrors({}); - setRetirementFormMessage(null); - }} - header={editingRetirement ? 'Edit Retirement Plan' : 'Add Retirement Plan'} - footer={ - <> - - - - } - > - Plan Type} required> - - - - {retirementType === 'other' && ( - - { - setRetirementCustomLabel(e.target.value); - if (retirementFieldErrors.customLabel) { - setRetirementFieldErrors((prev) => ({ ...prev, customLabel: undefined })); - } - }} - placeholder="e.g., 457(b), Solo 401(k), SIMPLE IRA" - required - /> - - )} - -
-

Your Contribution

- - - - - -
-
- - { - setEmployeeAmount(e.target.value); - if (retirementFieldErrors.employeeAmount) { - setRetirementFieldErrors((prev) => ({ ...prev, employeeAmount: undefined })); - } - }} - placeholder={employeeIsPercentage ? '0' : '0.00'} - step={employeeIsPercentage ? '0.1' : '0.01'} - min="0" - required - /> - -
-
- - - -
-
- - {retirementSource === 'paycheck' && ( -
- Tax Treatment}> - setRetirementIsPreTax(value === 'pre-tax')} - layout="column" - options={[ - { value: 'pre-tax', label: 'Pre-Tax', description: 'Reduces taxable income' }, - { value: 'post-tax', label: 'Post-Tax', description: 'Deducted after taxes' }, - ]} - /> - -
- )} - - {retirementFormMessage && ( - - {retirementFormMessage.message} - - )} - - {retirementSource === 'account' && ( -

- Account-based contributions are treated as post-tax and will be grouped under the selected account in Pay Breakdown. -

- )} - -
-

Yearly Limit (Optional)

- Maximum Yearly Contribution} error={retirementFieldErrors.yearlyLimit}> - { - setYearlyLimit(e.target.value); - if (retirementFieldErrors.yearlyLimit) { - setRetirementFieldErrors((prev) => ({ ...prev, yearlyLimit: undefined })); - } - }} - placeholder="0.00" - step="0.01" - min="0" - /> - - - {yearlyLimit && parseFloat(yearlyLimit) > 0 && ( -
- -
- )} - - {yearlyLimit && parseFloat(yearlyLimit) > 0 && ( -

- Set a yearly contribution limit and we'll help you auto-calculate the perfect per-paycheck amount to reach it without going over (includes employer match if enabled). -

- )} -
-
- -
-

Employer Match (Optional)

- - Employer Match Availability}> - setEmployerMatchOption(value as 'no-match' | 'has-match')} - layout="column" - options={[ - { value: 'no-match', label: 'Employer does not offer match' }, - { value: 'has-match', label: 'Employer offers match' }, - ]} - /> - - - {employerMatchOption === 'has-match' && ( - <> -
-
- Match Cap} required error={retirementFieldErrors.employerMatchCap}> - { - setEmployerMatchCap(e.target.value); - if (retirementFieldErrors.employerMatchCap) { - setRetirementFieldErrors((prev) => ({ ...prev, employerMatchCap: undefined })); - } - }} - placeholder={employerMatchCapIsPercentage ? '6' : '0.00'} - step={employerMatchCapIsPercentage ? '0.1' : '0.01'} - min="0" - required - /> - -
-
- Cap Type}> - - -
-
-

- Employer will match your contribution up to this cap. For example, if cap is 6% and you contribute 10%, employer matches 6%. -

- - )} -
-
-
- ); -}; - -export default BenefitsManager; diff --git a/src/components/BenefitsManager/index.ts b/src/components/BenefitsManager/index.ts deleted file mode 100644 index 13f9b71..0000000 --- a/src/components/BenefitsManager/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './BenefitsManager'; -export { default as BenefitsManager } from './BenefitsManager'; diff --git a/src/components/EncryptionSetup/EncryptionSetup.tsx b/src/components/EncryptionSetup/EncryptionSetup.tsx deleted file mode 100644 index fb2b413..0000000 --- a/src/components/EncryptionSetup/EncryptionSetup.tsx +++ /dev/null @@ -1,128 +0,0 @@ -// Encryption Setup Component - Shown on first launch -// Allows users to configure encryption or skip it -import React, { useState } from 'react'; -import { FileStorageService } from '../../services/fileStorage'; -import { KeychainService } from '../../services/keychainService'; -import EncryptionConfigPanel from './EncryptionConfigPanel'; -import { Button } from '../shared'; -import './EncryptionSetup.css'; - -interface EncryptionSetupProps { - onComplete: (encryptionEnabled: boolean) => void; // Called when setup is finished with selected state - onCancel?: () => void; // Called when user wants to exit without completing - planId?: string; // Plan ID to associate with the encryption key -} - -const EncryptionSetup: React.FC = ({ onComplete, onCancel, planId }) => { - const [encryptionEnabled, setEncryptionEnabled] = useState(null); - const [customKey, setCustomKey] = useState(''); - const [generatedKey, setGeneratedKey] = useState(''); - const [useCustomKey, setUseCustomKey] = useState(false); - const [isSaving, setIsSaving] = useState(false); - - // Generate a new random encryption key - const handleGenerateKey = () => { - const key = FileStorageService.generateEncryptionKey(); - setGeneratedKey(key); - setUseCustomKey(false); - }; - - // Save encryption settings and continue - const handleSaveSettings = async () => { - setIsSaving(true); - try { - const settings = FileStorageService.getAppSettings(); - - if (encryptionEnabled) { - const keyToUse = useCustomKey ? customKey : generatedKey; - - if (!keyToUse) { - alert('Please generate or enter an encryption key.'); - setIsSaving(false); - return; - } - - settings.encryptionEnabled = true; - // Don't store the key in localStorage - it goes to keychain - - // If we have a plan ID, save the key to keychain for that plan - if (planId) { - await KeychainService.saveKey(planId, keyToUse); - } - } else { - settings.encryptionEnabled = false; - if (planId) { - await KeychainService.deleteKey(planId); - } - // No key needed for unencrypted plans - } - - FileStorageService.saveAppSettings(settings); - onComplete(Boolean(encryptionEnabled)); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unknown error'; - alert(`Failed to save encryption settings: ${errorMsg}`); - setIsSaving(false); - } - }; - - // Reusable encryption config UI (used by SetupWizard too) - return ( -
-
-

{encryptionEnabled ? '🔐 Encryption Key Setup' : '🔐 Security Setup'}

-

- {encryptionEnabled ? 'This key will be used to encrypt and decrypt your budget files' : 'Choose how you want to protect your budget files'} -

- - - -
- {encryptionEnabled === null ? ( - onCancel && ( - - ) - ) : ( - - )} - -
-
-
- ); -}; - -export default EncryptionSetup; diff --git a/src/components/EncryptionSetup/index.ts b/src/components/EncryptionSetup/index.ts deleted file mode 100644 index d6d1f67..0000000 --- a/src/components/EncryptionSetup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './EncryptionSetup'; diff --git a/src/components/LoansManager/LoansManager.css.backup b/src/components/LoansManager/LoansManager.css.backup deleted file mode 100644 index 7560809..0000000 --- a/src/components/LoansManager/LoansManager.css.backup +++ /dev/null @@ -1,420 +0,0 @@ -.loans-manager { - display: flex; - flex-direction: column; - gap: 1.5rem; - padding: 1.5rem; - overflow-y: auto; - height: 100%; -} - -.loans-content { - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -.loans-manager .empty-state { - text-align: center; - color: var(--text-secondary); - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 12px; - box-shadow: var(--shadow-md); - padding: 2.5rem 1.5rem; -} - -.loans-manager .empty-state p { - margin: 0; - font-size: 0.95rem; -} - -.loans-manager .loans-empty-state { - padding: 2rem; - border: 2px dashed var(--border-color); - border-radius: 0.5rem; - background-color: var(--background-secondary); - box-shadow: none; -} - -.loans-manager .loans-empty-state .empty-icon { - font-size: 2.5rem; - margin-bottom: 0.75rem; -} - -.loans-manager .loans-empty-state h3 { - font-size: 1.125rem; - font-weight: 600; - margin: 0 0 0.5rem 0; - color: var(--text-primary); -} - -.loans-manager .loans-empty-state p { - color: var(--text-secondary); - margin: 0; - font-size: 0.875rem; -} - -.loans-manager .account-section { - background: var(--bg-primary); - border-radius: 12px; - box-shadow: var(--shadow-md); - border: 1px solid var(--border-color); - overflow: hidden; -} - -.loans-manager .account-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - padding: 1.25rem; - background: var(--header-gradient); - color: white; -} - -.loans-manager .account-title { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.loans-manager .account-icon { - font-size: 1.75rem; -} - -.loans-manager .account-title h3 { - margin: 0; - font-size: 1.1rem; - font-weight: 600; - color: white; -} - -.loans-manager .account-total { - display: flex; - flex-direction: column; - align-items: flex-end; - text-align: right; -} - -.loans-manager .total-label { - font-size: 0.75rem; - color: var(--header-text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 0.2rem; -} - -.loans-manager .total-amount { - font-size: 1.4rem; - font-weight: 700; - color: white; -} - -.loans-manager .loans-list { - display: flex; - flex-direction: column; - gap: 0.75rem; - padding: 1rem; -} - -.loans-manager .loan-item { - padding: 1rem; - border: 1px solid var(--border-color); - background: var(--bg-secondary); - border-radius: 10px; - transition: border-color 0.2s ease; -} - -.loans-manager .loan-item:hover { - border-color: var(--border-color-hover, var(--accent-primary)); -} - -.loans-manager .loan-item.loan-disabled { - opacity: 0.6; -} - -.loans-manager .loan-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 1rem; - margin-bottom: 0.9rem; -} - -.loans-manager .loan-title h4 { - margin: 0; - font-size: 1.05rem; - font-weight: 600; - color: var(--text-primary); - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.5rem; -} - -.loans-manager .loan-type-badge { - font-size: 0.68rem; - font-weight: 700; - padding: 0.2rem 0.5rem; - background-color: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 999px; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.loans-manager .loan-amount { - text-align: right; - font-size: 1.2rem; - font-weight: 700; - color: var(--text-primary); - display: flex; - flex-direction: column; - align-items: flex-end; -} - -.loans-manager .amount-label { - font-size: 0.8rem; - color: var(--text-secondary); - font-weight: 500; -} - -.loans-manager .loan-details { - display: flex; - flex-direction: column; - gap: 0.85rem; - margin-bottom: 0.85rem; -} - -.loans-manager .loan-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 0.85rem; -} - -.loans-manager .loan-payment-split { - border: 1px solid var(--border-color-light); - border-radius: 8px; - padding: 0.75rem; - background: var(--bg-primary); -} - -.loans-manager .payment-split-title { - display: block; - font-size: 0.72rem; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.4px; - margin-bottom: 0.5rem; -} - -.loans-manager .payment-split-row { - display: flex; - justify-content: space-between; - gap: 0.5rem; - font-size: 0.85rem; - color: var(--text-secondary); - padding: 0.25rem 0; -} - -.loans-manager .payment-split-row span:last-child { - color: var(--text-primary); - font-weight: 600; -} - -.loans-manager .payment-split-row.total { - margin-top: 0.25rem; - padding-top: 0.5rem; - border-top: 1px solid var(--border-color-light); - font-weight: 600; -} - -.loans-manager .payment-split-row.total span { - color: var(--text-primary); -} - -.loans-manager .loan-stat { - display: flex; - flex-direction: column; - gap: 0.2rem; -} - -.loans-manager .stat-label { - font-size: 0.72rem; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.4px; -} - -.loans-manager .stat-value { - font-size: 0.95rem; - font-weight: 600; - color: var(--text-primary); -} - -.loans-manager .loan-notes { - padding: 0.65rem 0.75rem; - background-color: var(--bg-primary); - border-radius: 6px; - border: 1px solid var(--border-color-light); - font-size: 0.88rem; - color: var(--text-secondary); -} - -.loans-manager .notes-label { - font-weight: 600; - color: var(--text-primary); -} - -.loans-manager .loan-actions { - display: flex; - gap: 0.5rem; - justify-content: flex-end; - padding-top: 0.75rem; - border-top: 1px solid var(--border-color-light); -} - -.loans-manager .loan-actions .btn-icon { - opacity: 0.7; -} - -.loans-manager .loan-actions .btn-icon:hover:not(:disabled) { - opacity: 1; -} - -.loans-manager .field-error, -.loans-manager .input-with-prefix input.field-error { - border-color: var(--error-color) !important; -} - -.loan-schedule-modal { - max-width: 1100px; - width: min(96vw, 1100px); -} - -.loans-manager .loan-schedule-content { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.loans-manager .loan-schedule-description { - margin: 0; - color: var(--text-secondary); - font-size: 0.9rem; -} - -.loans-manager .loan-schedule-empty { - border: 1px dashed var(--border-color); - border-radius: 8px; - padding: 1rem; - color: var(--text-secondary); - background: var(--bg-secondary); -} - -.loans-manager .loan-schedule-table-wrapper { - max-height: 55vh; - overflow: auto; - border: 1px solid var(--border-color); - border-radius: 8px; -} - -.loans-manager .loan-schedule-table { - width: 100%; - border-collapse: collapse; - min-width: 900px; - background: var(--bg-primary); -} - -.loans-manager .loan-schedule-table th, -.loans-manager .loan-schedule-table td { - padding: 0.55rem 0.65rem; - border-bottom: 1px solid var(--border-color-light); - font-size: 0.82rem; - text-align: center; - white-space: nowrap; -} - -.loans-manager .loan-schedule-table th { - position: sticky; - top: 0; - background: var(--bg-tertiary); - color: var(--text-secondary); - font-size: 0.72rem; - text-transform: uppercase; - letter-spacing: 0.4px; - z-index: 1; -} - -.loans-manager .loan-schedule-table tbody tr:nth-child(even) { - background: var(--bg-secondary); -} - -.loans-manager .loan-term-input-row { - display: grid; - grid-template-columns: 1fr 140px; - gap: 0.75rem; -} - -.loans-manager .loan-term-input-row select { - width: 100%; -} - -.loans-manager .insurance-end-balance-row { - display: grid; - grid-template-columns: 1fr 140px; - gap: 0.75rem; -} - -.loans-manager .insurance-end-balance-row select { - width: 100%; -} - -@media (max-width: 768px) { - .loans-manager { - padding: 1rem; - } - - .loans-manager .account-header { - flex-direction: column; - align-items: flex-start; - } - - .loans-manager .account-total { - align-items: flex-start; - text-align: left; - } - - .loans-manager .loan-header { - flex-direction: column; - align-items: flex-start; - gap: 0.65rem; - } - - .loans-manager .loan-amount { - align-items: flex-start; - text-align: left; - } - - .loans-manager .loan-stats { - grid-template-columns: 1fr; - } - - .loans-manager .loan-actions { - flex-wrap: wrap; - justify-content: flex-start; - } - - .loans-manager .loan-term-input-row { - grid-template-columns: 1fr; - } - - .loans-manager .insurance-end-balance-row { - grid-template-columns: 1fr; - } - - .loans-manager .loan-schedule-table-wrapper { - max-height: 50vh; - } -} diff --git a/src/components/LoansManager/LoansManager.tsx.backup b/src/components/LoansManager/LoansManager.tsx.backup deleted file mode 100644 index a50dbce..0000000 --- a/src/components/LoansManager/LoansManager.tsx.backup +++ /dev/null @@ -1,1135 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useBudget } from '../../contexts/BudgetContext'; -import type { Loan, BillFrequency } from '../../types/auth'; -import { formatWithSymbol, getCurrencySymbol } from '../../utils/currency'; -import { getPaychecksPerYear, convertToDisplayMode, getDisplayModeLabel } from '../../utils/payPeriod'; -import { getDefaultAccountIcon } from '../../utils/accountDefaults'; -import { convertBillToMonthly } from '../../utils/billFrequency'; -import { Modal, Button, FormGroup, InputWithPrefix, DateInput, SectionItemCard, ViewModeSelector, PageHeader, RadioGroup, ProgressBar } from '../shared'; -import { GlossaryTerm } from '../Glossary'; -import './LoansManager.css'; - -interface LoansManagerProps { - scrollToAccountId?: string; - displayMode: 'paycheck' | 'monthly' | 'yearly'; - onDisplayModeChange: (mode: 'paycheck' | 'monthly' | 'yearly') => void; -} - -type LoanFieldErrors = { - name?: string; - type?: string; - principal?: string; - currentBalance?: string; - interestRate?: string; - propertyTaxRate?: string; - propertyValue?: string; - monthlyPayment?: string; - insurancePayment?: string; - insuranceEndBalance?: string; - insuranceEndBalancePercent?: string; - termMonths?: string; - accountId?: string; - startDate?: string; -}; - -type LoanTermUnit = 'months' | 'years'; -type LoanPaymentFrequency = Exclude; - -type AmortizationRow = { - paymentNumber: number; - paymentDate: string; - beginningBalance: number; - paymentAmount: number; - principal: number; - interest: number; - pmiPayment: number; - propertyTaxPayment: number; - endingBalance: number; -}; - -const LOAN_TYPES = [ - { value: 'mortgage', label: 'Mortgage' }, - { value: 'auto', label: 'Auto Loan' }, - { value: 'student', label: 'Student Loan' }, - { value: 'personal', label: 'Personal Loan' }, - { value: 'credit-card', label: 'Credit Card' }, - { value: 'other', label: 'Other' }, -] as const; - -const INSURANCE_ELIGIBLE_LOAN_TYPES: Loan['type'][] = ['mortgage', 'auto', 'student', 'other']; -const LONG_TERM_LOAN_TYPES: Loan['type'][] = ['mortgage', 'student']; -const LOAN_PAYMENT_FREQUENCIES: Array<{ value: LoanPaymentFrequency; label: string }> = [ - { value: 'weekly', label: 'Weekly' }, - { value: 'bi-weekly', label: 'Bi-weekly' }, - { value: 'monthly', label: 'Monthly' }, - { value: 'quarterly', label: 'Quarterly' }, - { value: 'semi-annual', label: 'Semi-annual' }, - { value: 'yearly', label: 'Yearly' }, -]; - -const roundToCent = (value: number): number => Math.round(value * 100) / 100; - -const isInsuranceEligibleLoanType = (type: Loan['type']): boolean => INSURANCE_ELIGIBLE_LOAN_TYPES.includes(type); -const getDefaultTermUnitForLoanType = (type: Loan['type']): LoanTermUnit => (LONG_TERM_LOAN_TYPES.includes(type) ? 'years' : 'months'); - -const convertMonthlyPaymentToFrequency = (monthlyAmount: number, frequency: LoanPaymentFrequency): number => { - switch (frequency) { - case 'weekly': - return roundToCent((monthlyAmount * 12) / 52); - case 'bi-weekly': - return roundToCent((monthlyAmount * 12) / 26); - case 'quarterly': - return roundToCent((monthlyAmount * 12) / 4); - case 'semi-annual': - return roundToCent((monthlyAmount * 12) / 2); - case 'yearly': - return roundToCent(monthlyAmount * 12); - case 'monthly': - default: - return roundToCent(monthlyAmount); - } -}; - -const LoansManager: React.FC = ({ scrollToAccountId, displayMode, onDisplayModeChange }) => { - const { budgetData, addLoan, updateLoan, deleteLoan } = useBudget(); - - // Scroll to account when scrollToAccountId changes - useEffect(() => { - if (scrollToAccountId) { - const element = document.getElementById(`account-${scrollToAccountId}`); - element?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, [scrollToAccountId]); - const [showAddLoan, setShowAddLoan] = useState(false); - const [editingLoan, setEditingLoan] = useState(null); - - // Form state for new/edit loan - const [loanName, setLoanName] = useState(''); - const [loanType, setLoanType] = useState('personal'); - const [loanPrincipal, setLoanPrincipal] = useState(''); - const [loanCurrentBalance, setLoanCurrentBalance] = useState(''); - const [loanInterestRate, setLoanInterestRate] = useState(''); - const [loanPropertyTaxRate, setLoanPropertyTaxRate] = useState(''); - const [loanPropertyValue, setLoanPropertyValue] = useState(''); - const [loanMonthlyPayment, setLoanMonthlyPayment] = useState(''); - const [loanPaymentFrequency, setLoanPaymentFrequency] = useState('monthly'); - const [loanInsuranceEnabled, setLoanInsuranceEnabled] = useState(false); - const [loanInsurancePayment, setLoanInsurancePayment] = useState(''); - const [loanInsuranceEndBalanceMode, setLoanInsuranceEndBalanceMode] = useState<'amount' | 'percent'>('amount'); - const [loanInsuranceEndBalance, setLoanInsuranceEndBalance] = useState(''); - const [loanInsuranceEndBalancePercent, setLoanInsuranceEndBalancePercent] = useState(''); - const [loanAccountId, setLoanAccountId] = useState(''); - const [loanStartDate, setLoanStartDate] = useState(''); - const [loanTermMonths, setLoanTermMonths] = useState(''); - const [loanTermUnit, setLoanTermUnit] = useState('months'); - const [loanNotes, setLoanNotes] = useState(''); - const [loanFieldErrors, setLoanFieldErrors] = useState({}); - const [scheduleLoan, setScheduleLoan] = useState(null); - - if (!budgetData) return null; - - const currency = budgetData.settings?.currency || 'USD'; - const isLoanEnabled = (loan: Loan) => loan.enabled !== false; - - const parsedBalanceForWarning = parseFloat(loanCurrentBalance); - const parsedRateForWarning = parseFloat(loanInterestRate); - const parsedPaymentForWarning = parseFloat(loanMonthlyPayment); - const paymentAsMonthlyForWarning = Number.isFinite(parsedPaymentForWarning) - ? convertBillToMonthly(parsedPaymentForWarning, loanPaymentFrequency) - : 0; - const monthlyInterestForWarning = - Number.isFinite(parsedBalanceForWarning) && Number.isFinite(parsedRateForWarning) - ? (parsedBalanceForWarning * (parsedRateForWarning / 100)) / 12 - : 0; - const loanPaymentWarning = - parsedBalanceForWarning > 0 && - parsedRateForWarning > 0 && - parsedPaymentForWarning > 0 && - paymentAsMonthlyForWarning <= monthlyInterestForWarning - ? `Warning: This payment may be too low to reduce principal. Estimated monthly interest is ${formatWithSymbol(monthlyInterestForWarning, currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}.` - : undefined; - - const handleAddLoan = () => { - setEditingLoan(null); - setLoanName(''); - setLoanType('personal'); - setLoanPrincipal(''); - setLoanCurrentBalance(''); - setLoanInterestRate(''); - setLoanPropertyTaxRate(''); - setLoanPropertyValue(''); - setLoanMonthlyPayment(''); - setLoanPaymentFrequency('monthly'); - setLoanInsurancePayment(''); - setLoanInsuranceEndBalance(''); - setLoanAccountId(budgetData.accounts[0]?.id || ''); - setLoanStartDate(new Date().toISOString().split('T')[0]); - setLoanTermMonths(''); - setLoanTermUnit(getDefaultTermUnitForLoanType('personal')); - setLoanNotes(''); - setLoanFieldErrors({}); - setShowAddLoan(true); - }; - - const handleEditLoan = (loan: Loan) => { - setEditingLoan(loan); - setLoanName(loan.name); - setLoanType(loan.type); - setLoanPrincipal(loan.principal.toString()); - setLoanCurrentBalance(loan.currentBalance.toString()); - setLoanInterestRate(loan.interestRate.toString()); - setLoanPropertyTaxRate(loan.propertyTaxRate?.toString() || ''); - setLoanPropertyValue(loan.propertyValue?.toString() || ''); - const paymentFrequency = (loan.paymentFrequency ?? 'monthly') as LoanPaymentFrequency; - setLoanPaymentFrequency(paymentFrequency); - setLoanMonthlyPayment(convertMonthlyPaymentToFrequency(loan.monthlyPayment, paymentFrequency).toString()); - const hasInsurance = (loan.insurancePayment ?? 0) > 0; - setLoanInsuranceEnabled(hasInsurance); - setLoanInsurancePayment(loan.insurancePayment?.toString() || ''); - if (typeof loan.insuranceEndBalancePercent === 'number') { - setLoanInsuranceEndBalanceMode('percent'); - setLoanInsuranceEndBalancePercent(loan.insuranceEndBalancePercent.toString()); - setLoanInsuranceEndBalance(''); - } else { - setLoanInsuranceEndBalanceMode('amount'); - setLoanInsuranceEndBalance(loan.insuranceEndBalance?.toString() || ''); - setLoanInsuranceEndBalancePercent(''); - } - setLoanAccountId(loan.accountId); - setLoanStartDate(loan.startDate.split('T')[0]); - if (loan.termMonths) { - const isFullYears = loan.termMonths >= 12 && loan.termMonths % 12 === 0; - setLoanTermUnit(isFullYears ? 'years' : 'months'); - setLoanTermMonths(isFullYears ? (loan.termMonths / 12).toString() : loan.termMonths.toString()); - } else { - setLoanTermUnit('months'); - setLoanTermMonths(''); - } - setLoanNotes(loan.notes || ''); - setLoanFieldErrors({}); - setShowAddLoan(true); - }; - - const handleSaveLoan = () => { - const trimmedLoanName = loanName.trim(); - const parsedPrincipal = parseFloat(loanPrincipal); - const parsedCurrentBalance = parseFloat(loanCurrentBalance); - const parsedInterestRate = parseFloat(loanInterestRate); - const parsedPropertyTaxRate = loanType === 'mortgage' && loanPropertyTaxRate.trim() !== '' - ? parseFloat(loanPropertyTaxRate) - : undefined; - const parsedPropertyValue = loanType === 'mortgage' && loanPropertyValue.trim() !== '' - ? parseFloat(loanPropertyValue) - : undefined; - const parsedPaymentAmount = parseFloat(loanMonthlyPayment); - const parsedMonthlyPayment = Number.isFinite(parsedPaymentAmount) - ? convertBillToMonthly(parsedPaymentAmount, loanPaymentFrequency) - : NaN; - const parsedInsurancePayment = loanInsuranceEnabled && loanInsurancePayment ? parseFloat(loanInsurancePayment) : undefined; - const parsedInsuranceEndBalance = loanInsuranceEnabled && loanInsuranceEndBalanceMode === 'amount' && loanInsuranceEndBalance ? parseFloat(loanInsuranceEndBalance) : undefined; - const parsedInsuranceEndBalancePercent = loanInsuranceEnabled && loanInsuranceEndBalanceMode === 'percent' && loanInsuranceEndBalancePercent ? parseFloat(loanInsuranceEndBalancePercent) : undefined; - const parsedTermValue = loanTermMonths ? parseFloat(loanTermMonths) : undefined; - const parsedTermMonths = parsedTermValue - ? Math.round(loanTermUnit === 'years' ? parsedTermValue * 12 : parsedTermValue) - : undefined; - const errors: LoanFieldErrors = {}; - - if (!trimmedLoanName) { - errors.name = 'Loan name is required.'; - } - - if (!Number.isFinite(parsedPrincipal) || parsedPrincipal <= 0) { - errors.principal = 'Please enter a valid principal amount greater than zero.'; - } - - if (!Number.isFinite(parsedCurrentBalance) || parsedCurrentBalance < 0) { - errors.currentBalance = 'Please enter a valid current balance.'; - } - - if (!Number.isFinite(parsedInterestRate) || parsedInterestRate < 0) { - errors.interestRate = 'Please enter a valid interest rate.'; - } - - if ( - loanType === 'mortgage' && - loanPropertyTaxRate.trim() !== '' && - (!Number.isFinite(parsedPropertyTaxRate) || (parsedPropertyTaxRate ?? 0) < 0 || (parsedPropertyTaxRate ?? 0) > 100) - ) { - errors.propertyTaxRate = 'Please enter a valid tax rate between 0 and 100.'; - } - - if ( - loanType === 'mortgage' && - loanPropertyTaxRate.trim() !== '' && - (!Number.isFinite(parsedPropertyValue) || (parsedPropertyValue ?? 0) <= 0) - ) { - errors.propertyValue = 'Please enter a valid property value greater than zero.'; - } - - if (!Number.isFinite(parsedPaymentAmount) || parsedPaymentAmount <= 0) { - errors.monthlyPayment = 'Please enter a valid payment amount greater than zero.'; - } - - if (loanInsuranceEnabled) { - if (!loanInsurancePayment || !Number.isFinite(parsedInsurancePayment) || (parsedInsurancePayment ?? 0) < 0) { - errors.insurancePayment = 'Please enter a valid monthly insurance amount.'; - } - - if (loanInsuranceEndBalanceMode === 'amount') { - if (loanInsuranceEndBalance && (!Number.isFinite(parsedInsuranceEndBalance) || (parsedInsuranceEndBalance ?? 0) < 0)) { - errors.insuranceEndBalance = 'Please enter a valid insurance cutoff balance.'; - } - } else { - if (loanInsuranceEndBalancePercent && (!Number.isFinite(parsedInsuranceEndBalancePercent) || (parsedInsuranceEndBalancePercent ?? 0) < 0 || (parsedInsuranceEndBalancePercent ?? 0) > 100)) { - errors.insuranceEndBalancePercent = 'Please enter a valid percentage between 0 and 100.'; - } - } - } - - if (loanTermMonths && (!Number.isFinite(parsedTermValue) || (parsedTermValue ?? 0) <= 0)) { - errors.termMonths = `Please enter a valid loan term in ${loanTermUnit}.`; - } - - if (!loanAccountId) { - errors.accountId = 'Please select an account.'; - } - - if (!loanStartDate) { - errors.startDate = 'Please enter a start date.'; - } - - if (Object.keys(errors).length > 0) { - setLoanFieldErrors(errors); - return; - } - - const loanData = { - name: trimmedLoanName, - type: loanType, - principal: parsedPrincipal, - currentBalance: parsedCurrentBalance, - interestRate: parsedInterestRate, - propertyTaxRate: loanType === 'mortgage' ? parsedPropertyTaxRate : undefined, - propertyValue: loanType === 'mortgage' ? parsedPropertyValue : undefined, - monthlyPayment: parsedMonthlyPayment, - paymentFrequency: loanPaymentFrequency, - insurancePayment: isInsuranceEligibleLoanType(loanType) ? parsedInsurancePayment : undefined, - insuranceEndBalance: isInsuranceEligibleLoanType(loanType) && loanInsuranceEndBalanceMode === 'amount' ? parsedInsuranceEndBalance : undefined, - insuranceEndBalancePercent: isInsuranceEligibleLoanType(loanType) && loanInsuranceEndBalanceMode === 'percent' ? parsedInsuranceEndBalancePercent : undefined, - accountId: loanAccountId, - startDate: new Date(loanStartDate).toISOString(), - termMonths: parsedTermMonths, - enabled: editingLoan ? editingLoan.enabled !== false : true, - notes: loanNotes.trim() || undefined, - }; - - if (editingLoan) { - updateLoan(editingLoan.id, loanData); - } else { - addLoan(loanData); - } - - setShowAddLoan(false); - setEditingLoan(null); - setLoanFieldErrors({}); - }; - - const handleDeleteLoan = (id: string) => { - if (confirm('Are you sure you want to delete this loan?')) { - deleteLoan(id); - } - }; - - const handleToggleLoanEnabled = (loan: Loan) => { - updateLoan(loan.id, { enabled: !isLoanEnabled(loan) }); - }; - - const getMonthlyInsurancePayment = (loan: Loan, balance: number = loan.currentBalance): number => { - if (!isInsuranceEligibleLoanType(loan.type)) return 0; - - const insurancePayment = loan.insurancePayment ?? 0; - if (insurancePayment <= 0) return 0; - - let endBalanceThreshold = 0; - if (typeof loan.insuranceEndBalance === 'number') { - endBalanceThreshold = loan.insuranceEndBalance; - } else if (typeof loan.insuranceEndBalancePercent === 'number') { - endBalanceThreshold = (loan.principal * loan.insuranceEndBalancePercent) / 100; - } - - if (endBalanceThreshold > 0 && balance <= endBalanceThreshold) { - return 0; - } - - return insurancePayment; - }; - - const getMonthlyPropertyTaxPayment = (loan: Loan): number => { - if (loan.type !== 'mortgage') return 0; - if (!Number.isFinite(loan.propertyTaxRate) || (loan.propertyTaxRate ?? 0) <= 0) return 0; - const taxablePropertyValue = loan.propertyValue ?? loan.principal; - if (!Number.isFinite(taxablePropertyValue) || taxablePropertyValue <= 0) return 0; - return roundToCent((taxablePropertyValue * (loan.propertyTaxRate ?? 0)) / 100 / 12); - }; - - const getCurrentPaymentSplit = (loan: Loan): { principal: number; interest: number; insurance: number; propertyTax: number; total: number } => { - if (loan.currentBalance <= 0 || loan.monthlyPayment <= 0) { - return { principal: 0, interest: 0, insurance: 0, propertyTax: 0, total: 0 }; - } - - const monthlyRate = loan.interestRate / 100 / 12; - const interest = roundToCent(loan.currentBalance * monthlyRate); - const principal = roundToCent(Math.max(0, Math.min(loan.monthlyPayment - interest, loan.currentBalance))); - const insurance = roundToCent(getMonthlyInsurancePayment(loan)); - const propertyTax = roundToCent(getMonthlyPropertyTaxPayment(loan)); - - return { - principal, - interest, - insurance, - propertyTax, - total: roundToCent(principal + interest + insurance + propertyTax), - }; - }; - - const buildAmortizationSchedule = (loan: Loan): AmortizationRow[] => { - const schedule: AmortizationRow[] = []; - - if (loan.monthlyPayment <= 0 || loan.principal <= 0) return schedule; - - const monthlyRate = loan.interestRate / 100 / 12; - // Allow up to 1200 months (100 years) to prevent infinite loops, but will exit early when balance reaches zero - const maxScheduleLength = 1200; - let balance = loan.principal; - - const startDate = new Date(loan.startDate); - - for (let i = 1; i <= maxScheduleLength && balance > 0; i += 1) { - const beginningBalance = balance; - const interest = roundToCent(balance * monthlyRate); - let principal = roundToCent(loan.monthlyPayment - interest); - - if (principal <= 0) { - break; - } - - if (principal > balance) { - principal = roundToCent(balance); - } - - const pmiPayment = roundToCent(getMonthlyInsurancePayment(loan, balance)); - const propertyTaxPayment = roundToCent(getMonthlyPropertyTaxPayment(loan)); - const paymentAmount = roundToCent(principal + interest + pmiPayment + propertyTaxPayment); - const nextBalance = roundToCent(Math.max(0, balance - principal)); - - const paymentDate = new Date(startDate); - paymentDate.setMonth(paymentDate.getMonth() + (i - 1)); - - schedule.push({ - paymentNumber: i, - paymentDate: paymentDate.toLocaleDateString(), - beginningBalance, - paymentAmount, - principal, - interest, - pmiPayment, - propertyTaxPayment, - endingBalance: nextBalance, - }); - - balance = nextBalance; - } - - return schedule; - }; - - // Group loans by account - const loansList = budgetData.loans ?? []; - const loansByAccount = loansList.reduce((acc, loan) => { - if (!acc[loan.accountId]) { - acc[loan.accountId] = []; - } - acc[loan.accountId].push(loan); - return acc; - }, {} as Record); - - const paychecksPerYear = getPaychecksPerYear(budgetData.paySettings.payFrequency); - - // Convert monthly payment to display mode - const toDisplayAmount = (monthlyAmount: number): number => { - const perPaycheckAmount = (monthlyAmount * 12) / paychecksPerYear; - return convertToDisplayMode(perPaycheckAmount, paychecksPerYear, displayMode); - }; - - // Calculate remaining months - const getRemainingMonths = (loan: Loan): number | null => { - if (loan.currentBalance <= 0 || loan.monthlyPayment <= 0) return null; - - const monthlyRate = loan.interestRate / 100 / 12; - if (monthlyRate === 0) { - return Math.ceil(loan.currentBalance / loan.monthlyPayment); - } - - // Use loan payment formula to calculate remaining months - const months = Math.log(loan.monthlyPayment / (loan.monthlyPayment - loan.currentBalance * monthlyRate)) / Math.log(1 + monthlyRate); - return Math.ceil(months); - }; - - return ( -
- - - - - } - /> - -
- {(budgetData.accounts ?? []).length === 0 ? ( -
-

No accounts available. Please add an account first in the Accounts settings.

-
- ) : loansList.length === 0 ? ( -
-
🏦
-

No Loans Yet

-

Add your loans or debts to start tracking your payoff progress

-
- ) : ( - <> - {(budgetData.accounts ?? []).map(account => { - const accountLoans = loansByAccount[account.id] || []; - const totalMonthly = accountLoans - .filter(loan => isLoanEnabled(loan)) - .reduce((sum, loan) => sum + loan.monthlyPayment + getMonthlyInsurancePayment(loan) + getMonthlyPropertyTaxPayment(loan), 0); - return { account, accountLoans, totalMonthly }; - }) - .filter(({ accountLoans }) => accountLoans.length > 0) - .map(({ account, accountLoans, totalMonthly }) => ( -
-
-
- - {account.icon || getDefaultAccountIcon(account.type)} - -

{account.name}

-
-
- Total {getDisplayModeLabel(displayMode)}: - - {formatWithSymbol(toDisplayAmount(totalMonthly), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - -
-
- -
- {accountLoans - .sort((a, b) => { - const aEnabled = isLoanEnabled(a); - const bEnabled = isLoanEnabled(b); - if (aEnabled !== bEnabled) return aEnabled ? -1 : 1; - return b.currentBalance - a.currentBalance; - }) - .map(loan => { - const remainingMonths = getRemainingMonths(loan); - const remainingYears = remainingMonths ? (remainingMonths / 12).toFixed(1) : null; - const rawPercentPaid = loan.principal > 0 ? ((loan.principal - loan.currentBalance) / loan.principal) * 100 : 0; - const percentPaidValue = Math.max(0, Math.min(100, Number.isFinite(rawPercentPaid) ? rawPercentPaid : 0)); - const percentPaid = percentPaidValue.toFixed(1); - const amountPaid = Math.max(0, Math.min(loan.principal, loan.principal - loan.currentBalance)); - const paymentSplit = getCurrentPaymentSplit(loan); - const displayMonthlyTotal = toDisplayAmount(loan.monthlyPayment + paymentSplit.insurance + paymentSplit.propertyTax); - - return ( - -
-
-

- {loan.name} - {LOAN_TYPES.find(t => t.value === loan.type)?.label} -

-
-
- {formatWithSymbol(displayMonthlyTotal, currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - / {getDisplayModeLabel(displayMode)} -
-
- -
- - {percentPaid}% paid - - {formatWithSymbol(amountPaid, currency)} of {formatWithSymbol(loan.principal, currency)} - - - } - /> -
- Payment Split ({getDisplayModeLabel(displayMode)}) -
- Principal - {formatWithSymbol(toDisplayAmount(paymentSplit.principal), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} -
-
- Interest - {formatWithSymbol(toDisplayAmount(paymentSplit.interest), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} -
- {paymentSplit.insurance > 0 && ( -
- Insurance (PMI/GAP) - {formatWithSymbol(toDisplayAmount(paymentSplit.insurance), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} -
- )} - {paymentSplit.propertyTax > 0 && ( -
- Property Tax - {formatWithSymbol(toDisplayAmount(paymentSplit.propertyTax), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} -
- )} -
- Total - {formatWithSymbol(toDisplayAmount(paymentSplit.total), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} -
-
-
-
- Interest Rate - {loan.interestRate}% -
- {loan.type === 'mortgage' && (loan.propertyTaxRate ?? 0) > 0 && ( -
- Property Tax Rate - {loan.propertyTaxRate}% -
- )} - {loan.type === 'mortgage' && (loan.propertyValue ?? 0) > 0 && ( -
- Property Value - {formatWithSymbol(loan.propertyValue ?? 0, currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} -
- )} - {remainingYears && ( -
- Time Remaining - {remainingYears} years -
- )} -
- Started - {new Date(loan.startDate).toLocaleDateString()} -
-
- {loan.notes && ( -
- Notes: {loan.notes} -
- )} -
- -
- {/* - - */} - - - -
-
- ); - })} -
-
- ))} - - )} -
- - {/* Add/Edit Loan Modal */} - { - setShowAddLoan(false); - setEditingLoan(null); - setLoanFieldErrors({}); - }} - header={editingLoan ? 'Edit Loan' : 'Add New Loan'} - footer={ - <> - - - - } - > - - { - setLoanName(e.target.value); - if (loanFieldErrors.name) { - setLoanFieldErrors(prev => ({ ...prev, name: undefined })); - } - }} - placeholder="e.g., Home Mortgage, Car Loan" - className={loanFieldErrors.name ? 'field-error' : ''} - /> - - - - - - - Original Principal Amount} required error={loanFieldErrors.principal}> - { - setLoanPrincipal(e.target.value); - if (loanFieldErrors.principal) { - setLoanFieldErrors(prev => ({ ...prev, principal: undefined })); - } - }} - placeholder="50000" - min="0" - step="100" - className={loanFieldErrors.principal ? 'field-error' : ''} - /> - - - Current Balance} required error={loanFieldErrors.currentBalance}> - { - setLoanCurrentBalance(e.target.value); - if (loanFieldErrors.currentBalance) { - setLoanFieldErrors(prev => ({ ...prev, currentBalance: undefined })); - } - }} - placeholder="45000" - min="0" - step="100" - className={loanFieldErrors.currentBalance ? 'field-error' : ''} - /> - - - Annual Interest Rate (%)} required error={loanFieldErrors.interestRate}> - { - setLoanInterestRate(e.target.value); - if (loanFieldErrors.interestRate) { - setLoanFieldErrors(prev => ({ ...prev, interestRate: undefined })); - } - }} - placeholder="4.5" - min="0" - step="0.1" - className={loanFieldErrors.interestRate ? 'field-error' : ''} - /> - - - {loanType === 'mortgage' && ( - <> - - { - setLoanPropertyTaxRate(e.target.value); - if (loanFieldErrors.propertyTaxRate) { - setLoanFieldErrors(prev => ({ ...prev, propertyTaxRate: undefined })); - } - }} - placeholder="1.25" - min="0" - max="100" - step="0.001" - className={loanFieldErrors.propertyTaxRate ? 'field-error' : ''} - /> - - - - { - setLoanPropertyValue(e.target.value); - if (loanFieldErrors.propertyValue) { - setLoanFieldErrors(prev => ({ ...prev, propertyValue: undefined })); - } - }} - placeholder="425000" - min="0" - step="1000" - className={loanFieldErrors.propertyValue ? 'field-error' : ''} - /> - - - )} - - - { - setLoanMonthlyPayment(e.target.value); - if (loanFieldErrors.monthlyPayment) { - setLoanFieldErrors(prev => ({ ...prev, monthlyPayment: undefined })); - } - }} - placeholder="350" - min="0" - step="10" - className={loanFieldErrors.monthlyPayment ? 'field-error' : ''} - /> - - - - - - - {isInsuranceEligibleLoanType(loanType) && ( - <> - Mortgage Insurance (PMI/GAP/etc.)} helperText="Add optional insurance to this loan"> - { - const enabled = value === 'enabled'; - setLoanInsuranceEnabled(enabled); - if (!enabled) { - setLoanInsurancePayment(''); - setLoanInsuranceEndBalance(''); - setLoanInsuranceEndBalancePercent(''); - } - }} - layout="row" - /> - - - {loanInsuranceEnabled && ( - <> - - { - setLoanInsurancePayment(e.target.value); - if (loanFieldErrors.insurancePayment) { - setLoanFieldErrors(prev => ({ ...prev, insurancePayment: undefined })); - } - }} - placeholder="150" - min="0" - step="1" - className={loanFieldErrors.insurancePayment ? 'field-error' : ''} - /> - - - -
- {loanInsuranceEndBalanceMode === 'amount' ? ( - { - setLoanInsuranceEndBalance(e.target.value); - if (loanFieldErrors.insuranceEndBalance) { - setLoanFieldErrors(prev => ({ ...prev, insuranceEndBalance: undefined })); - } - }} - placeholder="300000" - min="0" - step="100" - className={loanFieldErrors.insuranceEndBalance ? 'field-error' : ''} - /> - ) : ( - { - setLoanInsuranceEndBalancePercent(e.target.value); - if (loanFieldErrors.insuranceEndBalancePercent) { - setLoanFieldErrors(prev => ({ ...prev, insuranceEndBalancePercent: undefined })); - } - }} - placeholder="80" - min="0" - max="100" - step="1" - className={loanFieldErrors.insuranceEndBalancePercent ? 'field-error' : ''} - /> - )} - -
-
- - )} - - )} - - - - - - - { - setLoanStartDate(e.target.value); - if (loanFieldErrors.startDate) { - setLoanFieldErrors(prev => ({ ...prev, startDate: undefined })); - } - }} - className={loanFieldErrors.startDate ? 'field-error' : ''} - /> - - - Loan Term} - helperText="Optional: Enter duration in months or years" - error={loanFieldErrors.termMonths} - > -
- { - setLoanTermMonths(e.target.value); - if (loanFieldErrors.termMonths) { - setLoanFieldErrors(prev => ({ ...prev, termMonths: undefined })); - } - }} - placeholder={loanTermUnit === 'years' ? '30' : '360'} - min="0" - step={loanTermUnit === 'years' ? '0.5' : '1'} - className={loanFieldErrors.termMonths ? 'field-error' : ''} - /> - -
-
- - -