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 })}
-
-
-
- + Add Benefit
-
-
-
-
- {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 })}
-
-
- )}
-
-
- handleEditBenefit(benefit)} title="Edit">✏️
- handleDeleteBenefit(benefit.id)} title="Delete">🗑️
-
-
- );
- })}
-
- )}
-
-
- {/* Retirement Section */}
-
-
-
-
Retirement Elections
-
401k, 403b, IRA, and other retirement plan contributions
-
-
-
- Total {getDisplayModeLabel(displayMode)}
-
- {formatWithSymbol(toDisplayAmount(retirementTotalPerPaycheck), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
-
-
-
- + Add Retirement Plan
-
-
-
-
- {!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
-
-
- )}
-
-
-
- handleEditRetirement(retirement)} title="Edit">✏️
- handleDeleteRetirement(retirement.id)} title="Delete">🗑️
-
-
- );
- })}
-
- )}
-
-
- {/* Add/Edit Benefit Modal */}
- {
- setShowAddBenefit(false);
- setBenefitFieldErrors({});
- }}
- header={editingBenefit ? 'Edit Benefit' : 'Add Benefit'}
- footer={
- <>
- {
- setShowAddBenefit(false);
- setBenefitFieldErrors({});
- }}>
- Cancel
-
-
- {editingBenefit ? 'Update Benefit' : 'Add Benefit'}
-
- >
- }
- >
-
- {
- setBenefitName(e.target.value);
- if (benefitFieldErrors.name) {
- setBenefitFieldErrors((prev) => ({ ...prev, name: undefined }));
- }
- }}
- placeholder="e.g., Health Insurance, FSA"
- required
- />
-
-
-
- {
- if (e.target.value === 'paycheck') {
- setBenefitSource('paycheck');
- setBenefitSourceAccountId('');
- } else {
- setBenefitSource('account');
- setBenefitSourceAccountId(e.target.value);
- setBenefitIsTaxable(true);
- }
- if (benefitFieldErrors.sourceAccountId) {
- setBenefitFieldErrors((prev) => ({ ...prev, sourceAccountId: undefined }));
- }
- }}
- >
- Paid from Paycheck
- {budgetData.accounts.map((account) => (
- {account.icon || getDefaultAccountIcon(account.type)} {account.name}
- ))}
-
-
-
-
-
-
- {
- 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
- />
-
-
-
-
- setBenefitIsPercentage(e.target.value === 'percentage')}>
- Fixed Amount
- Percentage of Gross
-
-
-
-
-
- {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={
- <>
- {
- setShowAddRetirement(false);
- setRetirementFieldErrors({});
- setRetirementFormMessage(null);
- }}>
- Cancel
-
-
- {editingRetirement ? 'Update Plan' : 'Add Plan'}
-
- >
- }
- >
- Plan Type >} required>
- setRetirementType(e.target.value as RetirementElection['type'])} required>
- {RETIREMENT_PLAN_OPTIONS.map((option) => (
- {option.label}
- ))}
-
-
-
- {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
-
-
- {
- if (e.target.value === 'paycheck') {
- setRetirementSource('paycheck');
- setRetirementSourceAccountId('');
- setRetirementIsPreTax(true);
- } else {
- setRetirementSource('account');
- setRetirementSourceAccountId(e.target.value);
- setRetirementIsPreTax(false);
- }
- if (retirementFieldErrors.sourceAccountId) {
- setRetirementFieldErrors((prev) => ({ ...prev, sourceAccountId: undefined }));
- }
- }}
- >
- Paid from Paycheck
- {budgetData.accounts.map((account) => (
- {account.icon || getDefaultAccountIcon(account.type)} {account.name}
- ))}
-
-
-
-
-
-
- {
- 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
- />
-
-
-
-
- setEmployeeIsPercentage(e.target.value === 'percentage')}>
- Fixed Amount
- Percentage of Gross
-
-
-
-
-
- {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 && (
-
-
- Auto-Calculate to Limit
-
-
- )}
-
- {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 >}>
- setEmployerMatchCapIsPercentage(e.target.value === 'percentage')}>
- % of Gross Pay
- Fixed Amount
-
-
-
-
-
- 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 })}
-
-
-
- + Add Benefit
-
-
-
-
- {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 })}
-
-
- )}
-
-
- handleEditBenefit(benefit)} title="Edit">✏️
- handleDeleteBenefit(benefit.id)} title="Delete">🗑️
-
-
- );
- })}
-
- )}
-
-
- {/* Retirement Section */}
-
-
-
-
Retirement Elections
-
401k, 403b, IRA, and other retirement plan contributions
-
-
-
- Total {getDisplayModeLabel(displayMode)}
-
- {formatWithSymbol(toDisplayAmount(retirementTotalPerPaycheck), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
-
-
-
- + Add Retirement Plan
-
-
-
-
- {!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
-
-
- )}
-
-
-
- handleEditRetirement(retirement)} title="Edit">✏️
- handleDeleteRetirement(retirement.id)} title="Delete">🗑️
-
-
- );
- })}
-
- )}
-
-
- {/* Add/Edit Benefit Modal */}
- {
- setShowAddBenefit(false);
- setBenefitFieldErrors({});
- }}
- header={editingBenefit ? 'Edit Benefit' : 'Add Benefit'}
- footer={
- <>
- {
- setShowAddBenefit(false);
- setBenefitFieldErrors({});
- }}>
- Cancel
-
-
- {editingBenefit ? 'Update Benefit' : 'Add Benefit'}
-
- >
- }
- >
-
- {
- setBenefitName(e.target.value);
- if (benefitFieldErrors.name) {
- setBenefitFieldErrors((prev) => ({ ...prev, name: undefined }));
- }
- }}
- placeholder="e.g., Health Insurance, FSA"
- required
- />
-
-
-
- {
- if (e.target.value === 'paycheck') {
- setBenefitSource('paycheck');
- setBenefitSourceAccountId('');
- } else {
- setBenefitSource('account');
- setBenefitSourceAccountId(e.target.value);
- setBenefitIsTaxable(true);
- }
- if (benefitFieldErrors.sourceAccountId) {
- setBenefitFieldErrors((prev) => ({ ...prev, sourceAccountId: undefined }));
- }
- }}
- >
- Paid from Paycheck
- {budgetData.accounts.map((account) => (
- {account.icon || getDefaultAccountIcon(account.type)} {account.name}
- ))}
-
-
-
-
-
-
- {
- 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
- />
-
-
-
-
- setBenefitIsPercentage(e.target.value === 'percentage')}>
- Fixed Amount
- Percentage of Gross
-
-
-
-
-
- {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={
- <>
- {
- setShowAddRetirement(false);
- setRetirementFieldErrors({});
- setRetirementFormMessage(null);
- }}>
- Cancel
-
-
- {editingRetirement ? 'Update Plan' : 'Add Plan'}
-
- >
- }
- >
- Plan Type >} required>
- setRetirementType(e.target.value as RetirementElection['type'])} required>
- 401(k)
- 403(b)
- Roth IRA
- Traditional IRA
- Pension
- Other
-
-
-
- {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
-
-
- {
- if (e.target.value === 'paycheck') {
- setRetirementSource('paycheck');
- setRetirementSourceAccountId('');
- setRetirementIsPreTax(true);
- } else {
- setRetirementSource('account');
- setRetirementSourceAccountId(e.target.value);
- setRetirementIsPreTax(false);
- }
- if (retirementFieldErrors.sourceAccountId) {
- setRetirementFieldErrors((prev) => ({ ...prev, sourceAccountId: undefined }));
- }
- }}
- >
- Paid from Paycheck
- {budgetData.accounts.map((account) => (
- {account.icon || getDefaultAccountIcon(account.type)} {account.name}
- ))}
-
-
-
-
-
-
- {
- 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
- />
-
-
-
-
- setEmployeeIsPercentage(e.target.value === 'percentage')}>
- Fixed Amount
- Percentage of Gross
-
-
-
-
-
- {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 && (
-
-
- Auto-Calculate to Limit
-
-
- )}
-
- {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 >}>
- setEmployerMatchCapIsPercentage(e.target.value === 'percentage')}>
- % of Gross Pay
- Fixed Amount
-
-
-
-
-
- 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 && (
-
- ← Cancel
-
- )
- ) : (
- setEncryptionEnabled(null)}
- disabled={isSaving}
- >
- ← Back
-
- )}
-
- Continue
-
-
-
-
- );
-};
-
-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 (
-
-
-
-
- + Add Loan
-
- >
- }
- />
-
-
- {(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}
-
- )}
-
-
-
- {/*
- setScheduleLoan(loan)}
- title="View payment plan"
- className="payment-plan-btn"
- >
- 📊 Payment Plan
-
- */}
- handleToggleLoanEnabled(loan)}
- title={isLoanEnabled(loan) ? 'Disable loan' : 'Enable loan'}
- >
- {isLoanEnabled(loan) ? '⏸️' : '▶️'}
-
- handleEditLoan(loan)}
- title="Edit loan"
- >
- ✏️
-
- handleDeleteLoan(loan.id)}
- title="Delete loan"
- >
- 🗑️
-
-
-
- );
- })}
-
-
- ))}
- >
- )}
-
-
- {/* Add/Edit Loan Modal */}
- {
- setShowAddLoan(false);
- setEditingLoan(null);
- setLoanFieldErrors({});
- }}
- header={editingLoan ? 'Edit Loan' : 'Add New Loan'}
- footer={
- <>
- {
- setShowAddLoan(false);
- setEditingLoan(null);
- setLoanFieldErrors({});
- }}
- >
- Cancel
-
-
- {editingLoan ? 'Save Changes' : 'Add Loan'}
-
- >
- }
- >
-
- {
- setLoanName(e.target.value);
- if (loanFieldErrors.name) {
- setLoanFieldErrors(prev => ({ ...prev, name: undefined }));
- }
- }}
- placeholder="e.g., Home Mortgage, Car Loan"
- className={loanFieldErrors.name ? 'field-error' : ''}
- />
-
-
-
- {
- const selectedType = e.target.value as Loan['type'];
- setLoanType(selectedType);
- if (!loanTermMonths.trim()) {
- setLoanTermUnit(getDefaultTermUnitForLoanType(selectedType));
- }
- }}
- >
- {LOAN_TYPES.map(type => (
-
- {type.label}
-
- ))}
-
-
-
- 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' : ''}
- />
-
-
-
- setLoanPaymentFrequency(e.target.value as LoanPaymentFrequency)}
- >
- {LOAN_PAYMENT_FREQUENCIES.map((frequency) => (
-
- {frequency.label}
-
- ))}
-
-
-
- {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' : ''}
- />
- )}
- {
- const mode = e.target.value as 'amount' | 'percent';
- setLoanInsuranceEndBalanceMode(mode);
- if (mode === 'amount') {
- setLoanInsuranceEndBalancePercent('');
- } else {
- setLoanInsuranceEndBalance('');
- }
- }}
- aria-label="Insurance threshold mode"
- >
- Amount
- % of Loan
-
-
-
- >
- )}
- >
- )}
-
-
- {
- setLoanAccountId(e.target.value);
- if (loanFieldErrors.accountId) {
- setLoanFieldErrors(prev => ({ ...prev, accountId: undefined }));
- }
- }}
- className={loanFieldErrors.accountId ? 'field-error' : ''}
- >
- Select an account
- {budgetData.accounts.map(account => (
-
- {account.icon || getDefaultAccountIcon(account.type)} {account.name}
-
- ))}
-
-
-
-
- {
- 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' : ''}
- />
- setLoanTermUnit(e.target.value as LoanTermUnit)}
- aria-label="Loan term unit"
- >
- Months
- Years
-
-
-
-
-
-
-
-
- setScheduleLoan(null)}
- contentClassName="loan-schedule-modal"
- header={scheduleLoan ? `${scheduleLoan.name} Payment Schedule` : 'Payment Schedule'}
- footer={
- setScheduleLoan(null)}>
- Close
-
- }
- >
- {scheduleLoan && (() => {
- const schedule = buildAmortizationSchedule(scheduleLoan);
- return (
-
-
- This amortization schedule shows how each monthly payment reduces your loan balance over time.
- Early payments go mostly toward interest , while later payments pay down more principal .
-
-
- The Beginning Balance is what you owe at the start of each month, and the Ending Balance is what remains after your payment. Insurance (PMI/GAP) is included while active and will drop off automatically when your balance reaches the specified threshold.
-
- {schedule.length === 0 ? (
-
- Unable to generate schedule. Monthly payment may be too low to reduce principal.
-
- ) : (
-
-
-
-
- Payment #
- Payment Date
- Beginning Balance
- Payment Amount
- Principal
- Interest
- Ending Balance
- Insurance Payment
- Property Tax
-
-
-
- {schedule.map(row => (
-
- {row.paymentNumber}
- {row.paymentDate}
- {formatWithSymbol(row.beginningBalance, currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
- {formatWithSymbol(row.paymentAmount, currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
- {formatWithSymbol(row.principal, currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
- {formatWithSymbol(row.interest, currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
- {formatWithSymbol(row.endingBalance, currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
- {formatWithSymbol(row.pmiPayment, currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
- {formatWithSymbol(row.propertyTaxPayment, currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
-
- ))}
-
-
-
- )}
-
- );
- })()}
-
-
- );
-};
-
-export default LoansManager;
diff --git a/src/components/PlanDashboard/PlanDashboard.css b/src/components/PlanDashboard/PlanDashboard.css
index 9de6d0c..2e9dbd9 100644
--- a/src/components/PlanDashboard/PlanDashboard.css
+++ b/src/components/PlanDashboard/PlanDashboard.css
@@ -455,39 +455,6 @@
border-color: var(--accent-primary);
}
-.plan-relink-modal {
- max-width: 560px;
-}
-
-.plan-relink-modal-message {
- margin: 0;
- color: var(--text-primary);
- line-height: 1.5;
-}
-
-.plan-relink-modal-path {
- display: block;
- margin-top: 0.75rem;
- padding: 0.75rem 0.875rem;
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- font-family: 'SF Mono', 'Monaco', 'Cascadia Mono', 'Segoe UI Mono', Consolas, monospace;
- font-size: 0.85rem;
- color: var(--text-secondary);
- word-break: break-all;
-}
-
-.plan-relink-modal-error {
- margin: 0.75rem 0 0;
- padding: 0.625rem 0.75rem;
- border: 1px solid color-mix(in srgb, var(--danger-color) 45%, transparent);
- background: color-mix(in srgb, var(--danger-color) 14%, transparent);
- color: color-mix(in srgb, var(--danger-color) 85%, var(--text-primary));
- border-radius: 8px;
- font-size: 0.9rem;
-}
-
@media (max-width: 768px) {
.dashboard-header {
padding: 1rem;
diff --git a/src/components/PlanDashboard/PlanDashboard.tsx b/src/components/PlanDashboard/PlanDashboard.tsx
index 1d7e72b..551c56d 100644
--- a/src/components/PlanDashboard/PlanDashboard.tsx
+++ b/src/components/PlanDashboard/PlanDashboard.tsx
@@ -6,30 +6,30 @@ interface StatusToastState {
}
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react';
import { flushSync } from 'react-dom';
+import { MENU_EVENTS } from '../../constants/events';
import { useBudget } from '../../contexts/BudgetContext';
+import { useAppDialogs, useEncryptionSetupFlow, useFileRelinkFlow } from '../../hooks';
import { FileStorageService } from '../../services/fileStorage';
-import { KeychainService } from '../../services/keychainService';
-import SetupWizard from '../SetupWizard';
-import EncryptionConfigPanel from '../EncryptionSetup/EncryptionConfigPanel';
-import KeyMetrics from '../KeyMetrics';
-import PayBreakdown from '../PayBreakdown';
-import BillsManager from '../BillsManager';
-import LoansManager from '../LoansManager';
-import SavingsManager from '../SavingsManager';
-import TaxBreakdown from '../TaxBreakdown';
-import Settings from '../Settings';
-import AccountsManager from '../AccountsManager';
-import ExportModal from '../ExportModal';
-import FeedbackModal from '../FeedbackModal';
+import SetupWizard from '../views/SetupWizard';
+import KeyMetrics from '../tabViews/KeyMetrics';
+import PayBreakdown from '../tabViews/PayBreakdown';
+import BillsManager from '../tabViews/BillsManager';
+import LoansManager from '../tabViews/LoansManager';
+import SavingsManager from '../tabViews/SavingsManager';
+import TaxBreakdown from '../tabViews/TaxBreakdown';
+import SettingsModal from '../modals/SettingsModal';
+import AccountsModal from '../modals/AccountsModal';
+import ExportModal from '../modals/ExportModal';
+import FeedbackModal from '../modals/FeedbackModal';
import { PlanTabs, TabManagementModal } from './PlanTabs';
-import { Toast, Modal, Button, FormGroup } from '../shared';
+import { Toast, Modal, Button, ErrorDialog, FileRelinkModal, FormGroup, EncryptionConfigPanel } from '../_shared';
import { initializeTabConfigs, getVisibleTabs, getHiddenTabs, toggleTabVisibility, reorderTabs, normalizeLegacyTabId } from '../../utils/tabManagement';
-import type { TabPosition, TabDisplayMode, TabConfig } from '../../types/auth';
+import type { TabPosition, TabDisplayMode, TabConfig } from '../../types/tabs';
+import type { ViewMode } from '../../types/viewMode';
import './PlanDashboard.css';
import type { TabId } from '../../utils/tabManagement';
-type DisplayMode = 'paycheck' | 'monthly' | 'yearly';
type TabScrollPosition = 'top' | 'bottom';
interface PlanHistoryState {
@@ -65,7 +65,7 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
const [scrollToAccountId, setScrollToAccountId] = useState(undefined);
const [shouldScrollToRetirement, setShouldScrollToRetirement] = useState(false);
const [pendingTabScroll, setPendingTabScroll] = useState<{ tab: TabId; position: TabScrollPosition } | null>(null);
- const [displayMode, setDisplayMode] = useState('paycheck');
+ const [displayMode, setDisplayMode] = useState('paycheck');
const [showCopyModal, setShowCopyModal] = useState(false);
const [newYear, setNewYear] = useState('');
const [copyYearError, setCopyYearError] = useState(null);
@@ -77,11 +77,6 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
const [isEditingPlanName, setIsEditingPlanName] = useState(false);
const [draftPlanName, setDraftPlanName] = useState('');
const [draftYear, setDraftYear] = useState('');
- const [encryptionEnabled, setEncryptionEnabled] = useState(null);
- const [customKey, setCustomKey] = useState('');
- const [generatedKey, setGeneratedKey] = useState('');
- const [useCustomKey, setUseCustomKey] = useState(false);
- const [encryptionSaving, setEncryptionSaving] = useState(false);
const [statusToast, setStatusToast] = useState(null);
const [showTabManagementModal, setShowTabManagementModal] = useState(false);
const [draggedTabIndex, setDraggedTabIndex] = useState(null);
@@ -90,6 +85,21 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
const [showPlanLoadingScreen, setShowPlanLoadingScreen] = useState(false);
const [tabPosition, setTabPosition] = useState('left');
const [tabDisplayMode, setTabDisplayMode] = useState('icons-with-labels');
+ const {
+ encryptionEnabled,
+ setEncryptionEnabled,
+ customKey,
+ setCustomKey,
+ generatedKey,
+ useCustomKey,
+ setUseCustomKey,
+ isSaving: encryptionSaving,
+ canSaveSelection,
+ generateKey: handleGenerateEncryptionKey,
+ reset: resetEncryptionSetupFlow,
+ goBackToSelection,
+ saveSelection: saveEncryptionSelection,
+ } = useEncryptionSetupFlow();
const tabContentRef = useRef(null);
const tabPanelRefs = useRef>>({});
const planLoadingStartRef = useRef(null);
@@ -106,43 +116,57 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
const historyStateKeyRef = useRef(null);
const suppressHistoryPushRef = useRef(false);
const lastMissingPathPromptRef = useRef(null);
- const [missingActiveFilePath, setMissingActiveFilePath] = useState(null);
- const [activeRelinkMismatchMessage, setActiveRelinkMismatchMessage] = useState(null);
- const [activeRelinkLoading, setActiveRelinkLoading] = useState(false);
+ const { errorDialog, openErrorDialog, closeErrorDialog } = useAppDialogs();
+ const {
+ missingFile: missingActiveFile,
+ relinkMismatchMessage: activeRelinkMismatchMessage,
+ relinkLoading: activeRelinkLoading,
+ promptFileRelink: promptActiveFileRelink,
+ clearFileRelinkPrompt: clearActiveFileRelinkPrompt,
+ locateRelinkedFile: locateActiveRelinkedFile,
+ } = useFileRelinkFlow({
+ getExpectedPlanId: () => budgetData?.id,
+ fallbackErrorMessage: 'Unable to relink moved file.',
+ onRelinkSuccess: (result) => {
+ lastMissingPathPromptRef.current = null;
+ updateBudgetData({
+ name: result.planName,
+ settings: {
+ ...budgetData!.settings,
+ filePath: result.filePath,
+ },
+ });
+ setStatusToast({ message: 'File moved on disk. Plan path was relinked.', type: 'success' });
+ },
+ });
// Initialize tab configs from budget settings or use defaults
const tabConfigs = useMemo(() => {
return budgetData?.settings?.tabConfigs
? initializeTabConfigs(budgetData.settings.tabConfigs)
: initializeTabConfigs();
- }, [budgetData?.settings?.tabConfigs]);
+ }, [budgetData]);
const visibleTabs = useMemo(() => getVisibleTabs(tabConfigs), [tabConfigs]);
const hiddenTabs = useMemo(() => getHiddenTabs(tabConfigs), [tabConfigs]);
+ const effectiveTemporarilyVisibleTab = temporarilyVisibleTab === activeTab ? temporarilyVisibleTab : null;
const visibleTabsForRender = useMemo(() => {
- if (!temporarilyVisibleTab) return visibleTabs;
+ if (!effectiveTemporarilyVisibleTab) return visibleTabs;
- const alreadyVisible = visibleTabs.some((tab) => tab.id === temporarilyVisibleTab);
+ const alreadyVisible = visibleTabs.some((tab) => tab.id === effectiveTemporarilyVisibleTab);
if (alreadyVisible) return visibleTabs;
- const tempTab = tabConfigs.find((tab) => tab.id === temporarilyVisibleTab);
+ const tempTab = tabConfigs.find((tab) => tab.id === effectiveTemporarilyVisibleTab);
if (!tempTab) return visibleTabs;
const maxOrder = visibleTabs.reduce((max, tab) => Math.max(max, tab.order), -1);
return [...visibleTabs, { ...tempTab, visible: true, order: maxOrder + 1 }];
- }, [visibleTabs, tabConfigs, temporarilyVisibleTab]);
+ }, [effectiveTemporarilyVisibleTab, visibleTabs, tabConfigs]);
useEffect(() => {
latestTabConfigsRef.current = tabConfigs;
}, [tabConfigs]);
- useEffect(() => {
- if (!temporarilyVisibleTab) return;
- if (activeTab !== temporarilyVisibleTab) {
- setTemporarilyVisibleTab(null);
- }
- }, [activeTab, temporarilyVisibleTab]);
-
// Restore active tab before paint to avoid flashing default tab
useLayoutEffect(() => {
const tabRestoreContext = `${budgetData?.id ?? 'none'}:${viewMode ?? 'default'}`;
@@ -152,6 +176,7 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
const normalizedViewMode = normalizeLegacyTabId(viewMode);
if (normalizedViewMode && VALID_TABS.includes(normalizedViewMode)) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setActiveTab(normalizedViewMode);
initializedTabContextRef.current = tabRestoreContext;
return;
@@ -265,6 +290,7 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
// Initialize tab position and display mode from budget settings
useEffect(() => {
if (budgetData?.settings?.tabPosition) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setTabPosition(budgetData.settings.tabPosition);
}
if (budgetData?.settings?.tabDisplayMode) {
@@ -313,16 +339,15 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
}
lastMissingPathPromptRef.current = currentPath;
- setActiveRelinkMismatchMessage(null);
- setMissingActiveFilePath(currentPath);
+ promptActiveFileRelink(currentPath);
return false;
- }, [budgetData?.settings?.filePath]);
+ }, [budgetData?.settings?.filePath, promptActiveFileRelink]);
// Handle save with success toast
const handleSave = useCallback(async () => {
if (!budgetData) return;
- if (missingActiveFilePath) {
+ if (missingActiveFile) {
setStatusToast({ message: 'Locate moved file before saving this plan.', type: 'warning' });
return;
}
@@ -344,7 +369,7 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
if (success) {
setStatusToast({ message: 'Saved successfully', type: 'success' });
}
- }, [saveBudget, activeTab, budgetData, tabPosition, tabDisplayMode, missingActiveFilePath, ensureValidSavePath]);
+ }, [saveBudget, activeTab, budgetData, tabPosition, tabDisplayMode, missingActiveFile, ensureValidSavePath]);
const scrollTabToPosition = useCallback((tab: TabId, position: TabScrollPosition = 'top') => {
const getScrollTop = (element: { scrollHeight: number }, nextPosition: TabScrollPosition) => {
@@ -410,48 +435,48 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
useEffect(() => {
if (!window.electronAPI?.onMenuEvent) return;
- const unsubscribeNew = window.electronAPI.onMenuEvent('new-budget', () => {
+ const unsubscribeNew = window.electronAPI.onMenuEvent(MENU_EVENTS.newBudget, () => {
const year = new Date().getFullYear();
createNewBudget(year);
});
- const unsubscribeOpen = window.electronAPI.onMenuEvent('open-budget', () => {
+ const unsubscribeOpen = window.electronAPI.onMenuEvent(MENU_EVENTS.openBudget, () => {
loadBudget();
});
- const unsubscribeEncryption = window.electronAPI.onMenuEvent('change-encryption', () => {
+ const unsubscribeEncryption = window.electronAPI.onMenuEvent(MENU_EVENTS.changeEncryption, () => {
onResetSetup?.();
});
- const unsubscribeSave = window.electronAPI.onMenuEvent('save-plan', () => {
+ const unsubscribeSave = window.electronAPI.onMenuEvent(MENU_EVENTS.savePlan, () => {
handleSaveRef.current?.();
});
- const unsubscribeSettings = window.electronAPI.onMenuEvent('open-settings', () => {
+ const unsubscribeSettings = window.electronAPI.onMenuEvent(MENU_EVENTS.openSettings, () => {
setShowSettings(true);
});
- const unsubscribePayOptions = window.electronAPI.onMenuEvent('open-pay-options', () => {
+ const unsubscribePayOptions = window.electronAPI.onMenuEvent(MENU_EVENTS.openPayOptions, () => {
selectTab('breakdown', { resetBillsAnchor: true });
});
- const unsubscribeAccounts = window.electronAPI.onMenuEvent('open-accounts', () => {
+ const unsubscribeAccounts = window.electronAPI.onMenuEvent(MENU_EVENTS.openAccounts, () => {
setShowAccountsModal(true);
});
- const unsubscribeHistoryBack = window.electronAPI.onMenuEvent('history-back', () => {
+ const unsubscribeHistoryBack = window.electronAPI.onMenuEvent(MENU_EVENTS.historyBack, () => {
if (!viewMode) {
window.history.back();
}
});
- const unsubscribeHistoryForward = window.electronAPI.onMenuEvent('history-forward', () => {
+ const unsubscribeHistoryForward = window.electronAPI.onMenuEvent(MENU_EVENTS.historyForward, () => {
if (!viewMode) {
window.history.forward();
}
});
- const unsubscribeHistoryHome = window.electronAPI.onMenuEvent('history-home', () => {
+ const unsubscribeHistoryHome = window.electronAPI.onMenuEvent(MENU_EVENTS.historyHome, () => {
if (viewMode) return;
const homeTab = visibleTabs[0]?.id as TabId | undefined;
@@ -465,7 +490,7 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
selectTab(homeTab, { resetBillsAnchor: true });
});
- const unsubscribeSetTabPosition = window.electronAPI.onMenuEvent('set-tab-position', (position) => {
+ const unsubscribeSetTabPosition = window.electronAPI.onMenuEvent(MENU_EVENTS.setTabPosition, (position) => {
if (
position === 'top' ||
position === 'bottom' ||
@@ -476,7 +501,7 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
}
});
- const unsubscribeToggleDisplayMode = window.electronAPI.onMenuEvent('toggle-tab-display-mode', () => {
+ const unsubscribeToggleDisplayMode = window.electronAPI.onMenuEvent(MENU_EVENTS.toggleTabDisplayMode, () => {
const newMode: TabDisplayMode = tabDisplayModeRef.current === 'icons-only' ? 'icons-with-labels' : 'icons-only';
handleTabDisplayModeChangeRef.current?.(newMode);
});
@@ -562,20 +587,19 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
}
lastMissingPathPromptRef.current = currentPath;
- setActiveRelinkMismatchMessage(null);
- setMissingActiveFilePath(currentPath);
+ promptActiveFileRelink(currentPath);
}, 2500);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
- }, [budgetData, updateBudgetData]);
+ }, [budgetData, promptActiveFileRelink]);
const handleCloseActiveRelinkModal = useCallback(() => {
if (activeRelinkLoading || !budgetData) return;
- const stalePath = missingActiveFilePath || budgetData.settings.filePath || null;
+ const stalePath = missingActiveFile?.filePath || budgetData.settings.filePath || null;
if (stalePath) {
FileStorageService.removeRecentFile(stalePath);
}
@@ -589,48 +613,12 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
});
lastMissingPathPromptRef.current = null;
- setMissingActiveFilePath(null);
- setActiveRelinkMismatchMessage(null);
+ clearActiveFileRelinkPrompt();
setStatusToast({
message: 'File location was cleared. Use Save to choose a new file location.',
type: 'warning',
});
- }, [activeRelinkLoading, budgetData, missingActiveFilePath, updateBudgetData]);
-
- const handleLocateActiveRelinkFile = useCallback(async () => {
- if (!budgetData || !missingActiveFilePath || activeRelinkLoading) return;
-
- setActiveRelinkLoading(true);
- try {
- const result = await FileStorageService.relinkMovedBudgetFile(missingActiveFilePath, budgetData.id);
-
- if (result.status === 'cancelled') {
- return;
- }
-
- if (result.status === 'mismatch' || result.status === 'invalid') {
- setActiveRelinkMismatchMessage(result.message);
- return;
- }
-
- lastMissingPathPromptRef.current = null;
- setMissingActiveFilePath(null);
- setActiveRelinkMismatchMessage(null);
- updateBudgetData({
- name: result.planName,
- settings: {
- ...budgetData.settings,
- filePath: result.filePath,
- },
- });
- setStatusToast({ message: 'File moved on disk. Plan path was relinked.', type: 'success' });
- } catch (error) {
- const message = (error as Error).message || 'Unable to relink moved file.';
- setActiveRelinkMismatchMessage(message);
- } finally {
- setActiveRelinkLoading(false);
- }
- }, [activeRelinkLoading, budgetData, missingActiveFilePath, updateBudgetData]);
+ }, [activeRelinkLoading, budgetData, missingActiveFile, updateBudgetData, clearActiveFileRelinkPrompt]);
// Auto-dismiss status toast
useEffect(() => {
@@ -656,6 +644,7 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
planLoadingStartRef.current = Date.now();
}
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setShowPlanLoadingScreen(true);
return;
}
@@ -691,7 +680,10 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
if (!visible) {
const currentVisibleCount = tabConfigs.filter(t => t.visible).length;
if (currentVisibleCount <= 1) {
- alert('Cannot hide the last visible tab. At least one tab must remain visible.');
+ openErrorDialog({
+ title: 'Cannot Hide Tab',
+ message: 'Cannot hide the last visible tab. At least one tab must remain visible.',
+ });
return;
}
}
@@ -706,7 +698,7 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
if (!visible && activeTab === tabId) {
setActiveTab('metrics');
}
- }, [budgetData, tabConfigs, activeTab, updateBudgetSettings]);
+ }, [activeTab, budgetData, openErrorDialog, tabConfigs, updateBudgetSettings]);
const handleReorderTab = useCallback((fromIndex: number, toIndex: number) => {
if (!budgetData) return;
@@ -882,17 +874,8 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
setNewYear('');
};
- const handleGenerateEncryptionKey = () => {
- const key = FileStorageService.generateEncryptionKey();
- setGeneratedKey(key);
- setUseCustomKey(false);
- };
-
const handleEncryptionModalOpen = () => {
- setEncryptionEnabled(null);
- setCustomKey('');
- setGeneratedKey('');
- setUseCustomKey(false);
+ resetEncryptionSetupFlow();
setShowEncryptionSetup(true);
};
@@ -1005,54 +988,35 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
const handleEncryptionModalClose = () => {
setShowEncryptionSetup(false);
- setEncryptionEnabled(null);
- setCustomKey('');
- setGeneratedKey('');
- setUseCustomKey(false);
- setEncryptionSaving(false);
+ resetEncryptionSetupFlow();
};
const handleSaveEncryption = async () => {
if (!budgetData) return;
-
- setEncryptionSaving(true);
- try {
- const settings = FileStorageService.getAppSettings();
-
- if (encryptionEnabled) {
- const keyToUse = useCustomKey ? customKey : generatedKey;
-
- if (!keyToUse) {
- alert('Please generate or enter an encryption key.');
- setEncryptionSaving(false);
- return;
- }
-
- settings.encryptionEnabled = true;
- await KeychainService.saveKey(budgetData.id, keyToUse);
- } else {
- settings.encryptionEnabled = false;
- await KeychainService.deleteKey(budgetData.id);
- }
-
- FileStorageService.saveAppSettings(settings);
+
+ const result = await saveEncryptionSelection({
+ planId: budgetData.id,
+ persistAppSettings: true,
+ deleteStoredKeyWhenDisabled: true,
+ });
+
+ if (!result.success) {
+ openErrorDialog(result.errorDialog);
+ return;
+ }
+
updateBudgetSettings({
...budgetData.settings,
- encryptionEnabled: Boolean(encryptionEnabled),
+ encryptionEnabled: result.encryptionEnabled,
});
-
+
setStatusToast({
- message: encryptionEnabled
+ message: result.encryptionEnabled
? '🔒 Encryption enabled for this plan'
: '📄 Encryption disabled for this plan',
type: 'success',
});
handleEncryptionModalClose();
- } catch (error) {
- const errorMsg = error instanceof Error ? error.message : 'Unknown error';
- alert(`Failed to save encryption settings: ${errorMsg}`);
- setEncryptionSaving(false);
- }
};
const showYearSubtitle = !budgetData.name.includes(String(budgetData.year));
@@ -1188,7 +1152,7 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
variant="primary"
size="small"
onClick={handleSave}
- disabled={loading || !!missingActiveFilePath || activeRelinkLoading}
+ disabled={loading || !!missingActiveFile || activeRelinkLoading}
className="header-btn-primary"
>
💾 Save
@@ -1492,7 +1456,7 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
{/* Settings Modal */}
- setShowSettings(false)}
/>
@@ -1517,7 +1481,7 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
setEncryptionEnabled(null)}
+ onClick={goBackToSelection}
disabled={encryptionSaving}
>
Back
@@ -1527,11 +1491,7 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
type="button"
variant="primary"
onClick={handleSaveEncryption}
- disabled={
- encryptionEnabled === null ||
- (encryptionEnabled === true && !useCustomKey && !generatedKey) ||
- encryptionSaving
- }
+ disabled={!canSaveSelection || encryptionSaving}
isLoading={encryptionSaving}
loadingText="Saving..."
>
@@ -1559,38 +1519,24 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode })
/>
-
-
- Cancel
-
-
- Locate File
-
- >
- }
- >
-
- Your open plan file was moved or renamed on disk. Locate it to keep saving to the correct file.
-
-
- {missingActiveFilePath || ''}
-
- {activeRelinkMismatchMessage && (
- {activeRelinkMismatchMessage} Please try again.
- )}
-
+ message="Your open plan file was moved or renamed on disk. Locate it to keep saving to the correct file."
+ filePath={missingActiveFile?.filePath || ''}
+ errorMessage={activeRelinkMismatchMessage ? `${activeRelinkMismatchMessage} Please try again.` : null}
+ isLoading={activeRelinkLoading}
+ />
+
+
{statusToast && (
= ({ onResetSetup, viewMode })
/>
)}
- {/* Accounts Manager Modal */}
+ {/* Accounts Modal */}
{showAccountsModal && (
- setShowAccountsModal(false)} />
+ setShowAccountsModal(false)} />
)}
{/* Tab Management Modal */}
diff --git a/src/components/PlanDashboard/PlanTabs/PlanTabs.tsx b/src/components/PlanDashboard/PlanTabs/PlanTabs.tsx
index 7ecc982..d3d1b85 100644
--- a/src/components/PlanDashboard/PlanTabs/PlanTabs.tsx
+++ b/src/components/PlanDashboard/PlanTabs/PlanTabs.tsx
@@ -1,5 +1,7 @@
import React from 'react';
-import type { TabConfig, TabPosition, TabDisplayMode } from '../../../types/auth';
+import { useAppDialogs } from '../../../hooks';
+import { ConfirmDialog, ErrorDialog } from '../../_shared';
+import type { TabConfig, TabPosition, TabDisplayMode } from '../../../types/tabs';
import TabPositionHandle from './TabPositionHandle';
import './PlanTabs.css';
@@ -40,20 +42,36 @@ const PlanTabs: React.FC = ({
onTabPositionChange,
onTabDisplayModeChange,
}) => {
+ const {
+ confirmDialog,
+ errorDialog,
+ openConfirmDialog,
+ closeConfirmDialog,
+ confirmCurrentDialog,
+ openErrorDialog,
+ closeErrorDialog,
+ } = useAppDialogs();
+
const handleHideTab = (e: React.MouseEvent, tab: TabConfig) => {
e.stopPropagation();
e.preventDefault();
// Check if this is the last visible tab
if (visibleTabs.length <= 1) {
- alert('Cannot hide the last visible tab. At least one tab must remain visible.');
+ openErrorDialog({
+ title: 'Cannot Hide Tab',
+ message: 'Cannot hide the last visible tab. At least one tab must remain visible.',
+ });
return;
}
- const confirmed = window.confirm(`Are you sure you want to hide the ${tab.label} tab?`);
- if (confirmed) {
- onHideTab(tab.id);
- }
+ openConfirmDialog({
+ title: 'Hide Tab',
+ message: `Are you sure you want to hide the ${tab.label} tab?`,
+ confirmLabel: 'Hide Tab',
+ confirmVariant: 'danger',
+ onConfirm: () => onHideTab(tab.id),
+ });
};
const isSidebar = tabPosition === 'left' || tabPosition === 'right';
@@ -80,7 +98,8 @@ const PlanTabs: React.FC = ({
};
return (
-
+ <>
+
{/* Tab Position Handle Display */}
{onTabPositionChange && (
= ({
)}
-
+
+
+
+
+
+ >
);
};
diff --git a/src/components/PlanDashboard/PlanTabs/TabManagementModal.tsx b/src/components/PlanDashboard/PlanTabs/TabManagementModal.tsx
index a2e9500..fae914a 100644
--- a/src/components/PlanDashboard/PlanTabs/TabManagementModal.tsx
+++ b/src/components/PlanDashboard/PlanTabs/TabManagementModal.tsx
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
-import { Modal, Button } from '../../shared';
-import type { TabConfig } from '../../../types/auth';
+import { Modal, Button } from '../../_shared';
+import type { TabConfig } from '../../../types/tabs';
import './PlanTabs.css';
import './TabManagementModal.css';
diff --git a/src/components/PlanDashboard/PlanTabs/TabPositionHandle.tsx b/src/components/PlanDashboard/PlanTabs/TabPositionHandle.tsx
index 069809b..6a347a4 100644
--- a/src/components/PlanDashboard/PlanTabs/TabPositionHandle.tsx
+++ b/src/components/PlanDashboard/PlanTabs/TabPositionHandle.tsx
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
-import type { TabPosition } from '../../../types/auth';
+import type { TabPosition } from '../../../types/tabs';
import './TabPositionHandle.css';
interface TabPositionHandleProps {
diff --git a/src/components/Settings/index.ts b/src/components/Settings/index.ts
deleted file mode 100644
index 63a5e96..0000000
--- a/src/components/Settings/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './Settings';
diff --git a/src/components/shared/Button/Button.css b/src/components/_shared/controls/Button/Button.css
similarity index 100%
rename from src/components/shared/Button/Button.css
rename to src/components/_shared/controls/Button/Button.css
diff --git a/src/components/shared/Button/Button.tsx b/src/components/_shared/controls/Button/Button.tsx
similarity index 100%
rename from src/components/shared/Button/Button.tsx
rename to src/components/_shared/controls/Button/Button.tsx
diff --git a/src/components/shared/Button/index.ts b/src/components/_shared/controls/Button/index.ts
similarity index 100%
rename from src/components/shared/Button/index.ts
rename to src/components/_shared/controls/Button/index.ts
diff --git a/src/components/shared/DateInput/DateInput.css b/src/components/_shared/controls/DateInput/DateInput.css
similarity index 100%
rename from src/components/shared/DateInput/DateInput.css
rename to src/components/_shared/controls/DateInput/DateInput.css
diff --git a/src/components/shared/DateInput/DateInput.tsx b/src/components/_shared/controls/DateInput/DateInput.tsx
similarity index 100%
rename from src/components/shared/DateInput/DateInput.tsx
rename to src/components/_shared/controls/DateInput/DateInput.tsx
diff --git a/src/components/shared/DateInput/index.ts b/src/components/_shared/controls/DateInput/index.ts
similarity index 100%
rename from src/components/shared/DateInput/index.ts
rename to src/components/_shared/controls/DateInput/index.ts
diff --git a/src/components/shared/FormGroup/FormGroup.css b/src/components/_shared/controls/FormGroup/FormGroup.css
similarity index 100%
rename from src/components/shared/FormGroup/FormGroup.css
rename to src/components/_shared/controls/FormGroup/FormGroup.css
diff --git a/src/components/shared/FormGroup/FormGroup.tsx b/src/components/_shared/controls/FormGroup/FormGroup.tsx
similarity index 100%
rename from src/components/shared/FormGroup/FormGroup.tsx
rename to src/components/_shared/controls/FormGroup/FormGroup.tsx
diff --git a/src/components/shared/FormGroup/index.ts b/src/components/_shared/controls/FormGroup/index.ts
similarity index 100%
rename from src/components/shared/FormGroup/index.ts
rename to src/components/_shared/controls/FormGroup/index.ts
diff --git a/src/components/shared/FormattedNumberInput/FormattedNumberInput.css b/src/components/_shared/controls/FormattedNumberInput/FormattedNumberInput.css
similarity index 100%
rename from src/components/shared/FormattedNumberInput/FormattedNumberInput.css
rename to src/components/_shared/controls/FormattedNumberInput/FormattedNumberInput.css
diff --git a/src/components/shared/FormattedNumberInput/FormattedNumberInput.tsx b/src/components/_shared/controls/FormattedNumberInput/FormattedNumberInput.tsx
similarity index 99%
rename from src/components/shared/FormattedNumberInput/FormattedNumberInput.tsx
rename to src/components/_shared/controls/FormattedNumberInput/FormattedNumberInput.tsx
index 07c0672..cdc25df 100644
--- a/src/components/shared/FormattedNumberInput/FormattedNumberInput.tsx
+++ b/src/components/_shared/controls/FormattedNumberInput/FormattedNumberInput.tsx
@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react';
-import { formatNumberDisplay, parseFormattedNumber } from '../../../utils/money';
+import { formatNumberDisplay, parseFormattedNumber } from '../../../../utils/money';
import './FormattedNumberInput.css';
interface FormattedNumberInputProps extends Omit, 'type' | 'value' | 'onChange'> {
diff --git a/src/components/shared/FormattedNumberInput/index.ts b/src/components/_shared/controls/FormattedNumberInput/index.ts
similarity index 100%
rename from src/components/shared/FormattedNumberInput/index.ts
rename to src/components/_shared/controls/FormattedNumberInput/index.ts
diff --git a/src/components/shared/InputWithPrefix/InputWithPrefix.css b/src/components/_shared/controls/InputWithPrefix/InputWithPrefix.css
similarity index 100%
rename from src/components/shared/InputWithPrefix/InputWithPrefix.css
rename to src/components/_shared/controls/InputWithPrefix/InputWithPrefix.css
diff --git a/src/components/shared/InputWithPrefix/InputWithPrefix.tsx b/src/components/_shared/controls/InputWithPrefix/InputWithPrefix.tsx
similarity index 100%
rename from src/components/shared/InputWithPrefix/InputWithPrefix.tsx
rename to src/components/_shared/controls/InputWithPrefix/InputWithPrefix.tsx
diff --git a/src/components/shared/InputWithPrefix/index.ts b/src/components/_shared/controls/InputWithPrefix/index.ts
similarity index 100%
rename from src/components/shared/InputWithPrefix/index.ts
rename to src/components/_shared/controls/InputWithPrefix/index.ts
diff --git a/src/components/shared/PillToggle/PillToggle.css b/src/components/_shared/controls/PillToggle/PillToggle.css
similarity index 100%
rename from src/components/shared/PillToggle/PillToggle.css
rename to src/components/_shared/controls/PillToggle/PillToggle.css
diff --git a/src/components/shared/PillToggle/PillToggle.tsx b/src/components/_shared/controls/PillToggle/PillToggle.tsx
similarity index 100%
rename from src/components/shared/PillToggle/PillToggle.tsx
rename to src/components/_shared/controls/PillToggle/PillToggle.tsx
diff --git a/src/components/shared/PillToggle/index.ts b/src/components/_shared/controls/PillToggle/index.ts
similarity index 100%
rename from src/components/shared/PillToggle/index.ts
rename to src/components/_shared/controls/PillToggle/index.ts
diff --git a/src/components/shared/RadioGroup/RadioGroup.css b/src/components/_shared/controls/RadioGroup/RadioGroup.css
similarity index 100%
rename from src/components/shared/RadioGroup/RadioGroup.css
rename to src/components/_shared/controls/RadioGroup/RadioGroup.css
diff --git a/src/components/shared/RadioGroup/RadioGroup.tsx b/src/components/_shared/controls/RadioGroup/RadioGroup.tsx
similarity index 100%
rename from src/components/shared/RadioGroup/RadioGroup.tsx
rename to src/components/_shared/controls/RadioGroup/RadioGroup.tsx
diff --git a/src/components/shared/RadioGroup/index.ts b/src/components/_shared/controls/RadioGroup/index.ts
similarity index 100%
rename from src/components/shared/RadioGroup/index.ts
rename to src/components/_shared/controls/RadioGroup/index.ts
diff --git a/src/components/shared/Toggle/Toggle.css b/src/components/_shared/controls/Toggle/Toggle.css
similarity index 100%
rename from src/components/shared/Toggle/Toggle.css
rename to src/components/_shared/controls/Toggle/Toggle.css
diff --git a/src/components/shared/Toggle/Toggle.tsx b/src/components/_shared/controls/Toggle/Toggle.tsx
similarity index 100%
rename from src/components/shared/Toggle/Toggle.tsx
rename to src/components/_shared/controls/Toggle/Toggle.tsx
diff --git a/src/components/shared/Toggle/index.ts b/src/components/_shared/controls/Toggle/index.ts
similarity index 100%
rename from src/components/shared/Toggle/index.ts
rename to src/components/_shared/controls/Toggle/index.ts
diff --git a/src/components/shared/Alert/Alert.css b/src/components/_shared/feedback/Alert/Alert.css
similarity index 100%
rename from src/components/shared/Alert/Alert.css
rename to src/components/_shared/feedback/Alert/Alert.css
diff --git a/src/components/shared/Alert/Alert.tsx b/src/components/_shared/feedback/Alert/Alert.tsx
similarity index 100%
rename from src/components/shared/Alert/Alert.tsx
rename to src/components/_shared/feedback/Alert/Alert.tsx
diff --git a/src/components/shared/Alert/index.ts b/src/components/_shared/feedback/Alert/index.ts
similarity index 100%
rename from src/components/shared/Alert/index.ts
rename to src/components/_shared/feedback/Alert/index.ts
diff --git a/src/components/_shared/feedback/ConfirmDialog/ConfirmDialog.css b/src/components/_shared/feedback/ConfirmDialog/ConfirmDialog.css
new file mode 100644
index 0000000..9db8a95
--- /dev/null
+++ b/src/components/_shared/feedback/ConfirmDialog/ConfirmDialog.css
@@ -0,0 +1,9 @@
+.confirm-dialog {
+ max-width: 500px;
+}
+
+.confirm-dialog-message {
+ margin: 0;
+ color: var(--text-secondary);
+ line-height: 1.6;
+}
\ No newline at end of file
diff --git a/src/components/_shared/feedback/ConfirmDialog/ConfirmDialog.tsx b/src/components/_shared/feedback/ConfirmDialog/ConfirmDialog.tsx
new file mode 100644
index 0000000..f2e9981
--- /dev/null
+++ b/src/components/_shared/feedback/ConfirmDialog/ConfirmDialog.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { Button, Modal } from '../../';
+import './ConfirmDialog.css';
+
+interface ConfirmDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ title: string;
+ message: React.ReactNode;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ confirmVariant?: 'primary' | 'danger';
+}
+
+const ConfirmDialog: React.FC = ({
+ isOpen,
+ onClose,
+ onConfirm,
+ title,
+ message,
+ confirmLabel = 'Confirm',
+ cancelLabel = 'Cancel',
+ confirmVariant = 'primary',
+}) => {
+ return (
+
+
+ {cancelLabel}
+
+
+ {confirmLabel}
+
+ >
+ }
+ >
+ {message}
+
+ );
+};
+
+export default ConfirmDialog;
\ No newline at end of file
diff --git a/src/components/_shared/feedback/ConfirmDialog/index.ts b/src/components/_shared/feedback/ConfirmDialog/index.ts
new file mode 100644
index 0000000..1e01a07
--- /dev/null
+++ b/src/components/_shared/feedback/ConfirmDialog/index.ts
@@ -0,0 +1 @@
+export { default } from './ConfirmDialog';
\ No newline at end of file
diff --git a/src/components/_shared/feedback/ErrorDialog/ErrorDialog.css b/src/components/_shared/feedback/ErrorDialog/ErrorDialog.css
new file mode 100644
index 0000000..2bf2e71
--- /dev/null
+++ b/src/components/_shared/feedback/ErrorDialog/ErrorDialog.css
@@ -0,0 +1,9 @@
+.error-dialog {
+ max-width: 520px;
+}
+
+.error-dialog-message {
+ margin: 0;
+ color: var(--text-secondary);
+ line-height: 1.6;
+}
\ No newline at end of file
diff --git a/src/components/_shared/feedback/ErrorDialog/ErrorDialog.tsx b/src/components/_shared/feedback/ErrorDialog/ErrorDialog.tsx
new file mode 100644
index 0000000..c59d01f
--- /dev/null
+++ b/src/components/_shared/feedback/ErrorDialog/ErrorDialog.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import { Button, Modal } from '../../';
+import './ErrorDialog.css';
+
+interface ErrorDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ title: string;
+ message: React.ReactNode;
+ actionLabel?: string;
+}
+
+const ErrorDialog: React.FC = ({
+ isOpen,
+ onClose,
+ title,
+ message,
+ actionLabel = 'OK',
+}) => {
+ return (
+
+ {actionLabel}
+
+ }
+ >
+ {message}
+
+ );
+};
+
+export default ErrorDialog;
\ No newline at end of file
diff --git a/src/components/_shared/feedback/ErrorDialog/index.ts b/src/components/_shared/feedback/ErrorDialog/index.ts
new file mode 100644
index 0000000..58f17fd
--- /dev/null
+++ b/src/components/_shared/feedback/ErrorDialog/index.ts
@@ -0,0 +1 @@
+export { default } from './ErrorDialog';
\ No newline at end of file
diff --git a/src/components/shared/InfoBox/InfoBox.css b/src/components/_shared/feedback/InfoBox/InfoBox.css
similarity index 100%
rename from src/components/shared/InfoBox/InfoBox.css
rename to src/components/_shared/feedback/InfoBox/InfoBox.css
diff --git a/src/components/shared/InfoBox/InfoBox.tsx b/src/components/_shared/feedback/InfoBox/InfoBox.tsx
similarity index 100%
rename from src/components/shared/InfoBox/InfoBox.tsx
rename to src/components/_shared/feedback/InfoBox/InfoBox.tsx
diff --git a/src/components/shared/InfoBox/index.ts b/src/components/_shared/feedback/InfoBox/index.ts
similarity index 100%
rename from src/components/shared/InfoBox/index.ts
rename to src/components/_shared/feedback/InfoBox/index.ts
diff --git a/src/components/shared/ProgressBar/ProgressBar.css b/src/components/_shared/feedback/ProgressBar/ProgressBar.css
similarity index 100%
rename from src/components/shared/ProgressBar/ProgressBar.css
rename to src/components/_shared/feedback/ProgressBar/ProgressBar.css
diff --git a/src/components/shared/ProgressBar/ProgressBar.tsx b/src/components/_shared/feedback/ProgressBar/ProgressBar.tsx
similarity index 100%
rename from src/components/shared/ProgressBar/ProgressBar.tsx
rename to src/components/_shared/feedback/ProgressBar/ProgressBar.tsx
diff --git a/src/components/shared/ProgressBar/index.ts b/src/components/_shared/feedback/ProgressBar/index.ts
similarity index 100%
rename from src/components/shared/ProgressBar/index.ts
rename to src/components/_shared/feedback/ProgressBar/index.ts
diff --git a/src/components/shared/Toast/Toast.css b/src/components/_shared/feedback/Toast/Toast.css
similarity index 100%
rename from src/components/shared/Toast/Toast.css
rename to src/components/_shared/feedback/Toast/Toast.css
diff --git a/src/components/shared/Toast/Toast.tsx b/src/components/_shared/feedback/Toast/Toast.tsx
similarity index 100%
rename from src/components/shared/Toast/Toast.tsx
rename to src/components/_shared/feedback/Toast/Toast.tsx
diff --git a/src/components/shared/Toast/index.ts b/src/components/_shared/feedback/Toast/index.ts
similarity index 100%
rename from src/components/shared/Toast/index.ts
rename to src/components/_shared/feedback/Toast/index.ts
diff --git a/src/components/_shared/index.ts b/src/components/_shared/index.ts
new file mode 100644
index 0000000..23f455e
--- /dev/null
+++ b/src/components/_shared/index.ts
@@ -0,0 +1,22 @@
+export { default as Alert } from './feedback/Alert';
+export { default as Toast } from './feedback/Toast';
+export { default as Modal } from './layout/Modal';
+export { default as Button } from './controls/Button';
+export { default as FormGroup } from './controls/FormGroup';
+export { default as InputWithPrefix } from './controls/InputWithPrefix';
+export { default as DateInput } from './controls/DateInput';
+export { default as FormattedNumberInput } from './controls/FormattedNumberInput';
+export { default as StickyActions } from './layout/StickyActions';
+export { default as RadioGroup } from './controls/RadioGroup';
+export { default as Toggle } from './controls/Toggle';
+export { default as PillToggle } from './controls/PillToggle';
+export { default as SectionItemCard } from './layout/SectionItemCard';
+export { default as InfoBox } from './feedback/InfoBox';
+export { default as AccountsEditor } from './workflows/AccountsEditor';
+export { default as EncryptionConfigPanel } from './workflows/EncryptionConfigPanel';
+export { default as ViewModeSelector } from './layout/ViewModeSelector';
+export { default as PageHeader } from './layout/PageHeader';
+export { default as ProgressBar } from './feedback/ProgressBar';
+export { default as FileRelinkModal } from './workflows/FileRelinkModal';
+export { default as ConfirmDialog } from './feedback/ConfirmDialog';
+export { default as ErrorDialog } from './feedback/ErrorDialog';
diff --git a/src/components/shared/Modal/Modal.css b/src/components/_shared/layout/Modal/Modal.css
similarity index 100%
rename from src/components/shared/Modal/Modal.css
rename to src/components/_shared/layout/Modal/Modal.css
diff --git a/src/components/shared/Modal/Modal.tsx b/src/components/_shared/layout/Modal/Modal.tsx
similarity index 98%
rename from src/components/shared/Modal/Modal.tsx
rename to src/components/_shared/layout/Modal/Modal.tsx
index cd5e3f3..d19af31 100644
--- a/src/components/shared/Modal/Modal.tsx
+++ b/src/components/_shared/layout/Modal/Modal.tsx
@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
-import { Button } from '../';
+import { Button } from '../../';
import './Modal.css';
interface ModalProps {
diff --git a/src/components/shared/Modal/index.ts b/src/components/_shared/layout/Modal/index.ts
similarity index 100%
rename from src/components/shared/Modal/index.ts
rename to src/components/_shared/layout/Modal/index.ts
diff --git a/src/components/shared/PageHeader/PageHeader.css b/src/components/_shared/layout/PageHeader/PageHeader.css
similarity index 100%
rename from src/components/shared/PageHeader/PageHeader.css
rename to src/components/_shared/layout/PageHeader/PageHeader.css
diff --git a/src/components/shared/PageHeader/PageHeader.tsx b/src/components/_shared/layout/PageHeader/PageHeader.tsx
similarity index 100%
rename from src/components/shared/PageHeader/PageHeader.tsx
rename to src/components/_shared/layout/PageHeader/PageHeader.tsx
diff --git a/src/components/shared/PageHeader/index.ts b/src/components/_shared/layout/PageHeader/index.ts
similarity index 100%
rename from src/components/shared/PageHeader/index.ts
rename to src/components/_shared/layout/PageHeader/index.ts
diff --git a/src/components/shared/SectionItemCard/SectionItemCard.css b/src/components/_shared/layout/SectionItemCard/SectionItemCard.css
similarity index 100%
rename from src/components/shared/SectionItemCard/SectionItemCard.css
rename to src/components/_shared/layout/SectionItemCard/SectionItemCard.css
diff --git a/src/components/shared/SectionItemCard/SectionItemCard.tsx b/src/components/_shared/layout/SectionItemCard/SectionItemCard.tsx
similarity index 100%
rename from src/components/shared/SectionItemCard/SectionItemCard.tsx
rename to src/components/_shared/layout/SectionItemCard/SectionItemCard.tsx
diff --git a/src/components/shared/SectionItemCard/index.ts b/src/components/_shared/layout/SectionItemCard/index.ts
similarity index 100%
rename from src/components/shared/SectionItemCard/index.ts
rename to src/components/_shared/layout/SectionItemCard/index.ts
diff --git a/src/components/shared/StickyActions/StickyActions.css b/src/components/_shared/layout/StickyActions/StickyActions.css
similarity index 100%
rename from src/components/shared/StickyActions/StickyActions.css
rename to src/components/_shared/layout/StickyActions/StickyActions.css
diff --git a/src/components/shared/StickyActions/StickyActions.tsx b/src/components/_shared/layout/StickyActions/StickyActions.tsx
similarity index 100%
rename from src/components/shared/StickyActions/StickyActions.tsx
rename to src/components/_shared/layout/StickyActions/StickyActions.tsx
diff --git a/src/components/shared/StickyActions/index.ts b/src/components/_shared/layout/StickyActions/index.ts
similarity index 100%
rename from src/components/shared/StickyActions/index.ts
rename to src/components/_shared/layout/StickyActions/index.ts
diff --git a/src/components/shared/ViewModeSelector/ViewModeSelector.css b/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.css
similarity index 100%
rename from src/components/shared/ViewModeSelector/ViewModeSelector.css
rename to src/components/_shared/layout/ViewModeSelector/ViewModeSelector.css
diff --git a/src/components/shared/ViewModeSelector/ViewModeSelector.tsx b/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.tsx
similarity index 96%
rename from src/components/shared/ViewModeSelector/ViewModeSelector.tsx
rename to src/components/_shared/layout/ViewModeSelector/ViewModeSelector.tsx
index 1faa77c..e7ac5d0 100644
--- a/src/components/shared/ViewModeSelector/ViewModeSelector.tsx
+++ b/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.tsx
@@ -1,8 +1,7 @@
import React from 'react';
+import type { ViewMode } from '../../../../types/viewMode';
import './ViewModeSelector.css';
-type ViewMode = 'paycheck' | 'monthly' | 'yearly';
-
interface ViewModeSelectorProps {
mode: ViewMode;
onChange: (mode: ViewMode) => void;
diff --git a/src/components/shared/ViewModeSelector/index.ts b/src/components/_shared/layout/ViewModeSelector/index.ts
similarity index 100%
rename from src/components/shared/ViewModeSelector/index.ts
rename to src/components/_shared/layout/ViewModeSelector/index.ts
diff --git a/src/components/_shared/sharedPathDisplay.css b/src/components/_shared/sharedPathDisplay.css
new file mode 100644
index 0000000..6cf68af
--- /dev/null
+++ b/src/components/_shared/sharedPathDisplay.css
@@ -0,0 +1,15 @@
+.shared-path-display {
+ display: block;
+ padding: 0.75rem 0.875rem;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ font-family: 'SF Mono', 'Monaco', 'Cascadia Mono', 'Segoe UI Mono', Consolas, monospace;
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ word-break: break-all;
+}
+
+.shared-path-display--primary {
+ color: var(--text-primary);
+}
\ No newline at end of file
diff --git a/src/components/shared/AccountsEditor/AccountsEditor.css b/src/components/_shared/workflows/AccountsEditor/AccountsEditor.css
similarity index 100%
rename from src/components/shared/AccountsEditor/AccountsEditor.css
rename to src/components/_shared/workflows/AccountsEditor/AccountsEditor.css
diff --git a/src/components/shared/AccountsEditor/AccountsEditor.tsx b/src/components/_shared/workflows/AccountsEditor/AccountsEditor.tsx
similarity index 98%
rename from src/components/shared/AccountsEditor/AccountsEditor.tsx
rename to src/components/_shared/workflows/AccountsEditor/AccountsEditor.tsx
index 864d08e..0784d70 100644
--- a/src/components/shared/AccountsEditor/AccountsEditor.tsx
+++ b/src/components/_shared/workflows/AccountsEditor/AccountsEditor.tsx
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
-import type { Account } from '../../../types/auth';
-import { getDefaultAccountColor, getDefaultAccountIcon } from '../../../utils/accountDefaults';
-import { Button, InfoBox } from '../';
+import type { Account } from '../../../../types/accounts';
+import { getDefaultAccountColor, getDefaultAccountIcon } from '../../../../utils/accountDefaults';
+import { Button, InfoBox } from '../../';
import './AccountsEditor.css';
interface AccountsEditorProps {
diff --git a/src/components/shared/AccountsEditor/index.ts b/src/components/_shared/workflows/AccountsEditor/index.ts
similarity index 100%
rename from src/components/shared/AccountsEditor/index.ts
rename to src/components/_shared/workflows/AccountsEditor/index.ts
diff --git a/src/components/EncryptionSetup/EncryptionConfigPanel.css b/src/components/_shared/workflows/EncryptionConfigPanel/EncryptionConfigPanel.css
similarity index 88%
rename from src/components/EncryptionSetup/EncryptionConfigPanel.css
rename to src/components/_shared/workflows/EncryptionConfigPanel/EncryptionConfigPanel.css
index 7c64ca2..c4ec735 100644
--- a/src/components/EncryptionSetup/EncryptionConfigPanel.css
+++ b/src/components/_shared/workflows/EncryptionConfigPanel/EncryptionConfigPanel.css
@@ -59,20 +59,12 @@
.encryption-config-panel .encryption-key-box {
display: flex;
align-items: center;
- gap: 0.5rem;
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 0.5rem;
- padding: 0.75rem;
margin-bottom: 0.75rem;
- overflow-x: auto;
}
-.encryption-config-panel .encryption-key-box code {
+.encryption-config-panel .encryption-key-code {
flex: 1;
- font-size: 0.85rem;
- color: var(--text-primary);
- word-break: break-all;
+ margin: 0;
}
.encryption-config-panel .encryption-btn-icon {
@@ -129,7 +121,7 @@
align-items: flex-start;
}
- .encryption-config-panel .encryption-key-box code {
+ .encryption-config-panel .encryption-key-code {
width: 100%;
}
}
diff --git a/src/components/EncryptionSetup/EncryptionConfigPanel.tsx b/src/components/_shared/workflows/EncryptionConfigPanel/EncryptionConfigPanel.tsx
similarity index 95%
rename from src/components/EncryptionSetup/EncryptionConfigPanel.tsx
rename to src/components/_shared/workflows/EncryptionConfigPanel/EncryptionConfigPanel.tsx
index 0ef309a..18f7091 100644
--- a/src/components/EncryptionSetup/EncryptionConfigPanel.tsx
+++ b/src/components/_shared/workflows/EncryptionConfigPanel/EncryptionConfigPanel.tsx
@@ -1,5 +1,6 @@
import React from 'react';
-import { Alert, RadioGroup, Button } from '../shared';
+import { Alert, RadioGroup, Button } from '../../';
+import '../../sharedPathDisplay.css';
import './EncryptionConfigPanel.css';
interface EncryptionConfigPanelProps {
@@ -118,7 +119,7 @@ const EncryptionConfigPanel: React.FC = ({
{!useCustomKey && (
<>
- navigator.clipboard.writeText(generatedKey)}>{generatedKey}
+ navigator.clipboard.writeText(generatedKey)}>{generatedKey}
void;
+ onLocate: () => void;
+ extraAction?: {
+ label: string;
+ onClick: () => void;
+ variant: 'danger' | 'secondary';
+ };
+}
+
+const FileRelinkModal: React.FC = ({
+ isOpen,
+ header,
+ message,
+ filePath,
+ errorMessage,
+ isLoading = false,
+ onClose,
+ onLocate,
+ extraAction,
+}) => {
+ return (
+
+
+ Cancel
+
+ {extraAction && (
+
+ {extraAction.label}
+
+ )}
+
+ Locate File
+
+ >
+ }
+ >
+ {message}
+
+ {filePath}
+
+ {errorMessage && {errorMessage}
}
+
+ );
+};
+
+export default FileRelinkModal;
\ No newline at end of file
diff --git a/src/components/_shared/workflows/FileRelinkModal/index.ts b/src/components/_shared/workflows/FileRelinkModal/index.ts
new file mode 100644
index 0000000..25ed3ed
--- /dev/null
+++ b/src/components/_shared/workflows/FileRelinkModal/index.ts
@@ -0,0 +1 @@
+export { default } from './FileRelinkModal';
\ No newline at end of file
diff --git a/src/components/About/About.css b/src/components/modals/AboutModal/AboutModal.css
similarity index 100%
rename from src/components/About/About.css
rename to src/components/modals/AboutModal/AboutModal.css
diff --git a/src/components/About/About.tsx b/src/components/modals/AboutModal/AboutModal.tsx
similarity index 95%
rename from src/components/About/About.tsx
rename to src/components/modals/AboutModal/AboutModal.tsx
index 670f685..5b4277e 100644
--- a/src/components/About/About.tsx
+++ b/src/components/modals/AboutModal/AboutModal.tsx
@@ -1,13 +1,13 @@
import React from 'react';
-import { Button } from '../shared';
-import './About.css';
+import { Button } from '../../_shared';
+import './AboutModal.css';
-interface AboutProps {
+interface AboutModalProps {
isOpen: boolean;
onClose: () => void;
}
-const About: React.FC = ({ isOpen, onClose }) => {
+const AboutModal: React.FC = ({ isOpen, onClose }) => {
// Handle Esc key to close - use capture phase for reliability
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -113,4 +113,4 @@ const About: React.FC = ({ isOpen, onClose }) => {
);
};
-export default About;
+export default AboutModal;
diff --git a/src/components/modals/AboutModal/index.ts b/src/components/modals/AboutModal/index.ts
new file mode 100644
index 0000000..fd5f0fb
--- /dev/null
+++ b/src/components/modals/AboutModal/index.ts
@@ -0,0 +1 @@
+export { default } from './AboutModal';
diff --git a/src/components/AccountsManager/AccountsManager.css b/src/components/modals/AccountsModal/AccountsModal.css
similarity index 100%
rename from src/components/AccountsManager/AccountsManager.css
rename to src/components/modals/AccountsModal/AccountsModal.css
diff --git a/src/components/AccountsManager/AccountsManager.tsx b/src/components/modals/AccountsModal/AccountsModal.tsx
similarity index 95%
rename from src/components/AccountsManager/AccountsManager.tsx
rename to src/components/modals/AccountsModal/AccountsModal.tsx
index 5fda1d5..bff3f05 100644
--- a/src/components/AccountsManager/AccountsManager.tsx
+++ b/src/components/modals/AccountsModal/AccountsModal.tsx
@@ -1,14 +1,14 @@
import React, { useState } from 'react';
-import { useBudget } from '../../contexts/BudgetContext';
-import type { Account } from '../../types/auth';
-import { Modal, Button, FormGroup, AccountsEditor } from '../shared';
-import './AccountsManager.css';
+import { useBudget } from '../../../contexts/BudgetContext';
+import type { Account } from '../../../types/accounts';
+import { Modal, Button, FormGroup, AccountsEditor } from '../../_shared';
+import './AccountsModal.css';
-interface AccountsManagerProps {
+interface AccountsModalProps {
onClose: () => void;
}
-const AccountsManager: React.FC = ({ onClose }) => {
+const AccountsModal: React.FC = ({ onClose }) => {
const { budgetData, addAccount, updateAccount, updateBudgetData } = useBudget();
const [deleteTargetAccountId, setDeleteTargetAccountId] = useState('');
const [deleteDialogState, setDeleteDialogState] = useState<{
@@ -227,4 +227,4 @@ const AccountsManager: React.FC = ({ onClose }) => {
);
};
-export default AccountsManager;
+export default AccountsModal;
diff --git a/src/components/modals/AccountsModal/index.ts b/src/components/modals/AccountsModal/index.ts
new file mode 100644
index 0000000..211c461
--- /dev/null
+++ b/src/components/modals/AccountsModal/index.ts
@@ -0,0 +1 @@
+export { default } from './AccountsModal';
diff --git a/src/components/ExportModal/ExportModal.css b/src/components/modals/ExportModal/ExportModal.css
similarity index 100%
rename from src/components/ExportModal/ExportModal.css
rename to src/components/modals/ExportModal/ExportModal.css
diff --git a/src/components/ExportModal/ExportModal.tsx b/src/components/modals/ExportModal/ExportModal.tsx
similarity index 96%
rename from src/components/ExportModal/ExportModal.tsx
rename to src/components/modals/ExportModal/ExportModal.tsx
index 8f4d0fd..743d856 100644
--- a/src/components/ExportModal/ExportModal.tsx
+++ b/src/components/modals/ExportModal/ExportModal.tsx
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
-import { useBudget } from '../../contexts/BudgetContext';
-import { exportToPDF, type PDFExportOptions } from '../../services/pdfExport';
-import { Modal, Button, FormGroup } from '../shared';
+import { useBudget } from '../../../contexts/BudgetContext';
+import { exportToPDF, type PDFExportOptions } from '../../../services/pdfExport';
+import { Modal, Button, FormGroup } from '../../_shared';
import './ExportModal.css';
interface ExportModalProps {
diff --git a/src/components/ExportModal/index.ts b/src/components/modals/ExportModal/index.ts
similarity index 100%
rename from src/components/ExportModal/index.ts
rename to src/components/modals/ExportModal/index.ts
diff --git a/src/components/FeedbackModal/FeedbackModal.css b/src/components/modals/FeedbackModal/FeedbackModal.css
similarity index 100%
rename from src/components/FeedbackModal/FeedbackModal.css
rename to src/components/modals/FeedbackModal/FeedbackModal.css
diff --git a/src/components/FeedbackModal/FeedbackModal.tsx b/src/components/modals/FeedbackModal/FeedbackModal.tsx
similarity index 99%
rename from src/components/FeedbackModal/FeedbackModal.tsx
rename to src/components/modals/FeedbackModal/FeedbackModal.tsx
index 5e585dd..41297c2 100644
--- a/src/components/FeedbackModal/FeedbackModal.tsx
+++ b/src/components/modals/FeedbackModal/FeedbackModal.tsx
@@ -1,5 +1,5 @@
import React, { useMemo, useRef, useState } from 'react';
-import { Button, FormGroup, Modal } from '../shared';
+import { Button, FormGroup, Modal } from '../../_shared';
import './FeedbackModal.css';
type FeedbackCategory = 'bug' | 'feature' | 'ui' | 'performance' | 'other';
diff --git a/src/components/FeedbackModal/index.ts b/src/components/modals/FeedbackModal/index.ts
similarity index 100%
rename from src/components/FeedbackModal/index.ts
rename to src/components/modals/FeedbackModal/index.ts
diff --git a/src/components/Glossary/Glossary.css b/src/components/modals/GlossaryModal/GlossaryModal.css
similarity index 100%
rename from src/components/Glossary/Glossary.css
rename to src/components/modals/GlossaryModal/GlossaryModal.css
diff --git a/src/components/Glossary/Glossary.tsx b/src/components/modals/GlossaryModal/GlossaryModal.tsx
similarity index 95%
rename from src/components/Glossary/Glossary.tsx
rename to src/components/modals/GlossaryModal/GlossaryModal.tsx
index 7f5daab..d77d756 100644
--- a/src/components/Glossary/Glossary.tsx
+++ b/src/components/modals/GlossaryModal/GlossaryModal.tsx
@@ -1,13 +1,13 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
-import { Modal, Button } from '../shared';
+import { Modal, Button } from '../../_shared';
import {
glossaryCategoryLabels,
glossaryTerms,
type GlossaryCategory,
-} from '../../data/glossary';
-import './Glossary.css';
+} from '../../../data/glossary';
+import './GlossaryModal.css';
-interface GlossaryProps {
+interface GlossaryModalProps {
isOpen: boolean;
onClose: () => void;
initialTermId?: string | null;
@@ -15,7 +15,7 @@ interface GlossaryProps {
type CategoryFilter = 'all' | GlossaryCategory;
-const Glossary: React.FC = ({ isOpen, onClose, initialTermId }) => {
+const GlossaryModal: React.FC = ({ isOpen, onClose, initialTermId }) => {
const [query, setQuery] = useState('');
const [category, setCategory] = useState('all');
const searchRef = useRef(null);
@@ -200,4 +200,4 @@ const Glossary: React.FC = ({ isOpen, onClose, initialTermId }) =
);
};
-export default Glossary;
+export default GlossaryModal;
diff --git a/src/components/Glossary/GlossaryTerm.css b/src/components/modals/GlossaryModal/GlossaryTerm.css
similarity index 100%
rename from src/components/Glossary/GlossaryTerm.css
rename to src/components/modals/GlossaryModal/GlossaryTerm.css
diff --git a/src/components/Glossary/GlossaryTerm.tsx b/src/components/modals/GlossaryModal/GlossaryTerm.tsx
similarity index 88%
rename from src/components/Glossary/GlossaryTerm.tsx
rename to src/components/modals/GlossaryModal/GlossaryTerm.tsx
index cac0015..2b19aa0 100644
--- a/src/components/Glossary/GlossaryTerm.tsx
+++ b/src/components/modals/GlossaryModal/GlossaryTerm.tsx
@@ -1,7 +1,8 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
-import { FileStorageService } from '../../services/fileStorage';
-import { glossaryTerms } from '../../data/glossary';
+import { APP_CUSTOM_EVENTS } from '../../../constants/events';
+import { FileStorageService } from '../../../services/fileStorage';
+import { glossaryTerms } from '../../../data/glossary';
import './GlossaryTerm.css';
interface GlossaryTermProps {
@@ -26,8 +27,8 @@ const GlossaryTerm: React.FC = ({ termId, children, className
setTermsEnabled(e.detail.enabled);
if (!e.detail.enabled) setIsOpen(false);
};
- window.addEventListener('glossary-terms-changed', handleChange as EventListener);
- return () => window.removeEventListener('glossary-terms-changed', handleChange as EventListener);
+ window.addEventListener(APP_CUSTOM_EVENTS.glossaryTermsChanged, handleChange as EventListener);
+ return () => window.removeEventListener(APP_CUSTOM_EVENTS.glossaryTermsChanged, handleChange as EventListener);
}, []);
useLayoutEffect(() => {
@@ -76,7 +77,7 @@ const GlossaryTerm: React.FC = ({ termId, children, className
const openGlossary = () => {
window.dispatchEvent(
- new CustomEvent('app:open-glossary', {
+ new CustomEvent(APP_CUSTOM_EVENTS.openGlossary, {
detail: { termId },
})
);
diff --git a/src/components/Glossary/index.ts b/src/components/modals/GlossaryModal/index.ts
similarity index 57%
rename from src/components/Glossary/index.ts
rename to src/components/modals/GlossaryModal/index.ts
index 4101f00..f66c4aa 100644
--- a/src/components/Glossary/index.ts
+++ b/src/components/modals/GlossaryModal/index.ts
@@ -1,2 +1,2 @@
-export { default } from './Glossary';
+export { default } from './GlossaryModal';
export { default as GlossaryTerm } from './GlossaryTerm';
diff --git a/src/components/KeyboardShortcutsModal/KeyboardShortcutsModal.css b/src/components/modals/KeyboardShortcutsModal/KeyboardShortcutsModal.css
similarity index 100%
rename from src/components/KeyboardShortcutsModal/KeyboardShortcutsModal.css
rename to src/components/modals/KeyboardShortcutsModal/KeyboardShortcutsModal.css
diff --git a/src/components/KeyboardShortcutsModal/KeyboardShortcutsModal.tsx b/src/components/modals/KeyboardShortcutsModal/KeyboardShortcutsModal.tsx
similarity index 98%
rename from src/components/KeyboardShortcutsModal/KeyboardShortcutsModal.tsx
rename to src/components/modals/KeyboardShortcutsModal/KeyboardShortcutsModal.tsx
index 3bbca01..90cbfe8 100644
--- a/src/components/KeyboardShortcutsModal/KeyboardShortcutsModal.tsx
+++ b/src/components/modals/KeyboardShortcutsModal/KeyboardShortcutsModal.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { Button, Modal } from '../shared';
+import { Button, Modal } from '../../_shared';
import './KeyboardShortcutsModal.css';
interface KeyboardShortcutsModalProps {
diff --git a/src/components/KeyboardShortcutsModal/index.ts b/src/components/modals/KeyboardShortcutsModal/index.ts
similarity index 100%
rename from src/components/KeyboardShortcutsModal/index.ts
rename to src/components/modals/KeyboardShortcutsModal/index.ts
diff --git a/src/components/PaySettingsModal/PaySettingsModal.css b/src/components/modals/PaySettingsModal/PaySettingsModal.css
similarity index 100%
rename from src/components/PaySettingsModal/PaySettingsModal.css
rename to src/components/modals/PaySettingsModal/PaySettingsModal.css
diff --git a/src/components/PaySettingsModal/PaySettingsModal.tsx b/src/components/modals/PaySettingsModal/PaySettingsModal.tsx
similarity index 78%
rename from src/components/PaySettingsModal/PaySettingsModal.tsx
rename to src/components/modals/PaySettingsModal/PaySettingsModal.tsx
index 139edf9..3adafdc 100644
--- a/src/components/PaySettingsModal/PaySettingsModal.tsx
+++ b/src/components/modals/PaySettingsModal/PaySettingsModal.tsx
@@ -1,9 +1,13 @@
import React, { useState, useEffect } from 'react';
-import { useBudget } from '../../contexts/BudgetContext';
-import type { BudgetData, PaySettings } from '../../types/auth';
-import { CURRENCIES, getCurrencySymbol } from '../../utils/currency';
-import { getPaychecksPerYear } from '../../utils/payPeriod';
-import { Modal, Button, FormGroup, InputWithPrefix, FormattedNumberInput, RadioGroup } from '../shared';
+import { useBudget } from '../../../contexts/BudgetContext';
+import { useAppDialogs } from '../../../hooks';
+import type { BudgetData } from '../../../types/budget';
+import type { PaySettings } from '../../../types/payroll';
+import { convertBudgetAmounts } from '../../../services/budgetCurrencyConversion';
+import { CURRENCIES, getCurrencySymbol } from '../../../utils/currency';
+import { getPaychecksPerYear } from '../../../utils/payPeriod';
+import { formatSuggestedLeftover, getSuggestedLeftoverPerPaycheck } from '../../../utils/paySuggestions';
+import { Modal, Button, ErrorDialog, FormGroup, InputWithPrefix, FormattedNumberInput, RadioGroup } from '../../_shared';
import './PaySettingsModal.css';
interface PaySettingsModalProps {
@@ -20,6 +24,7 @@ type PaySettingsFieldErrors = {
const PaySettingsModal: React.FC = ({ isOpen, onClose }) => {
const { budgetData, updateBudgetData } = useBudget();
+ const { errorDialog, openErrorDialog, closeErrorDialog } = useAppDialogs();
// Form state
const [editPayType, setEditPayType] = useState<'salary' | 'hourly'>('salary');
@@ -78,81 +83,9 @@ const PaySettingsModal: React.FC = ({ isOpen, onClose })
return (parseFloat(editHourlyRate) || 0) * (parseFloat(editHoursPerPayPeriod) || 0);
};
- const suggestedLeftoverPerPaycheck = (() => {
- const estimatedGross = estimateGrossPerPaycheck();
- if (estimatedGross <= 0) return 0;
+ const suggestedLeftoverPerPaycheck = getSuggestedLeftoverPerPaycheck(estimateGrossPerPaycheck());
- // Mirror setup wizard recommendation: keep about 20% with practical rounding and floor.
- const rawSuggestion = estimatedGross * 0.2;
- const rounded = Math.round(rawSuggestion / 10) * 10;
- return Math.max(75, rounded);
- })();
-
- const formattedSuggestedLeftover = suggestedLeftoverPerPaycheck > 0
- ? new Intl.NumberFormat(undefined, {
- style: 'currency',
- currency: editCurrency,
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }).format(suggestedLeftoverPerPaycheck)
- : null;
-
- const roundCurrency = (value: number) => Math.round((value + Number.EPSILON) * 100) / 100;
-
- const convertBudgetAmounts = (data: BudgetData, exchangeRate: number): BudgetData => {
- const convert = (value: number | undefined) => {
- if (typeof value !== 'number' || !Number.isFinite(value)) return value;
- return roundCurrency(value * exchangeRate);
- };
-
- return {
- ...data,
- paySettings: {
- ...data.paySettings,
- annualSalary: convert(data.paySettings.annualSalary),
- hourlyRate: convert(data.paySettings.hourlyRate),
- minLeftover: convert(data.paySettings.minLeftover),
- },
- preTaxDeductions: data.preTaxDeductions.map((deduction) => ({
- ...deduction,
- amount: deduction.isPercentage ? deduction.amount : convert(deduction.amount) || 0,
- })),
- benefits: data.benefits.map((benefit) => ({
- ...benefit,
- amount: benefit.isPercentage ? benefit.amount : convert(benefit.amount) || 0,
- })),
- retirement: data.retirement.map((election) => ({
- ...election,
- employeeContribution: election.employeeContributionIsPercentage
- ? election.employeeContribution
- : convert(election.employeeContribution) || 0,
- employerMatchCap: election.employerMatchCapIsPercentage
- ? election.employerMatchCap
- : convert(election.employerMatchCap) || 0,
- yearlyLimit: convert(election.yearlyLimit),
- })),
- taxSettings: {
- ...data.taxSettings,
- additionalWithholding: convert(data.taxSettings.additionalWithholding) || 0,
- },
- accounts: data.accounts.map((account) => ({
- ...account,
- allocation: convert(account.allocation),
- allocationCategories: (account.allocationCategories || []).map((category) => ({
- ...category,
- amount: convert(category.amount) || 0,
- })),
- })),
- bills: data.bills.map((bill) => ({
- ...bill,
- amount: convert(bill.amount) || 0,
- })),
- savingsContributions: (data.savingsContributions || []).map((contribution) => ({
- ...contribution,
- amount: convert(contribution.amount) || 0,
- })),
- };
- };
+ const formattedSuggestedLeftover = formatSuggestedLeftover(suggestedLeftoverPerPaycheck, editCurrency);
const handleSaveSettings = () => {
const parsedAnnualSalary = parseFloat(editAnnualSalary);
@@ -211,7 +144,10 @@ const PaySettingsModal: React.FC = ({ isOpen, onClose })
if (currencyChanged && exchangeRate.trim() !== '') {
const parsedExchangeRate = parseFloat(exchangeRate);
if (!Number.isFinite(parsedExchangeRate) || parsedExchangeRate <= 0) {
- alert('Please enter a valid exchange rate greater than zero, or leave it blank to skip conversion.');
+ openErrorDialog({
+ title: 'Invalid Exchange Rate',
+ message: 'Please enter a valid exchange rate greater than zero, or leave it blank to skip conversion.',
+ });
return;
}
updatedBudget = convertBudgetAmounts(updatedBudget, parsedExchangeRate);
@@ -397,6 +333,13 @@ const PaySettingsModal: React.FC = ({ isOpen, onClose })
)}
+
);
};
diff --git a/src/components/PaySettingsModal/index.ts b/src/components/modals/PaySettingsModal/index.ts
similarity index 100%
rename from src/components/PaySettingsModal/index.ts
rename to src/components/modals/PaySettingsModal/index.ts
diff --git a/src/components/Settings/Settings.css b/src/components/modals/SettingsModal/SettingsModal.css
similarity index 100%
rename from src/components/Settings/Settings.css
rename to src/components/modals/SettingsModal/SettingsModal.css
diff --git a/src/components/Settings/Settings.tsx b/src/components/modals/SettingsModal/SettingsModal.tsx
similarity index 84%
rename from src/components/Settings/Settings.tsx
rename to src/components/modals/SettingsModal/SettingsModal.tsx
index 2bfcea9..710ded9 100644
--- a/src/components/Settings/Settings.tsx
+++ b/src/components/modals/SettingsModal/SettingsModal.tsx
@@ -1,10 +1,12 @@
import React, { useState } from 'react';
-import { useTheme } from '../../contexts/ThemeContext';
-import { Button, Modal, PillToggle } from '../shared';
-import { FileStorageService } from '../../services/fileStorage';
-import './Settings.css';
+import { APP_CUSTOM_EVENTS } from '../../../constants/events';
+import { useAppDialogs } from '../../../hooks';
+import { useTheme } from '../../../contexts/ThemeContext';
+import { Button, ErrorDialog, Modal, PillToggle } from '../../_shared';
+import { FileStorageService } from '../../../services/fileStorage';
+import './SettingsModal.css';
-interface SettingsProps {
+interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
@@ -16,13 +18,14 @@ interface SettingsState {
glossaryTermsEnabled: boolean;
}
-const Settings: React.FC = ({ isOpen, onClose }) => {
+const SettingsModal: React.FC = ({ isOpen, onClose }) => {
const { theme, setTheme } = useTheme();
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [resettingMemory, setResettingMemory] = useState(false);
const [backingUp, setBackingUp] = useState(false);
const [backedUp, setBackedUp] = useState(false);
const [importing, setImporting] = useState(false);
+ const { errorDialog, openErrorDialog, closeErrorDialog } = useAppDialogs();
const [settings, setSettings] = useState(() => {
const appSettings = FileStorageService.getAppSettings();
return {
@@ -56,7 +59,7 @@ const Settings: React.FC = ({ isOpen, onClose }) => {
}
// Dispatch custom event to notify ThemeProvider
- window.dispatchEvent(new Event('theme-mode-changed'));
+ window.dispatchEvent(new Event(APP_CUSTOM_EVENTS.themeModeChanged));
return updated;
});
@@ -66,7 +69,7 @@ const Settings: React.FC = ({ isOpen, onClose }) => {
setSettings((prev) => {
const updated = { ...prev, glossaryTermsEnabled: enabled };
persistSettings(updated);
- window.dispatchEvent(new CustomEvent('glossary-terms-changed', { detail: { enabled } }));
+ window.dispatchEvent(new CustomEvent(APP_CUSTOM_EVENTS.glossaryTermsChanged, { detail: { enabled } }));
return updated;
});
};
@@ -84,7 +87,11 @@ const Settings: React.FC = ({ isOpen, onClose }) => {
setBackedUp(true);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
- alert(`Failed to export backup: ${message}`);
+ openErrorDialog({
+ title: 'Backup Export Failed',
+ message: `Failed to export backup: ${message}`,
+ actionLabel: 'Retry',
+ });
} finally {
setBackingUp(false);
}
@@ -113,15 +120,19 @@ const Settings: React.FC = ({ isOpen, onClose }) => {
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setTheme(systemDark ? 'dark' : 'light');
}
- window.dispatchEvent(new Event('theme-mode-changed'));
- window.dispatchEvent(new CustomEvent('glossary-terms-changed', { detail: { enabled: newGlossary } }));
+ window.dispatchEvent(new Event(APP_CUSTOM_EVENTS.themeModeChanged));
+ window.dispatchEvent(new CustomEvent(APP_CUSTOM_EVENTS.glossaryTermsChanged, { detail: { enabled: newGlossary } }));
const reopenResult = await window.electronAPI.reopenWelcomeWindow();
if (!reopenResult.success) {
throw new Error(reopenResult.error || 'Failed to reopen welcome window');
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
- alert(`Failed to import app data: ${message}`);
+ openErrorDialog({
+ title: 'Import Failed',
+ message: `Failed to import app data: ${message}`,
+ actionLabel: 'Retry',
+ });
} finally {
setImporting(false);
}
@@ -136,14 +147,18 @@ const Settings: React.FC = ({ isOpen, onClose }) => {
// and encrypted plans reconnect automatically without needing to re-enter keys.
FileStorageService.clearAppMemory();
setTheme('light');
- window.dispatchEvent(new Event('theme-mode-changed'));
+ window.dispatchEvent(new Event(APP_CUSTOM_EVENTS.themeModeChanged));
setBackedUp(false);
setShowResetConfirm(false);
await window.electronAPI.quitApp();
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
- alert(`Failed to reset app memory: ${message}`);
+ openErrorDialog({
+ title: 'Reset Failed',
+ message: `Failed to reset app memory: ${message}`,
+ actionLabel: 'Retry',
+ });
} finally {
setResettingMemory(false);
}
@@ -290,8 +305,16 @@ const Settings: React.FC = ({ isOpen, onClose }) => {
Optionally back up your preferences first — they can be restored later via "Import app data" in Settings.
+
+
);
};
-export default Settings;
+export default SettingsModal;
diff --git a/src/components/modals/SettingsModal/index.ts b/src/components/modals/SettingsModal/index.ts
new file mode 100644
index 0000000..a5bf953
--- /dev/null
+++ b/src/components/modals/SettingsModal/index.ts
@@ -0,0 +1 @@
+export { default } from './SettingsModal';
diff --git a/src/components/shared/Button/Button.css.backup b/src/components/shared/Button/Button.css.backup
deleted file mode 100644
index 1b590ef..0000000
--- a/src/components/shared/Button/Button.css.backup
+++ /dev/null
@@ -1,210 +0,0 @@
-.btn {
- padding: 0.75rem 1.5rem;
- border: none;
- border-radius: 0.5rem;
- font-weight: 600;
- cursor: pointer;
- transition: all 0.2s;
- font-size: 0.95rem;
- white-space: nowrap;
- font-family: inherit;
-}
-
-.btn:disabled {
- opacity: 0.6;
- cursor: not-allowed;
-}
-
-.btn:focus-visible {
- outline: 2px solid var(--accent-primary);
- outline-offset: 2px;
-}
-
-.btn:focus:not(:focus-visible) {
- outline: 2px solid var(--accent-primary);
- outline-offset: 2px;
-}
-
-/* Primary button */
-.btn-primary {
- background: var(--bg-primary);
- color: var(--accent-primary);
-}
-
-.btn-primary:hover:not(:disabled) {
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
-}
-
-.btn-primary:active:not(:disabled) {
- transform: translateY(0);
-}
-
-/* Secondary button */
-.btn-secondary {
- background: rgba(255, 255, 255, 0.2);
- color: white;
- border: 2px solid rgba(255, 255, 255, 0.5);
-}
-
-.btn-secondary:hover:not(:disabled) {
- background: rgba(255, 255, 255, 0.3);
- border-color: white;
- transform: translateY(-2px);
-}
-
-.btn-secondary:active:not(:disabled) {
- transform: translateY(0);
-}
-
-/* Tertiary button */
-.btn-tertiary {
- background: var(--bg-tertiary);
- color: var(--text-primary);
- border: 1px solid var(--border-color);
- box-shadow: inset 0 0 0 1px transparent;
-}
-
-.btn-tertiary:hover:not(:disabled) {
- background: var(--bg-secondary);
- border-color: var(--accent-primary);
- box-shadow: inset 0 0 0 1px var(--accent-primary);
- transform: none;
-}
-
-.btn-tertiary:active:not(:disabled) {
- transform: none;
-}
-
-.btn-tertiary:focus-visible,
-.btn-tertiary:focus:not(:focus-visible) {
- border-color: var(--accent-primary);
- box-shadow: inset 0 0 0 1px var(--accent-primary);
-}
-
-/* Utility button: compact, low-emphasis action for contextual UI (e.g. card chrome) */
-.btn.btn-utility {
- background: transparent;
- color: var(--text-secondary);
- border: 1px solid transparent;
- box-shadow: none;
- padding: 0.35rem 0.65rem;
- font-size: 0.82rem;
- font-weight: 500;
- border-radius: 0.4rem;
- line-height: 1;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 0.35rem;
- font-family: inherit;
- transition: background 0.15s, border-color 0.15s, color 0.15s;
- transform: none;
-}
-
-.btn.btn-utility:hover:not(:disabled) {
- background: var(--bg-secondary);
- border-color: var(--border-color);
- color: var(--text-primary);
- transform: none;
- box-shadow: none;
-}
-
-.btn.btn-utility:active:not(:disabled) {
- transform: none;
-}
-
-/* Danger button */
-.btn-danger {
- background: var(--error-color);
- color: white;
-}
-
-.btn-danger:hover:not(:disabled) {
- background: var(--alert-error-text);
- transform: translateY(-2px);
-}
-
-.btn-danger:active:not(:disabled) {
- transform: translateY(0);
-}
-
-/* Icon button */
-.btn-icon {
- background: transparent;
- color: var(--text-secondary);
- border: none;
- padding: 0.5rem;
- min-width: auto;
- font-size: 1.2rem;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- transition: all 0.2s;
-}
-
-.btn-icon:hover:not(:disabled) {
- color: var(--text-primary);
- transform: scale(1.1);
-}
-
-.btn-icon:active:not(:disabled) {
- transform: scale(1);
-}
-
-.btn-icon:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-.btn-icon:focus-visible {
- outline: 2px solid var(--accent-primary);
- outline-offset: 2px;
-}
-
-.btn-icon:focus:not(:focus-visible) {
- outline: 2px solid var(--accent-primary);
- outline-offset: 2px;
-}
-
-/* Remove button — icon variant with red hover */
-.btn-icon.btn-remove:hover:not(:disabled) {
- background: rgba(239, 68, 68, 0.1);
- color: var(--error-color);
-}
-
-.btn-icon.btn-danger {
- color: var(--error-color);
-}
-
-.btn-icon.btn-danger:hover:not(:disabled) {
- color: var(--alert-error-text);
-}
-
-/* Size variants */
-.btn-xsmall {
- padding: 0.3rem 0.6rem;
- font-size: 0.75rem;
- border-radius: 0.25rem !important;
-}
-
-.btn-small {
- padding: 0.5rem 1rem;
- font-size: 0.85rem;
-}
-
-.btn-large {
- padding: 1rem 2rem;
- font-size: 1.05rem;
-}
-
-@media (max-width: 600px) {
- .btn {
- padding: 0.65rem 1.25rem;
- font-size: 0.9rem;
- }
-
- .btn-large {
- padding: 0.85rem 1.75rem;
- }
-}
diff --git a/src/components/shared/Button/Button.tsx.backup b/src/components/shared/Button/Button.tsx.backup
deleted file mode 100644
index 1af18d5..0000000
--- a/src/components/shared/Button/Button.tsx.backup
+++ /dev/null
@@ -1,93 +0,0 @@
-import React, { useState, useCallback, useRef, useEffect } from 'react';
-import './Button.css';
-
-interface ButtonProps extends React.ButtonHTMLAttributes {
- /** Button variant */
- variant?: 'primary' | 'secondary' | 'tertiary' | 'danger' | 'utility' | 'icon' | 'remove';
- /** Button size */
- size?: 'xsmall' | 'small' | 'medium' | 'large';
- /** Whether button is in a loading state */
- isLoading?: boolean;
- /** Loading text to display */
- loadingText?: string;
- /** Text shown briefly after a successful action; auto-resets after 2 s */
- successText?: string;
- /** The button content */
- children: React.ReactNode;
-}
-
-const Button: React.FC = ({
- variant = 'primary',
- size = 'medium',
- isLoading = false,
- loadingText = 'Loading...',
- successText,
- disabled,
- className,
- style,
- children,
- onClick,
- ...props
-}) => {
- const [showSuccess, setShowSuccess] = useState(false);
- const [lockedWidth, setLockedWidth] = useState(null);
- const buttonRef = useRef(null);
- const successTimerRef = useRef(null);
-
- useEffect(() => {
- return () => {
- if (successTimerRef.current !== null) {
- window.clearTimeout(successTimerRef.current);
- }
- };
- }, []);
-
- const handleClick = useCallback(
- (e: React.MouseEvent) => {
- if (successText) {
- const currentWidth = buttonRef.current?.offsetWidth;
- if (currentWidth) {
- setLockedWidth(currentWidth);
- }
-
- setShowSuccess(true);
- if (successTimerRef.current !== null) {
- window.clearTimeout(successTimerRef.current);
- }
- successTimerRef.current = window.setTimeout(() => {
- setShowSuccess(false);
- setLockedWidth(null);
- successTimerRef.current = null;
- }, 2000);
- }
- onClick?.(e);
- },
- [onClick, successText],
- );
-
- const variantClass = `btn-${variant}`;
- const sizeClass = size !== 'medium' ? `btn-${size}` : '';
- const baseClass = variant === 'icon' || variant === 'remove' ? 'btn-icon' : 'btn';
- const allClasses = `${baseClass} ${variantClass} ${sizeClass} ${className || ''}`.trim();
-
- let label: React.ReactNode = children;
- if (isLoading) label = loadingText;
- else if (showSuccess && successText) label = successText;
-
- const mergedStyle = lockedWidth ? { ...style, width: `${lockedWidth}px` } : style;
-
- return (
-
- {label}
-
- );
-};
-
-export default Button;
diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts
deleted file mode 100644
index 24e0771..0000000
--- a/src/components/shared/index.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-export { default as Alert } from './Alert';
-export { default as Toast } from './Toast';
-export { default as Modal } from './Modal';
-export { default as Button } from './Button';
-export { default as FormGroup } from './FormGroup';
-export { default as InputWithPrefix } from './InputWithPrefix';
-export { default as DateInput } from './DateInput';
-export { default as FormattedNumberInput } from './FormattedNumberInput';
-export { default as StickyActions } from './StickyActions';
-export { default as RadioGroup } from './RadioGroup';
-export { default as Toggle } from './Toggle';
-export { default as PillToggle } from './PillToggle';
-export { default as SectionItemCard } from './SectionItemCard';
-export { default as InfoBox } from './InfoBox';
-export { default as AccountsEditor } from './AccountsEditor';
-export { default as ViewModeSelector } from './ViewModeSelector';
-export { default as PageHeader } from './PageHeader';
-export { default as ProgressBar } from './ProgressBar';
diff --git a/src/components/BillsManager/BillsManager.css b/src/components/tabViews/BillsManager/BillsManager.css
similarity index 100%
rename from src/components/BillsManager/BillsManager.css
rename to src/components/tabViews/BillsManager/BillsManager.css
diff --git a/src/components/BillsManager/BillsManager.tsx b/src/components/tabViews/BillsManager/BillsManager.tsx
similarity index 80%
rename from src/components/BillsManager/BillsManager.tsx
rename to src/components/tabViews/BillsManager/BillsManager.tsx
index ad4f232..d6d0739 100644
--- a/src/components/BillsManager/BillsManager.tsx
+++ b/src/components/tabViews/BillsManager/BillsManager.tsx
@@ -1,18 +1,24 @@
import React, { useEffect, useState } from 'react';
-import { useBudget } from '../../contexts/BudgetContext';
-import type { Benefit, Bill, BillFrequency } from '../../types/auth';
-import { formatWithSymbol, getCurrencySymbol } from '../../utils/currency';
-import { roundUpToCent } from '../../utils/money';
-import { calculateGrossPayPerPaycheck, convertToDisplayMode, getDisplayModeLabel, getPaychecksPerYear, formatPayFrequencyLabel } from '../../utils/payPeriod';
-import { getDefaultAccountIcon } from '../../utils/accountDefaults';
-import { convertBillToMonthly, formatBillFrequency } from '../../utils/billFrequency';
-import { Button, FormGroup, InputWithPrefix, Modal, PageHeader, RadioGroup, SectionItemCard, ViewModeSelector } from '../shared';
+import { useBudget } from '../../../contexts/BudgetContext';
+import { useAppDialogs, useFieldErrors, useModalEntityEditor } from '../../../hooks';
+import type { Bill } from '../../../types/obligations';
+import type { Benefit } from '../../../types/payroll';
+import type { BillFrequency } from '../../../types/frequencies';
+import type { ViewMode } from '../../../types/viewMode';
+import { formatWithSymbol, getCurrencySymbol } from '../../../utils/currency';
+import { roundUpToCent } from '../../../utils/money';
+import { calculateGrossPayPerPaycheck, getDisplayModeLabel, getPaychecksPerYear, formatPayFrequencyLabel } from '../../../utils/payPeriod';
+import { getDefaultAccountIcon } from '../../../utils/accountDefaults';
+import { buildAccountRows, groupByAccountId } from '../../../utils/accountGrouping';
+import { convertBillToMonthly, formatBillFrequency } from '../../../utils/billFrequency';
+import { monthlyToDisplayAmount } from '../../../utils/displayAmounts';
+import { Button, ConfirmDialog, FormGroup, InputWithPrefix, Modal, PageHeader, RadioGroup, SectionItemCard, ViewModeSelector } from '../../_shared';
import './BillsManager.css';
interface BillsManagerProps {
scrollToAccountId?: string;
- displayMode: 'paycheck' | 'monthly' | 'yearly';
- onDisplayModeChange: (mode: 'paycheck' | 'monthly' | 'yearly') => void;
+ displayMode: ViewMode;
+ onDisplayModeChange: (mode: ViewMode) => void;
}
type BillFieldErrors = {
@@ -29,19 +35,16 @@ type BenefitFieldErrors = {
const BillsManager: React.FC = ({ scrollToAccountId, displayMode, onDisplayModeChange }) => {
const { budgetData, addBill, updateBill, deleteBill, addBenefit, updateBenefit, deleteBenefit } = useBudget();
-
- const [showAddBill, setShowAddBill] = useState(false);
- const [editingBill, setEditingBill] = useState(null);
-
- const [showAddBenefit, setShowAddBenefit] = useState(false);
- const [editingBenefit, setEditingBenefit] = useState(null);
+ const { confirmDialog, openConfirmDialog, closeConfirmDialog, confirmCurrentDialog } = useAppDialogs();
+ const billEditor = useModalEntityEditor();
+ const benefitEditor = useModalEntityEditor();
const [billName, setBillName] = useState('');
const [billAmount, setBillAmount] = useState('');
const [billFrequency, setBillFrequency] = useState('monthly');
const [billAccountId, setBillAccountId] = useState('');
const [billNotes, setBillNotes] = useState('');
- const [billFieldErrors, setBillFieldErrors] = useState({});
+ const billErrors = useFieldErrors();
const [benefitName, setBenefitName] = useState('');
const [benefitAmount, setBenefitAmount] = useState('');
@@ -49,7 +52,7 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
const [benefitIsTaxable, setBenefitIsTaxable] = useState(false);
const [benefitSource, setBenefitSource] = useState<'paycheck' | 'account'>('paycheck');
const [benefitSourceAccountId, setBenefitSourceAccountId] = useState('');
- const [benefitFieldErrors, setBenefitFieldErrors] = useState({});
+ const benefitErrors = useFieldErrors();
useEffect(() => {
if (scrollToAccountId) {
@@ -67,12 +70,23 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
const payFrequencyLabel = formatPayFrequencyLabel(budgetData.paySettings.payFrequency);
const grossPayPerPaycheck = calculateGrossPayPerPaycheck(budgetData.paySettings);
const isBillEnabled = (bill: Bill) => bill.enabled !== false;
+ const editingBill = billEditor.editingEntity;
+ const editingBenefit = benefitEditor.editingEntity;
+ const billFieldErrors = billErrors.errors;
+ const benefitFieldErrors = benefitErrors.errors;
+
+ const closeBillModal = () => {
+ billEditor.closeEditor();
+ billErrors.clearErrors();
+ };
- const toDisplayAmount = (monthlyAmount: number): number => {
- const perPaycheckAmount = (monthlyAmount * 12) / paychecksPerYear;
- return convertToDisplayMode(perPaycheckAmount, paychecksPerYear, displayMode);
+ const closeBenefitModal = () => {
+ benefitEditor.closeEditor();
+ benefitErrors.clearErrors();
};
+ const displayAmount = (monthlyAmount: number): number => monthlyToDisplayAmount(monthlyAmount, paychecksPerYear, displayMode);
+
const getBenefitPerPaycheck = (benefit: Benefit): number => {
if (benefit.isPercentage) {
return roundUpToCent((grossPayPerPaycheck * benefit.amount) / 100);
@@ -84,13 +98,7 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
return roundUpToCent((getBenefitPerPaycheck(benefit) * paychecksPerYear) / 12);
};
- const billsByAccount = budgetData.bills.reduce((acc, bill) => {
- if (!acc[bill.accountId]) {
- acc[bill.accountId] = [];
- }
- acc[bill.accountId].push(bill);
- return acc;
- }, {} as Record);
+ const billsByAccount = groupByAccountId(budgetData.bills);
const accountBenefitsByAccount = budgetData.benefits.reduce((acc, benefit) => {
if (benefit.deductionSource !== 'account' || !benefit.sourceAccountId) {
@@ -109,47 +117,40 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
const paycheckBenefitsTotalMonthly = paycheckBenefits.reduce((sum, benefit) => sum + getBenefitMonthly(benefit), 0);
- const accountRows = budgetData.accounts
- .map((account) => {
- const accountBills = billsByAccount[account.id] || [];
- const accountBenefits = accountBenefitsByAccount[account.id] || [];
- const billsTotalMonthly = accountBills.reduce((sum, bill) => {
- if (!isBillEnabled(bill)) return sum;
- return sum + convertBillToMonthly(bill.amount, bill.frequency);
- }, 0);
- const benefitsTotalMonthly = accountBenefits.reduce((sum, benefit) => sum + getBenefitMonthly(benefit), 0);
- return {
- account,
- accountBills,
- accountBenefits,
- totalMonthly: roundUpToCent(billsTotalMonthly + benefitsTotalMonthly),
- };
- })
- .filter(({ accountBills, accountBenefits }) => accountBills.length > 0 || accountBenefits.length > 0)
- .sort((a, b) => b.totalMonthly - a.totalMonthly);
+ const accountRows = buildAccountRows(budgetData.accounts, billsByAccount, (accountBills, account) => {
+ const accountBenefits = accountBenefitsByAccount[account.id] || [];
+ const billsTotalMonthly = accountBills.reduce((sum, bill) => {
+ if (!isBillEnabled(bill)) return sum;
+ return sum + convertBillToMonthly(bill.amount, bill.frequency);
+ }, 0);
+ const benefitsTotalMonthly = accountBenefits.reduce((sum, benefit) => sum + getBenefitMonthly(benefit), 0);
+ return roundUpToCent(billsTotalMonthly + benefitsTotalMonthly);
+ }).map((row) => ({
+ ...row,
+ accountBills: row.items,
+ accountBenefits: accountBenefitsByAccount[row.account.id] || [],
+ }));
const hasAnyItems = budgetData.bills.length > 0 || budgetData.benefits.length > 0;
const handleAddBill = () => {
- setEditingBill(null);
setBillName('');
setBillAmount('');
setBillFrequency('monthly');
setBillAccountId(budgetData.accounts[0]?.id || '');
setBillNotes('');
- setBillFieldErrors({});
- setShowAddBill(true);
+ billErrors.clearErrors();
+ billEditor.openForCreate();
};
const handleEditBill = (bill: Bill) => {
- setEditingBill(bill);
setBillName(bill.name);
setBillAmount(bill.amount.toString());
setBillFrequency(bill.frequency);
setBillAccountId(bill.accountId);
setBillNotes(bill.notes || '');
- setBillFieldErrors({});
- setShowAddBill(true);
+ billErrors.clearErrors();
+ billEditor.openForEdit(bill);
};
const handleSaveBill = () => {
@@ -168,7 +169,7 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
}
if (Object.keys(errors).length > 0) {
- setBillFieldErrors(errors);
+ billErrors.setErrors(errors);
return;
}
@@ -187,15 +188,17 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
addBill(billData);
}
- setShowAddBill(false);
- setEditingBill(null);
- setBillFieldErrors({});
+ closeBillModal();
};
const handleDeleteBill = (id: string) => {
- if (window.confirm('Are you sure you want to delete this bill?')) {
- deleteBill(id);
- }
+ openConfirmDialog({
+ title: 'Delete Bill',
+ message: 'Are you sure you want to delete this bill?',
+ confirmLabel: 'Delete Bill',
+ confirmVariant: 'danger',
+ onConfirm: () => deleteBill(id),
+ });
};
const handleToggleBillEnabled = (bill: Bill) => {
@@ -203,27 +206,25 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
};
const handleAddBenefit = () => {
- setEditingBenefit(null);
setBenefitName('');
setBenefitAmount('');
setBenefitIsPercentage(false);
setBenefitIsTaxable(false);
setBenefitSource('paycheck');
setBenefitSourceAccountId('');
- setBenefitFieldErrors({});
- setShowAddBenefit(true);
+ benefitErrors.clearErrors();
+ benefitEditor.openForCreate();
};
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);
+ benefitErrors.clearErrors();
+ benefitEditor.openForEdit(benefit);
};
const handleSaveBenefit = () => {
@@ -243,7 +244,7 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
}
if (Object.keys(errors).length > 0) {
- setBenefitFieldErrors(errors);
+ benefitErrors.setErrors(errors);
return;
}
@@ -262,15 +263,17 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
addBenefit(payload);
}
- setShowAddBenefit(false);
- setEditingBenefit(null);
- setBenefitFieldErrors({});
+ closeBenefitModal();
};
const handleDeleteBenefit = (id: string) => {
- if (window.confirm('Are you sure you want to delete this deduction?')) {
- deleteBenefit(id);
- }
+ openConfirmDialog({
+ title: 'Delete Deduction',
+ message: 'Are you sure you want to delete this deduction?',
+ confirmLabel: 'Delete Deduction',
+ confirmVariant: 'danger',
+ onConfirm: () => deleteBenefit(id),
+ });
};
return (
@@ -323,14 +326,14 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
{getDisplayModeLabel(displayMode)} Total
- {formatWithSymbol(toDisplayAmount(paycheckBenefitsTotalMonthly), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ {formatWithSymbol(displayAmount(paycheckBenefitsTotalMonthly), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{paycheckBenefits.map((benefit) => {
const perPaycheck = getBenefitPerPaycheck(benefit);
- const inDisplayMode = toDisplayAmount(getBenefitMonthly(benefit));
+ const inDisplayMode = displayAmount(getBenefitMonthly(benefit));
return (
@@ -373,7 +376,7 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
{getDisplayModeLabel(displayMode)} Total
- {formatWithSymbol(toDisplayAmount(totalMonthly), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ {formatWithSymbol(displayAmount(totalMonthly), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
@@ -382,7 +385,7 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
.sort((a, b) => getBenefitPerPaycheck(b) - getBenefitPerPaycheck(a))
.map((benefit) => {
const perPaycheck = getBenefitPerPaycheck(benefit);
- const inDisplayMode = toDisplayAmount(getBenefitMonthly(benefit));
+ const inDisplayMode = displayAmount(getBenefitMonthly(benefit));
return (
@@ -426,7 +429,7 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
- {formatWithSymbol(toDisplayAmount(convertBillToMonthly(bill.amount, bill.frequency)), currency, { minimumFractionDigits: 2 })}
+ {formatWithSymbol(displayAmount(convertBillToMonthly(bill.amount, bill.frequency)), currency, { minimumFractionDigits: 2 })}
{getDisplayModeLabel(displayMode)}
@@ -448,21 +451,15 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
)}
{
- setShowAddBill(false);
- setBillFieldErrors({});
- }}
+ isOpen={billEditor.isOpen}
+ onClose={closeBillModal}
header={editingBill ? 'Edit Bill' : 'Add New Bill'}
footer={
<>
{
- setShowAddBill(false);
- setBillFieldErrors({});
- }}
+ onClick={closeBillModal}
>
Cancel
@@ -478,9 +475,7 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
value={billName}
onChange={(e) => {
setBillName(e.target.value);
- if (billFieldErrors.name) {
- setBillFieldErrors((prev) => ({ ...prev, name: undefined }));
- }
+ billErrors.clearFieldError('name');
}}
placeholder="e.g., Electric Bill, Netflix"
className={billFieldErrors.name ? 'field-error' : ''}
@@ -496,9 +491,7 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
value={billAmount}
onChange={(e) => {
setBillAmount(e.target.value);
- if (billFieldErrors.amount) {
- setBillFieldErrors((prev) => ({ ...prev, amount: undefined }));
- }
+ billErrors.clearFieldError('amount');
}}
placeholder="0.00"
step="0.01"
@@ -525,9 +518,7 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
value={billAccountId}
onChange={(e) => {
setBillAccountId(e.target.value);
- if (billFieldErrors.accountId) {
- setBillFieldErrors((prev) => ({ ...prev, accountId: undefined }));
- }
+ billErrors.clearFieldError('accountId');
}}
className={billFieldErrors.accountId ? 'field-error' : ''}
required
@@ -551,18 +542,12 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
{
- setShowAddBenefit(false);
- setBenefitFieldErrors({});
- }}
+ isOpen={benefitEditor.isOpen}
+ onClose={closeBenefitModal}
header={editingBenefit ? 'Edit Deduction' : 'Add Deduction'}
footer={
<>
- {
- setShowAddBenefit(false);
- setBenefitFieldErrors({});
- }}>
+
Cancel
@@ -578,9 +563,7 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
className={benefitFieldErrors.name ? 'field-error' : ''}
onChange={(e) => {
setBenefitName(e.target.value);
- if (benefitFieldErrors.name) {
- setBenefitFieldErrors((prev) => ({ ...prev, name: undefined }));
- }
+ benefitErrors.clearFieldError('name');
}}
placeholder="e.g., Health Insurance, HSA"
required
@@ -600,9 +583,7 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
setBenefitSourceAccountId(e.target.value);
setBenefitIsTaxable(true);
}
- if (benefitFieldErrors.sourceAccountId) {
- setBenefitFieldErrors((prev) => ({ ...prev, sourceAccountId: undefined }));
- }
+ benefitErrors.clearFieldError('sourceAccountId');
}}
>
Deduct from Paycheck
@@ -624,9 +605,7 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
className={benefitFieldErrors.amount ? 'field-error' : ''}
onChange={(e) => {
setBenefitAmount(e.target.value);
- if (benefitFieldErrors.amount) {
- setBenefitFieldErrors((prev) => ({ ...prev, amount: undefined }));
- }
+ benefitErrors.clearFieldError('amount');
}}
placeholder={benefitIsPercentage ? '0' : '0.00'}
step={benefitIsPercentage ? '0.1' : '0.01'}
@@ -662,6 +641,17 @@ const BillsManager: React.FC = ({ scrollToAccountId, displayM
)}
+
+
);
};
diff --git a/src/components/BillsManager/index.ts b/src/components/tabViews/BillsManager/index.ts
similarity index 100%
rename from src/components/BillsManager/index.ts
rename to src/components/tabViews/BillsManager/index.ts
diff --git a/src/components/KeyMetrics/KeyMetrics.css b/src/components/tabViews/KeyMetrics/KeyMetrics.css
similarity index 100%
rename from src/components/KeyMetrics/KeyMetrics.css
rename to src/components/tabViews/KeyMetrics/KeyMetrics.css
diff --git a/src/components/KeyMetrics/KeyMetrics.tsx b/src/components/tabViews/KeyMetrics/KeyMetrics.tsx
similarity index 93%
rename from src/components/KeyMetrics/KeyMetrics.tsx
rename to src/components/tabViews/KeyMetrics/KeyMetrics.tsx
index 5e2e532..63040e5 100644
--- a/src/components/KeyMetrics/KeyMetrics.tsx
+++ b/src/components/tabViews/KeyMetrics/KeyMetrics.tsx
@@ -1,11 +1,12 @@
import React from 'react';
-import { useBudget } from '../../contexts/BudgetContext';
-import { formatWithSymbol, getCurrencySymbol } from '../../utils/currency';
-import { roundUpToCent } from '../../utils/money';
-import { getPaychecksPerYear } from '../../utils/payPeriod';
-import { convertBillToYearly } from '../../utils/billFrequency';
-import { PageHeader } from '../shared';
-import { GlossaryTerm } from '../Glossary';
+import { useBudget } from '../../../contexts/BudgetContext';
+import { calculateAnnualizedPaySummary } from '../../../services/budgetCalculations';
+import { formatWithSymbol, getCurrencySymbol } from '../../../utils/currency';
+import { roundUpToCent } from '../../../utils/money';
+import { getPaychecksPerYear } from '../../../utils/payPeriod';
+import { convertBillToYearly } from '../../../utils/billFrequency';
+import { PageHeader } from '../../_shared';
+import { GlossaryTerm } from '../../modals/GlossaryModal';
import './KeyMetrics.css';
interface KeyMetricsProps {
@@ -79,9 +80,14 @@ const KeyMetrics: React.FC
= ({
// Calculate annualized values
const paychecksPerYear = getPaychecksPerYear(budgetData.paySettings.payFrequency);
- const annualGross = roundUpToCent(breakdown.grossPay * paychecksPerYear);
- const annualNet = roundUpToCent(breakdown.netPay * paychecksPerYear);
- const annualTaxes = roundUpToCent(breakdown.totalTaxes * paychecksPerYear);
+ const {
+ annualGross,
+ annualNet,
+ annualTaxes,
+ monthlyGross,
+ monthlyNet,
+ monthlyTaxes,
+ } = calculateAnnualizedPaySummary(breakdown, paychecksPerYear);
// Calculate total bills (annualized)
const annualBills = roundUpToCent(budgetData.bills.reduce((sum, bill) => {
@@ -89,9 +95,6 @@ const KeyMetrics: React.FC = ({
}, 0));
// Calculate monthly averages
- const monthlyGross = roundUpToCent(annualGross / 12);
- const monthlyNet = roundUpToCent(annualNet / 12);
- const monthlyTaxes = roundUpToCent(annualTaxes / 12);
const monthlyBills = roundUpToCent(annualBills / 12);
// Calculate remaining/free money
diff --git a/src/components/KeyMetrics/index.ts b/src/components/tabViews/KeyMetrics/index.ts
similarity index 100%
rename from src/components/KeyMetrics/index.ts
rename to src/components/tabViews/KeyMetrics/index.ts
diff --git a/src/components/LoansManager/LoansManager.css b/src/components/tabViews/LoansManager/LoansManager.css
similarity index 100%
rename from src/components/LoansManager/LoansManager.css
rename to src/components/tabViews/LoansManager/LoansManager.css
diff --git a/src/components/LoansManager/LoansManager.tsx b/src/components/tabViews/LoansManager/LoansManager.tsx
similarity index 87%
rename from src/components/LoansManager/LoansManager.tsx
rename to src/components/tabViews/LoansManager/LoansManager.tsx
index c7821ec..0121d48 100644
--- a/src/components/LoansManager/LoansManager.tsx
+++ b/src/components/tabViews/LoansManager/LoansManager.tsx
@@ -1,17 +1,22 @@
import React, { useEffect, useMemo, useState } from 'react';
-import { useBudget } from '../../contexts/BudgetContext';
-import type { Loan, LoanPaymentFrequency, LoanPaymentLine } from '../../types/auth';
-import { formatWithSymbol, getCurrencySymbol } from '../../utils/currency';
-import { getPaychecksPerYear, convertToDisplayMode, getDisplayModeLabel, formatPayFrequencyLabel } from '../../utils/payPeriod';
-import { getDefaultAccountIcon } from '../../utils/accountDefaults';
-import { convertBillToMonthly, formatBillFrequency } from '../../utils/billFrequency';
-import { Modal, Button, FormGroup, InputWithPrefix, SectionItemCard, ViewModeSelector, PageHeader } from '../shared';
+import { useBudget } from '../../../contexts/BudgetContext';
+import { useAppDialogs, useFieldErrors, useModalEntityEditor } from '../../../hooks';
+import type { Loan, LoanPaymentLine } from '../../../types/obligations';
+import type { LoanPaymentFrequency } from '../../../types/frequencies';
+import type { ViewMode } from '../../../types/viewMode';
+import { formatWithSymbol, getCurrencySymbol } from '../../../utils/currency';
+import { getPaychecksPerYear, getDisplayModeLabel, formatPayFrequencyLabel } from '../../../utils/payPeriod';
+import { getDefaultAccountIcon } from '../../../utils/accountDefaults';
+import { buildAccountRows, groupByAccountId } from '../../../utils/accountGrouping';
+import { convertBillToMonthly, formatBillFrequency } from '../../../utils/billFrequency';
+import { monthlyToDisplayAmount } from '../../../utils/displayAmounts';
+import { Modal, Button, ConfirmDialog, FormGroup, InputWithPrefix, SectionItemCard, ViewModeSelector, PageHeader } from '../../_shared';
import './LoansManager.css';
interface LoansManagerProps {
scrollToAccountId?: string;
- displayMode: 'paycheck' | 'monthly' | 'yearly';
- onDisplayModeChange: (mode: 'paycheck' | 'monthly' | 'yearly') => void;
+ displayMode: ViewMode;
+ onDisplayModeChange: (mode: ViewMode) => void;
}
type LoanFieldErrors = {
@@ -98,15 +103,15 @@ const mapLoanPaymentLinesToEditable = (paymentBreakdown: LoanPaymentLine[]): Edi
const LoansManager: React.FC = ({ scrollToAccountId, displayMode, onDisplayModeChange }) => {
const { budgetData, addLoan, updateLoan, deleteLoan } = useBudget();
- const [showAddLoan, setShowAddLoan] = useState(false);
- const [editingLoan, setEditingLoan] = useState(null);
+ const { confirmDialog, openConfirmDialog, closeConfirmDialog, confirmCurrentDialog } = useAppDialogs();
+ const loanEditor = useModalEntityEditor();
const [loanName, setLoanName] = useState('');
const [loanType, setLoanType] = useState('personal');
const [loanPaymentFrequency, setLoanPaymentFrequency] = useState('monthly');
const [loanAccountId, setLoanAccountId] = useState('');
const [loanNotes, setLoanNotes] = useState('');
const [loanPaymentLines, setLoanPaymentLines] = useState(createDefaultPaymentLines('personal'));
- const [loanFieldErrors, setLoanFieldErrors] = useState({});
+ const loanErrors = useFieldErrors();
useEffect(() => {
if (scrollToAccountId) {
@@ -119,6 +124,8 @@ const LoansManager: React.FC = ({ scrollToAccountId, displayM
const currency = budgetData?.settings?.currency || 'USD';
const isLoanEnabled = (loan: Loan) => loan.enabled !== false;
+ const editingLoan = loanEditor.editingEntity;
+ const loanFieldErrors = loanErrors.errors;
const parsedPaymentLinesSummary = useMemo(() => {
const validLines = loanPaymentLines
@@ -140,25 +147,28 @@ const LoansManager: React.FC = ({ scrollToAccountId, displayM
}, [loanPaymentLines, loanPaymentFrequency]);
const resetForm = () => {
- setEditingLoan(null);
setLoanName('');
setLoanType('personal');
setLoanPaymentFrequency('monthly');
setLoanAccountId(budgetData?.accounts[0]?.id || '');
setLoanNotes('');
setLoanPaymentLines(createDefaultPaymentLines('personal'));
- setLoanFieldErrors({});
+ loanErrors.clearErrors();
+ };
+
+ const closeLoanModal = () => {
+ loanEditor.closeEditor();
+ resetForm();
};
if (!budgetData) return null;
const handleAddLoan = () => {
resetForm();
- setShowAddLoan(true);
+ loanEditor.openForCreate();
};
const handleEditLoan = (loan: Loan) => {
- setEditingLoan(loan);
setLoanName(loan.name);
setLoanType(loan.type);
setLoanPaymentFrequency((loan.paymentFrequency ?? 'monthly') as LoanPaymentFrequency);
@@ -178,8 +188,8 @@ const LoansManager: React.FC = ({ scrollToAccountId, displayM
]);
}
- setLoanFieldErrors({});
- setShowAddLoan(true);
+ loanErrors.clearErrors();
+ loanEditor.openForEdit(loan);
};
const handlePaymentLineChange = (
@@ -194,9 +204,7 @@ const LoansManager: React.FC = ({ scrollToAccountId, displayM
})
);
- if (loanFieldErrors.paymentLines) {
- setLoanFieldErrors((prev) => ({ ...prev, paymentLines: undefined }));
- }
+ loanErrors.clearFieldError('paymentLines');
};
const handleAddPaymentLine = () => {
@@ -216,7 +224,7 @@ const LoansManager: React.FC = ({ scrollToAccountId, displayM
const handleApplyTypeDefaults = () => {
setLoanPaymentLines(createDefaultPaymentLines(loanType));
- setLoanFieldErrors((prev) => ({ ...prev, paymentLines: undefined }));
+ loanErrors.clearFieldError('paymentLines');
};
const handleSaveLoan = () => {
@@ -270,7 +278,7 @@ const LoansManager: React.FC = ({ scrollToAccountId, displayM
}
if (Object.keys(errors).length > 0) {
- setLoanFieldErrors(errors);
+ loanErrors.setErrors(errors);
return;
}
@@ -319,14 +327,17 @@ const LoansManager: React.FC = ({ scrollToAccountId, displayM
});
}
- setShowAddLoan(false);
- resetForm();
+ closeLoanModal();
};
const handleDeleteLoan = (id: string) => {
- if (confirm('Are you sure you want to delete this loan payment?')) {
- deleteLoan(id);
- }
+ openConfirmDialog({
+ title: 'Delete Loan Payment',
+ message: 'Are you sure you want to delete this loan payment?',
+ confirmLabel: 'Delete Loan',
+ confirmVariant: 'danger',
+ onConfirm: () => deleteLoan(id),
+ });
};
const handleToggleLoanEnabled = (loan: Loan) => {
@@ -334,21 +345,12 @@ const LoansManager: React.FC = ({ scrollToAccountId, displayM
};
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 loansByAccount = groupByAccountId(loansList);
const paychecksPerYear = getPaychecksPerYear(budgetData.paySettings.payFrequency);
const payFrequencyLabel = formatPayFrequencyLabel(budgetData.paySettings.payFrequency);
- const toDisplayAmount = (monthlyAmount: number): number => {
- const perPaycheckAmount = (monthlyAmount * 12) / paychecksPerYear;
- return convertToDisplayMode(perPaycheckAmount, paychecksPerYear, displayMode);
- };
+ const displayAmount = (monthlyAmount: number): number => monthlyToDisplayAmount(monthlyAmount, paychecksPerYear, displayMode);
return (
@@ -389,22 +391,16 @@ const LoansManager: React.FC = ({ scrollToAccountId, displayM
) : (
<>
- {budgetData.accounts
- .map((account) => {
- const accountLoans = loansByAccount[account.id] || [];
- const totalMonthly = roundToCent(
- accountLoans.reduce((sum, loan) => {
- if (!isLoanEnabled(loan)) {
- return sum;
- }
- return sum + loan.monthlyPayment;
- }, 0)
- );
- return { account, accountLoans, totalMonthly };
- })
- .filter(({ accountLoans }) => accountLoans.length > 0)
- .sort((a, b) => b.totalMonthly - a.totalMonthly)
- .map(({ account, accountLoans, totalMonthly }) => (
+ {buildAccountRows(budgetData.accounts, loansByAccount, (accountLoans) => {
+ return roundToCent(
+ accountLoans.reduce((sum, loan) => {
+ if (!isLoanEnabled(loan)) {
+ return sum;
+ }
+ return sum + loan.monthlyPayment;
+ }, 0)
+ );
+ }).map(({ account, items: accountLoans, totalMonthly }) => (
@@ -416,7 +412,7 @@ const LoansManager: React.FC
= ({ scrollToAccountId, displayM
Total {getDisplayModeLabel(displayMode)}:
- {formatWithSymbol(toDisplayAmount(totalMonthly), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ {formatWithSymbol(displayAmount(totalMonthly), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
@@ -447,7 +443,7 @@ const LoansManager: React.FC
= ({ scrollToAccountId, displayM
- {formatWithSymbol(toDisplayAmount(loan.monthlyPayment), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ {formatWithSymbol(displayAmount(loan.monthlyPayment), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{getDisplayModeLabel(displayMode)}
@@ -460,7 +456,7 @@ const LoansManager: React.FC = ({ scrollToAccountId, displayM
{line.label}
- {formatWithSymbol(toDisplayAmount(convertBillToMonthly(line.amount, line.frequency)), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ {formatWithSymbol(displayAmount(convertBillToMonthly(line.amount, line.frequency)), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
))}
@@ -495,21 +491,15 @@ const LoansManager: React.FC = ({ scrollToAccountId, displayM
{
- setShowAddLoan(false);
- resetForm();
- }}
+ isOpen={loanEditor.isOpen}
+ onClose={closeLoanModal}
contentClassName="loan-payment-modal"
header={editingLoan ? 'Edit Loan Payment' : 'Add Loan Payment'}
footer={
<>
{
- setShowAddLoan(false);
- resetForm();
- }}
+ onClick={closeLoanModal}
>
Cancel
@@ -526,9 +516,7 @@ const LoansManager: React.FC = ({ scrollToAccountId, displayM
value={loanName}
onChange={(event) => {
setLoanName(event.target.value);
- if (loanFieldErrors.name) {
- setLoanFieldErrors((prev) => ({ ...prev, name: undefined }));
- }
+ loanErrors.clearFieldError('name');
}}
placeholder="e.g., Home Mortgage, Car Loan"
className={loanFieldErrors.name ? 'field-error' : ''}
@@ -561,9 +549,7 @@ const LoansManager: React.FC = ({ scrollToAccountId, displayM
value={loanAccountId}
onChange={(event) => {
setLoanAccountId(event.target.value);
- if (loanFieldErrors.accountId) {
- setLoanFieldErrors((prev) => ({ ...prev, accountId: undefined }));
- }
+ loanErrors.clearFieldError('accountId');
}}
className={loanFieldErrors.accountId ? 'field-error' : ''}
>
@@ -660,6 +646,17 @@ const LoansManager: React.FC = ({ scrollToAccountId, displayM
/>
+
+
);
};
diff --git a/src/components/LoansManager/index.ts b/src/components/tabViews/LoansManager/index.ts
similarity index 100%
rename from src/components/LoansManager/index.ts
rename to src/components/tabViews/LoansManager/index.ts
diff --git a/src/components/PayBreakdown/PayBreakdown.css b/src/components/tabViews/PayBreakdown/PayBreakdown.css
similarity index 100%
rename from src/components/PayBreakdown/PayBreakdown.css
rename to src/components/tabViews/PayBreakdown/PayBreakdown.css
diff --git a/src/components/PayBreakdown/PayBreakdown.tsx b/src/components/tabViews/PayBreakdown/PayBreakdown.tsx
similarity index 86%
rename from src/components/PayBreakdown/PayBreakdown.tsx
rename to src/components/tabViews/PayBreakdown/PayBreakdown.tsx
index 490fa90..aa8f21f 100644
--- a/src/components/PayBreakdown/PayBreakdown.tsx
+++ b/src/components/tabViews/PayBreakdown/PayBreakdown.tsx
@@ -1,14 +1,19 @@
import React, { useMemo, useState } from 'react';
-import { useBudget } from '../../contexts/BudgetContext';
-import { formatWithSymbol, getCurrencySymbol } from '../../utils/currency';
-import { roundUpToCent } from '../../utils/money';
-import { getPaychecksPerYear, convertToDisplayMode, convertFromDisplayMode, getDisplayModeLabel, formatPayFrequencyLabel } from '../../utils/payPeriod';
-import { getBillFrequencyOccurrencesPerYear, getSavingsFrequencyOccurrencesPerYear } from '../../utils/frequency';
-import { getDefaultAccountIcon } from '../../utils/accountDefaults';
-import type { Account, Bill, Benefit, RetirementElection, Loan, SavingsContribution } from '../../types/auth';
-import { Alert, Button, InputWithPrefix, ViewModeSelector, PageHeader } from '../shared';
-import PaySettingsModal from '../PaySettingsModal';
-import { GlossaryTerm } from '../Glossary';
+import { useBudget } from '../../../contexts/BudgetContext';
+import { calculateAnnualizedPayBreakdown, calculateDisplayPayBreakdown } from '../../../services/budgetCalculations';
+import { formatWithSymbol, getCurrencySymbol } from '../../../utils/currency';
+import { roundUpToCent } from '../../../utils/money';
+import { getPaychecksPerYear, getDisplayModeLabel, formatPayFrequencyLabel } from '../../../utils/payPeriod';
+import { getBillFrequencyOccurrencesPerYear, getSavingsFrequencyOccurrencesPerYear } from '../../../utils/frequency';
+import { getDefaultAccountIcon } from '../../../utils/accountDefaults';
+import type { Account } from '../../../types/accounts';
+import type { Bill, Loan, SavingsContribution } from '../../../types/obligations';
+import type { Benefit, RetirementElection } from '../../../types/payroll';
+import type { ViewMode } from '../../../types/viewMode';
+import { fromDisplayAmount, toDisplayAmount } from '../../../utils/displayAmounts';
+import { Alert, Button, InputWithPrefix, ViewModeSelector, PageHeader } from '../../_shared';
+import PaySettingsModal from '../../modals/PaySettingsModal';
+import { GlossaryTerm } from '../../modals/GlossaryModal';
import './PayBreakdown.css';
type AllocationCategory = {
@@ -43,8 +48,8 @@ type ValidationMessage = {
};
interface PayBreakdownProps {
- displayMode: 'paycheck' | 'monthly' | 'yearly';
- onDisplayModeChange: (mode: 'paycheck' | 'monthly' | 'yearly') => void;
+ displayMode: ViewMode;
+ onDisplayModeChange: (mode: ViewMode) => void;
onNavigateToBills?: (accountId: string) => void;
onNavigateToSavings?: (accountId: string) => void;
onNavigateToRetirement?: (accountId: string) => void;
@@ -64,67 +69,10 @@ const PayBreakdown: React.FC = ({ displayMode, onDisplayModeC
const currency = budgetData.settings?.currency || 'USD';
const paychecksPerYear = getPaychecksPerYear(budgetData.paySettings.payFrequency);
const payFrequencyLabel = formatPayFrequencyLabel(budgetData.paySettings.payFrequency);
-
- // Calculate yearly breakdown from configured salary/hourly rate
- // eslint-disable-next-line react-hooks/rules-of-hooks
- const yearlyBreakdown = useMemo(() => {
- const { paySettings, benefits = [] } = budgetData;
-
- // Calculate yearly gross pay
- let yearlyGrossPay = 0;
- if (paySettings.payType === 'salary') {
- yearlyGrossPay = paySettings.annualSalary || 0;
- } else {
- yearlyGrossPay = (paySettings.hourlyRate || 0) * (paySettings.hoursPerPayPeriod || 0) * paychecksPerYear;
- }
-
- // Get per-paycheck breakdown to get tax rates and deduction information
- const paycheckBreakdown = calculatePaycheckBreakdown();
-
- // Calculate yearly values by scaling the per-paycheck percentages
- const yearlyGrossPayCalc = yearlyGrossPay;
- const yearlyPreTax = roundUpToCent(paycheckBreakdown.preTaxDeductions * paychecksPerYear);
- const yearlyTaxableIncome = roundUpToCent(yearlyGrossPayCalc - yearlyPreTax);
-
- // Scale per-paycheck tax line amounts to yearly
- const yearlyTaxLineAmounts = paycheckBreakdown.taxLineAmounts.map(line => ({
- ...line,
- amount: roundUpToCent(line.amount * paychecksPerYear),
- }));
- const yearlyAdditionalWithholding = roundUpToCent(paycheckBreakdown.additionalWithholding * paychecksPerYear);
- const yearlyTotalTaxes = roundUpToCent(
- yearlyTaxLineAmounts.reduce((sum, l) => sum + l.amount, 0) + yearlyAdditionalWithholding
- );
-
- // Calculate post-tax paycheck deductions only (account-sourced benefits are handled in account allocations)
- let yearlyPostTaxDeductions = 0;
- (benefits || []).forEach((benefit) => {
- if ((benefit.deductionSource || 'paycheck') === 'paycheck' && benefit.isTaxable) {
- if (benefit.isPercentage) {
- yearlyPostTaxDeductions += roundUpToCent((yearlyGrossPayCalc * benefit.amount) / 100);
- } else {
- yearlyPostTaxDeductions += roundUpToCent(benefit.amount * paychecksPerYear);
- }
- }
- });
-
- const yearlyNetPayBeforeTax = roundUpToCent(yearlyGrossPayCalc - yearlyPreTax - yearlyTotalTaxes);
- const yearlyNetPay = roundUpToCent(Math.max(0, yearlyNetPayBeforeTax - yearlyPostTaxDeductions));
-
- return {
- grossPay: yearlyGrossPayCalc,
- preTaxDeductions: yearlyPreTax,
- taxableIncome: yearlyTaxableIncome,
- taxLineAmounts: yearlyTaxLineAmounts,
- additionalWithholding: yearlyAdditionalWithholding,
- totalTaxes: yearlyTotalTaxes,
- postTaxDeductions: yearlyPostTaxDeductions,
- netPay: yearlyNetPay,
- };
- }, [budgetData, paychecksPerYear]); // eslint-disable-line react-hooks/exhaustive-deps
// Get per-paycheck breakdown for allocation purposes
const paycheckBreakdown = calculatePaycheckBreakdown();
+ const annualBreakdown = calculateAnnualizedPayBreakdown(paycheckBreakdown, paychecksPerYear);
// eslint-disable-next-line react-hooks/rules-of-hooks
const normalizedAccounts = useMemo(
() => normalizeAccounts(budgetData.accounts, budgetData.bills, budgetData.benefits, budgetData.retirement, budgetData.loans, budgetData.savingsContributions || [], budgetData.paySettings.payFrequency, paycheckBreakdown.grossPay),
@@ -133,36 +81,15 @@ const PayBreakdown: React.FC = ({ displayMode, onDisplayModeC
const allocationPlan = calculateAllocationPlan(normalizedAccounts, paycheckBreakdown.netPay);
const leftoverPerPaycheck = allocationPlan.remaining;
- // Calculate display breakdown based on view mode
- const displayDivisor = displayMode === 'paycheck' ? paychecksPerYear : (displayMode === 'monthly' ? 12 : 1);
- const displayBreakdown = {
- grossPay: roundUpToCent(yearlyBreakdown.grossPay / displayDivisor),
- preTaxDeductions: roundUpToCent(yearlyBreakdown.preTaxDeductions / displayDivisor),
- taxableIncome: roundUpToCent(yearlyBreakdown.taxableIncome / displayDivisor),
- taxLineAmounts: yearlyBreakdown.taxLineAmounts.map(line => ({
- ...line,
- amount: roundUpToCent(line.amount / displayDivisor),
- })),
- additionalWithholding: roundUpToCent(yearlyBreakdown.additionalWithholding / displayDivisor),
- totalTaxes: roundUpToCent(yearlyBreakdown.totalTaxes / displayDivisor),
- postTaxDeductions: roundUpToCent(yearlyBreakdown.postTaxDeductions / displayDivisor),
- netPay: roundUpToCent(yearlyBreakdown.netPay / displayDivisor),
- };
+ const displayBreakdown = calculateDisplayPayBreakdown(annualBreakdown, displayMode, paychecksPerYear);
const preTaxDeductionCount = (budgetData.preTaxDeductions || []).filter((deduction) => deduction.amount > 0).length;
const preTaxBenefitCount = (budgetData.benefits || []).filter((benefit) => (benefit.deductionSource || 'paycheck') === 'paycheck' && !benefit.isTaxable && benefit.amount > 0).length;
const retirementContributionCount = (budgetData.retirement || []).filter((election) => election.enabled !== false && election.employeeContribution > 0).length;
const totalPreTaxItemCount = preTaxDeductionCount + preTaxBenefitCount + retirementContributionCount;
- const postTaxDeductionCount = (budgetData.benefits || []).filter((benefit) => (benefit.deductionSource || 'paycheck') === 'paycheck' && benefit.isTaxable && benefit.amount > 0).length;
-
- // Helper function to convert per-paycheck values to display values based on display mode
- const toDisplayAmount = (paycheckAmount: number) => {
- return convertToDisplayMode(paycheckAmount, paychecksPerYear, displayMode);
- };
-
- const fromDisplayAmount = (displayAmount: number) => {
- return convertFromDisplayMode(displayAmount, paychecksPerYear, displayMode);
- };
+ const postTaxBenefitCount = (budgetData.benefits || []).filter((benefit) => (benefit.deductionSource || 'paycheck') === 'paycheck' && benefit.isTaxable && benefit.amount > 0).length;
+ const postTaxRetirementCount = (budgetData.retirement || []).filter((election) => (election.deductionSource || 'paycheck') === 'paycheck' && election.isPreTax === false && election.enabled !== false && election.employeeContribution > 0).length;
+ const postTaxDeductionCount = postTaxBenefitCount + postTaxRetirementCount;
// Calculate percentages for visual bar
const grossPay = displayBreakdown.grossPay;
@@ -507,7 +434,7 @@ const PayBreakdown: React.FC = ({ displayMode, onDisplayModeC
{allocationPlan.accountFunding.map((fundingItem) => {
- const accountAmount = toDisplayAmount(fundingItem.totalAmount);
+ const accountAmount = toDisplayAmount(fundingItem.totalAmount, paychecksPerYear, displayMode);
const isEditing = editingAccountIds.has(fundingItem.account.id);
const displayAccount = isEditing ? draftAccounts.get(fundingItem.account.id) : fundingItem.account;
@@ -556,7 +483,7 @@ const PayBreakdown: React.FC = ({ displayMode, onDisplayModeC
className="category-name-input"
/>
- {formatWithSymbol(toDisplayAmount(category.amount), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ {formatWithSymbol(toDisplayAmount(category.amount, paychecksPerYear, displayMode), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{category.isBill && category.billCount && category.billCount > 0 && (
{category.billCount}
)}
@@ -589,11 +516,11 @@ const PayBreakdown: React.FC
= ({ displayMode, onDisplayModeC
type="number"
min="0"
step="0.01"
- value={String(inputValues.has(category.id) ? inputValues.get(category.id) : toDisplayAmount(category.amount))}
+ value={String(inputValues.has(category.id) ? inputValues.get(category.id) : toDisplayAmount(category.amount, paychecksPerYear, displayMode))}
onChange={(e) => setInputValues(prev => new Map(prev).set(category.id, parseFloat(e.target.value) || 0))}
onBlur={(e) => {
const displayValue = parseFloat(e.target.value) || 0;
- updateCategory(displayAccount.id, category.id, { amount: fromDisplayAmount(displayValue) });
+ updateCategory(displayAccount.id, category.id, { amount: fromDisplayAmount(displayValue, paychecksPerYear, displayMode) });
setInputValues(prev => {
const next = new Map(prev);
next.delete(category.id);
@@ -658,7 +585,7 @@ const PayBreakdown: React.FC = ({ displayMode, onDisplayModeC
)}
- {category.amount === 0 ? '-' : formatWithSymbol(toDisplayAmount(category.amount), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ {category.amount === 0 ? '-' : formatWithSymbol(toDisplayAmount(category.amount, paychecksPerYear, displayMode), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
))
@@ -669,12 +596,12 @@ const PayBreakdown: React.FC = ({ displayMode, onDisplayModeC
All that remains for spending
- {formatWithSymbol(Math.max(0, toDisplayAmount(leftoverPerPaycheck)), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ {formatWithSymbol(Math.max(0, toDisplayAmount(leftoverPerPaycheck, paychecksPerYear, displayMode)), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{leftoverPerPaycheck < (budgetData.paySettings.minLeftover || 0) && (budgetData.paySettings.minLeftover || 0) > 0 && (
- You are {formatWithSymbol(toDisplayAmount((budgetData.paySettings.minLeftover || 0) - leftoverPerPaycheck), currency, { minimumFractionDigits: 2 })} below your target minimum of {formatWithSymbol(toDisplayAmount(budgetData.paySettings.minLeftover || 0), currency, { minimumFractionDigits: 2 })}
+ You are {formatWithSymbol(toDisplayAmount((budgetData.paySettings.minLeftover || 0) - leftoverPerPaycheck, paychecksPerYear, displayMode), currency, { minimumFractionDigits: 2 })} below your target minimum of {formatWithSymbol(toDisplayAmount(budgetData.paySettings.minLeftover || 0, paychecksPerYear, displayMode), currency, { minimumFractionDigits: 2 })}
)}
diff --git a/src/components/PayBreakdown/index.ts b/src/components/tabViews/PayBreakdown/index.ts
similarity index 100%
rename from src/components/PayBreakdown/index.ts
rename to src/components/tabViews/PayBreakdown/index.ts
diff --git a/src/components/SavingsManager/SavingsManager.css b/src/components/tabViews/SavingsManager/SavingsManager.css
similarity index 100%
rename from src/components/SavingsManager/SavingsManager.css
rename to src/components/tabViews/SavingsManager/SavingsManager.css
diff --git a/src/components/SavingsManager/SavingsManager.tsx b/src/components/tabViews/SavingsManager/SavingsManager.tsx
similarity index 93%
rename from src/components/SavingsManager/SavingsManager.tsx
rename to src/components/tabViews/SavingsManager/SavingsManager.tsx
index 39cdfc5..8cc8efa 100644
--- a/src/components/SavingsManager/SavingsManager.tsx
+++ b/src/components/tabViews/SavingsManager/SavingsManager.tsx
@@ -1,21 +1,26 @@
import React, { useEffect, useRef, useState } from 'react';
-import { useBudget } from '../../contexts/BudgetContext';
-import type { RetirementElection, SavingsContribution } from '../../types/auth';
-import { formatWithSymbol, getCurrencySymbol } from '../../utils/currency';
-import { getPaychecksPerYear, convertToDisplayMode, getDisplayModeLabel, calculateGrossPayPerPaycheck, formatPayFrequencyLabel } from '../../utils/payPeriod';
-import { getSavingsFrequencyOccurrencesPerYear } from '../../utils/frequency';
-import { getDefaultAccountIcon } from '../../utils/accountDefaults';
-import { formatBillFrequency } from '../../utils/billFrequency';
-import { getRetirementPlanDisplayLabel, RETIREMENT_PLAN_OPTIONS } from '../../utils/retirement';
-import { Alert, Button, FormGroup, InputWithPrefix, Modal, RadioGroup, SectionItemCard, ViewModeSelector, PageHeader } from '../shared';
-import { GlossaryTerm } from '../Glossary';
+import { useBudget } from '../../../contexts/BudgetContext';
+import { useAppDialogs } from '../../../hooks';
+import type { SavingsContribution } from '../../../types/obligations';
+import type { RetirementElection } from '../../../types/payroll';
+import type { ViewMode } from '../../../types/viewMode';
+import { formatWithSymbol, getCurrencySymbol } from '../../../utils/currency';
+import { getPaychecksPerYear, getDisplayModeLabel, calculateGrossPayPerPaycheck, formatPayFrequencyLabel } from '../../../utils/payPeriod';
+import { getSavingsFrequencyOccurrencesPerYear } from '../../../utils/frequency';
+import { getDefaultAccountIcon } from '../../../utils/accountDefaults';
+import { getAccountNameById } from '../../../utils/accountGrouping';
+import { formatBillFrequency } from '../../../utils/billFrequency';
+import { getRetirementPlanDisplayLabel, RETIREMENT_PLAN_OPTIONS } from '../../../utils/retirement';
+import { toDisplayAmount } from '../../../utils/displayAmounts';
+import { Alert, Button, ConfirmDialog, FormGroup, InputWithPrefix, Modal, RadioGroup, SectionItemCard, ViewModeSelector, PageHeader } from '../../_shared';
+import { GlossaryTerm } from '../../modals/GlossaryModal';
import './SavingsManager.css';
interface SavingsManagerProps {
shouldScrollToRetirement?: boolean;
onScrollToRetirementComplete?: () => void;
- displayMode?: 'paycheck' | 'monthly' | 'yearly';
- onDisplayModeChange?: (mode: 'paycheck' | 'monthly' | 'yearly') => void;
+ displayMode?: ViewMode;
+ onDisplayModeChange?: (mode: ViewMode) => void;
}
type SavingsFieldErrors = {
@@ -38,6 +43,7 @@ const SavingsManager: React.FC = ({
displayMode = 'paycheck',
onDisplayModeChange,
}) => {
+ const { confirmDialog, openConfirmDialog, closeConfirmDialog, confirmCurrentDialog } = useAppDialogs();
const {
budgetData,
addSavingsContribution,
@@ -99,10 +105,6 @@ const SavingsManager: React.FC = ({
const payFrequencyLabel = formatPayFrequencyLabel(budgetData.paySettings.payFrequency);
const grossPayPerPaycheck = calculateGrossPayPerPaycheck(budgetData.paySettings);
- const toDisplayAmount = (perPaycheckAmount: number): number => {
- return convertToDisplayMode(perPaycheckAmount, paychecksPerYear, displayMode);
- };
-
const frequencyMatchesPaySchedule = (itemFrequency: string): boolean => {
return getSavingsFrequencyOccurrencesPerYear(itemFrequency) === paychecksPerYear;
};
@@ -117,11 +119,6 @@ const SavingsManager: React.FC = ({
const savingsContributions = budgetData.savingsContributions || [];
- const getAccountName = (accountId?: string) => {
- if (!accountId) return 'Unknown Account';
- return budgetData.accounts.find((account) => account.id === accountId)?.name || 'Unknown Account';
- };
-
const sortedSavings = [...savingsContributions].sort((a, b) => {
const aEnabled = a.enabled !== false;
const bEnabled = b.enabled !== false;
@@ -219,9 +216,13 @@ const SavingsManager: React.FC = ({
};
const handleDeleteSavings = (id: string) => {
- if (window.confirm('Are you sure you want to delete this contribution?')) {
- deleteSavingsContribution(id);
- }
+ openConfirmDialog({
+ title: 'Delete Contribution',
+ message: 'Are you sure you want to delete this contribution?',
+ confirmLabel: 'Delete Contribution',
+ confirmVariant: 'danger',
+ onConfirm: () => deleteSavingsContribution(id),
+ });
};
const handleToggleSavingsEnabled = (item: SavingsContribution) => {
@@ -441,9 +442,13 @@ const SavingsManager: React.FC = ({
};
const handleDeleteRetirement = (id: string) => {
- if (window.confirm('Are you sure you want to delete this retirement election?')) {
- deleteRetirementElection(id);
- }
+ openConfirmDialog({
+ title: 'Delete Retirement Election',
+ message: 'Are you sure you want to delete this retirement election?',
+ confirmLabel: 'Delete Election',
+ confirmVariant: 'danger',
+ onConfirm: () => deleteRetirementElection(id),
+ });
};
const handleToggleRetirementEnabled = (retirement: RetirementElection) => {
@@ -476,7 +481,7 @@ const SavingsManager: React.FC = ({
Total {getDisplayModeLabel(displayMode)}
- {formatWithSymbol(toDisplayAmount(savingsTotalPerPaycheck), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ {formatWithSymbol(toDisplayAmount(savingsTotalPerPaycheck, paychecksPerYear, displayMode), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ Add Contribution
@@ -492,9 +497,9 @@ const SavingsManager: React.FC = ({
) : (
{sortedSavings.map((item) => {
- const accountName = getAccountName(item.accountId);
+ const accountName = getAccountNameById(budgetData.accounts, item.accountId);
const perPaycheck = getSavingsPerPaycheck(item);
- const displayAmount = toDisplayAmount(perPaycheck);
+ const displayAmount = toDisplayAmount(perPaycheck, paychecksPerYear, displayMode);
const isEnabled = item.enabled !== false;
return (
@@ -548,7 +553,7 @@ const SavingsManager: React.FC
= ({
Total {getDisplayModeLabel(displayMode)}
- {formatWithSymbol(toDisplayAmount(retirementTotalPerPaycheck), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ {formatWithSymbol(toDisplayAmount(retirementTotalPerPaycheck, paychecksPerYear, displayMode), currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ Add Retirement Plan
@@ -566,11 +571,11 @@ const SavingsManager: React.FC = ({
{sortedRetirement.map((retirement) => {
const { employeeAmount: employeePerPaycheck, employerAmount } = calculateRetirementContributions(retirement);
const totalPerPaycheck = employeePerPaycheck + employerAmount;
- const totalInDisplayMode = toDisplayAmount(totalPerPaycheck);
+ const totalInDisplayMode = toDisplayAmount(totalPerPaycheck, paychecksPerYear, displayMode);
const isEnabled = retirement.enabled !== false;
const isPreTaxRetirement = retirement.isPreTax !== false;
const sourceLabel = retirement.deductionSource === 'account'
- ? `From ${getAccountName(retirement.sourceAccountId)}`
+ ? `From ${getAccountNameById(budgetData.accounts, retirement.sourceAccountId)}`
: 'From Paycheck';
const displayLabel = getRetirementPlanDisplayLabel(retirement);
@@ -931,6 +936,17 @@ const SavingsManager: React.FC = ({
)}
+
+
);
};
diff --git a/src/components/SavingsManager/index.ts b/src/components/tabViews/SavingsManager/index.ts
similarity index 100%
rename from src/components/SavingsManager/index.ts
rename to src/components/tabViews/SavingsManager/index.ts
diff --git a/src/components/TaxBreakdown/TaxBreakdown.css b/src/components/tabViews/TaxBreakdown/TaxBreakdown.css
similarity index 100%
rename from src/components/TaxBreakdown/TaxBreakdown.css
rename to src/components/tabViews/TaxBreakdown/TaxBreakdown.css
diff --git a/src/components/TaxBreakdown/TaxBreakdown.tsx b/src/components/tabViews/TaxBreakdown/TaxBreakdown.tsx
similarity index 95%
rename from src/components/TaxBreakdown/TaxBreakdown.tsx
rename to src/components/tabViews/TaxBreakdown/TaxBreakdown.tsx
index 743314e..fed97d2 100644
--- a/src/components/TaxBreakdown/TaxBreakdown.tsx
+++ b/src/components/tabViews/TaxBreakdown/TaxBreakdown.tsx
@@ -1,15 +1,16 @@
import React, { useState } from 'react';
-import { useBudget } from '../../contexts/BudgetContext';
-import { formatWithSymbol, getCurrencySymbol } from '../../utils/currency';
-import { getPaychecksPerYear, convertToDisplayMode, getDisplayModeLabel, formatPayFrequencyLabel } from '../../utils/payPeriod';
-import { Button, InputWithPrefix, Modal, FormGroup, PageHeader, ViewModeSelector } from '../shared';
-import { GlossaryTerm } from '../Glossary';
-import type { TaxLine } from '../../types/auth';
+import { useBudget } from '../../../contexts/BudgetContext';
+import { formatWithSymbol, getCurrencySymbol } from '../../../utils/currency';
+import { getPaychecksPerYear, convertToDisplayMode, getDisplayModeLabel, formatPayFrequencyLabel } from '../../../utils/payPeriod';
+import { Button, InputWithPrefix, Modal, FormGroup, PageHeader, ViewModeSelector } from '../../_shared';
+import { GlossaryTerm } from '../../modals/GlossaryModal';
+import type { TaxLine } from '../../../types/payroll';
+import type { ViewMode } from '../../../types/viewMode';
import './TaxBreakdown.css';
interface TaxBreakdownProps {
- displayMode: 'paycheck' | 'monthly' | 'yearly';
- onDisplayModeChange: (mode: 'paycheck' | 'monthly' | 'yearly') => void;
+ displayMode: ViewMode;
+ onDisplayModeChange: (mode: ViewMode) => void;
}
interface EditableTaxLine {
diff --git a/src/components/TaxBreakdown/index.ts b/src/components/tabViews/TaxBreakdown/index.ts
similarity index 100%
rename from src/components/TaxBreakdown/index.ts
rename to src/components/tabViews/TaxBreakdown/index.ts
diff --git a/src/components/EncryptionSetup/EncryptionSetup.css b/src/components/views/EncryptionSetup/EncryptionSetup.css
similarity index 100%
rename from src/components/EncryptionSetup/EncryptionSetup.css
rename to src/components/views/EncryptionSetup/EncryptionSetup.css
diff --git a/src/components/views/EncryptionSetup/EncryptionSetup.tsx b/src/components/views/EncryptionSetup/EncryptionSetup.tsx
new file mode 100644
index 0000000..ad23219
--- /dev/null
+++ b/src/components/views/EncryptionSetup/EncryptionSetup.tsx
@@ -0,0 +1,110 @@
+// Encryption Setup Component - Shown on first launch
+// Allows users to configure encryption or skip it
+import React from 'react';
+import { useAppDialogs, useEncryptionSetupFlow } from '../../../hooks';
+import { Button, EncryptionConfigPanel, ErrorDialog } 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 { errorDialog, openErrorDialog, closeErrorDialog } = useAppDialogs();
+ const {
+ encryptionEnabled,
+ setEncryptionEnabled,
+ customKey,
+ setCustomKey,
+ generatedKey,
+ useCustomKey,
+ setUseCustomKey,
+ isSaving,
+ canSaveSelection,
+ generateKey,
+ goBackToSelection,
+ saveSelection,
+ } = useEncryptionSetupFlow();
+
+ // Save encryption settings and continue
+ const handleSaveSettings = async () => {
+ const result = await saveSelection({
+ planId,
+ persistAppSettings: true,
+ deleteStoredKeyWhenDisabled: true,
+ });
+
+ if (!result.success) {
+ openErrorDialog(result.errorDialog);
+ return;
+ }
+
+ onComplete(result.encryptionEnabled);
+ };
+
+ // 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 && (
+
+ ← Cancel
+
+ )
+ ) : (
+
+ ← Back
+
+ )}
+
+ Continue
+
+
+
+
+
+
+ );
+};
+
+export default EncryptionSetup;
diff --git a/src/components/views/EncryptionSetup/index.ts b/src/components/views/EncryptionSetup/index.ts
new file mode 100644
index 0000000..3af5e63
--- /dev/null
+++ b/src/components/views/EncryptionSetup/index.ts
@@ -0,0 +1 @@
+export { default } from './EncryptionSetup';
\ No newline at end of file
diff --git a/src/components/SetupWizard/SetupWizard.css b/src/components/views/SetupWizard/SetupWizard.css
similarity index 100%
rename from src/components/SetupWizard/SetupWizard.css
rename to src/components/views/SetupWizard/SetupWizard.css
diff --git a/src/components/SetupWizard/SetupWizard.tsx b/src/components/views/SetupWizard/SetupWizard.tsx
similarity index 89%
rename from src/components/SetupWizard/SetupWizard.tsx
rename to src/components/views/SetupWizard/SetupWizard.tsx
index 825614b..4b854a6 100644
--- a/src/components/SetupWizard/SetupWizard.tsx
+++ b/src/components/views/SetupWizard/SetupWizard.tsx
@@ -1,13 +1,13 @@
import React, { useState } from 'react';
-import { useBudget } from '../../contexts/BudgetContext';
-import { getCurrencySymbol, CURRENCIES } from '../../utils/currency';
-import { getDefaultAccountColor, getDefaultAccountIcon } from '../../utils/accountDefaults';
-import { getPaychecksPerYear } from '../../utils/payPeriod';
-import { KeychainService } from '../../services/keychainService';
-import { FileStorageService } from '../../services/fileStorage';
-import EncryptionConfigPanel from '../EncryptionSetup/EncryptionConfigPanel';
-import type { PaySettings, TaxSettings, Account } from '../../types/auth';
-import { Button, FormGroup, InputWithPrefix, RadioGroup, InfoBox, AccountsEditor, ProgressBar } from '../shared';
+import { useBudget } from '../../../contexts/BudgetContext';
+import { useAppDialogs, useEncryptionSetupFlow } from '../../../hooks';
+import { getCurrencySymbol, CURRENCIES } from '../../../utils/currency';
+import { getDefaultAccountColor, getDefaultAccountIcon } from '../../../utils/accountDefaults';
+import { getPaychecksPerYear } from '../../../utils/payPeriod';
+import { formatSuggestedLeftover, getSuggestedLeftoverPerPaycheck } from '../../../utils/paySuggestions';
+import type { Account } from '../../../types/accounts';
+import type { PaySettings, TaxSettings } from '../../../types/payroll';
+import { Button, FormGroup, InputWithPrefix, RadioGroup, InfoBox, AccountsEditor, EncryptionConfigPanel, ProgressBar, ErrorDialog } from '../../_shared';
import './SetupWizard.css';
interface SetupWizardProps {
@@ -24,6 +24,7 @@ interface EditableTaxLine {
const SetupWizard: React.FC = ({ onComplete, onCancel }) => {
const { updatePaySettings, updateTaxSettings, updateBudgetSettings, updateBudgetData, budgetData } = useBudget();
+ const { errorDialog, openErrorDialog, closeErrorDialog } = useAppDialogs();
const [step, setStep] = useState(1);
const totalSteps = 6; // Increased from 5 to include encryption step
@@ -32,10 +33,18 @@ const SetupWizard: React.FC = ({ onComplete, onCancel }) => {
const [currency, setCurrency] = useState(budgetData?.settings?.currency || 'USD');
// Encryption configuration
- const [encryptionEnabled, setEncryptionEnabled] = useState(null);
- const [customEncryptionKey, setCustomEncryptionKey] = useState('');
- const [generatedEncryptionKey, setGeneratedEncryptionKey] = useState('');
- const [useCustomEncryptionKey, setUseCustomEncryptionKey] = useState(false);
+ const {
+ encryptionEnabled,
+ setEncryptionEnabled,
+ customKey: customEncryptionKey,
+ setCustomKey: setCustomEncryptionKey,
+ generatedKey: generatedEncryptionKey,
+ useCustomKey: useCustomEncryptionKey,
+ setUseCustomKey: setUseCustomEncryptionKey,
+ generateKey: handleGenerateEncryptionKey,
+ goBackToSelection,
+ saveSelection: saveEncryptionSelection,
+ } = useEncryptionSetupFlow();
const [payType, setPayType] = useState<'salary' | 'hourly'>('salary');
const [annualSalary, setAnnualSalary] = useState('');
@@ -80,7 +89,7 @@ const SetupWizard: React.FC = ({ onComplete, onCancel }) => {
// If on encryption step and currently in key setup view (encryptionEnabled is not null),
// go back to the selection view instead of going to previous step
if (step === 6 && encryptionEnabled !== null) {
- setEncryptionEnabled(null);
+ goBackToSelection();
return;
}
@@ -89,31 +98,20 @@ const SetupWizard: React.FC = ({ onComplete, onCancel }) => {
}
};
- // Generate a new encryption key
- const handleGenerateEncryptionKey = () => {
- const key = FileStorageService.generateEncryptionKey();
- setGeneratedEncryptionKey(key);
- setUseCustomEncryptionKey(false);
- };
-
- const handleCompleteEncryptionSetup = async () => {
+ const handleComplete = async () => {
if (!budgetData) return;
- if (encryptionEnabled) {
- const keyToUse = useCustomEncryptionKey ? customEncryptionKey : generatedEncryptionKey;
- if (!keyToUse) {
- alert('Please generate or enter an encryption key.');
- return;
- }
- // Save the encryption key to keychain
- await KeychainService.saveKey(budgetData.id, keyToUse);
- }
+ const encryptionResult = await saveEncryptionSelection({
+ planId: budgetData.id,
+ persistAppSettings: false,
+ deleteStoredKeyWhenDisabled: false,
+ });
- // Proceed to next step
- handleNext();
- };
+ if (!encryptionResult.success) {
+ openErrorDialog(encryptionResult.errorDialog);
+ return;
+ }
- const handleComplete = async () => {
let hasTaxErrors = false;
const validatedTaxLines = taxLines.map((line) => {
const parsedRate = parseFloat(line.rate);
@@ -293,25 +291,9 @@ const SetupWizard: React.FC = ({ onComplete, onCancel }) => {
return (hourly * weeklyHours * 52) / paychecksPerYear;
};
- const suggestedLeftoverPerPaycheck = (() => {
- const estimatedGross = estimateGrossPerPaycheck();
- if (estimatedGross <= 0) return 0;
+ const suggestedLeftoverPerPaycheck = getSuggestedLeftoverPerPaycheck(estimateGrossPerPaycheck());
- // Starter recommendation: keep about 20% as a stronger day-to-day buffer,
- // rounded to $10 with a practical minimum floor.
- const rawSuggestion = estimatedGross * 0.2;
- const rounded = Math.round(rawSuggestion / 10) * 10;
- return Math.max(75, rounded);
- })();
-
- const formattedSuggestedLeftover = suggestedLeftoverPerPaycheck > 0
- ? new Intl.NumberFormat(undefined, {
- style: 'currency',
- currency,
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }).format(suggestedLeftoverPerPaycheck)
- : null;
+ const formattedSuggestedLeftover = formatSuggestedLeftover(suggestedLeftoverPerPaycheck, currency);
const canProceed = () => {
switch (step) {
@@ -647,14 +629,7 @@ const SetupWizard: React.FC = ({ onComplete, onCancel }) => {
{step < totalSteps ? (
{
- // Special handling for encryption step
- if (step === 6) {
- handleCompleteEncryptionSetup();
- } else {
- handleNext();
- }
- }}
+ onClick={handleNext}
disabled={!canProceed()}
>
Next →
@@ -670,6 +645,14 @@ const SetupWizard: React.FC = ({ onComplete, onCancel }) => {
)}
+
+
);
};
diff --git a/src/components/SetupWizard/index.ts b/src/components/views/SetupWizard/index.ts
similarity index 100%
rename from src/components/SetupWizard/index.ts
rename to src/components/views/SetupWizard/index.ts
diff --git a/src/components/WelcomeScreen/WelcomeScreen.css b/src/components/views/WelcomeScreen/WelcomeScreen.css
similarity index 86%
rename from src/components/WelcomeScreen/WelcomeScreen.css
rename to src/components/views/WelcomeScreen/WelcomeScreen.css
index a67c82b..79704de 100644
--- a/src/components/WelcomeScreen/WelcomeScreen.css
+++ b/src/components/views/WelcomeScreen/WelcomeScreen.css
@@ -281,39 +281,6 @@
flex-wrap: wrap;
}
-.welcome-relink-modal {
- max-width: 560px;
-}
-
-.welcome-relink-modal-message {
- margin: 0;
- color: var(--text-primary);
- line-height: 1.5;
-}
-
-.welcome-relink-modal-path {
- display: block;
- margin-top: 0.75rem;
- padding: 0.75rem 0.875rem;
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- font-family: 'SF Mono', 'Monaco', 'Cascadia Mono', 'Segoe UI Mono', Consolas, monospace;
- font-size: 0.85rem;
- color: var(--text-secondary);
- word-break: break-all;
-}
-
-.welcome-relink-modal-error {
- margin: 0.75rem 0 0;
- padding: 0.625rem 0.75rem;
- border: 1px solid color-mix(in srgb, var(--danger-color) 45%, transparent);
- background: color-mix(in srgb, var(--danger-color) 14%, transparent);
- color: color-mix(in srgb, var(--danger-color) 85%, var(--text-primary));
- border-radius: 8px;
- font-size: 0.9rem;
-}
-
/* Mobile Responsive */
@media (max-width: 768px) {
.welcome-screen {
diff --git a/src/components/WelcomeScreen/WelcomeScreen.tsx b/src/components/views/WelcomeScreen/WelcomeScreen.tsx
similarity index 72%
rename from src/components/WelcomeScreen/WelcomeScreen.tsx
rename to src/components/views/WelcomeScreen/WelcomeScreen.tsx
index 0ba91f7..2e21d2e 100644
--- a/src/components/WelcomeScreen/WelcomeScreen.tsx
+++ b/src/components/views/WelcomeScreen/WelcomeScreen.tsx
@@ -1,20 +1,16 @@
import React, { useState } from 'react';
-import { useBudget } from '../../contexts/BudgetContext';
-import { FileStorageService } from '../../services/fileStorage';
-import type { RecentFile } from '../../services/fileStorage';
-import { Button, FormGroup, Modal } from '../shared';
-import Settings from '../Settings';
+import { useBudget } from '../../../contexts/BudgetContext';
+import { useAppDialogs, useFileRelinkFlow } from '../../../hooks';
+import { FileStorageService } from '../../../services/fileStorage';
+import type { RecentFile } from '../../../services/fileStorage';
+import { Button, ErrorDialog, FileRelinkModal, FormGroup } from '../../_shared';
+import SettingsModal from '../../modals/SettingsModal';
import './WelcomeScreen.css';
interface WelcomeScreenProps {
initialError?: string;
}
-interface MissingRecentFileState {
- filePath: string;
- fileName: string;
-}
-
const DEMO_LAUNCH_DELAY_MS = 2200;
const WelcomeScreen: React.FC = ({ initialError }) => {
@@ -25,10 +21,23 @@ const WelcomeScreen: React.FC = ({ initialError }) => {
const [recentFiles, setRecentFiles] = useState(() => FileStorageService.getRecentFiles());
const [showSettings, setShowSettings] = useState(false);
const [demoLoading, setDemoLoading] = useState(false);
- const [missingRecentFile, setMissingRecentFile] = useState(null);
- const [relinkMismatchMessage, setRelinkMismatchMessage] = useState(null);
- const [relinkLoading, setRelinkLoading] = useState(false);
const isBusy = loading || demoLoading;
+ const { errorDialog, openErrorDialog, closeErrorDialog } = useAppDialogs();
+ const {
+ missingFile: missingRecentFile,
+ relinkMismatchMessage,
+ relinkLoading,
+ promptFileRelink,
+ clearFileRelinkPrompt,
+ locateRelinkedFile,
+ } = useFileRelinkFlow({
+ getExpectedPlanId: (filePath) => FileStorageService.getKnownPlanIdForFile(filePath) || undefined,
+ fallbackErrorMessage: 'Unable to locate moved file.',
+ onRelinkSuccess: async (result) => {
+ await loadBudget(result.filePath);
+ setRecentFiles(FileStorageService.getRecentFiles());
+ },
+ });
const handleCreateNew = () => {
setShowNewPlanForm(true);
@@ -65,11 +74,7 @@ const WelcomeScreen: React.FC = ({ initialError }) => {
try {
const exists = await window.electronAPI.fileExists(filePath);
if (!exists) {
- setMissingRecentFile({
- filePath,
- fileName: filePath.split(/[\\/]/).pop() || filePath,
- });
- setRelinkMismatchMessage(null);
+ promptFileRelink(filePath);
return;
}
} catch (error) {
@@ -85,58 +90,28 @@ const WelcomeScreen: React.FC = ({ initialError }) => {
const isFileNotFound = /not found|enoent|no such file/i.test(message);
if (isFileNotFound) {
- setMissingRecentFile({
- filePath,
- fileName: filePath.split(/[\\/]/).pop() || filePath,
- });
- setRelinkMismatchMessage(null);
+ promptFileRelink(filePath);
return;
}
- alert('Failed to open file: ' + message);
+ openErrorDialog({
+ title: 'Open Plan Failed',
+ message: `Failed to open file: ${message}`,
+ actionLabel: 'Retry',
+ });
}
};
const handleCloseMissingRecentModal = () => {
if (relinkLoading) return;
- setMissingRecentFile(null);
- setRelinkMismatchMessage(null);
+ clearFileRelinkPrompt();
};
const handleRemoveMissingRecent = () => {
if (!missingRecentFile || relinkLoading) return;
FileStorageService.removeRecentFile(missingRecentFile.filePath);
setRecentFiles(FileStorageService.getRecentFiles());
- setMissingRecentFile(null);
- setRelinkMismatchMessage(null);
- };
-
- const handleRelinkMissingRecent = async () => {
- if (!missingRecentFile || relinkLoading) return;
-
- setRelinkLoading(true);
- try {
- const expectedPlanId = FileStorageService.getKnownPlanIdForFile(missingRecentFile.filePath) || undefined;
- const result = await FileStorageService.relinkMovedBudgetFile(missingRecentFile.filePath, expectedPlanId);
- if (result.status === 'cancelled') {
- return;
- }
-
- if (result.status === 'mismatch' || result.status === 'invalid') {
- setRelinkMismatchMessage(result.message);
- return;
- }
-
- setMissingRecentFile(null);
- setRelinkMismatchMessage(null);
- await loadBudget(result.filePath);
- setRecentFiles(FileStorageService.getRecentFiles());
- } catch (error) {
- const message = (error as Error).message || 'Unable to locate moved file.';
- setRelinkMismatchMessage(message);
- } finally {
- setRelinkLoading(false);
- }
+ clearFileRelinkPrompt();
};
const handleRemoveRecent = (filePath: string, e: React.MouseEvent) => {
@@ -312,42 +287,36 @@ const WelcomeScreen: React.FC = ({ initialError }) => {
)}
- setShowSettings(false)} />
+ setShowSettings(false)} />
-
-
- Cancel
-
-
- Remove
-
-
- Locate File
-
+ "{missingRecentFile?.fileName || 'This file'}" could not be found. Locate it to open, or remove this entry from Recents.
>
}
- >
-
- "{missingRecentFile?.fileName || 'This file'}" could not be found. Locate it to open, or remove this entry from Recents.
-
-
- {missingRecentFile?.filePath || ''}
-
- {relinkMismatchMessage && {relinkMismatchMessage}
}
-
+ filePath={missingRecentFile?.filePath || ''}
+ errorMessage={relinkMismatchMessage}
+ isLoading={relinkLoading}
+ extraAction={{
+ label: 'Remove',
+ onClick: handleRemoveMissingRecent,
+ variant: 'danger',
+ }}
+ />
+
+
);
};
diff --git a/src/components/WelcomeScreen/index.ts b/src/components/views/WelcomeScreen/index.ts
similarity index 100%
rename from src/components/WelcomeScreen/index.ts
rename to src/components/views/WelcomeScreen/index.ts
diff --git a/src/constants/events.ts b/src/constants/events.ts
new file mode 100644
index 0000000..9b03278
--- /dev/null
+++ b/src/constants/events.ts
@@ -0,0 +1,28 @@
+export const MENU_EVENTS = {
+ newBudget: 'new-budget',
+ openBudget: 'open-budget',
+ openBudgetFile: 'open-budget-file',
+ changeEncryption: 'change-encryption',
+ savePlan: 'save-plan',
+ openSettings: 'open-settings',
+ openAbout: 'open-about',
+ openGlossary: 'open-glossary',
+ openKeyboardShortcuts: 'open-keyboard-shortcuts',
+ openPayOptions: 'open-pay-options',
+ openAccounts: 'open-accounts',
+ setTabPosition: 'set-tab-position',
+ toggleTabDisplayMode: 'toggle-tab-display-mode',
+ historyBack: 'history-back',
+ historyForward: 'history-forward',
+ historyHome: 'history-home',
+} as const;
+
+export type MenuEventName = (typeof MENU_EVENTS)[keyof typeof MENU_EVENTS];
+
+export const APP_CUSTOM_EVENTS = {
+ openGlossary: 'app:open-glossary',
+ themeModeChanged: 'theme-mode-changed',
+ glossaryTermsChanged: 'glossary-terms-changed',
+} as const;
+
+export const menuChannel = (event: MenuEventName) => `menu:${event}`;
\ No newline at end of file
diff --git a/src/constants/storage.ts b/src/constants/storage.ts
new file mode 100644
index 0000000..ad60888
--- /dev/null
+++ b/src/constants/storage.ts
@@ -0,0 +1,23 @@
+export const STORAGE_KEYS = {
+ settings: 'paycheck-planner-settings',
+ recentFiles: 'paycheck-planner-recent-files',
+ fileToPlanMapping: 'paycheck-planner-file-to-plan-mapping',
+ theme: 'paycheck-planner-theme',
+ accounts: 'paycheck-planner-accounts',
+} as const;
+
+export const APP_STORAGE_PREFIX = 'paycheck-planner-';
+
+export const APP_STORAGE_KEYS = [
+ STORAGE_KEYS.settings,
+ STORAGE_KEYS.recentFiles,
+ STORAGE_KEYS.fileToPlanMapping,
+ STORAGE_KEYS.theme,
+ STORAGE_KEYS.accounts,
+] as const;
+
+export const SETTINGS_PLAN_SPECIFIC_FIELDS = ['encryptionEnabled', 'encryptionKey'] as const;
+
+export const BACKUP_EXCLUDED_STORAGE_KEYS = [STORAGE_KEYS.accounts] as const;
+
+export const MAX_RECENT_FILES = 10;
\ No newline at end of file
diff --git a/src/contexts/BudgetContext.tsx b/src/contexts/BudgetContext.tsx
index 78d7271..3ea5593 100644
--- a/src/contexts/BudgetContext.tsx
+++ b/src/contexts/BudgetContext.tsx
@@ -1,25 +1,39 @@
// Budget Context - Manages all paycheck planning data and operations
// This is like a "global state" that any component can access
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
+import { useAppDialogs } from '../hooks';
+import { ErrorDialog } from '../components/_shared';
import type { ReactNode } from 'react';
import type {
- BudgetData,
- BudgetContextType,
- PaySettings,
- Deduction,
- TaxSettings,
- BudgetSettings,
- Account,
+ Account
+} from '../types/accounts';
+import type {
+ BudgetData
+} from '../types/budget';
+import type {
+ BudgetContextType
+} from '../types/budgetContext';
+import type {
Bill,
+ Loan,
+ SavingsContribution
+} from '../types/obligations';
+import type {
Benefit,
- SavingsContribution,
- RetirementElection,
+ Deduction,
+ PaySettings,
PaycheckBreakdown,
- Loan
-} from '../types/auth';
+ RetirementElection,
+ TaxSettings
+} from '../types/payroll';
+import type {
+ BudgetSettings
+} from '../types/settings';
import { FileStorageService } from '../services/fileStorage';
+import { calculatePaycheckBreakdown as calculateBudgetPaycheckBreakdown, getEmptyPaycheckBreakdown } from '../services/budgetCalculations';
import { KeychainService } from '../services/keychainService';
import { roundUpToCent } from '../utils/money';
+import { getPlanNameFromPath } from '../utils/filePath';
import { getPaychecksPerYear } from '../utils/payPeriod';
import { generateDemoBudgetData } from '../utils/demoDataGenerator';
@@ -33,19 +47,6 @@ type LegacyRetirementElection = Partial & {
employerMatchIsPercentage?: boolean;
};
-const derivePlanNameFromFilePath = (filePath?: string): string | null => {
- if (!filePath) return null;
-
- const lastSeparatorIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
- const fileName = lastSeparatorIndex >= 0 ? filePath.slice(lastSeparatorIndex + 1) : filePath;
- if (!fileName) return null;
-
- const lastDotIndex = fileName.lastIndexOf('.');
- const baseName = lastDotIndex > 0 ? fileName.slice(0, lastDotIndex) : fileName;
- const normalized = baseName.trim();
- return normalized || null;
-};
-
/**
* Custom hook to access budget data from any component
* Usage: const { budgetData, saveBudget } = useBudget()
@@ -71,6 +72,7 @@ interface BudgetProviderProps {
* Think of this as the "manager" that holds and controls all budget data
*/
export const BudgetProvider: React.FC = ({ children }) => {
+ const { errorDialog, openErrorDialog, closeErrorDialog } = useAppDialogs();
// State for the current budget data (null means no budget loaded)
// The type annotation ensures budgetData matches our BudgetData interface
const [budgetData, setBudgetData] = useState(null);
@@ -203,7 +205,7 @@ export const BudgetProvider: React.FC = ({ children }) => {
return false;
}
- const derivedPlanName = derivePlanNameFromFilePath(filePath);
+ const derivedPlanName = getPlanNameFromPath(filePath);
// Update state with the new file path, window size, active tab, and save timestamp
const savedBudget = {
@@ -226,14 +228,17 @@ export const BudgetProvider: React.FC = ({ children }) => {
return true;
} catch (error) {
console.error('Error saving budget:', error);
- // Type assertion: tell TypeScript that error is an Error object
- alert('Failed to save budget: ' + (error as Error).message);
+ openErrorDialog({
+ title: 'Save Failed',
+ message: 'Failed to save budget: ' + (error as Error).message,
+ actionLabel: 'Retry',
+ });
return false;
} finally {
// Always runs, even if there's an error
setLoading(false);
}
- }, [budgetData]); // Dependency: recreate this function if budgetData changes
+ }, [budgetData, openErrorDialog]); // Dependency: recreate this function if budgetData changes
/**
* Save only window state (size and active tab) to the budget file
@@ -355,7 +360,7 @@ export const BudgetProvider: React.FC = ({ children }) => {
});
// Keep plan name in sync with the actual file name when opened from disk.
- const derivedPlanName = derivePlanNameFromFilePath(data.settings?.filePath);
+ const derivedPlanName = getPlanNameFromPath(data.settings?.filePath);
if (derivedPlanName) {
data.name = derivedPlanName;
}
@@ -372,11 +377,15 @@ export const BudgetProvider: React.FC = ({ children }) => {
}
} catch (error) {
console.error('Error loading budget:', error);
- alert('Failed to load budget: ' + (error as Error).message);
+ openErrorDialog({
+ title: 'Load Failed',
+ message: 'Failed to load budget: ' + (error as Error).message,
+ actionLabel: 'Retry',
+ });
} finally {
setLoading(false);
}
- }, []); // No dependencies: this function never needs to be recreated
+ }, [openErrorDialog]); // No dependencies beyond dialog display
/**
* Create a new empty budget plan
@@ -876,119 +885,10 @@ export const BudgetProvider: React.FC = ({ children }) => {
*/
const calculatePaycheckBreakdown = useCallback((): PaycheckBreakdown => {
if (!budgetData) {
- return {
- grossPay: 0,
- preTaxDeductions: 0,
- taxableIncome: 0,
- taxLineAmounts: [],
- additionalWithholding: 0,
- totalTaxes: 0,
- netPay: 0,
- };
- }
-
- const { paySettings, preTaxDeductions, benefits = [], retirement = [], taxSettings } = budgetData;
-
- // Calculate gross pay per paycheck
- let grossPay = 0;
- if (paySettings.payType === 'salary' && paySettings.annualSalary) {
- const paychecksPerYear = getPaychecksPerYear(paySettings.payFrequency);
- grossPay = roundUpToCent(paySettings.annualSalary / paychecksPerYear);
- } else if (paySettings.payType === 'hourly' && paySettings.hourlyRate && paySettings.hoursPerPayPeriod) {
- grossPay = roundUpToCent(paySettings.hourlyRate * paySettings.hoursPerPayPeriod);
+ return getEmptyPaycheckBreakdown();
}
- // Calculate pre-tax deductions (existing deductions)
- let totalPreTaxDeductions = 0;
- preTaxDeductions.forEach((deduction) => {
- if (deduction.isPercentage) {
- totalPreTaxDeductions += (grossPay * deduction.amount) / 100;
- } else {
- totalPreTaxDeductions += deduction.amount;
- }
- });
-
- // Add pre-tax benefits deducted from paycheck
- (benefits || []).forEach((benefit) => {
- if ((benefit.deductionSource || 'paycheck') === 'paycheck' && !benefit.isTaxable) { // Pre-tax paycheck benefit
- if (benefit.isPercentage) {
- totalPreTaxDeductions += (grossPay * benefit.amount) / 100;
- } else {
- totalPreTaxDeductions += benefit.amount;
- }
- }
- });
-
- // Add employee retirement contributions (pre-tax or account-sourced)
- (retirement || []).forEach((election) => {
- if (election.enabled === false) return;
-
- if ((election.deductionSource || 'paycheck') === 'paycheck' && (election.isPreTax !== false)) {
- if (election.employeeContributionIsPercentage) {
- totalPreTaxDeductions += (grossPay * election.employeeContribution) / 100;
- } else {
- totalPreTaxDeductions += election.employeeContribution;
- }
- }
- });
-
- totalPreTaxDeductions = roundUpToCent(totalPreTaxDeductions);
-
- // Calculate taxable income
- const taxableIncome = roundUpToCent(grossPay - totalPreTaxDeductions);
-
- // Calculate taxes
- const taxLineAmounts = (taxSettings.taxLines || []).map(line => ({
- id: line.id,
- label: line.label,
- amount: roundUpToCent((taxableIncome * line.rate) / 100),
- }));
- const additionalWithholding = roundUpToCent(taxSettings.additionalWithholding);
-
- const totalTaxes = roundUpToCent(
- taxLineAmounts.reduce((sum, l) => sum + l.amount, 0) + additionalWithholding
- );
-
- // Calculate net pay before post-tax deductions
- let netPayBeforePostTax = roundUpToCent(taxableIncome - totalTaxes);
-
- // Subtract post-tax benefits deducted from paycheck
- (benefits || []).forEach((benefit) => {
- if ((benefit.deductionSource || 'paycheck') === 'paycheck' && benefit.isTaxable) { // Post-tax paycheck benefit
- if (benefit.isPercentage) {
- netPayBeforePostTax -= roundUpToCent((grossPay * benefit.amount) / 100);
- } else {
- netPayBeforePostTax -= roundUpToCent(benefit.amount);
- }
- }
- });
-
- // Subtract post-tax retirement contributions deducted from paycheck
- (retirement || []).forEach((election) => {
- if (election.enabled === false) return;
-
- if ((election.deductionSource || 'paycheck') === 'paycheck' && election.isPreTax === false) { // Post-tax paycheck retirement
- if (election.employeeContributionIsPercentage) {
- netPayBeforePostTax -= roundUpToCent((grossPay * election.employeeContribution) / 100);
- } else {
- netPayBeforePostTax -= roundUpToCent(election.employeeContribution);
- }
- }
- });
-
- // Note: Employer match is not deducted from net pay, it's added to the employee's retirement account
- // So we don't subtract it here - it's handled separately
- const netPay = roundUpToCent(Math.max(0, netPayBeforePostTax));
-
- return {
- grossPay,
- preTaxDeductions: totalPreTaxDeductions,
- taxableIncome,
- taxLineAmounts,
- additionalWithholding,
- totalTaxes,
- netPay,
- };
+ return calculateBudgetPaycheckBreakdown(budgetData);
}, [budgetData]);
/**
@@ -1062,9 +962,13 @@ export const BudgetProvider: React.FC = ({ children }) => {
}
} catch (error) {
console.error('Error selecting save location:', error);
- alert('Failed to select save location: ' + (error as Error).message);
+ openErrorDialog({
+ title: 'Select Save Location Failed',
+ message: 'Failed to select save location: ' + (error as Error).message,
+ actionLabel: 'Retry',
+ });
}
- }, [budgetData]);
+ }, [budgetData, openErrorDialog]);
// Bundle all our state and functions into a single object
// This is what gets provided to all child components
@@ -1109,5 +1013,16 @@ export const BudgetProvider: React.FC = ({ children }) => {
};
// Provide the value to all children components
- return {children} ;
+ return (
+
+ {children}
+
+
+ );
};
diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx
index 910657c..0bc0eed 100644
--- a/src/contexts/ThemeContext.tsx
+++ b/src/contexts/ThemeContext.tsx
@@ -1,5 +1,7 @@
// Theme Context - Manages light/dark mode preference
import React, { createContext, useContext, useState, useEffect } from 'react';
+import { APP_CUSTOM_EVENTS } from '../constants/events';
+import { STORAGE_KEYS } from '../constants/storage';
import type { ReactNode } from 'react';
type Theme = 'light' | 'dark';
@@ -12,8 +14,6 @@ interface ThemeContextType {
const ThemeContext = createContext(undefined);
-const THEME_STORAGE_KEY = 'paycheck-planner-theme';
-
// eslint-disable-next-line react-refresh/only-export-components
export const useTheme = () => {
const context = useContext(ThemeContext);
@@ -31,7 +31,7 @@ export const ThemeProvider: React.FC = ({ children }) => {
// Initialize theme from localStorage or system preference
const [theme, setTheme] = useState(() => {
// Check if user has selected system mode
- const settingsStr = localStorage.getItem('paycheck-planner-settings');
+ const settingsStr = localStorage.getItem(STORAGE_KEYS.settings);
if (settingsStr) {
try {
const settings = JSON.parse(settingsStr);
@@ -49,7 +49,7 @@ export const ThemeProvider: React.FC = ({ children }) => {
}
// Fallback: check stored theme
- const stored = localStorage.getItem(THEME_STORAGE_KEY);
+ const stored = localStorage.getItem(STORAGE_KEYS.theme);
if (stored === 'light' || stored === 'dark') {
return stored;
}
@@ -60,13 +60,13 @@ export const ThemeProvider: React.FC = ({ children }) => {
// Apply theme to document element
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
- localStorage.setItem(THEME_STORAGE_KEY, theme);
+ localStorage.setItem(STORAGE_KEYS.theme, theme);
}, [theme]);
// Listen for system theme changes when in System mode
useEffect(() => {
const getCurrentThemeMode = () => {
- const settingsStr = localStorage.getItem('paycheck-planner-settings');
+ const settingsStr = localStorage.getItem(STORAGE_KEYS.settings);
if (settingsStr) {
try {
const settings = JSON.parse(settingsStr);
@@ -110,11 +110,11 @@ export const ThemeProvider: React.FC = ({ children }) => {
setupSystemListener();
};
- window.addEventListener('theme-mode-changed', handleThemeModeChange);
+ window.addEventListener(APP_CUSTOM_EVENTS.themeModeChanged, handleThemeModeChange);
return () => {
if (cleanup) cleanup();
- window.removeEventListener('theme-mode-changed', handleThemeModeChange);
+ window.removeEventListener(APP_CUSTOM_EVENTS.themeModeChanged, handleThemeModeChange);
};
}, []);
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 2842dd1..5998a3f 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -1 +1,6 @@
export { useGlobalKeyboardShortcuts } from './useGlobalKeyboardShortcuts';
+export { useFileRelinkFlow } from './useFileRelinkFlow';
+export { useAppDialogs } from './useAppDialogs';
+export { useEncryptionSetupFlow } from './useEncryptionSetupFlow';
+export { useModalEntityEditor } from './useModalEntityEditor';
+export { useFieldErrors } from './useFieldErrors';
diff --git a/src/hooks/useAppDialogs.test.ts b/src/hooks/useAppDialogs.test.ts
new file mode 100644
index 0000000..914594d
--- /dev/null
+++ b/src/hooks/useAppDialogs.test.ts
@@ -0,0 +1,116 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const hookState: unknown[] = [];
+let hookCursor = 0;
+
+function resetHookCursor() {
+ hookCursor = 0;
+}
+
+function resetHookState() {
+ hookState.length = 0;
+ hookCursor = 0;
+}
+
+vi.mock('react', () => ({
+ useState: (initialValue: T) => {
+ const slot = hookCursor++;
+
+ if (!(slot in hookState)) {
+ hookState[slot] = initialValue;
+ }
+
+ const setState = (value: T | ((current: T) => T)) => {
+ const currentValue = hookState[slot] as T;
+ hookState[slot] = typeof value === 'function'
+ ? (value as (current: T) => T)(currentValue)
+ : value;
+ };
+
+ return [hookState[slot] as T, setState] as const;
+ },
+ useCallback: unknown>(callback: T) => callback,
+}));
+
+import { useAppDialogs } from './useAppDialogs';
+
+describe('useAppDialogs', () => {
+ const useTestHook = () => {
+ resetHookCursor();
+ return useAppDialogs();
+ };
+
+ beforeEach(() => {
+ resetHookState();
+ vi.clearAllMocks();
+ });
+
+ it('opens and closes an error dialog while calling the close callback', () => {
+ const onClose = vi.fn();
+
+ let hook = useTestHook();
+ hook.openErrorDialog({
+ title: 'Open Failed',
+ message: 'Something went wrong.',
+ onClose,
+ });
+ hook = useTestHook();
+
+ expect(hook.errorDialog).toMatchObject({
+ title: 'Open Failed',
+ message: 'Something went wrong.',
+ actionLabel: 'OK',
+ });
+
+ hook.closeErrorDialog();
+ hook = useTestHook();
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(hook.errorDialog).toBeNull();
+ });
+
+ it('opens a confirm dialog and runs the confirm callback when accepted', async () => {
+ const onConfirm = vi.fn();
+
+ let hook = useTestHook();
+ hook.openConfirmDialog({
+ title: 'Hide Tab',
+ message: 'Are you sure?',
+ onConfirm,
+ confirmVariant: 'danger',
+ });
+ hook = useTestHook();
+
+ expect(hook.confirmDialog).toMatchObject({
+ title: 'Hide Tab',
+ message: 'Are you sure?',
+ confirmLabel: 'Confirm',
+ cancelLabel: 'Cancel',
+ confirmVariant: 'danger',
+ });
+
+ await hook.confirmCurrentDialog();
+ hook = useTestHook();
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ expect(hook.confirmDialog).toBeNull();
+ });
+
+ it('closes a confirm dialog and runs the cancel callback when dismissed', () => {
+ const onCancel = vi.fn();
+
+ let hook = useTestHook();
+ hook.openConfirmDialog({
+ title: 'Hide Tab',
+ message: 'Are you sure?',
+ onCancel,
+ });
+ hook = useTestHook();
+
+ hook.closeConfirmDialog();
+ hook = useTestHook();
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ expect(hook.confirmDialog).toBeNull();
+ });
+});
\ No newline at end of file
diff --git a/src/hooks/useAppDialogs.ts b/src/hooks/useAppDialogs.ts
new file mode 100644
index 0000000..9ea53fd
--- /dev/null
+++ b/src/hooks/useAppDialogs.ts
@@ -0,0 +1,67 @@
+import type { ReactNode } from 'react';
+import { useCallback, useState } from 'react';
+
+export interface ConfirmDialogOptions {
+ title: string;
+ message: ReactNode;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ confirmVariant?: 'primary' | 'danger';
+ onConfirm?: () => void | Promise;
+ onCancel?: () => void;
+}
+
+export interface ErrorDialogOptions {
+ title: string;
+ message: ReactNode;
+ actionLabel?: string;
+ onClose?: () => void;
+}
+
+export function useAppDialogs() {
+ const [confirmDialog, setConfirmDialog] = useState(null);
+ const [errorDialog, setErrorDialog] = useState(null);
+
+ const openConfirmDialog = useCallback((options: ConfirmDialogOptions) => {
+ setConfirmDialog({
+ confirmLabel: 'Confirm',
+ cancelLabel: 'Cancel',
+ confirmVariant: 'primary',
+ ...options,
+ });
+ }, []);
+
+ const closeConfirmDialog = useCallback(() => {
+ confirmDialog?.onCancel?.();
+ setConfirmDialog(null);
+ }, [confirmDialog]);
+
+ const confirmCurrentDialog = useCallback(async () => {
+ if (!confirmDialog) return;
+
+ setConfirmDialog(null);
+ await confirmDialog.onConfirm?.();
+ }, [confirmDialog]);
+
+ const openErrorDialog = useCallback((options: ErrorDialogOptions) => {
+ setErrorDialog({
+ actionLabel: 'OK',
+ ...options,
+ });
+ }, []);
+
+ const closeErrorDialog = useCallback(() => {
+ errorDialog?.onClose?.();
+ setErrorDialog(null);
+ }, [errorDialog]);
+
+ return {
+ confirmDialog,
+ errorDialog,
+ openConfirmDialog,
+ closeConfirmDialog,
+ confirmCurrentDialog,
+ openErrorDialog,
+ closeErrorDialog,
+ };
+}
\ No newline at end of file
diff --git a/src/hooks/useEncryptionSetupFlow.test.ts b/src/hooks/useEncryptionSetupFlow.test.ts
new file mode 100644
index 0000000..4ede18e
--- /dev/null
+++ b/src/hooks/useEncryptionSetupFlow.test.ts
@@ -0,0 +1,163 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const hookState: unknown[] = [];
+let hookCursor = 0;
+
+function resetHookCursor() {
+ hookCursor = 0;
+}
+
+function resetHookState() {
+ hookState.length = 0;
+ hookCursor = 0;
+}
+
+vi.mock('react', () => ({
+ useState: (initialValue: T) => {
+ const slot = hookCursor++;
+
+ if (!(slot in hookState)) {
+ hookState[slot] = initialValue;
+ }
+
+ const setState = (value: T | ((current: T) => T)) => {
+ const currentValue = hookState[slot] as T;
+ hookState[slot] = typeof value === 'function'
+ ? (value as (current: T) => T)(currentValue)
+ : value;
+ };
+
+ return [hookState[slot] as T, setState] as const;
+ },
+}));
+
+vi.mock('../services/fileStorage', () => ({
+ FileStorageService: {
+ generateEncryptionKey: vi.fn(() => 'generated-key-123'),
+ getAppSettings: vi.fn(() => ({ themeMode: 'light' })),
+ saveAppSettings: vi.fn(),
+ },
+}));
+
+vi.mock('../services/keychainService', () => ({
+ KeychainService: {
+ saveKey: vi.fn(),
+ deleteKey: vi.fn(),
+ },
+}));
+
+import { FileStorageService } from '../services/fileStorage';
+import { KeychainService } from '../services/keychainService';
+import { useEncryptionSetupFlow } from './useEncryptionSetupFlow';
+
+describe('useEncryptionSetupFlow', () => {
+ const useTestHook = () => {
+ resetHookCursor();
+ return useEncryptionSetupFlow();
+ };
+
+ beforeEach(() => {
+ resetHookState();
+ vi.clearAllMocks();
+ vi.mocked(FileStorageService.generateEncryptionKey).mockReturnValue('generated-key-123');
+ vi.mocked(FileStorageService.getAppSettings).mockReturnValue({ themeMode: 'light' });
+ });
+
+ it('generates a new key and switches back to generated mode', () => {
+ let hook = useTestHook();
+
+ hook.setUseCustomKey(true);
+ hook = useTestHook();
+
+ hook.generateKey();
+ hook = useTestHook();
+
+ expect(FileStorageService.generateEncryptionKey).toHaveBeenCalledTimes(1);
+ expect(hook.generatedKey).toBe('generated-key-123');
+ expect(hook.useCustomKey).toBe(false);
+ });
+
+ it('returns a required-key dialog when encryption is enabled without a key', async () => {
+ let hook = useTestHook();
+ hook.setEncryptionEnabled(true);
+ hook = useTestHook();
+
+ const result = await hook.saveSelection({ planId: 'plan-1', persistAppSettings: true });
+ hook = useTestHook();
+
+ expect(result).toEqual({
+ success: false,
+ errorDialog: {
+ title: 'Encryption Key Required',
+ message: 'Please generate or enter an encryption key.',
+ },
+ });
+ expect(KeychainService.saveKey).not.toHaveBeenCalled();
+ expect(hook.isSaving).toBe(false);
+ });
+
+ it('saves an enabled selection to keychain and app settings', async () => {
+ let hook = useTestHook();
+ hook.setEncryptionEnabled(true);
+ hook = useTestHook();
+ hook.generateKey();
+ hook = useTestHook();
+
+ const result = await hook.saveSelection({
+ planId: 'plan-1',
+ persistAppSettings: true,
+ deleteStoredKeyWhenDisabled: true,
+ });
+ hook = useTestHook();
+
+ expect(result).toEqual({ success: true, encryptionEnabled: true });
+ expect(KeychainService.saveKey).toHaveBeenCalledWith('plan-1', 'generated-key-123');
+ expect(FileStorageService.saveAppSettings).toHaveBeenCalledWith({
+ themeMode: 'light',
+ encryptionEnabled: true,
+ });
+ expect(hook.isSaving).toBe(false);
+ });
+
+ it('saves a disabled selection and removes the stored key when requested', async () => {
+ let hook = useTestHook();
+ hook.setEncryptionEnabled(false);
+ hook = useTestHook();
+
+ const result = await hook.saveSelection({
+ planId: 'plan-1',
+ persistAppSettings: true,
+ deleteStoredKeyWhenDisabled: true,
+ });
+
+ expect(result).toEqual({ success: true, encryptionEnabled: false });
+ expect(KeychainService.deleteKey).toHaveBeenCalledWith('plan-1');
+ expect(FileStorageService.saveAppSettings).toHaveBeenCalledWith({
+ themeMode: 'light',
+ encryptionEnabled: false,
+ });
+ });
+
+ it('returns a standard save-failure dialog when keychain persistence throws', async () => {
+ vi.mocked(KeychainService.saveKey).mockRejectedValue(new Error('Keychain unavailable'));
+
+ let hook = useTestHook();
+ hook.setEncryptionEnabled(true);
+ hook = useTestHook();
+ hook.generateKey();
+ hook = useTestHook();
+
+ const result = await hook.saveSelection({ planId: 'plan-1', persistAppSettings: true });
+ hook = useTestHook();
+
+ expect(result).toEqual({
+ success: false,
+ errorDialog: {
+ title: 'Encryption Save Failed',
+ message: 'Failed to save encryption settings: Keychain unavailable',
+ actionLabel: 'Retry',
+ },
+ });
+ expect(hook.isSaving).toBe(false);
+ });
+});
\ No newline at end of file
diff --git a/src/hooks/useEncryptionSetupFlow.ts b/src/hooks/useEncryptionSetupFlow.ts
new file mode 100644
index 0000000..323ce7c
--- /dev/null
+++ b/src/hooks/useEncryptionSetupFlow.ts
@@ -0,0 +1,128 @@
+import { useState } from 'react';
+import { FileStorageService } from '../services/fileStorage';
+import { KeychainService } from '../services/keychainService';
+
+interface EncryptionDialogState {
+ title: string;
+ message: string;
+ actionLabel?: string;
+}
+
+interface SaveEncryptionSelectionOptions {
+ planId?: string;
+ persistAppSettings?: boolean;
+ deleteStoredKeyWhenDisabled?: boolean;
+}
+
+type SaveEncryptionSelectionResult =
+ | { success: true; encryptionEnabled: boolean }
+ | { success: false; errorDialog: EncryptionDialogState };
+
+const createMissingKeyError = (): SaveEncryptionSelectionResult => ({
+ success: false,
+ errorDialog: {
+ title: 'Encryption Key Required',
+ message: 'Please generate or enter an encryption key.',
+ },
+});
+
+const createSaveFailure = (message: string): SaveEncryptionSelectionResult => ({
+ success: false,
+ errorDialog: {
+ title: 'Encryption Save Failed',
+ message: `Failed to save encryption settings: ${message}`,
+ actionLabel: 'Retry',
+ },
+});
+
+export const useEncryptionSetupFlow = () => {
+ const [encryptionEnabled, setEncryptionEnabled] = useState(null);
+ const [customKey, setCustomKey] = useState('');
+ const [generatedKey, setGeneratedKey] = useState('');
+ const [useCustomKey, setUseCustomKey] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const generateKey = () => {
+ const key = FileStorageService.generateEncryptionKey();
+ setGeneratedKey(key);
+ setUseCustomKey(false);
+ };
+
+ const reset = () => {
+ setEncryptionEnabled(null);
+ setCustomKey('');
+ setGeneratedKey('');
+ setUseCustomKey(false);
+ setIsSaving(false);
+ };
+
+ const goBackToSelection = () => {
+ setEncryptionEnabled(null);
+ };
+
+ const canSaveSelection =
+ encryptionEnabled !== null && (encryptionEnabled === false || useCustomKey || Boolean(generatedKey));
+
+ const saveSelection = async (
+ options: SaveEncryptionSelectionOptions = {}
+ ): Promise => {
+ const { planId, persistAppSettings = false, deleteStoredKeyWhenDisabled = false } = options;
+
+ setIsSaving(true);
+ try {
+ if (encryptionEnabled) {
+ const keyToUse = (useCustomKey ? customKey : generatedKey).trim();
+ if (!keyToUse) {
+ setIsSaving(false);
+ return createMissingKeyError();
+ }
+
+ if (planId) {
+ await KeychainService.saveKey(planId, keyToUse);
+ }
+
+ if (persistAppSettings) {
+ const settings = FileStorageService.getAppSettings();
+ settings.encryptionEnabled = true;
+ FileStorageService.saveAppSettings(settings);
+ }
+
+ setIsSaving(false);
+ return { success: true, encryptionEnabled: true };
+ }
+
+ if (deleteStoredKeyWhenDisabled && planId) {
+ await KeychainService.deleteKey(planId);
+ }
+
+ if (persistAppSettings) {
+ const settings = FileStorageService.getAppSettings();
+ settings.encryptionEnabled = false;
+ FileStorageService.saveAppSettings(settings);
+ }
+
+ setIsSaving(false);
+ return { success: true, encryptionEnabled: false };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ setIsSaving(false);
+ return createSaveFailure(errorMessage);
+ }
+ };
+
+ return {
+ encryptionEnabled,
+ setEncryptionEnabled,
+ customKey,
+ setCustomKey,
+ generatedKey,
+ useCustomKey,
+ setUseCustomKey,
+ isSaving,
+ canSaveSelection,
+ generateKey,
+ reset,
+ goBackToSelection,
+ saveSelection,
+ };
+};
\ No newline at end of file
diff --git a/src/hooks/useFieldErrors.test.ts b/src/hooks/useFieldErrors.test.ts
new file mode 100644
index 0000000..b7fde7d
--- /dev/null
+++ b/src/hooks/useFieldErrors.test.ts
@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const hookState: unknown[] = [];
+let hookCursor = 0;
+
+function resetHookCursor() {
+ hookCursor = 0;
+}
+
+function resetHookState() {
+ hookState.length = 0;
+ hookCursor = 0;
+}
+
+vi.mock('react', () => ({
+ useState: (initialValue: T) => {
+ const slot = hookCursor++;
+
+ if (!(slot in hookState)) {
+ hookState[slot] = initialValue;
+ }
+
+ const setState = (value: T | ((current: T) => T)) => {
+ const currentValue = hookState[slot] as T;
+ hookState[slot] = typeof value === 'function'
+ ? (value as (current: T) => T)(currentValue)
+ : value;
+ };
+
+ return [hookState[slot] as T, setState] as const;
+ },
+ useCallback: unknown>(callback: T) => callback,
+}));
+
+import { useFieldErrors } from './useFieldErrors';
+
+type ExampleErrors = {
+ name?: string;
+ amount?: string;
+};
+
+describe('useFieldErrors', () => {
+ const useTestHook = () => {
+ resetHookCursor();
+ return useFieldErrors();
+ };
+
+ beforeEach(() => {
+ resetHookState();
+ vi.clearAllMocks();
+ });
+
+ it('stores validation errors', () => {
+ let hook = useTestHook();
+ hook.setErrors({ name: 'Required.', amount: 'Invalid.' });
+ hook = useTestHook();
+
+ expect(hook.errors).toEqual({ name: 'Required.', amount: 'Invalid.' });
+ });
+
+ it('clears an individual field error without touching other fields', () => {
+ let hook = useTestHook();
+ hook.setErrors({ name: 'Required.', amount: 'Invalid.' });
+ hook = useTestHook();
+
+ hook.clearFieldError('name');
+ hook = useTestHook();
+
+ expect(hook.errors).toEqual({ name: undefined, amount: 'Invalid.' });
+ });
+
+ it('clears all field errors', () => {
+ let hook = useTestHook();
+ hook.setErrors({ name: 'Required.' });
+ hook = useTestHook();
+
+ hook.clearErrors();
+ hook = useTestHook();
+
+ expect(hook.errors).toEqual({});
+ });
+});
\ No newline at end of file
diff --git a/src/hooks/useFieldErrors.ts b/src/hooks/useFieldErrors.ts
new file mode 100644
index 0000000..0b33f7d
--- /dev/null
+++ b/src/hooks/useFieldErrors.ts
@@ -0,0 +1,31 @@
+import { useCallback, useState } from 'react';
+
+type FieldErrorState = Record;
+
+export function useFieldErrors() {
+ const [errors, setErrors] = useState({} as T);
+
+ const clearErrors = useCallback(() => {
+ setErrors({} as T);
+ }, []);
+
+ const clearFieldError = useCallback((field: keyof T) => {
+ setErrors((currentErrors) => {
+ if (!currentErrors[field]) {
+ return currentErrors;
+ }
+
+ return {
+ ...currentErrors,
+ [field]: undefined,
+ };
+ });
+ }, []);
+
+ return {
+ errors,
+ setErrors,
+ clearErrors,
+ clearFieldError,
+ };
+}
\ No newline at end of file
diff --git a/src/hooks/useFileRelinkFlow.test.ts b/src/hooks/useFileRelinkFlow.test.ts
new file mode 100644
index 0000000..bedad6d
--- /dev/null
+++ b/src/hooks/useFileRelinkFlow.test.ts
@@ -0,0 +1,126 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const hookState: unknown[] = [];
+let hookCursor = 0;
+
+function resetHookCursor() {
+ hookCursor = 0;
+}
+
+function resetHookState() {
+ hookState.length = 0;
+ hookCursor = 0;
+}
+
+vi.mock('react', () => ({
+ useState: (initialValue: T) => {
+ const slot = hookCursor++;
+
+ if (!(slot in hookState)) {
+ hookState[slot] = initialValue;
+ }
+
+ const setState = (value: T | ((current: T) => T)) => {
+ const currentValue = hookState[slot] as T;
+ hookState[slot] = typeof value === 'function'
+ ? (value as (current: T) => T)(currentValue)
+ : value;
+ };
+
+ return [hookState[slot] as T, setState] as const;
+ },
+ useCallback: unknown>(callback: T) => callback,
+}));
+
+vi.mock('../services/fileStorage', () => ({
+ FileStorageService: {
+ relinkMovedBudgetFile: vi.fn(),
+ },
+}));
+
+import { FileStorageService } from '../services/fileStorage';
+import { useFileRelinkFlow } from './useFileRelinkFlow';
+
+describe('useFileRelinkFlow', () => {
+ const onRelinkSuccess = vi.fn();
+ const getExpectedPlanId = vi.fn(() => 'plan-1');
+
+ const useTestHook = () => {
+ resetHookCursor();
+ return useFileRelinkFlow({
+ getExpectedPlanId,
+ onRelinkSuccess,
+ fallbackErrorMessage: 'Unable to relink moved file.',
+ });
+ };
+
+ beforeEach(() => {
+ resetHookState();
+ vi.clearAllMocks();
+ });
+
+ it('stores the missing file prompt and clears any prior mismatch message', () => {
+ let hook = useTestHook();
+
+ hook.promptFileRelink('/tmp/missing-plan.budget');
+ hook = useTestHook();
+
+ expect(hook.missingFile).toEqual({
+ filePath: '/tmp/missing-plan.budget',
+ fileName: 'missing-plan.budget',
+ });
+ expect(hook.relinkMismatchMessage).toBeNull();
+ });
+
+ it('surfaces mismatch results without clearing the missing file prompt', async () => {
+ vi.mocked(FileStorageService.relinkMovedBudgetFile).mockResolvedValue({
+ status: 'mismatch',
+ message: 'That file belongs to a different plan.',
+ });
+
+ let hook = useTestHook();
+ hook.promptFileRelink('/tmp/missing-plan.budget');
+ hook = useTestHook();
+
+ await hook.locateRelinkedFile();
+ hook = useTestHook();
+
+ expect(FileStorageService.relinkMovedBudgetFile).toHaveBeenCalledWith('/tmp/missing-plan.budget', 'plan-1');
+ expect(hook.missingFile).toEqual({
+ filePath: '/tmp/missing-plan.budget',
+ fileName: 'missing-plan.budget',
+ });
+ expect(hook.relinkMismatchMessage).toBe('That file belongs to a different plan.');
+ expect(onRelinkSuccess).not.toHaveBeenCalled();
+ });
+
+ it('clears the prompt and calls the success handler when relinking succeeds', async () => {
+ vi.mocked(FileStorageService.relinkMovedBudgetFile).mockResolvedValue({
+ status: 'success',
+ filePath: '/tmp/moved-plan.budget',
+ planName: 'moved-plan',
+ });
+
+ let hook = useTestHook();
+ hook.promptFileRelink('/tmp/missing-plan.budget', 'Missing Plan');
+ hook = useTestHook();
+
+ await hook.locateRelinkedFile();
+ hook = useTestHook();
+
+ expect(onRelinkSuccess).toHaveBeenCalledWith(
+ {
+ status: 'success',
+ filePath: '/tmp/moved-plan.budget',
+ planName: 'moved-plan',
+ },
+ {
+ filePath: '/tmp/missing-plan.budget',
+ fileName: 'Missing Plan',
+ },
+ );
+ expect(hook.missingFile).toBeNull();
+ expect(hook.relinkMismatchMessage).toBeNull();
+ expect(hook.relinkLoading).toBe(false);
+ });
+});
\ No newline at end of file
diff --git a/src/hooks/useFileRelinkFlow.ts b/src/hooks/useFileRelinkFlow.ts
new file mode 100644
index 0000000..2bc7456
--- /dev/null
+++ b/src/hooks/useFileRelinkFlow.ts
@@ -0,0 +1,74 @@
+import { useCallback, useState } from 'react';
+import { FileStorageService, type RelinkMovedBudgetFileResult } from '../services/fileStorage';
+import { getBaseFileName } from '../utils/filePath';
+
+type RelinkSuccessResult = Extract;
+
+export interface MissingFileState {
+ filePath: string;
+ fileName: string;
+}
+
+interface UseFileRelinkFlowOptions {
+ getExpectedPlanId?: (missingFilePath: string) => string | undefined;
+ onRelinkSuccess: (result: RelinkSuccessResult, missingFile: MissingFileState) => Promise | void;
+ fallbackErrorMessage: string;
+}
+
+export function useFileRelinkFlow({
+ getExpectedPlanId,
+ onRelinkSuccess,
+ fallbackErrorMessage,
+}: UseFileRelinkFlowOptions) {
+ const [missingFile, setMissingFile] = useState(null);
+ const [relinkMismatchMessage, setRelinkMismatchMessage] = useState(null);
+ const [relinkLoading, setRelinkLoading] = useState(false);
+
+ const promptFileRelink = useCallback((filePath: string, fileName = getBaseFileName(filePath) || filePath) => {
+ setMissingFile({ filePath, fileName });
+ setRelinkMismatchMessage(null);
+ }, []);
+
+ const clearFileRelinkPrompt = useCallback(() => {
+ setMissingFile(null);
+ setRelinkMismatchMessage(null);
+ }, []);
+
+ const locateRelinkedFile = useCallback(async () => {
+ if (!missingFile || relinkLoading) return;
+
+ setRelinkLoading(true);
+ try {
+ const result = await FileStorageService.relinkMovedBudgetFile(
+ missingFile.filePath,
+ getExpectedPlanId?.(missingFile.filePath),
+ );
+
+ if (result.status === 'cancelled') {
+ return;
+ }
+
+ if (result.status === 'mismatch' || result.status === 'invalid') {
+ setRelinkMismatchMessage(result.message);
+ return;
+ }
+
+ clearFileRelinkPrompt();
+ await onRelinkSuccess(result, missingFile);
+ } catch (error) {
+ const message = (error as Error).message || fallbackErrorMessage;
+ setRelinkMismatchMessage(message);
+ } finally {
+ setRelinkLoading(false);
+ }
+ }, [clearFileRelinkPrompt, fallbackErrorMessage, getExpectedPlanId, missingFile, onRelinkSuccess, relinkLoading]);
+
+ return {
+ missingFile,
+ relinkMismatchMessage,
+ relinkLoading,
+ promptFileRelink,
+ clearFileRelinkPrompt,
+ locateRelinkedFile,
+ };
+}
\ No newline at end of file
diff --git a/src/hooks/useModalEntityEditor.test.ts b/src/hooks/useModalEntityEditor.test.ts
new file mode 100644
index 0000000..597c718
--- /dev/null
+++ b/src/hooks/useModalEntityEditor.test.ts
@@ -0,0 +1,85 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const hookState: unknown[] = [];
+let hookCursor = 0;
+
+function resetHookCursor() {
+ hookCursor = 0;
+}
+
+function resetHookState() {
+ hookState.length = 0;
+ hookCursor = 0;
+}
+
+vi.mock('react', () => ({
+ useState: (initialValue: T) => {
+ const slot = hookCursor++;
+
+ if (!(slot in hookState)) {
+ hookState[slot] = initialValue;
+ }
+
+ const setState = (value: T | ((current: T) => T)) => {
+ const currentValue = hookState[slot] as T;
+ hookState[slot] = typeof value === 'function'
+ ? (value as (current: T) => T)(currentValue)
+ : value;
+ };
+
+ return [hookState[slot] as T, setState] as const;
+ },
+ useCallback: unknown>(callback: T) => callback,
+}));
+
+import { useModalEntityEditor } from './useModalEntityEditor';
+
+describe('useModalEntityEditor', () => {
+ const useTestHook = () => {
+ resetHookCursor();
+ return useModalEntityEditor<{ id: string; name: string }>();
+ };
+
+ beforeEach(() => {
+ resetHookState();
+ vi.clearAllMocks();
+ });
+
+ it('opens a create flow with no editing entity selected', () => {
+ let hook = useTestHook();
+
+ hook.openForCreate();
+ hook = useTestHook();
+
+ expect(hook.isOpen).toBe(true);
+ expect(hook.isEditing).toBe(false);
+ expect(hook.editingEntity).toBeNull();
+ });
+
+ it('opens an edit flow with the selected entity', () => {
+ const entity = { id: 'loan-1', name: 'Car Loan' };
+
+ let hook = useTestHook();
+ hook.openForEdit(entity);
+ hook = useTestHook();
+
+ expect(hook.isOpen).toBe(true);
+ expect(hook.isEditing).toBe(true);
+ expect(hook.editingEntity).toEqual(entity);
+ });
+
+ it('closes the editor and clears the editing entity', () => {
+ const entity = { id: 'bill-1', name: 'Electric' };
+
+ let hook = useTestHook();
+ hook.openForEdit(entity);
+ hook = useTestHook();
+
+ hook.closeEditor();
+ hook = useTestHook();
+
+ expect(hook.isOpen).toBe(false);
+ expect(hook.isEditing).toBe(false);
+ expect(hook.editingEntity).toBeNull();
+ });
+});
\ No newline at end of file
diff --git a/src/hooks/useModalEntityEditor.ts b/src/hooks/useModalEntityEditor.ts
new file mode 100644
index 0000000..7cd80bc
--- /dev/null
+++ b/src/hooks/useModalEntityEditor.ts
@@ -0,0 +1,30 @@
+import { useCallback, useState } from 'react';
+
+export function useModalEntityEditor() {
+ const [editingEntity, setEditingEntity] = useState(null);
+ const [isOpen, setIsOpen] = useState(false);
+
+ const openForCreate = useCallback(() => {
+ setEditingEntity(null);
+ setIsOpen(true);
+ }, []);
+
+ const openForEdit = useCallback((entity: T) => {
+ setEditingEntity(entity);
+ setIsOpen(true);
+ }, []);
+
+ const closeEditor = useCallback(() => {
+ setIsOpen(false);
+ setEditingEntity(null);
+ }, []);
+
+ return {
+ isOpen,
+ editingEntity,
+ isEditing: editingEntity !== null,
+ openForCreate,
+ openForEdit,
+ closeEditor,
+ };
+}
\ No newline at end of file
diff --git a/src/services/accountsService.test.ts b/src/services/accountsService.test.ts
index 9a31f99..030f6b9 100644
--- a/src/services/accountsService.test.ts
+++ b/src/services/accountsService.test.ts
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { STORAGE_KEYS } from '../constants/storage';
import { AccountsService } from './accountsService';
class LocalStorageMock {
@@ -46,9 +47,12 @@ describe('AccountsService', () => {
});
it('falls back to defaults when stored JSON is invalid', () => {
- localStorage.setItem('paycheck-planner-accounts', 'not-json');
+ localStorage.setItem(STORAGE_KEYS.accounts, 'not-json');
+
const accounts = AccountsService.getAccounts();
+
expect(accounts).toHaveLength(3);
+ expect(() => JSON.parse(localStorage.getItem(STORAGE_KEYS.accounts) ?? '')).not.toThrow();
});
it('adds and persists an account', () => {
@@ -63,7 +67,7 @@ describe('AccountsService', () => {
const single = [
{ id: '1', name: 'Only', type: 'checking', color: '#000', icon: 'x' },
];
- localStorage.setItem('paycheck-planner-accounts', JSON.stringify(single));
+ localStorage.setItem(STORAGE_KEYS.accounts, JSON.stringify(single));
expect(AccountsService.deleteAccount('1')).toBe(false);
expect(AccountsService.getAccounts()).toHaveLength(1);
diff --git a/src/services/accountsService.ts b/src/services/accountsService.ts
index 90647f8..71c035c 100644
--- a/src/services/accountsService.ts
+++ b/src/services/accountsService.ts
@@ -1,11 +1,9 @@
// Service for managing app-wide accounts that can be reused across plans
// Accounts are stored in localStorage and persist across sessions
-import type { Account } from '../types/auth';
+import { STORAGE_KEYS } from '../constants/storage';
+import type { Account } from '../types/accounts';
import { getDefaultAccountColor, getDefaultAccountIcon } from '../utils/accountDefaults';
-// LocalStorage key for global accounts
-const ACCOUNTS_KEY = 'paycheck-planner-accounts';
-
// Helper function to generate color based on account type
export class AccountsService {
@@ -14,13 +12,14 @@ export class AccountsService {
* @returns Array of accounts or default accounts if none exist
*/
static getAccounts(): Account[] {
- const stored = localStorage.getItem(ACCOUNTS_KEY);
+ const stored = localStorage.getItem(STORAGE_KEYS.accounts);
if (stored) {
try {
return JSON.parse(stored);
- } catch (error) {
- console.error('Error parsing stored accounts:', error);
- return this.getDefaultAccounts();
+ } catch {
+ const defaultAccounts = this.getDefaultAccounts();
+ this.saveAccounts(defaultAccounts);
+ return defaultAccounts;
}
}
// Return default accounts if none exist
@@ -62,7 +61,7 @@ export class AccountsService {
* @param accounts - Array of accounts to save
*/
static saveAccounts(accounts: Account[]): void {
- localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
+ localStorage.setItem(STORAGE_KEYS.accounts, JSON.stringify(accounts));
}
/**
@@ -70,7 +69,7 @@ export class AccountsService {
* @returns true if accounts exist in localStorage
*/
static hasAccounts(): boolean {
- return localStorage.getItem(ACCOUNTS_KEY) !== null;
+ return localStorage.getItem(STORAGE_KEYS.accounts) !== null;
}
/**
diff --git a/src/services/budgetCalculations.test.ts b/src/services/budgetCalculations.test.ts
new file mode 100644
index 0000000..04e8969
--- /dev/null
+++ b/src/services/budgetCalculations.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from 'vitest';
+import {
+ calculateAnnualizedPayBreakdown,
+ calculateAnnualizedPaySummary,
+ calculateDisplayPayBreakdown,
+ calculatePaycheckBreakdown,
+ getEmptyPaycheckBreakdown,
+} from './budgetCalculations';
+
+describe('budgetCalculations', () => {
+ it('returns an empty breakdown when no data is provided', () => {
+ expect(calculatePaycheckBreakdown(null)).toEqual(getEmptyPaycheckBreakdown());
+ });
+
+ it('calculates paycheck breakdown using pre-tax and post-tax rules consistently', () => {
+ const breakdown = calculatePaycheckBreakdown({
+ paySettings: {
+ payType: 'salary',
+ annualSalary: 130000,
+ payFrequency: 'bi-weekly',
+ minLeftover: 100,
+ },
+ preTaxDeductions: [
+ {
+ id: 'deduction-1',
+ name: 'HSA',
+ amount: 100,
+ isPercentage: false,
+ },
+ ],
+ benefits: [
+ {
+ id: 'benefit-1',
+ name: 'Health Insurance',
+ amount: 75,
+ isTaxable: false,
+ isPercentage: false,
+ deductionSource: 'paycheck',
+ },
+ ],
+ retirement: [
+ {
+ id: 'ret-1',
+ type: 'roth-ira',
+ employeeContribution: 5,
+ employeeContributionIsPercentage: true,
+ hasEmployerMatch: false,
+ employerMatchCap: 0,
+ employerMatchCapIsPercentage: false,
+ isPreTax: false,
+ deductionSource: 'paycheck',
+ },
+ ],
+ taxSettings: {
+ taxLines: [
+ { id: 'tax-1', label: 'Federal Tax', rate: 10 },
+ { id: 'tax-2', label: 'State Tax', rate: 5 },
+ ],
+ additionalWithholding: 25,
+ },
+ });
+
+ expect(breakdown.grossPay).toBe(5000);
+ expect(breakdown.preTaxDeductions).toBe(175);
+ expect(breakdown.taxableIncome).toBe(4825);
+ expect(breakdown.taxLineAmounts).toEqual([
+ { id: 'tax-1', label: 'Federal Tax', amount: 482.5 },
+ { id: 'tax-2', label: 'State Tax', amount: 241.25 },
+ ]);
+ expect(breakdown.additionalWithholding).toBe(25);
+ expect(breakdown.totalTaxes).toBe(748.75);
+ expect(breakdown.netPay).toBe(3826.25);
+ });
+
+ it('annualizes gross, net, and tax values from a paycheck breakdown', () => {
+ const summary = calculateAnnualizedPaySummary(
+ {
+ grossPay: 5000,
+ preTaxDeductions: 175,
+ taxableIncome: 4825,
+ taxLineAmounts: [],
+ additionalWithholding: 25,
+ totalTaxes: 748.75,
+ netPay: 3826.25,
+ },
+ 26,
+ );
+
+ expect(summary).toEqual({
+ annualGross: 130000,
+ annualNet: 99482.5,
+ annualTaxes: 19467.5,
+ monthlyGross: 10833.34,
+ monthlyNet: 8290.21,
+ monthlyTaxes: 1622.3,
+ });
+ });
+
+ it('builds annual and display pay breakdowns that stay aligned with the paycheck breakdown', () => {
+ const paycheckBreakdown = {
+ grossPay: 5000,
+ preTaxDeductions: 175,
+ taxableIncome: 4825,
+ taxLineAmounts: [
+ { id: 'tax-1', label: 'Federal Tax', amount: 482.5 },
+ { id: 'tax-2', label: 'State Tax', amount: 241.25 },
+ ],
+ additionalWithholding: 25,
+ totalTaxes: 748.75,
+ netPay: 3826.25,
+ };
+
+ const annualBreakdown = calculateAnnualizedPayBreakdown(paycheckBreakdown, 26);
+ const monthlyBreakdown = calculateDisplayPayBreakdown(annualBreakdown, 'monthly', 26);
+
+ expect(annualBreakdown).toEqual({
+ grossPay: 130000,
+ preTaxDeductions: 4550,
+ taxableIncome: 125450,
+ taxLineAmounts: [
+ { id: 'tax-1', label: 'Federal Tax', amount: 12545 },
+ { id: 'tax-2', label: 'State Tax', amount: 6272.5 },
+ ],
+ additionalWithholding: 650,
+ totalTaxes: 19467.5,
+ postTaxDeductions: 6500,
+ netPay: 99482.5,
+ });
+
+ expect(monthlyBreakdown).toEqual({
+ grossPay: 10833.34,
+ preTaxDeductions: 379.17,
+ taxableIncome: 10454.17,
+ taxLineAmounts: [
+ { id: 'tax-1', label: 'Federal Tax', amount: 1045.42 },
+ { id: 'tax-2', label: 'State Tax', amount: 522.71 },
+ ],
+ additionalWithholding: 54.17,
+ totalTaxes: 1622.3,
+ postTaxDeductions: 541.67,
+ netPay: 8290.21,
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/services/budgetCalculations.ts b/src/services/budgetCalculations.ts
new file mode 100644
index 0000000..62900cc
--- /dev/null
+++ b/src/services/budgetCalculations.ts
@@ -0,0 +1,208 @@
+import type { BudgetData } from '../types/budget';
+import type { PaycheckBreakdown, TaxLineAmount } from '../types/payroll';
+import type { ViewMode } from '../types/viewMode';
+import { roundUpToCent } from '../utils/money';
+import { getPaychecksPerYear } from '../utils/payPeriod';
+
+type BudgetCalculationInput = Pick<
+ BudgetData,
+ 'paySettings' | 'preTaxDeductions' | 'benefits' | 'retirement' | 'taxSettings'
+>;
+
+export interface AnnualizedPaySummary {
+ annualGross: number;
+ annualNet: number;
+ annualTaxes: number;
+ monthlyGross: number;
+ monthlyNet: number;
+ monthlyTaxes: number;
+}
+
+export interface PayBreakdownSummary extends PaycheckBreakdown {
+ postTaxDeductions: number;
+}
+
+export function getEmptyPaycheckBreakdown(): PaycheckBreakdown {
+ return {
+ grossPay: 0,
+ preTaxDeductions: 0,
+ taxableIncome: 0,
+ taxLineAmounts: [],
+ additionalWithholding: 0,
+ totalTaxes: 0,
+ netPay: 0,
+ };
+}
+
+function calculateFixedOrPercentageAmount(baseAmount: number, amount: number, isPercentage?: boolean): number {
+ return isPercentage ? (baseAmount * amount) / 100 : amount;
+}
+
+function calculateGrossPayPerPaycheck(input: BudgetCalculationInput): number {
+ const { paySettings } = input;
+
+ if (paySettings.payType === 'salary' && paySettings.annualSalary) {
+ const paychecksPerYear = getPaychecksPerYear(paySettings.payFrequency);
+ return roundUpToCent(paySettings.annualSalary / paychecksPerYear);
+ }
+
+ if (paySettings.payType === 'hourly' && paySettings.hourlyRate && paySettings.hoursPerPayPeriod) {
+ return roundUpToCent(paySettings.hourlyRate * paySettings.hoursPerPayPeriod);
+ }
+
+ return 0;
+}
+
+export function calculatePaycheckBreakdown(input?: BudgetCalculationInput | null): PaycheckBreakdown {
+ if (!input) {
+ return getEmptyPaycheckBreakdown();
+ }
+
+ const grossPay = calculateGrossPayPerPaycheck(input);
+ const benefits = input.benefits || [];
+ const retirement = input.retirement || [];
+
+ let totalPreTaxDeductions = input.preTaxDeductions.reduce((sum, deduction) => {
+ return sum + calculateFixedOrPercentageAmount(grossPay, deduction.amount, deduction.isPercentage);
+ }, 0);
+
+ benefits.forEach((benefit) => {
+ if ((benefit.deductionSource || 'paycheck') !== 'paycheck' || benefit.isTaxable) {
+ return;
+ }
+
+ totalPreTaxDeductions += calculateFixedOrPercentageAmount(grossPay, benefit.amount, benefit.isPercentage);
+ });
+
+ retirement.forEach((election) => {
+ if (election.enabled === false) return;
+ if ((election.deductionSource || 'paycheck') !== 'paycheck') return;
+ if (election.isPreTax === false) return;
+
+ totalPreTaxDeductions += calculateFixedOrPercentageAmount(
+ grossPay,
+ election.employeeContribution,
+ election.employeeContributionIsPercentage,
+ );
+ });
+
+ const preTaxDeductions = roundUpToCent(totalPreTaxDeductions);
+ const taxableIncome = roundUpToCent(grossPay - preTaxDeductions);
+
+ const taxLineAmounts: TaxLineAmount[] = (input.taxSettings.taxLines || []).map((line) => ({
+ id: line.id,
+ label: line.label,
+ amount: roundUpToCent((taxableIncome * line.rate) / 100),
+ }));
+
+ const additionalWithholding = roundUpToCent(input.taxSettings.additionalWithholding || 0);
+ const totalTaxes = roundUpToCent(
+ taxLineAmounts.reduce((sum, line) => sum + line.amount, 0) + additionalWithholding,
+ );
+
+ let netPayBeforePostTax = roundUpToCent(taxableIncome - totalTaxes);
+
+ benefits.forEach((benefit) => {
+ if ((benefit.deductionSource || 'paycheck') !== 'paycheck' || !benefit.isTaxable) {
+ return;
+ }
+
+ netPayBeforePostTax -= roundUpToCent(
+ calculateFixedOrPercentageAmount(grossPay, benefit.amount, benefit.isPercentage),
+ );
+ });
+
+ retirement.forEach((election) => {
+ if (election.enabled === false) return;
+ if ((election.deductionSource || 'paycheck') !== 'paycheck') return;
+ if (election.isPreTax !== false) return;
+
+ netPayBeforePostTax -= roundUpToCent(
+ calculateFixedOrPercentageAmount(
+ grossPay,
+ election.employeeContribution,
+ election.employeeContributionIsPercentage,
+ ),
+ );
+ });
+
+ return {
+ grossPay,
+ preTaxDeductions,
+ taxableIncome,
+ taxLineAmounts,
+ additionalWithholding,
+ totalTaxes,
+ netPay: roundUpToCent(Math.max(0, netPayBeforePostTax)),
+ };
+}
+
+export function calculateAnnualizedPaySummary(
+ breakdown: PaycheckBreakdown,
+ paychecksPerYear: number,
+): AnnualizedPaySummary {
+ const annualGross = roundUpToCent(breakdown.grossPay * paychecksPerYear);
+ const annualNet = roundUpToCent(breakdown.netPay * paychecksPerYear);
+ const annualTaxes = roundUpToCent(breakdown.totalTaxes * paychecksPerYear);
+
+ return {
+ annualGross,
+ annualNet,
+ annualTaxes,
+ monthlyGross: roundUpToCent(annualGross / 12),
+ monthlyNet: roundUpToCent(annualNet / 12),
+ monthlyTaxes: roundUpToCent(annualTaxes / 12),
+ };
+}
+
+export function calculateAnnualizedPayBreakdown(
+ breakdown: PaycheckBreakdown,
+ paychecksPerYear: number,
+): PayBreakdownSummary {
+ const annualGross = roundUpToCent(breakdown.grossPay * paychecksPerYear);
+ const annualPreTaxDeductions = roundUpToCent(breakdown.preTaxDeductions * paychecksPerYear);
+ const annualTaxableIncome = roundUpToCent(breakdown.taxableIncome * paychecksPerYear);
+ const annualTaxLineAmounts = breakdown.taxLineAmounts.map((line) => ({
+ ...line,
+ amount: roundUpToCent(line.amount * paychecksPerYear),
+ }));
+ const annualAdditionalWithholding = roundUpToCent(breakdown.additionalWithholding * paychecksPerYear);
+ const annualTotalTaxes = roundUpToCent(breakdown.totalTaxes * paychecksPerYear);
+ const annualNetPay = roundUpToCent(breakdown.netPay * paychecksPerYear);
+ const annualPostTaxDeductions = roundUpToCent(
+ Math.max(0, annualGross - annualPreTaxDeductions - annualTotalTaxes - annualNetPay),
+ );
+
+ return {
+ grossPay: annualGross,
+ preTaxDeductions: annualPreTaxDeductions,
+ taxableIncome: annualTaxableIncome,
+ taxLineAmounts: annualTaxLineAmounts,
+ additionalWithholding: annualAdditionalWithholding,
+ totalTaxes: annualTotalTaxes,
+ postTaxDeductions: annualPostTaxDeductions,
+ netPay: annualNetPay,
+ };
+}
+
+export function calculateDisplayPayBreakdown(
+ annualBreakdown: PayBreakdownSummary,
+ mode: ViewMode,
+ paychecksPerYear: number,
+): PayBreakdownSummary {
+ const divisor = mode === 'paycheck' ? paychecksPerYear : mode === 'monthly' ? 12 : 1;
+
+ return {
+ grossPay: roundUpToCent(annualBreakdown.grossPay / divisor),
+ preTaxDeductions: roundUpToCent(annualBreakdown.preTaxDeductions / divisor),
+ taxableIncome: roundUpToCent(annualBreakdown.taxableIncome / divisor),
+ taxLineAmounts: annualBreakdown.taxLineAmounts.map((line) => ({
+ ...line,
+ amount: roundUpToCent(line.amount / divisor),
+ })),
+ additionalWithholding: roundUpToCent(annualBreakdown.additionalWithholding / divisor),
+ totalTaxes: roundUpToCent(annualBreakdown.totalTaxes / divisor),
+ postTaxDeductions: roundUpToCent(annualBreakdown.postTaxDeductions / divisor),
+ netPay: roundUpToCent(annualBreakdown.netPay / divisor),
+ };
+}
\ No newline at end of file
diff --git a/src/services/budgetCurrencyConversion.test.ts b/src/services/budgetCurrencyConversion.test.ts
new file mode 100644
index 0000000..b23d90e
--- /dev/null
+++ b/src/services/budgetCurrencyConversion.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from 'vitest';
+
+import type { BudgetData } from '../types/budget';
+import { convertBudgetAmounts, roundCurrency } from './budgetCurrencyConversion';
+
+const sampleBudget: BudgetData = {
+ id: 'budget-1',
+ name: 'Test Budget',
+ year: 2026,
+ paySettings: {
+ payType: 'salary',
+ annualSalary: 50000,
+ hourlyRate: 25.555,
+ payFrequency: 'bi-weekly',
+ minLeftover: 123.456,
+ },
+ preTaxDeductions: [
+ { id: 'deduction-fixed', name: 'Transit', amount: 100, isPercentage: false },
+ { id: 'deduction-percent', name: 'Percent', amount: 5, isPercentage: true },
+ ],
+ benefits: [
+ { id: 'benefit-fixed', name: 'Insurance', amount: 50, isTaxable: false, isPercentage: false },
+ { id: 'benefit-percent', name: 'Benefit Percent', amount: 2, isTaxable: true, isPercentage: true },
+ ],
+ retirement: [
+ {
+ id: 'retirement-fixed',
+ type: '401k',
+ employeeContribution: 200,
+ employeeContributionIsPercentage: false,
+ hasEmployerMatch: true,
+ employerMatchCap: 100,
+ employerMatchCapIsPercentage: false,
+ yearlyLimit: 23000,
+ },
+ {
+ id: 'retirement-percent',
+ type: '403b',
+ employeeContribution: 6,
+ employeeContributionIsPercentage: true,
+ hasEmployerMatch: true,
+ employerMatchCap: 4,
+ employerMatchCapIsPercentage: true,
+ yearlyLimit: undefined,
+ },
+ ],
+ taxSettings: {
+ taxLines: [{ id: 'tax-1', label: 'Federal', rate: 0.22 }],
+ additionalWithholding: 10.125,
+ },
+ accounts: [
+ {
+ id: 'account-1',
+ name: 'Checking',
+ type: 'checking',
+ color: '#000000',
+ allocation: 400,
+ allocationCategories: [{ id: 'cat-1', name: 'Bills', amount: 150 }],
+ },
+ ],
+ bills: [
+ { id: 'bill-1', name: 'Rent', amount: 1200, frequency: 'monthly', accountId: 'account-1' },
+ ],
+ loans: [
+ {
+ id: 'loan-1',
+ name: 'Car Loan',
+ type: 'auto',
+ principal: 10000,
+ currentBalance: 8750.4,
+ interestRate: 4.9,
+ propertyTaxRate: 1.1,
+ propertyValue: 220000,
+ monthlyPayment: 310.33,
+ accountId: 'account-1',
+ startDate: '2026-01-01T00:00:00.000Z',
+ insurancePayment: 45.22,
+ insuranceEndBalance: 900,
+ insuranceEndBalancePercent: 78,
+ paymentBreakdown: [
+ { id: 'line-1', label: 'Principal & Interest', amount: 250.25, frequency: 'monthly' },
+ ],
+ },
+ ],
+ savingsContributions: [
+ { id: 'savings-1', name: 'Emergency Fund', amount: 75, frequency: 'monthly', accountId: 'account-1', type: 'savings' },
+ ],
+ settings: {
+ currency: 'USD',
+ locale: 'en-US',
+ },
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+};
+
+describe('budgetCurrencyConversion', () => {
+ it('rounds currency values to two decimal places', () => {
+ expect(roundCurrency(12.345)).toBe(12.35);
+ expect(roundCurrency(12.344)).toBe(12.34);
+ });
+
+ it('converts major budget amount fields while preserving percentage-based values', () => {
+ const converted = convertBudgetAmounts(sampleBudget, 1.5);
+
+ expect(converted.paySettings.annualSalary).toBe(75000);
+ expect(converted.paySettings.hourlyRate).toBe(38.33);
+ expect(converted.paySettings.minLeftover).toBe(185.18);
+
+ expect(converted.preTaxDeductions[0].amount).toBe(150);
+ expect(converted.preTaxDeductions[1].amount).toBe(5);
+
+ expect(converted.benefits[0].amount).toBe(75);
+ expect(converted.benefits[1].amount).toBe(2);
+
+ expect(converted.retirement[0].employeeContribution).toBe(300);
+ expect(converted.retirement[0].employerMatchCap).toBe(150);
+ expect(converted.retirement[0].yearlyLimit).toBe(34500);
+ expect(converted.retirement[1].employeeContribution).toBe(6);
+ expect(converted.retirement[1].employerMatchCap).toBe(4);
+
+ expect(converted.taxSettings.additionalWithholding).toBe(15.19);
+ expect(converted.accounts[0].allocation).toBe(600);
+ expect(converted.accounts[0].allocationCategories?.[0].amount).toBe(225);
+ expect(converted.bills[0].amount).toBe(1800);
+ expect(converted.savingsContributions?.[0].amount).toBe(112.5);
+ });
+
+ it('converts loan money fields without touching rate and percentage fields', () => {
+ const converted = convertBudgetAmounts(sampleBudget, 0.8);
+ const loan = converted.loans[0];
+
+ expect(loan.principal).toBe(8000);
+ expect(loan.currentBalance).toBe(7000.32);
+ expect(loan.monthlyPayment).toBe(248.26);
+ expect(loan.propertyValue).toBe(176000);
+ expect(loan.insurancePayment).toBe(36.18);
+ expect(loan.insuranceEndBalance).toBe(720);
+ expect(loan.paymentBreakdown?.[0].amount).toBe(200.2);
+
+ expect(loan.interestRate).toBe(4.9);
+ expect(loan.propertyTaxRate).toBe(1.1);
+ expect(loan.insuranceEndBalancePercent).toBe(78);
+ });
+});
\ No newline at end of file
diff --git a/src/services/budgetCurrencyConversion.ts b/src/services/budgetCurrencyConversion.ts
new file mode 100644
index 0000000..5f25dde
--- /dev/null
+++ b/src/services/budgetCurrencyConversion.ts
@@ -0,0 +1,76 @@
+import type { BudgetData } from '../types/budget';
+
+export function roundCurrency(value: number): number {
+ return Math.round((value + Number.EPSILON) * 100) / 100;
+}
+
+function convertCurrencyValue(value: number | undefined, exchangeRate: number) {
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ return value;
+ }
+
+ return roundCurrency(value * exchangeRate);
+}
+
+export function convertBudgetAmounts(data: BudgetData, exchangeRate: number): BudgetData {
+ return {
+ ...data,
+ paySettings: {
+ ...data.paySettings,
+ annualSalary: convertCurrencyValue(data.paySettings.annualSalary, exchangeRate),
+ hourlyRate: convertCurrencyValue(data.paySettings.hourlyRate, exchangeRate),
+ minLeftover: convertCurrencyValue(data.paySettings.minLeftover, exchangeRate),
+ },
+ preTaxDeductions: data.preTaxDeductions.map((deduction) => ({
+ ...deduction,
+ amount: deduction.isPercentage ? deduction.amount : convertCurrencyValue(deduction.amount, exchangeRate) || 0,
+ })),
+ benefits: data.benefits.map((benefit) => ({
+ ...benefit,
+ amount: benefit.isPercentage ? benefit.amount : convertCurrencyValue(benefit.amount, exchangeRate) || 0,
+ })),
+ retirement: data.retirement.map((election) => ({
+ ...election,
+ employeeContribution: election.employeeContributionIsPercentage
+ ? election.employeeContribution
+ : convertCurrencyValue(election.employeeContribution, exchangeRate) || 0,
+ employerMatchCap: election.employerMatchCapIsPercentage
+ ? election.employerMatchCap
+ : convertCurrencyValue(election.employerMatchCap, exchangeRate) || 0,
+ yearlyLimit: convertCurrencyValue(election.yearlyLimit, exchangeRate),
+ })),
+ taxSettings: {
+ ...data.taxSettings,
+ additionalWithholding: convertCurrencyValue(data.taxSettings.additionalWithholding, exchangeRate) || 0,
+ },
+ accounts: data.accounts.map((account) => ({
+ ...account,
+ allocation: convertCurrencyValue(account.allocation, exchangeRate),
+ allocationCategories: (account.allocationCategories || []).map((category) => ({
+ ...category,
+ amount: convertCurrencyValue(category.amount, exchangeRate) || 0,
+ })),
+ })),
+ bills: data.bills.map((bill) => ({
+ ...bill,
+ amount: convertCurrencyValue(bill.amount, exchangeRate) || 0,
+ })),
+ loans: data.loans.map((loan) => ({
+ ...loan,
+ principal: convertCurrencyValue(loan.principal, exchangeRate) || 0,
+ currentBalance: convertCurrencyValue(loan.currentBalance, exchangeRate) || 0,
+ monthlyPayment: convertCurrencyValue(loan.monthlyPayment, exchangeRate) || 0,
+ propertyValue: convertCurrencyValue(loan.propertyValue, exchangeRate),
+ insurancePayment: convertCurrencyValue(loan.insurancePayment, exchangeRate),
+ insuranceEndBalance: convertCurrencyValue(loan.insuranceEndBalance, exchangeRate),
+ paymentBreakdown: loan.paymentBreakdown?.map((line) => ({
+ ...line,
+ amount: convertCurrencyValue(line.amount, exchangeRate) || 0,
+ })),
+ })),
+ savingsContributions: (data.savingsContributions || []).map((contribution) => ({
+ ...contribution,
+ amount: convertCurrencyValue(contribution.amount, exchangeRate) || 0,
+ })),
+ };
+}
\ No newline at end of file
diff --git a/src/services/fileStorage.test.ts b/src/services/fileStorage.test.ts
index aca718f..d3496b7 100644
--- a/src/services/fileStorage.test.ts
+++ b/src/services/fileStorage.test.ts
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { APP_STORAGE_KEYS, STORAGE_KEYS } from '../constants/storage';
import { FileStorageService } from './fileStorage';
class LocalStorageMock {
@@ -71,7 +72,7 @@ describe('FileStorageService', () => {
lastOpenedFile: '/tmp/file',
});
- const raw = localStorage.getItem('paycheck-planner-settings');
+ const raw = localStorage.getItem(STORAGE_KEYS.settings);
expect(raw).toBeTruthy();
expect(raw).not.toContain('super-secret');
@@ -115,7 +116,7 @@ describe('FileStorageService', () => {
});
it('returns known plan IDs from path mappings', () => {
- localStorage.setItem('paycheck-planner-file-to-plan-mapping', JSON.stringify({
+ localStorage.setItem(STORAGE_KEYS.fileToPlanMapping, JSON.stringify({
'/plans/a.ppb': 'plan-a',
'/plans/b.ppb': 'plan-a',
'/plans/c.ppb': 'plan-c',
@@ -125,32 +126,30 @@ describe('FileStorageService', () => {
});
it('clears all app memory keys while preserving unrelated keys', () => {
- localStorage.setItem('paycheck-planner-settings', '{}');
- localStorage.setItem('paycheck-planner-recent-files', '[]');
- localStorage.setItem('paycheck-planner-file-to-plan-mapping', '{}');
- localStorage.setItem('paycheck-planner-theme', 'dark');
- localStorage.setItem('paycheck-planner-accounts', '[]');
+ localStorage.setItem(STORAGE_KEYS.settings, '{}');
+ localStorage.setItem(STORAGE_KEYS.recentFiles, '[]');
+ localStorage.setItem(STORAGE_KEYS.fileToPlanMapping, '{}');
+ localStorage.setItem(STORAGE_KEYS.theme, 'dark');
+ localStorage.setItem(STORAGE_KEYS.accounts, '[]');
localStorage.setItem('paycheck-planner-temp-experimental', '1');
localStorage.setItem('external-key', 'keep-me');
FileStorageService.clearAppMemory();
- expect(localStorage.getItem('paycheck-planner-settings')).toBeNull();
- expect(localStorage.getItem('paycheck-planner-recent-files')).toBeNull();
- expect(localStorage.getItem('paycheck-planner-file-to-plan-mapping')).toBeNull();
- expect(localStorage.getItem('paycheck-planner-theme')).toBeNull();
- expect(localStorage.getItem('paycheck-planner-accounts')).toBeNull();
+ APP_STORAGE_KEYS.forEach((key) => {
+ expect(localStorage.getItem(key)).toBeNull();
+ });
expect(localStorage.getItem('paycheck-planner-temp-experimental')).toBeNull();
expect(localStorage.getItem('external-key')).toBe('keep-me');
});
it('exports app data as a valid JSON envelope containing only global preference keys', () => {
localStorage.setItem(
- 'paycheck-planner-settings',
+ STORAGE_KEYS.settings,
'{"themeMode":"dark","encryptionEnabled":true,"encryptionKey":"secret","glossaryTermsEnabled":true}',
);
- localStorage.setItem('paycheck-planner-theme', 'dark');
- localStorage.setItem('paycheck-planner-accounts', '[{"id":"1","name":"Checking"}]');
+ localStorage.setItem(STORAGE_KEYS.theme, 'dark');
+ localStorage.setItem(STORAGE_KEYS.accounts, '[{"id":"1","name":"Checking"}]');
localStorage.setItem('external-key', 'ignore-me');
const json = FileStorageService.exportAppData();
@@ -161,17 +160,17 @@ describe('FileStorageService', () => {
expect(typeof envelope.exportedAt).toBe('string');
// Global preferences must be present
- expect(envelope.data['paycheck-planner-theme']).toBe('dark');
+ expect(envelope.data[STORAGE_KEYS.theme]).toBe('dark');
// Settings blob must exist but plan-specific fields must be stripped
- const settings = JSON.parse(envelope.data['paycheck-planner-settings']);
+ const settings = JSON.parse(envelope.data[STORAGE_KEYS.settings]);
expect(settings.themeMode).toBe('dark');
expect(settings.glossaryTermsEnabled).toBe(true);
expect(settings.encryptionEnabled).toBeUndefined();
expect(settings.encryptionKey).toBeUndefined();
// Plan-specific and foreign keys must be absent
- expect(envelope.data['paycheck-planner-accounts']).toBeUndefined();
+ expect(envelope.data[STORAGE_KEYS.accounts]).toBeUndefined();
expect(envelope.data['external-key']).toBeUndefined();
});
@@ -181,9 +180,9 @@ describe('FileStorageService', () => {
appName: 'paycheck-planner',
exportedAt: new Date().toISOString(),
data: {
- 'paycheck-planner-theme': 'dark',
- 'paycheck-planner-settings': '{"themeMode":"dark","encryptionEnabled":true,"encryptionKey":"secret"}',
- 'paycheck-planner-accounts': '[{"id":"1"}]',
+ [STORAGE_KEYS.theme]: 'dark',
+ [STORAGE_KEYS.settings]: '{"themeMode":"dark","encryptionEnabled":true,"encryptionKey":"secret"}',
+ [STORAGE_KEYS.accounts]: '[{"id":"1"}]',
'foreign-key': 'should-be-ignored',
},
});
@@ -191,16 +190,16 @@ describe('FileStorageService', () => {
FileStorageService.importAppData(envelope);
// Global preferences restored
- expect(localStorage.getItem('paycheck-planner-theme')).toBe('dark');
+ expect(localStorage.getItem(STORAGE_KEYS.theme)).toBe('dark');
// Settings blob restored, but plan-specific fields stripped
- const settings = JSON.parse(localStorage.getItem('paycheck-planner-settings')!);
+ const settings = JSON.parse(localStorage.getItem(STORAGE_KEYS.settings)!);
expect(settings.themeMode).toBe('dark');
expect(settings.encryptionEnabled).toBeUndefined();
expect(settings.encryptionKey).toBeUndefined();
// Plan-specific and foreign keys must not be written
- expect(localStorage.getItem('paycheck-planner-accounts')).toBeNull();
+ expect(localStorage.getItem(STORAGE_KEYS.accounts)).toBeNull();
expect(localStorage.getItem('foreign-key')).toBeNull();
});
@@ -217,7 +216,7 @@ describe('FileStorageService', () => {
version: 1,
exportedAt: new Date().toISOString(),
data: {
- 'paycheck-planner-settings': '{"themeMode":"dark"}',
+ [STORAGE_KEYS.settings]: '{"themeMode":"dark"}',
},
});
@@ -236,7 +235,7 @@ describe('FileStorageService', () => {
version: 1,
exportedAt: new Date().toISOString(),
data: {
- 'paycheck-planner-theme': 'dark',
+ [STORAGE_KEYS.theme]: 'dark',
},
});
@@ -254,7 +253,7 @@ describe('FileStorageService', () => {
version: 1,
exportedAt: new Date().toISOString(),
data: {
- 'paycheck-planner-theme': 'dark',
+ [STORAGE_KEYS.theme]: 'dark',
},
});
@@ -270,4 +269,85 @@ describe('FileStorageService', () => {
expect(result.message).toContain('settings export');
}
});
+
+ it('returns cancelled when relink picker is dismissed', async () => {
+ Object.assign(window.electronAPI, {
+ openFileDialog: vi.fn(async () => null),
+ });
+
+ const result = await FileStorageService.relinkMovedBudgetFile('/tmp/missing-plan.budget', 'plan-1');
+
+ expect(result).toEqual({ status: 'cancelled' });
+ });
+
+ it('returns mismatch when relink picker chooses a different plan', async () => {
+ const otherPlan = FileStorageService.createEmptyBudget(2026, 'USD');
+ otherPlan.id = 'different-plan';
+
+ Object.assign(window.electronAPI, {
+ openFileDialog: vi.fn(async () => '/tmp/other-plan.budget'),
+ loadBudget: vi.fn(async () => ({ success: true, data: JSON.stringify(otherPlan) })),
+ });
+
+ const result = await FileStorageService.relinkMovedBudgetFile('/tmp/missing-plan.budget', 'plan-1');
+
+ expect(result.status).toBe('mismatch');
+ if (result.status === 'mismatch') {
+ expect(result.message).toContain('different plan');
+ }
+ });
+
+ it('returns success and rewrites stale recent-file metadata when relinking a moved plan', async () => {
+ const movedPlan = FileStorageService.createEmptyBudget(2026, 'USD');
+ movedPlan.id = 'plan-1';
+ movedPlan.name = 'Moved Plan';
+
+ FileStorageService.addRecentFileForPlan('/tmp/missing-plan.budget', 'plan-1');
+
+ Object.assign(window.electronAPI, {
+ openFileDialog: vi.fn(async () => '/tmp/moved-plan.budget'),
+ loadBudget: vi.fn(async () => ({ success: true, data: JSON.stringify(movedPlan) })),
+ });
+
+ const result = await FileStorageService.relinkMovedBudgetFile('/tmp/missing-plan.budget', 'plan-1');
+
+ expect(result).toEqual({
+ status: 'success',
+ filePath: '/tmp/moved-plan.budget',
+ planName: 'moved-plan',
+ });
+
+ const recentFiles = FileStorageService.getRecentFiles();
+ expect(recentFiles.some((file) => file.filePath === '/tmp/missing-plan.budget')).toBe(false);
+ expect(recentFiles.some((file) => file.filePath === '/tmp/moved-plan.budget')).toBe(true);
+ expect(FileStorageService.getKnownPlanIdForFile('/tmp/moved-plan.budget')).toBe('plan-1');
+ });
+
+ it('throws after repeated bad key attempts for encrypted plan files', async () => {
+ const plan = FileStorageService.createEmptyBudget(2026, 'USD');
+ const encryptedEnvelope = JSON.stringify({
+ format: 'paycheck-planner-encrypted-v1',
+ planId: 'plan-1',
+ payload: FileStorageService.encrypt(JSON.stringify(plan), 'correct-key'),
+ });
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ Object.assign(window.electronAPI, {
+ loadBudget: vi.fn(async () => ({ success: true, data: encryptedEnvelope })),
+ getKeychainKey: vi.fn(async () => ({ success: true, key: null })),
+ });
+
+ vi.spyOn(FileStorageService as unknown as {
+ requestEncryptionKeyInput: (message: string) => Promise;
+ }, 'requestEncryptionKeyInput')
+ .mockResolvedValueOnce('wrong-key-1')
+ .mockResolvedValueOnce('wrong-key-2')
+ .mockResolvedValueOnce('wrong-key-3');
+
+ await expect(FileStorageService.loadBudget('/tmp/encrypted-plan.budget')).rejects.toThrow(
+ 'Failed to decrypt file after 3 attempts. Please check your encryption key.',
+ );
+
+ consoleWarnSpy.mockRestore();
+ });
});
diff --git a/src/services/fileStorage.ts b/src/services/fileStorage.ts
index b5383d8..dd377a9 100644
--- a/src/services/fileStorage.ts
+++ b/src/services/fileStorage.ts
@@ -1,28 +1,20 @@
// Service for handling local file storage and encryption
// This class manages reading/writing budget files and encrypting/decrypting the data
import CryptoJS from 'crypto-js';
-import type { BudgetData, AppSettings } from '../types/auth';
+import {
+ APP_STORAGE_KEYS,
+ APP_STORAGE_PREFIX,
+ BACKUP_EXCLUDED_STORAGE_KEYS,
+ MAX_RECENT_FILES,
+ SETTINGS_PLAN_SPECIFIC_FIELDS,
+ STORAGE_KEYS,
+} from '../constants/storage';
+import type { BudgetData } from '../types/budget';
+import type { AppSettings } from '../types/settings';
import { KeychainService } from './keychainService';
+import { getBaseFileName, getPlanNameFromPath } from '../utils/filePath';
-// LocalStorage key for app settings
-const SETTINGS_KEY = 'paycheck-planner-settings';
-const RECENT_FILES_KEY = 'paycheck-planner-recent-files';
-const FILE_TO_PLAN_MAPPING_KEY = 'paycheck-planner-file-to-plan-mapping';
-const APP_STORAGE_PREFIX = 'paycheck-planner-';
-// All keys owned by the app — used for the full memory wipe
-const APP_STORAGE_KEYS = [
- SETTINGS_KEY,
- RECENT_FILES_KEY,
- FILE_TO_PLAN_MAPPING_KEY,
- 'paycheck-planner-theme',
- 'paycheck-planner-accounts',
-];
-// Plan-specific fields that live inside the settings object but must never be
-// included in a global preferences backup (they are stored in the budget file).
-const SETTINGS_PLAN_SPECIFIC_FIELDS = ['encryptionEnabled', 'encryptionKey'] as const;
-// Keys that are plan-specific and must be excluded from global backups
-const BACKUP_EXCLUDED_KEYS = new Set(['paycheck-planner-accounts']);
-const MAX_RECENT_FILES = 10;
+const BACKUP_EXCLUDED_KEYS = new Set(BACKUP_EXCLUDED_STORAGE_KEYS);
export interface RecentFile {
filePath: string;
@@ -141,11 +133,7 @@ function migrateBudgetData(budgetData: BudgetData): BudgetData {
export class FileStorageService {
private static derivePlanNameFromFilePath(filePath: string): string {
- const fileName = filePath.split(/[\\/]/).pop() || filePath;
- const lastDotIndex = fileName.lastIndexOf('.');
- const baseName = lastDotIndex > 0 ? fileName.slice(0, lastDotIndex) : fileName;
- const normalized = baseName.trim();
- return normalized || 'plan';
+ return getPlanNameFromPath(filePath) || 'plan';
}
private static async inspectBudgetFile(
@@ -243,7 +231,7 @@ export class FileStorageService {
* @returns App settings or undefined if not yet configured
*/
static getAppSettings(): AppSettings {
- const stored = localStorage.getItem(SETTINGS_KEY);
+ const stored = localStorage.getItem(STORAGE_KEYS.settings);
if (stored) {
try {
const parsedSettings = JSON.parse(stored) as AppSettings & { encryptionKey?: string };
@@ -270,7 +258,7 @@ export class FileStorageService {
// Remove encryptionKey if it exists - we don't store keys in localStorage
const settingsToStore = { ...(settings as AppSettings & { encryptionKey?: string }) };
Reflect.deleteProperty(settingsToStore, 'encryptionKey');
- localStorage.setItem(SETTINGS_KEY, JSON.stringify(settingsToStore));
+ localStorage.setItem(STORAGE_KEYS.settings, JSON.stringify(settingsToStore));
}
/**
@@ -278,7 +266,7 @@ export class FileStorageService {
* @returns Array of recent files, sorted by most recently opened
*/
static getRecentFiles(): RecentFile[] {
- const stored = localStorage.getItem(RECENT_FILES_KEY);
+ const stored = localStorage.getItem(STORAGE_KEYS.recentFiles);
if (stored) {
try {
return JSON.parse(stored);
@@ -294,8 +282,7 @@ export class FileStorageService {
* @param filePath - The file path to add
*/
static addRecentFile(filePath: string): void {
- // Extract file name from path
- const fileName = filePath.split(/[\\/]/).pop() || filePath;
+ const fileName = getBaseFileName(filePath) || filePath;
const recentFiles = this.getRecentFiles();
@@ -312,15 +299,15 @@ export class FileStorageService {
// Keep only the most recent MAX_RECENT_FILES
const trimmed = filtered.slice(0, MAX_RECENT_FILES);
- localStorage.setItem(RECENT_FILES_KEY, JSON.stringify(trimmed));
+ localStorage.setItem(STORAGE_KEYS.recentFiles, JSON.stringify(trimmed));
}
/**
* Add/update a recent file while de-duplicating other entries that belong to the same plan.
* This keeps recents correct when a plan file is renamed/moved externally.
*/
- private static addRecentFileForPlan(filePath: string, planId?: string): void {
- const fileName = filePath.split(/[\\/]/).pop() || filePath;
+ static addRecentFileForPlan(filePath: string, planId?: string): void {
+ const fileName = getBaseFileName(filePath) || filePath;
const recentFiles = this.getRecentFiles();
const mapping = this.getPlanFileMappings();
@@ -337,7 +324,7 @@ export class FileStorageService {
});
const trimmed = filtered.slice(0, MAX_RECENT_FILES);
- localStorage.setItem(RECENT_FILES_KEY, JSON.stringify(trimmed));
+ localStorage.setItem(STORAGE_KEYS.recentFiles, JSON.stringify(trimmed));
}
/**
@@ -347,14 +334,14 @@ export class FileStorageService {
static removeRecentFile(filePath: string): void {
const recentFiles = this.getRecentFiles();
const filtered = recentFiles.filter(f => f.filePath !== filePath);
- localStorage.setItem(RECENT_FILES_KEY, JSON.stringify(filtered));
+ localStorage.setItem(STORAGE_KEYS.recentFiles, JSON.stringify(filtered));
}
/**
* Clear all recent files
*/
static clearRecentFiles(): void {
- localStorage.removeItem(RECENT_FILES_KEY);
+ localStorage.removeItem(STORAGE_KEYS.recentFiles);
}
/**
@@ -414,7 +401,7 @@ export class FileStorageService {
if (value === null) continue;
// Strip plan-specific fields from the settings blob
- if (key === SETTINGS_KEY) {
+ if (key === STORAGE_KEYS.settings) {
try {
const parsed = JSON.parse(value) as Record;
for (const field of SETTINGS_PLAN_SPECIFIC_FIELDS) {
@@ -477,7 +464,7 @@ export class FileStorageService {
let valueToStore = value;
// Strip plan-specific fields from a restored settings blob
- if (key === SETTINGS_KEY) {
+ if (key === STORAGE_KEYS.settings) {
try {
const parsed = JSON.parse(value) as Record;
for (const field of SETTINGS_PLAN_SPECIFIC_FIELDS) {
@@ -512,7 +499,7 @@ export class FileStorageService {
}
}
mapping[filePath] = planId;
- localStorage.setItem(FILE_TO_PLAN_MAPPING_KEY, JSON.stringify(mapping));
+ localStorage.setItem(STORAGE_KEYS.fileToPlanMapping, JSON.stringify(mapping));
} catch {
// If this fails, it's not critical - worst case the key lookup will fail
}
@@ -524,7 +511,7 @@ export class FileStorageService {
*/
private static getPlanFileMappings(): Record {
try {
- const stored = localStorage.getItem(FILE_TO_PLAN_MAPPING_KEY);
+ const stored = localStorage.getItem(STORAGE_KEYS.fileToPlanMapping);
if (stored) {
return JSON.parse(stored);
}
@@ -765,8 +752,7 @@ export class FileStorageService {
}
// Max attempts reached
- alert(`Failed to decrypt file after ${maxAttempts} attempts. Please check your encryption key.`);
- return null;
+ throw new Error(`Failed to decrypt file after ${maxAttempts} attempts. Please check your encryption key.`);
}
/**
diff --git a/src/services/pdfExport.test.ts b/src/services/pdfExport.test.ts
index 8102b37..641181e 100644
--- a/src/services/pdfExport.test.ts
+++ b/src/services/pdfExport.test.ts
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import type { BudgetData } from '../types/auth';
+import type { BudgetData } from '../types/budget';
const { pdfInstances, autoTableMock, MockJsPDF } = vi.hoisted(() => {
const instances: Array> = [];
@@ -46,6 +46,7 @@ vi.mock('jspdf-autotable', () => ({
}));
import { exportToPDF } from './pdfExport';
+import { calculatePaycheckBreakdown } from './budgetCalculations';
function createBudgetFixture(): BudgetData {
return {
@@ -191,4 +192,37 @@ describe('pdfExport', () => {
expect(warnSpy).toHaveBeenCalledWith('PDF password protection not yet implemented');
warnSpy.mockRestore();
});
+
+ it('uses shared budget calculation totals in the metrics section', async () => {
+ const budget = createBudgetFixture();
+ const breakdown = calculatePaycheckBreakdown(budget);
+
+ await exportToPDF(budget, {
+ includeMetrics: true,
+ includePayBreakdown: false,
+ includeAccounts: false,
+ includeBills: false,
+ includeBenefits: false,
+ includeRetirement: false,
+ includeTaxes: false,
+ });
+
+ const metricsCall = autoTableMock.mock.calls.find((call) => {
+ const options = call[1] as { head?: string[][] };
+ return options.head?.[0]?.[0] === 'Metric';
+ });
+
+ expect(metricsCall).toBeTruthy();
+ const options = metricsCall?.[1] as { body?: string[][] };
+ expect(options.body).toEqual([
+ ['Gross Pay (per paycheck)', '$5,000.00'],
+ ['Pre-Tax Deductions', '$175.00'],
+ ['Total Taxes', '$748.75'],
+ ['Net Pay', '$3,826.25'],
+ ['Total Allocations', '$1,200.00'],
+ ['Leftover', '$2,626.25'],
+ ]);
+ expect(breakdown.grossPay).toBe(5000);
+ expect(breakdown.netPay).toBe(3826.25);
+ });
});
diff --git a/src/services/pdfExport.ts b/src/services/pdfExport.ts
index facd271..cd1ae05 100644
--- a/src/services/pdfExport.ts
+++ b/src/services/pdfExport.ts
@@ -1,9 +1,9 @@
// Service for exporting budget data to PDF format
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
-import type { BudgetData } from '../types/auth';
+import type { BudgetData } from '../types/budget';
+import { calculatePaycheckBreakdown } from './budgetCalculations';
import { formatWithSymbol } from '../utils/currency';
-import { calculateGrossPayPerPaycheck } from '../utils/payPeriod';
import { getRetirementPlanDisplayLabel } from '../utils/retirement';
type JsPdfWithAutoTable = jsPDF & {
@@ -76,47 +76,17 @@ export async function exportToPDF(
doc.text(`Exported: ${new Date().toLocaleDateString()}`, 20, yPosition);
yPosition += 15;
- // Calculate pay breakdown metrics
- const paycheckAmount = calculateGrossPayPerPaycheck(budgetData.paySettings);
-
- // Calculate pre-tax deductions
- const preTaxDeductions = budgetData.preTaxDeductions.reduce((sum, deduction) => {
- const amount = deduction.isPercentage
- ? (paycheckAmount * deduction.amount) / 100
- : deduction.amount;
- return sum + amount;
- }, 0);
-
- // Calculate taxes
- const taxableIncome = paycheckAmount - preTaxDeductions;
- const taxLineAmounts = (budgetData.taxSettings.taxLines || []).map(line => ({
+ const breakdown = calculatePaycheckBreakdown(budgetData);
+ const paycheckAmount = breakdown.grossPay;
+ const preTaxDeductions = breakdown.preTaxDeductions;
+ const taxLineAmounts = (budgetData.taxSettings.taxLines || []).map((line, index) => ({
label: line.label,
rate: line.rate,
- amount: (taxableIncome * line.rate) / 100,
+ amount: breakdown.taxLineAmounts[index]?.amount ?? 0,
}));
- const additionalWithholding = budgetData.taxSettings.additionalWithholding || 0;
- const totalTaxes = taxLineAmounts.reduce((sum, l) => sum + l.amount, 0) + additionalWithholding;
-
- // Calculate post-tax deductions (benefits and retirement from paycheck)
- const postTaxBenefits = budgetData.benefits
- .filter(b => b.isTaxable && (!b.deductionSource || b.deductionSource === 'paycheck'))
- .reduce((sum, benefit) => {
- const amount = benefit.isPercentage
- ? (paycheckAmount * benefit.amount) / 100
- : benefit.amount;
- return sum + amount;
- }, 0);
-
- const postTaxRetirement = budgetData.retirement
- .filter(r => !r.isPreTax && (!r.deductionSource || r.deductionSource === 'paycheck'))
- .reduce((sum, retirement) => {
- const amount = retirement.employeeContributionIsPercentage
- ? (paycheckAmount * retirement.employeeContribution) / 100
- : retirement.employeeContribution;
- return sum + amount;
- }, 0);
-
- const netPay = paycheckAmount - preTaxDeductions - totalTaxes - postTaxBenefits - postTaxRetirement;
+ const additionalWithholding = breakdown.additionalWithholding;
+ const totalTaxes = breakdown.totalTaxes;
+ const netPay = breakdown.netPay;
// Calculate total account allocations
const totalAllocations = budgetData.accounts.reduce((sum, account) => {
diff --git a/src/types/accounts.ts b/src/types/accounts.ts
new file mode 100644
index 0000000..6f22527
--- /dev/null
+++ b/src/types/accounts.ts
@@ -0,0 +1,27 @@
+export interface AccountAllocationCategory {
+ id: string;
+ name: string;
+ amount: number;
+ isBill?: boolean;
+ billCount?: number;
+ isBenefit?: boolean;
+ benefitCount?: number;
+ isRetirement?: boolean;
+ retirementCount?: number;
+ isLoan?: boolean;
+ loanCount?: number;
+ isSavings?: boolean;
+ savingsCount?: number;
+}
+
+export interface Account {
+ id: string;
+ name: string;
+ type: 'checking' | 'savings' | 'investment' | 'other';
+ allocation?: number;
+ isRemainder?: boolean;
+ priority?: number;
+ allocationCategories?: AccountAllocationCategory[];
+ color: string;
+ icon?: string;
+}
\ No newline at end of file
diff --git a/src/types/auth.ts b/src/types/auth.ts
index a8b43b0..3666ec1 100644
--- a/src/types/auth.ts
+++ b/src/types/auth.ts
@@ -1,370 +1,8 @@
-// TypeScript Type Definitions
-// These are like "contracts" that describe the shape of our data
-// They help catch bugs by ensuring we use data correctly throughout the app
-
-/**
- * CoreFrequency - Shared recurring cadence values used across features
- */
-export type CoreFrequency = 'weekly' | 'bi-weekly' | 'semi-monthly' | 'monthly' | 'yearly';
-
-/**
- * PayFrequency - How often the user gets paid
- */
-export type PayFrequency = Exclude;
-
-/**
- * BillFrequency - How often a bill is due
- */
-export type BillFrequency = CoreFrequency | 'quarterly' | 'semi-annual' | 'custom';
-
-/**
- * LoanPaymentFrequency - Valid payment frequencies for loans (excludes 'custom')
- */
-export type LoanPaymentFrequency = Exclude;
-
-/**
- * SavingsFrequency - How often a savings/investment contribution occurs
- */
-export type SavingsFrequency = CoreFrequency | 'quarterly' | 'semi-annual';
-
-/**
- * PayType - Whether user is paid by salary or hourly
- */
-export type PayType = 'salary' | 'hourly';
-
-/**
- * Benefit - A benefits election (health insurance, FSA, etc.)
- */
-export interface Benefit {
- id: string;
- name: string; // Benefit name (e.g., "Health Insurance")
- amount: number; // Amount per paycheck
- isTaxable: boolean; // If true, this is post-tax; if false, pre-tax
- isPercentage?: boolean; // If true, amount is percentage of gross pay
- deductionSource?: 'paycheck' | 'account'; // Where deduction is applied
- sourceAccountId?: string; // Account ID when deductionSource is 'account'
-}
-
-/**
- * RetirementElection - 401k or similar retirement contribution election
- */
-export interface RetirementElection {
- id: string;
- type: '401k' | '403b' | 'roth-ira' | 'traditional-ira' | 'pension' | 'other'; // Type of retirement plan
- customLabel?: string; // Custom label when type is 'other'
- employeeContribution: number; // Amount employee contributes per paycheck
- employeeContributionIsPercentage: boolean; // If true, amount is percentage of gross pay
- enabled?: boolean; // Whether this retirement election is active
- isPreTax?: boolean; // If true/undefined, pre-tax; if false, post-tax
- deductionSource?: 'paycheck' | 'account'; // Where deduction is applied
- sourceAccountId?: string; // Account ID when deductionSource is 'account'
- hasEmployerMatch: boolean; // Whether employer offers matching contributions
- employerMatchCap: number; // Maximum employer will match (amount or percent)
- employerMatchCapIsPercentage: boolean; // If true, cap is percentage of gross; if false, it's dollar amount
- yearlyLimit?: number; // Optional yearly contribution limit (employee + employer total)
-}
-
-/**
- * BudgetData - The main data structure for a paycheck plan file
- * This is what gets saved to disk (after encryption)
- */
-export interface BudgetData {
- id: string; // Unique identifier for this plan
- name: string; // Display name (e.g., "2026 Plan")
- year: number; // Year this plan is for (e.g., 2026)
- paySettings: PaySettings; // How the user gets paid
- preTaxDeductions: Deduction[]; // Pre-tax deductions (401k, benefits, etc.)
- benefits: Benefit[]; // Benefits elections (health insurance, FSA, etc.)
- retirement: RetirementElection[]; // Retirement plan elections (401k, etc.)
- taxSettings: TaxSettings; // Tax configuration
- accounts: Account[]; // User's accounts (checking, savings, etc.)
- bills: Bill[]; // Recurring bills and expenses
- loans: Loan[]; // Loans and debts
- savingsContributions?: SavingsContribution[]; // Savings/investment transfers funded from accounts
- settings: BudgetSettings; // User preferences
- createdAt: string; // ISO date string when created
- updatedAt: string; // ISO date string when last modified
-}
-
-/**
- * PaySettings - Configuration for how the user gets paid
- */
-export interface PaySettings {
- payType: PayType; // Salary or hourly
- annualSalary?: number; // Annual salary (if payType is 'salary')
- hourlyRate?: number; // Hourly rate (if payType is 'hourly')
- hoursPerPayPeriod?: number; // Hours per pay period (if hourly)
- payFrequency: PayFrequency; // How often paid
- firstPaycheckDate?: string; // First paycheck date in YYYY-MM-DD (weekly, bi-weekly, monthly)
- semiMonthlyFirstDay?: number; // First paycheck day of month for semi-monthly schedules
- semiMonthlySecondDay?: number; // Second paycheck day of month for semi-monthly schedules
- minLeftover?: number; // Minimum amount to keep leftover per paycheck (default: 0)
-}
-
-/**
- * Deduction - A pre-tax deduction (401k, health insurance, etc.)
- */
-export interface Deduction {
- id: string;
- name: string; // Description (e.g., "401k", "Health Insurance")
- amount: number; // Amount per paycheck
- isPercentage: boolean; // If true, amount is percentage of gross pay
-}
-
-/**
- * TaxLine - A single named tax rate line
- */
-export interface TaxLine {
- id: string;
- label: string; // User-editable label (e.g. "Federal Tax", "VAT", "National Insurance")
- rate: number; // Percentage (0-100)
-}
-
-/**
- * TaxSettings - Tax configuration (user-entered)
- */
-export interface TaxSettings {
- taxLines: TaxLine[]; // Dynamic list of named tax rates
- additionalWithholding: number; // Additional dollar amount to withhold per paycheck
-}
-
-/**
- * Account - A financial account where money is allocated
- */
-export interface Account {
- id: string;
- name: string; // Account name (e.g., "Checking", "Savings")
- type: 'checking' | 'savings' | 'investment' | 'other';
- allocation?: number; // DEPRECATED: Dollar amount allocated per paycheck
- isRemainder?: boolean; // DEPRECATED: If true, gets whatever is left after other allocations
- priority?: number; // DEPRECATED: Funding order (1 = first funded)
- allocationCategories?: AccountAllocationCategory[]; // Category-level allocation targets
- color: string; // Hex color for UI display
- icon?: string; // Optional emoji or icon
-}
-
-/**
- * AccountAllocationCategory - Category targets within an account allocation
- */
-export interface AccountAllocationCategory {
- id: string;
- name: string; // Category name (e.g., "Emergency Fund", "Groceries")
- amount: number; // Amount per paycheck targeted for this category
- isBill?: boolean; // If true, this is an auto-calculated sum of bills for this account
- billCount?: number; // Number of bills in this category (if isBill is true)
- isBenefit?: boolean; // If true, this is an auto-calculated sum of benefits for this account
- benefitCount?: number; // Number of benefits in this category (if isBenefit is true)
- isRetirement?: boolean; // If true, this is an auto-calculated sum of retirement for this account
- retirementCount?: number; // Number of retirement contributions in this category (if isRetirement is true)
- isLoan?: boolean; // If true, this is an auto-calculated sum of loan payments for this account
- loanCount?: number; // Number of loan payments in this category (if isLoan is true)
- isSavings?: boolean; // If true, this is an auto-calculated sum of savings/investments for this account
- savingsCount?: number; // Number of savings/investment items in this category (if isSavings is true)
-}
-
-/**
- * SavingsContribution - A recurring transfer to savings/investment funded from an account
- */
-export interface SavingsContribution {
- id: string;
- name: string; // Contribution label (e.g., "Brokerage Transfer")
- amount: number; // Amount due each frequency interval
- frequency: SavingsFrequency; // How often contribution occurs
- accountId: string; // Which account funds this contribution
- type: 'savings' | 'investment'; // Contribution category for badges/filtering
- enabled?: boolean; // Whether this contribution is active
- notes?: string; // Optional notes
-}
-
-/**
- * Bill - A recurring bill or expense
- */
-export interface Bill {
- id: string;
- name: string; // Bill description
- amount: number; // Amount due
- frequency: BillFrequency; // How often it's due
- accountId: string; // Which account this is paid from
- enabled?: boolean; // Whether bill is active (undefined defaults to true)
- dueDay?: number; // Day of month/week it's due (if applicable)
- customFrequencyDays?: number; // For custom frequency: days between occurrences
- category?: string; // Optional category for organization
- notes?: string; // Optional notes
-}
-
-/**
- * Loan - A debt or loan with payment tracking
- */
-export interface Loan {
- id: string;
- name: string; // Loan description (e.g., "Mortgage", "Car Loan")
- type: 'mortgage' | 'auto' | 'student' | 'personal' | 'credit-card' | 'other';
- principal: number; // Original loan amount
- currentBalance: number; // Current remaining balance
- interestRate: number; // Annual interest rate (percentage)
- propertyTaxRate?: number; // Annual property tax rate (mortgage only, percentage of property value)
- propertyValue?: number; // Property value used for mortgage tax calculations
- monthlyPayment: number; // Monthly payment amount
- paymentFrequency?: Exclude; // Original entered payment frequency
- accountId: string; // Which account payments come from
- startDate: string; // ISO date string when loan started
- termMonths?: number; // Total loan term in months (optional)
- insurancePayment?: number; // Monthly insurance amount (PMI/GAP/etc.) (optional)
- insuranceEndBalance?: number; // Insurance stops at fixed balance amount (optional)
- insuranceEndBalancePercent?: number; // Insurance stops at % of original principal (optional)
- paymentBreakdown?: LoanPaymentLine[]; // Optional line-item payment components for tracking
- enabled?: boolean; // Whether loan is active (undefined defaults to true)
- notes?: string; // Optional notes
-}
-
-/**
- * LoanPaymentLine - A line item that contributes to a loan's recurring payment total
- */
-export interface LoanPaymentLine {
- id: string;
- label: string; // Line-item label (e.g., "Principal & Interest")
- amount: number; // Entered amount for this line item
- frequency: LoanPaymentFrequency; // Frequency for this line item amount
-}
-
-/**
- * Tab configuration for customizable dashboard tabs
- */
-export interface TabConfig {
- id: string; // Tab identifier
- label: string; // Display label
- icon: string; // Emoji/icon
- visible: boolean; // Whether tab is currently shown
- order: number; // Display order (lower numbers first)
- pinned: boolean; // Whether tab cannot be hidden (Key Metrics, Pay Breakdown)
-}
-
-/**
- * TabPosition - Where tabs are displayed on screen
- */
-export type TabPosition = 'top' | 'bottom' | 'left' | 'right';
-
-/**
- * TabDisplayMode - How tabs are displayed in sidebar orientations
- */
-export type TabDisplayMode = 'icons-only' | 'icons-with-labels';
-
-/**
- * BudgetSettings - User preferences and app configuration
- */
-export interface BudgetSettings {
- currency: string; // Currency code (e.g., "USD", "EUR")
- locale: string; // Locale for formatting (e.g., "en-US")
- filePath?: string; // Where the budget is saved (optional, may not be set yet)
- lastSavedAt?: string; // ISO date string of last successful save to disk
- encryptionEnabled?: boolean; // Whether to encrypt budget files (undefined = not set)
- encryptionKey?: string; // User's encryption key (only if encryption enabled)
- tabConfigs?: TabConfig[]; // Tab visibility and order configuration
- tabPosition?: TabPosition; // Where tabs are displayed (default: 'top')
- tabDisplayMode?: TabDisplayMode; // How tabs are displayed in sidebar (default: 'icons-with-labels')
- windowSize?: { // Window dimensions and position when last closed
- width: number;
- height: number;
- x: number;
- y: number;
- };
- activeTab?: string; // Last active tab ID when plan was closed
-}
-
-/**
- * AppSettings - Global app settings stored in localStorage
- */
-export interface AppSettings {
- encryptionEnabled?: boolean; // Global preference for encryption (undefined = not set up yet)
- encryptionKey?: string; // User's master encryption key
- lastOpenedFile?: string; // Path to last opened budget file
- themeMode?: 'light' | 'dark' | 'system'; // App theme preference mode
- glossaryTermsEnabled?: boolean; // Whether glossary term links are active (hover + click)
-}
-
-/**
- * PaycheckBreakdown - Calculated breakdown of a paycheck
- */
-export interface TaxLineAmount {
- id: string;
- label: string;
- amount: number;
-}
-
-export interface PaycheckBreakdown {
- grossPay: number; // Total before any deductions
- preTaxDeductions: number; // Total pre-tax deductions
- taxableIncome: number; // Gross minus pre-tax deductions
- taxLineAmounts: TaxLineAmount[]; // Per-line tax amounts calculated from taxLines
- additionalWithholding: number; // Additional withholding
- totalTaxes: number; // Sum of all taxes + additionalWithholding
- netPay: number; // Take-home pay after all deductions
-}
-
-/**
- * BudgetContextType - Describes what the budget context provides
- * This interface defines all the state and functions available via useBudget() hook
- */
-export interface BudgetContextType {
- budgetData: BudgetData | null; // Current budget data (null if none loaded)
- loading: boolean; // Whether an operation is in progress
-
- // File operations
- saveBudget: (activeTab?: string, budgetOverride?: Partial) => Promise; // Save to disk with optional active tab and override data, returns true on success
- saveWindowState: (width: number, height: number, x: number, y: number, activeTab?: string) => Promise; // Save only window state
- loadBudget: (filePath?: string) => Promise; // Load from disk
- createNewBudget: (year: number) => void; // Create empty plan for a year
- createDemoBudget: () => void; // Create demo plan with random realistic data
- closeBudget: () => void; // Close current budget (return to welcome)
- selectSaveLocation: () => Promise; // Choose where to save
- copyPlanToNewYear: (newYear: number) => Promise; // Duplicate plan to new year (with encryption key transfer)
- updateBudgetData: (data: Partial) => void; // Generic update for any budget data
-
- // Pay settings operations
- updatePaySettings: (settings: PaySettings) => void;
-
- // Deduction operations
- addDeduction: (deduction: Omit) => void;
- updateDeduction: (id: string, deduction: Partial) => void;
- deleteDeduction: (id: string) => void;
-
- // Tax settings operations
- updateTaxSettings: (settings: TaxSettings) => void;
-
- // Budget settings operations
- updateBudgetSettings: (settings: BudgetSettings) => void;
-
- // Account operations
- addAccount: (account: Omit) => void;
- updateAccount: (id: string, account: Partial) => void;
- deleteAccount: (id: string) => void;
-
- // Bill operations
- addBill: (bill: Omit) => void;
- updateBill: (id: string, bill: Partial) => void;
- deleteBill: (id: string) => void;
-
- // Loan operations
- addLoan: (loan: Omit) => void;
- updateLoan: (id: string, loan: Partial) => void;
- deleteLoan: (id: string) => void;
-
- // Benefit operations
- addBenefit: (benefit: Omit) => void;
- updateBenefit: (id: string, benefit: Partial) => void;
- deleteBenefit: (id: string) => void;
-
- // Savings operations
- addSavingsContribution: (contribution: Omit) => void;
- updateSavingsContribution: (id: string, contribution: Partial) => void;
- deleteSavingsContribution: (id: string) => void;
-
- // Retirement operations
- addRetirementElection: (election: Omit) => void;
- updateRetirementElection: (id: string, election: Partial) => void;
- deleteRetirementElection: (id: string) => void;
-
- // Calculation functions
- calculatePaycheckBreakdown: () => PaycheckBreakdown;
- calculateRetirementContributions: (election: RetirementElection) => { employeeAmount: number; employerAmount: number };
-}
+export * from './accounts';
+export * from './budget';
+export * from './budgetContext';
+export * from './frequencies';
+export * from './obligations';
+export * from './payroll';
+export * from './settings';
+export * from './tabs';
diff --git a/src/types/budget.ts b/src/types/budget.ts
new file mode 100644
index 0000000..953a706
--- /dev/null
+++ b/src/types/budget.ts
@@ -0,0 +1,22 @@
+import type { Account } from './accounts';
+import type { Bill, Loan, SavingsContribution } from './obligations';
+import type { Benefit, Deduction, PaySettings, RetirementElection, TaxSettings } from './payroll';
+import type { BudgetSettings } from './settings';
+
+export interface BudgetData {
+ id: string;
+ name: string;
+ year: number;
+ paySettings: PaySettings;
+ preTaxDeductions: Deduction[];
+ benefits: Benefit[];
+ retirement: RetirementElection[];
+ taxSettings: TaxSettings;
+ accounts: Account[];
+ bills: Bill[];
+ loans: Loan[];
+ savingsContributions?: SavingsContribution[];
+ settings: BudgetSettings;
+ createdAt: string;
+ updatedAt: string;
+}
\ No newline at end of file
diff --git a/src/types/budgetContext.ts b/src/types/budgetContext.ts
new file mode 100644
index 0000000..4a7b95c
--- /dev/null
+++ b/src/types/budgetContext.ts
@@ -0,0 +1,45 @@
+import type { Account } from './accounts';
+import type { BudgetData } from './budget';
+import type { Bill, Loan, SavingsContribution } from './obligations';
+import type { Benefit, Deduction, PaySettings, PaycheckBreakdown, RetirementElection, TaxSettings } from './payroll';
+import type { BudgetSettings } from './settings';
+
+export interface BudgetContextType {
+ budgetData: BudgetData | null;
+ loading: boolean;
+ saveBudget: (activeTab?: string, budgetOverride?: Partial) => Promise;
+ saveWindowState: (width: number, height: number, x: number, y: number, activeTab?: string) => Promise;
+ loadBudget: (filePath?: string) => Promise;
+ createNewBudget: (year: number) => void;
+ createDemoBudget: () => void;
+ closeBudget: () => void;
+ selectSaveLocation: () => Promise;
+ copyPlanToNewYear: (newYear: number) => Promise;
+ updateBudgetData: (data: Partial) => void;
+ updatePaySettings: (settings: PaySettings) => void;
+ addDeduction: (deduction: Omit) => void;
+ updateDeduction: (id: string, deduction: Partial) => void;
+ deleteDeduction: (id: string) => void;
+ updateTaxSettings: (settings: TaxSettings) => void;
+ updateBudgetSettings: (settings: BudgetSettings) => void;
+ addAccount: (account: Omit) => void;
+ updateAccount: (id: string, account: Partial) => void;
+ deleteAccount: (id: string) => void;
+ addBill: (bill: Omit) => void;
+ updateBill: (id: string, bill: Partial) => void;
+ deleteBill: (id: string) => void;
+ addLoan: (loan: Omit) => void;
+ updateLoan: (id: string, loan: Partial) => void;
+ deleteLoan: (id: string) => void;
+ addBenefit: (benefit: Omit) => void;
+ updateBenefit: (id: string, benefit: Partial) => void;
+ deleteBenefit: (id: string) => void;
+ addSavingsContribution: (contribution: Omit) => void;
+ updateSavingsContribution: (id: string, contribution: Partial) => void;
+ deleteSavingsContribution: (id: string) => void;
+ addRetirementElection: (election: Omit) => void;
+ updateRetirementElection: (id: string, election: Partial) => void;
+ deleteRetirementElection: (id: string) => void;
+ calculatePaycheckBreakdown: () => PaycheckBreakdown;
+ calculateRetirementContributions: (election: RetirementElection) => { employeeAmount: number; employerAmount: number };
+}
\ No newline at end of file
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts
index 9a82b1c..2c36d64 100644
--- a/src/types/electron.d.ts
+++ b/src/types/electron.d.ts
@@ -5,6 +5,8 @@
* ElectronAPI - Interface for communicating with Electron's main process
* These functions are exposed from the preload script to the renderer (React)
*/
+import type { MenuEventName } from '../constants/events';
+
export interface ElectronAPI {
// Open a folder picker dialog
selectDirectory: () => Promise;
@@ -74,7 +76,7 @@ export interface ElectronAPI {
// Takes an event name and a callback function
// Returns an unsubscribe function to remove the listener
onMenuEvent: (
- event: 'new-budget' | 'open-budget' | 'open-budget-file' | 'change-encryption' | 'open-settings' | 'open-about' | 'open-glossary' | 'open-keyboard-shortcuts' | 'open-pay-options' | 'open-accounts' | 'save-plan' | 'set-tab-position' | 'toggle-tab-display-mode' | 'history-back' | 'history-forward' | 'history-home',
+ event: MenuEventName,
callback: (arg?: unknown) => void
) => () => void;
diff --git a/src/types/frequencies.ts b/src/types/frequencies.ts
new file mode 100644
index 0000000..0af011e
--- /dev/null
+++ b/src/types/frequencies.ts
@@ -0,0 +1,9 @@
+export type CoreFrequency = 'weekly' | 'bi-weekly' | 'semi-monthly' | 'monthly' | 'yearly';
+
+export type PayFrequency = Exclude;
+
+export type BillFrequency = CoreFrequency | 'quarterly' | 'semi-annual' | 'custom';
+
+export type LoanPaymentFrequency = Exclude;
+
+export type SavingsFrequency = CoreFrequency | 'quarterly' | 'semi-annual';
\ No newline at end of file
diff --git a/src/types/obligations.ts b/src/types/obligations.ts
new file mode 100644
index 0000000..cef7e8b
--- /dev/null
+++ b/src/types/obligations.ts
@@ -0,0 +1,54 @@
+import type { BillFrequency, LoanPaymentFrequency, SavingsFrequency } from './frequencies';
+
+export interface SavingsContribution {
+ id: string;
+ name: string;
+ amount: number;
+ frequency: SavingsFrequency;
+ accountId: string;
+ type: 'savings' | 'investment';
+ enabled?: boolean;
+ notes?: string;
+}
+
+export interface Bill {
+ id: string;
+ name: string;
+ amount: number;
+ frequency: BillFrequency;
+ accountId: string;
+ enabled?: boolean;
+ dueDay?: number;
+ customFrequencyDays?: number;
+ category?: string;
+ notes?: string;
+}
+
+export interface LoanPaymentLine {
+ id: string;
+ label: string;
+ amount: number;
+ frequency: LoanPaymentFrequency;
+}
+
+export interface Loan {
+ id: string;
+ name: string;
+ type: 'mortgage' | 'auto' | 'student' | 'personal' | 'credit-card' | 'other';
+ principal: number;
+ currentBalance: number;
+ interestRate: number;
+ propertyTaxRate?: number;
+ propertyValue?: number;
+ monthlyPayment: number;
+ paymentFrequency?: Exclude;
+ accountId: string;
+ startDate: string;
+ termMonths?: number;
+ insurancePayment?: number;
+ insuranceEndBalance?: number;
+ insuranceEndBalancePercent?: number;
+ paymentBreakdown?: LoanPaymentLine[];
+ enabled?: boolean;
+ notes?: string;
+}
\ No newline at end of file
diff --git a/src/types/payroll.ts b/src/types/payroll.ts
new file mode 100644
index 0000000..9e16b81
--- /dev/null
+++ b/src/types/payroll.ts
@@ -0,0 +1,75 @@
+import type { PayFrequency } from './frequencies';
+
+export type PayType = 'salary' | 'hourly';
+
+export interface PaySettings {
+ payType: PayType;
+ annualSalary?: number;
+ hourlyRate?: number;
+ hoursPerPayPeriod?: number;
+ payFrequency: PayFrequency;
+ firstPaycheckDate?: string;
+ semiMonthlyFirstDay?: number;
+ semiMonthlySecondDay?: number;
+ minLeftover?: number;
+}
+
+export interface Deduction {
+ id: string;
+ name: string;
+ amount: number;
+ isPercentage: boolean;
+}
+
+export interface TaxLine {
+ id: string;
+ label: string;
+ rate: number;
+}
+
+export interface TaxSettings {
+ taxLines: TaxLine[];
+ additionalWithholding: number;
+}
+
+export interface Benefit {
+ id: string;
+ name: string;
+ amount: number;
+ isTaxable: boolean;
+ isPercentage?: boolean;
+ deductionSource?: 'paycheck' | 'account';
+ sourceAccountId?: string;
+}
+
+export interface RetirementElection {
+ id: string;
+ type: '401k' | '403b' | 'roth-ira' | 'traditional-ira' | 'pension' | 'other';
+ customLabel?: string;
+ employeeContribution: number;
+ employeeContributionIsPercentage: boolean;
+ enabled?: boolean;
+ isPreTax?: boolean;
+ deductionSource?: 'paycheck' | 'account';
+ sourceAccountId?: string;
+ hasEmployerMatch: boolean;
+ employerMatchCap: number;
+ employerMatchCapIsPercentage: boolean;
+ yearlyLimit?: number;
+}
+
+export interface TaxLineAmount {
+ id: string;
+ label: string;
+ amount: number;
+}
+
+export interface PaycheckBreakdown {
+ grossPay: number;
+ preTaxDeductions: number;
+ taxableIncome: number;
+ taxLineAmounts: TaxLineAmount[];
+ additionalWithholding: number;
+ totalTaxes: number;
+ netPay: number;
+}
\ No newline at end of file
diff --git a/src/types/settings.ts b/src/types/settings.ts
new file mode 100644
index 0000000..40955ea
--- /dev/null
+++ b/src/types/settings.ts
@@ -0,0 +1,28 @@
+import type { TabConfig, TabDisplayMode, TabPosition } from './tabs';
+
+export interface BudgetSettings {
+ currency: string;
+ locale: string;
+ filePath?: string;
+ lastSavedAt?: string;
+ encryptionEnabled?: boolean;
+ encryptionKey?: string;
+ tabConfigs?: TabConfig[];
+ tabPosition?: TabPosition;
+ tabDisplayMode?: TabDisplayMode;
+ windowSize?: {
+ width: number;
+ height: number;
+ x: number;
+ y: number;
+ };
+ activeTab?: string;
+}
+
+export interface AppSettings {
+ encryptionEnabled?: boolean;
+ encryptionKey?: string;
+ lastOpenedFile?: string;
+ themeMode?: 'light' | 'dark' | 'system';
+ glossaryTermsEnabled?: boolean;
+}
\ No newline at end of file
diff --git a/src/types/tabs.ts b/src/types/tabs.ts
new file mode 100644
index 0000000..dc27191
--- /dev/null
+++ b/src/types/tabs.ts
@@ -0,0 +1,12 @@
+export interface TabConfig {
+ id: string;
+ label: string;
+ icon: string;
+ visible: boolean;
+ order: number;
+ pinned: boolean;
+}
+
+export type TabPosition = 'top' | 'bottom' | 'left' | 'right';
+
+export type TabDisplayMode = 'icons-only' | 'icons-with-labels';
\ No newline at end of file
diff --git a/src/types/viewMode.ts b/src/types/viewMode.ts
new file mode 100644
index 0000000..d9fe2a5
--- /dev/null
+++ b/src/types/viewMode.ts
@@ -0,0 +1 @@
+export type ViewMode = 'paycheck' | 'monthly' | 'yearly';
\ No newline at end of file
diff --git a/src/utils/accountDefaults.ts b/src/utils/accountDefaults.ts
index 713d344..622f494 100644
--- a/src/utils/accountDefaults.ts
+++ b/src/utils/accountDefaults.ts
@@ -1,4 +1,4 @@
-import type { Account } from '../types/auth';
+import type { Account } from '../types/accounts';
export function getDefaultAccountColor(type: Account['type']): string {
switch (type) {
diff --git a/src/utils/accountGrouping.test.ts b/src/utils/accountGrouping.test.ts
new file mode 100644
index 0000000..707439c
--- /dev/null
+++ b/src/utils/accountGrouping.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest';
+
+import type { Account } from '../types/accounts';
+import { buildAccountRows, getAccountNameById, groupByAccountId } from './accountGrouping';
+
+const accounts: Account[] = [
+ { id: 'checking', name: 'Checking', type: 'checking', color: '#111111' },
+ { id: 'savings', name: 'Savings', type: 'savings', color: '#222222' },
+ { id: 'investment', name: 'Brokerage', type: 'investment', color: '#333333' },
+];
+
+describe('accountGrouping', () => {
+ it('groups items by account id', () => {
+ const grouped = groupByAccountId([
+ { id: '1', accountId: 'checking', amount: 10 },
+ { id: '2', accountId: 'checking', amount: 20 },
+ { id: '3', accountId: 'savings', amount: 30 },
+ ]);
+
+ expect(grouped).toEqual({
+ checking: [
+ { id: '1', accountId: 'checking', amount: 10 },
+ { id: '2', accountId: 'checking', amount: 20 },
+ ],
+ savings: [
+ { id: '3', accountId: 'savings', amount: 30 },
+ ],
+ });
+ });
+
+ it('builds sorted account rows and excludes empty accounts', () => {
+ const grouped = groupByAccountId([
+ { id: '1', accountId: 'checking', monthlyAmount: 50 },
+ { id: '2', accountId: 'checking', monthlyAmount: 25 },
+ { id: '3', accountId: 'savings', monthlyAmount: 100 },
+ ]);
+
+ const rows = buildAccountRows(accounts, grouped, (items) => items.reduce((sum, item) => sum + item.monthlyAmount, 0));
+
+ expect(rows).toEqual([
+ {
+ account: accounts[1],
+ items: [{ id: '3', accountId: 'savings', monthlyAmount: 100 }],
+ totalMonthly: 100,
+ },
+ {
+ account: accounts[0],
+ items: [
+ { id: '1', accountId: 'checking', monthlyAmount: 50 },
+ { id: '2', accountId: 'checking', monthlyAmount: 25 },
+ ],
+ totalMonthly: 75,
+ },
+ ]);
+ });
+
+ it('returns a stable fallback for missing account names', () => {
+ expect(getAccountNameById(accounts, 'savings')).toBe('Savings');
+ expect(getAccountNameById(accounts, 'missing')).toBe('Unknown Account');
+ expect(getAccountNameById(accounts)).toBe('Unknown Account');
+ });
+});
\ No newline at end of file
diff --git a/src/utils/accountGrouping.ts b/src/utils/accountGrouping.ts
new file mode 100644
index 0000000..22c0f1b
--- /dev/null
+++ b/src/utils/accountGrouping.ts
@@ -0,0 +1,46 @@
+import type { Account } from '../types/accounts';
+
+export type AccountGroupedItems = Record;
+
+export type AccountRow = {
+ account: Account;
+ items: T[];
+ totalMonthly: number;
+};
+
+export function groupByAccountId(items: T[]): AccountGroupedItems {
+ return items.reduce>((groupedItems, item) => {
+ if (!groupedItems[item.accountId]) {
+ groupedItems[item.accountId] = [];
+ }
+
+ groupedItems[item.accountId].push(item);
+ return groupedItems;
+ }, {});
+}
+
+export function buildAccountRows(
+ accounts: Account[],
+ groupedItems: AccountGroupedItems,
+ getTotalMonthly: (items: T[], account: Account) => number,
+): AccountRow[] {
+ return accounts
+ .map((account) => {
+ const items = groupedItems[account.id] || [];
+ return {
+ account,
+ items,
+ totalMonthly: getTotalMonthly(items, account),
+ };
+ })
+ .filter(({ items }) => items.length > 0)
+ .sort((leftRow, rightRow) => rightRow.totalMonthly - leftRow.totalMonthly);
+}
+
+export function getAccountNameById(accounts: Account[], accountId?: string): string {
+ if (!accountId) {
+ return 'Unknown Account';
+ }
+
+ return accounts.find((account) => account.id === accountId)?.name || 'Unknown Account';
+}
\ No newline at end of file
diff --git a/src/utils/billFrequency.ts b/src/utils/billFrequency.ts
index 2fd547f..743d119 100644
--- a/src/utils/billFrequency.ts
+++ b/src/utils/billFrequency.ts
@@ -1,4 +1,4 @@
-import type { BillFrequency } from '../types/auth';
+import type { BillFrequency } from '../types/frequencies';
import { roundUpToCent } from './money';
import { getBillFrequencyOccurrencesPerYear } from './frequency';
diff --git a/src/utils/demoDataGenerator.ts b/src/utils/demoDataGenerator.ts
index 357cf1d..329b5db 100644
--- a/src/utils/demoDataGenerator.ts
+++ b/src/utils/demoDataGenerator.ts
@@ -1,4 +1,8 @@
-import type { BudgetData, Account, Bill, Benefit, RetirementElection, PayFrequency, Loan, SavingsContribution } from '../types/auth';
+import type { Account } from '../types/accounts';
+import type { BudgetData } from '../types/budget';
+import type { Bill, Loan, SavingsContribution } from '../types/obligations';
+import type { Benefit, RetirementElection } from '../types/payroll';
+import type { PayFrequency } from '../types/frequencies';
import { getPaychecksPerYear } from './payPeriod';
/**
diff --git a/src/utils/displayAmounts.test.ts b/src/utils/displayAmounts.test.ts
new file mode 100644
index 0000000..cc8fe51
--- /dev/null
+++ b/src/utils/displayAmounts.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { fromDisplayAmount, monthlyToDisplayAmount, toDisplayAmount } from './displayAmounts';
+
+describe('displayAmounts utilities', () => {
+ it('converts per-paycheck amounts to each display mode', () => {
+ expect(toDisplayAmount(100, 26, 'paycheck')).toBe(100);
+ expect(toDisplayAmount(100, 26, 'monthly')).toBe(216.67);
+ expect(toDisplayAmount(100, 26, 'yearly')).toBe(2600);
+ });
+
+ it('converts display amounts back to per-paycheck values', () => {
+ expect(fromDisplayAmount(100, 26, 'paycheck')).toBe(100);
+ expect(fromDisplayAmount(216.67, 26, 'monthly')).toBeCloseTo(100, 2);
+ expect(fromDisplayAmount(2600, 26, 'yearly')).toBe(100);
+ });
+
+ it('converts monthly amounts into the requested display mode', () => {
+ expect(monthlyToDisplayAmount(200, 26, 'monthly')).toBe(200);
+ expect(monthlyToDisplayAmount(200, 26, 'paycheck')).toBeCloseTo(92.31, 2);
+ expect(monthlyToDisplayAmount(200, 26, 'yearly')).toBe(2400);
+ });
+});
\ No newline at end of file
diff --git a/src/utils/displayAmounts.ts b/src/utils/displayAmounts.ts
new file mode 100644
index 0000000..a800b45
--- /dev/null
+++ b/src/utils/displayAmounts.ts
@@ -0,0 +1,27 @@
+import type { ViewMode } from '../types/viewMode';
+import { convertFromDisplayMode, convertToDisplayMode } from './payPeriod';
+
+export function toDisplayAmount(
+ perPaycheckAmount: number,
+ paychecksPerYear: number,
+ mode: ViewMode,
+): number {
+ return convertToDisplayMode(perPaycheckAmount, paychecksPerYear, mode);
+}
+
+export function fromDisplayAmount(
+ displayAmount: number,
+ paychecksPerYear: number,
+ mode: ViewMode,
+): number {
+ return convertFromDisplayMode(displayAmount, paychecksPerYear, mode);
+}
+
+export function monthlyToDisplayAmount(
+ monthlyAmount: number,
+ paychecksPerYear: number,
+ mode: ViewMode,
+): number {
+ const perPaycheckAmount = (monthlyAmount * 12) / paychecksPerYear;
+ return toDisplayAmount(perPaycheckAmount, paychecksPerYear, mode);
+}
\ No newline at end of file
diff --git a/src/utils/filePath.test.ts b/src/utils/filePath.test.ts
new file mode 100644
index 0000000..d9856e3
--- /dev/null
+++ b/src/utils/filePath.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+import { getBaseFileName, getPlanNameFromPath, stripFileExtension } from './filePath';
+
+describe('filePath utilities', () => {
+ it('gets the base file name for unix and windows paths', () => {
+ expect(getBaseFileName('/plans/Paycheck Planner.budget')).toBe('Paycheck Planner.budget');
+ expect(getBaseFileName('C:\\plans\\Paycheck Planner.budget')).toBe('Paycheck Planner.budget');
+ });
+
+ it('handles trailing separators and empty input', () => {
+ expect(getBaseFileName('/plans/archive/')).toBe('archive');
+ expect(getBaseFileName('')).toBeNull();
+ expect(getBaseFileName(undefined)).toBeNull();
+ });
+
+ it('strips only the final file extension', () => {
+ expect(stripFileExtension('plan.budget')).toBe('plan');
+ expect(stripFileExtension('plan.backup.budget')).toBe('plan.backup');
+ expect(stripFileExtension('.env')).toBe('.env');
+ });
+
+ it('derives plan names with trimming and extension removal', () => {
+ expect(getPlanNameFromPath('/plans/ Household Budget.budget ')).toBe('Household Budget');
+ expect(getPlanNameFromPath('C:\\plans\\Quarterly Review')).toBe('Quarterly Review');
+ });
+
+ it('returns null for whitespace-only names', () => {
+ expect(getPlanNameFromPath('/plans/ ')).toBeNull();
+ });
+});
\ No newline at end of file
diff --git a/src/utils/filePath.ts b/src/utils/filePath.ts
new file mode 100644
index 0000000..93aaa17
--- /dev/null
+++ b/src/utils/filePath.ts
@@ -0,0 +1,28 @@
+export function getBaseFileName(filePath?: string | null): string | null {
+ if (!filePath) return null;
+
+ const normalizedPath = filePath.replace(/[\\/]+$/, '');
+ if (!normalizedPath) return null;
+
+ const lastSeparatorIndex = Math.max(normalizedPath.lastIndexOf('/'), normalizedPath.lastIndexOf('\\'));
+ const fileName = lastSeparatorIndex >= 0 ? normalizedPath.slice(lastSeparatorIndex + 1) : normalizedPath;
+ return fileName || null;
+}
+
+export function stripFileExtension(name?: string | null): string | null {
+ if (!name) return null;
+
+ const lastDotIndex = name.lastIndexOf('.');
+ if (lastDotIndex <= 0) {
+ return name;
+ }
+
+ return name.slice(0, lastDotIndex);
+}
+
+export function getPlanNameFromPath(filePath?: string | null): string | null {
+ const fileName = getBaseFileName(filePath);
+ const planName = stripFileExtension(fileName);
+ const normalized = planName?.trim();
+ return normalized || null;
+}
\ No newline at end of file
diff --git a/src/utils/frequency.ts b/src/utils/frequency.ts
index 8df33d5..a97d9cb 100644
--- a/src/utils/frequency.ts
+++ b/src/utils/frequency.ts
@@ -1,4 +1,4 @@
-import type { BillFrequency, CoreFrequency, PayFrequency, SavingsFrequency } from '../types/auth';
+import type { BillFrequency, CoreFrequency, PayFrequency, SavingsFrequency } from '../types/frequencies';
export function normalizeFrequencyToken(value: string): string {
return value
diff --git a/src/utils/payPeriod.ts b/src/utils/payPeriod.ts
index d71055a..b6cc90b 100644
--- a/src/utils/payPeriod.ts
+++ b/src/utils/payPeriod.ts
@@ -3,7 +3,9 @@
* These functions help convert between different time periods (paycheck, monthly, yearly)
*/
-import type { PayFrequency, PaySettings } from '../types/auth';
+import type { PayFrequency } from '../types/frequencies';
+import type { PaySettings } from '../types/payroll';
+import type { ViewMode } from '../types/viewMode';
import { getPayFrequencyOccurrencesPerYear } from './frequency';
/**
@@ -25,7 +27,7 @@ export function getPaychecksPerYear(frequency: PayFrequency | string): number {
export function convertToDisplayMode(
paycheckAmount: number,
paychecksPerYear: number,
- displayMode: 'paycheck' | 'monthly' | 'yearly'
+ displayMode: ViewMode
): number {
const roundToCent = (amount: number) => Math.round((amount + Number.EPSILON) * 100) / 100;
@@ -51,7 +53,7 @@ export function convertToDisplayMode(
export function convertFromDisplayMode(
displayAmount: number,
paychecksPerYear: number,
- displayMode: 'paycheck' | 'monthly' | 'yearly'
+ displayMode: ViewMode
): number {
switch (displayMode) {
case 'paycheck':
@@ -70,7 +72,7 @@ export function convertFromDisplayMode(
* @param displayMode - The display mode ('paycheck', 'monthly', 'yearly')
* @returns Human-readable label
*/
-export function getDisplayModeLabel(displayMode: 'paycheck' | 'monthly' | 'yearly'): string {
+export function getDisplayModeLabel(displayMode: ViewMode): string {
switch (displayMode) {
case 'paycheck':
return 'Per Paycheck';
diff --git a/src/utils/paySuggestions.test.ts b/src/utils/paySuggestions.test.ts
new file mode 100644
index 0000000..34593e1
--- /dev/null
+++ b/src/utils/paySuggestions.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+import { formatSuggestedLeftover, getSuggestedLeftoverPerPaycheck } from './paySuggestions';
+
+describe('paySuggestions utilities', () => {
+ it('returns zero for invalid or non-positive gross pay', () => {
+ expect(getSuggestedLeftoverPerPaycheck(0)).toBe(0);
+ expect(getSuggestedLeftoverPerPaycheck(-100)).toBe(0);
+ expect(getSuggestedLeftoverPerPaycheck(Number.NaN)).toBe(0);
+ });
+
+ it('applies the minimum floor for low gross pay', () => {
+ expect(getSuggestedLeftoverPerPaycheck(200)).toBe(75);
+ expect(getSuggestedLeftoverPerPaycheck(374)).toBe(75);
+ });
+
+ it('rounds to the nearest ten for larger gross pay', () => {
+ expect(getSuggestedLeftoverPerPaycheck(1000)).toBe(200);
+ expect(getSuggestedLeftoverPerPaycheck(963)).toBe(190);
+ expect(getSuggestedLeftoverPerPaycheck(987)).toBe(200);
+ });
+
+ it('formats positive suggestions as whole-currency values', () => {
+ expect(formatSuggestedLeftover(200, 'USD')).toBe('$200');
+ });
+
+ it('returns null for zero or invalid formatted suggestions', () => {
+ expect(formatSuggestedLeftover(0, 'USD')).toBeNull();
+ expect(formatSuggestedLeftover(Number.NaN, 'USD')).toBeNull();
+ });
+});
\ No newline at end of file
diff --git a/src/utils/paySuggestions.ts b/src/utils/paySuggestions.ts
new file mode 100644
index 0000000..f8e481d
--- /dev/null
+++ b/src/utils/paySuggestions.ts
@@ -0,0 +1,26 @@
+const LEFTOVER_SUGGESTION_RATE = 0.2;
+const LEFTOVER_SUGGESTION_ROUNDING = 10;
+const MIN_LEFTOVER_SUGGESTION = 75;
+
+export function getSuggestedLeftoverPerPaycheck(grossPerPaycheck: number): number {
+ if (!Number.isFinite(grossPerPaycheck) || grossPerPaycheck <= 0) {
+ return 0;
+ }
+
+ const rawSuggestion = grossPerPaycheck * LEFTOVER_SUGGESTION_RATE;
+ const rounded = Math.round(rawSuggestion / LEFTOVER_SUGGESTION_ROUNDING) * LEFTOVER_SUGGESTION_ROUNDING;
+ return Math.max(MIN_LEFTOVER_SUGGESTION, rounded);
+}
+
+export function formatSuggestedLeftover(amount: number, currencyCode: string): string | null {
+ if (!Number.isFinite(amount) || amount <= 0) {
+ return null;
+ }
+
+ return new Intl.NumberFormat(undefined, {
+ style: 'currency',
+ currency: currencyCode,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(amount);
+}
\ No newline at end of file
diff --git a/src/utils/retirement.ts b/src/utils/retirement.ts
index 68fabde..565bcc5 100644
--- a/src/utils/retirement.ts
+++ b/src/utils/retirement.ts
@@ -1,4 +1,4 @@
-import type { RetirementElection } from '../types/auth';
+import type { RetirementElection } from '../types/payroll';
export const RETIREMENT_PLAN_OPTIONS: Array<{ value: RetirementElection['type']; label: string }> = [
{ value: '401k', label: '401(k)' },
diff --git a/src/utils/tabManagement.ts b/src/utils/tabManagement.ts
index 59f0b1f..d61e95d 100644
--- a/src/utils/tabManagement.ts
+++ b/src/utils/tabManagement.ts
@@ -1,5 +1,5 @@
// Utility functions for managing dashboard tabs
-import type { TabConfig } from '../types/auth';
+import type { TabConfig } from '../types/tabs';
export type TabId = 'metrics' | 'breakdown' | 'bills' | 'loans' | 'savings' | 'taxes';
diff --git a/version b/version
index 9e11b32..d15723f 100644
--- a/version
+++ b/version
@@ -1 +1 @@
-0.3.1
+0.3.2