diff --git a/.claude/commands/frontend-ui-ux.md b/.claude/commands/frontend-ui-ux.md new file mode 100644 index 000000000..5d031d01b --- /dev/null +++ b/.claude/commands/frontend-ui-ux.md @@ -0,0 +1,86 @@ +--- +description: Designer-turned-developer who crafts stunning UI/UX even without design mockups +argument-hint: [component-or-page-description] +--- + +# Role: Designer-Turned-Developer + +You are a designer who learned to code. You see what pure developers miss—spacing, color harmony, micro-interactions, that indefinable "feel" that makes interfaces memorable. Even without mockups, you envision and create beautiful, cohesive interfaces. + +**Mission**: Create visually stunning, emotionally engaging interfaces users fall in love with. Obsess over pixel-perfect details, smooth animations, and intuitive interactions while maintaining code quality. + +--- + +# Work Principles + +1. **Complete what's asked** — Execute the exact task. No scope creep. Work until it works. Never mark work complete without proper verification. +2. **Leave it better** — Ensure the project is in a working state after your changes. +3. **Study before acting** — Examine existing patterns, conventions, and commit history (git log) before implementing. Understand why code is structured the way it is. +4. **Blend seamlessly** — Match existing code patterns. Your code should look like the team wrote it. +5. **Be transparent** — Announce each step. Explain reasoning. Report both successes and failures. + +--- + +# Design Process + +Before coding, commit to a **BOLD aesthetic direction**: + +1. **Purpose**: What problem does this solve? Who uses it? +2. **Tone**: Pick an extreme—brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian +3. **Constraints**: Technical requirements (framework, performance, accessibility) +4. **Differentiation**: What's the ONE thing someone will remember? + +**Key**: Choose a clear direction and execute with precision. Intentionality > intensity. + +Then implement working code (HTML/CSS/JS, React, Vue, Angular, etc.) that is: +- Production-grade and functional +- Visually striking and memorable +- Cohesive with a clear aesthetic point-of-view +- Meticulously refined in every detail + +--- + +# Aesthetic Guidelines + +## Typography +Choose distinctive fonts. **Avoid**: Arial, Inter, Roboto, system fonts, Space Grotesk. Pair a characterful display font with a refined body font. + +## Color +Commit to a cohesive palette. Use CSS variables. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. **Avoid**: purple gradients on white (AI slop). + +## Motion +Focus on high-impact moments. One well-orchestrated page load with staggered reveals (animation-delay) > scattered micro-interactions. Use scroll-triggering and hover states that surprise. Prioritize CSS-only. Use Motion library for React when available. + +## Spatial Composition +Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density. + +## Visual Details +Create atmosphere and depth—gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, grain overlays. Never default to solid colors. + +--- + +# Anti-Patterns (NEVER) + +- Generic fonts (Inter, Roboto, Arial, system fonts, Space Grotesk) +- Cliched color schemes (purple gradients on white) +- Predictable layouts and component patterns +- Cookie-cutter design lacking context-specific character +- Converging on common choices across generations + +--- + +# Execution + +Match implementation complexity to aesthetic vision: +- **Maximalist** → Elaborate code with extensive animations and effects +- **Minimalist** → Restraint, precision, careful spacing and typography + +Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. You are capable of extraordinary creative work—don't hold back. + +--- + +# Task + +$ARGUMENTS + +If no specific task is provided, ask what UI/UX component or page the user would like to create. diff --git a/.claude/plans/reflective-wiggling-dragonfly.md b/.claude/plans/reflective-wiggling-dragonfly.md new file mode 100644 index 000000000..57101a28c --- /dev/null +++ b/.claude/plans/reflective-wiggling-dragonfly.md @@ -0,0 +1,394 @@ +# Plan: Coverage Protection Dashboard by Person + +## Summary +Create a visual dashboard showing coverage targets vs actual coverage per person, with gap analysis and protection score. **Phase 1 focuses on UI with empty state** (0% coverage until policies are integrated). + +--- + +## ASCII Design: Coverage Dashboard (Empty State - Phase 1) + +After the wizard is completed, show a **Coverage Dashboard** view. Initially shows 0% coverage since no policies are linked yet: + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ My Coverage [Edit Targets] [⟳] │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌─────────────────────────────────┐ ┌───────────────────────────────┐ │ │ +│ │ │ 👤 John Tan [$120K/yr]│ │ PROTECTION SCORE │ │ │ +│ │ │ [Change Person ▼] │ │ │ │ │ +│ │ └─────────────────────────────────┘ │ ╭─────────╮ │ │ │ +│ │ │ ╱ ╲ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ 0% │ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ ╲ ╱ │ │ │ +│ │ │ ╰─────────╯ │ │ │ +│ │ │ │ │ │ +│ │ │ Add policies to see your │ │ │ +│ │ │ protection score │ │ │ +│ │ └───────────────────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ COVERAGE TARGETS │ │ +│ │ ───────────────────────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ 🏥 Hospitalization ⚠ No Policy │ │ +│ │ Target: Ward A + Rider │ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% │ │ +│ │ │ │ +│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ +│ │ │ │ +│ │ 🛡️ Life / TPD ⚠ No Policy │ │ +│ │ Target: $1.2M (10× income) │ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% │ │ +│ │ │ │ +│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ +│ │ │ │ +│ │ 💊 Critical Illness ⚠ No Policy │ │ +│ │ Target: $600K (5× income) │ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% │ │ +│ │ │ │ +│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ +│ │ │ │ +│ │ ⚡ Personal Accident ⚠ No Policy │ │ +│ │ Target: $360K (3× income) │ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ PREMIUM BUDGET │ │ +│ │ ───────────────────────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ Budget: $12,000/year (10% of income) Current: $0/year │ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% │ │ +│ │ $12,000 remaining │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────────────────────┐ │ │ +│ │ │ + Add Your First Policy to Track Coverage │ │ │ +│ │ └──────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Circular Gauge Component Detail + +The protection score gauge uses an SVG circle with stroke-dasharray for the progress arc: + +``` + ╭─────────────────╮ + ╱ ╲ + ╱ ┌───────────┐ ╲ + │ │ 67% │ │ + │ │ │ │ + │ │ ◉ Good │ │ ← Status text below percentage + │ └───────────┘ │ + ╲ ╭───────────╮ ╱ + ╲ ╱ ╲ ╱ + ╰─────────────────╯ + + ↑ Colored arc shows progress + (emerald for good, amber for partial, red for gap) + +Score Thresholds: + - 80-100%: "Well Protected" (emerald) + - 50-79%: "Partial Coverage" (amber) + - 0-49%: "Needs Attention" (red) +``` + +**Gauge Implementation (SVG):** +``` +┌─────────────────────────────────────┐ +│ │ +│ ╱‾‾‾‾‾‾‾‾‾‾‾╲ │ ← Background circle (gray) +│ │ ╱‾‾‾╲ │ │ +│ │ │ 67% │ │ │ ← Progress arc overlay (colored) +│ │ ╲___╱ │ │ +│ ╲___________╱ │ ← Stroke-dasharray controls fill +│ │ +│ 2 gaps to address │ ← Summary text +│ │ +└─────────────────────────────────────┘ +``` + +--- + +## Coverage Progress Bar Component Detail + +Each category has its own progress bar with contextual information: + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ 🛡️ Life / TPD ⚠ Gap │ +│ ─────────────────────────────────────────────────────────────── │ +│ │ +│ Target: $1.2M • Current: $800K • Gap: $400K │ +│ │ +│ ████████████████████████████████░░░░░░░░░░░░░░░░░░░░ 67% │ +│ ↑ filled portion (emerald) ↑ empty portion (gray) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Status Badge Colors: + - "✓ Covered" → emerald badge + - "⚠ Gap" → amber badge + - "⚠ No Policy" → gray/muted badge +``` + +**Hospitalization (Binary - no progress bar needed):** +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ 🏥 Hospitalization ✓ Covered │ +│ ─────────────────────────────────────────────────────────────── │ +│ │ +│ Ward A + Rider ← Just shows the coverage type │ +│ │ +│ ████████████████████████████████████████████████████████ 100% │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## With Policies (Future State - Phase 2) + +Once policies are linked, the dashboard shows actual coverage: + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ My Coverage [Edit Targets] [⟳] │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌─────────────────────────────────┐ ┌───────────────────────────────┐ │ │ +│ │ │ 👤 John Tan [$120K/yr]│ │ PROTECTION SCORE │ │ │ +│ │ │ [Change Person ▼] │ │ │ │ │ +│ │ └─────────────────────────────────┘ │ ╭─────────╮ │ │ │ +│ │ │ ╱ ██████████╲ │ │ │ +│ │ │ │ ██████████ │ │ │ │ +│ │ │ │ 67% │ │ │ │ +│ │ │ │ ██████████ │ │ │ │ +│ │ │ ╲ █████████ ╱ │ │ │ +│ │ │ ╰─────────╯ │ │ │ +│ │ │ │ │ │ +│ │ │ ⚠ Partial Coverage │ │ │ +│ │ │ 2 gaps need attention │ │ │ +│ │ └───────────────────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ COVERAGE BY CATEGORY │ │ +│ │ ───────────────────────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ 🏥 Hospitalization ✓ Covered │ │ +│ │ Ward A + Rider │ │ +│ │ ████████████████████████████████████████████████████████████ 100% │ │ +│ │ │ │ +│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ +│ │ │ │ +│ │ 🛡️ Life / TPD ⚠ Gap │ │ +│ │ Target: $1.2M • Current: $800K • Gap: $400K │ │ +│ │ ███████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░ 67% │ │ +│ │ │ │ +│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ +│ │ │ │ +│ │ 💊 Critical Illness ⚠ Gap │ │ +│ │ Target: $600K • Current: $200K • Gap: $400K │ │ +│ │ ███████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 33% │ │ +│ │ │ │ +│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ +│ │ │ │ +│ │ ⚡ Personal Accident ✓ Covered │ │ +│ │ Target: $360K • Current: $500K • Excess: +$140K │ │ +│ │ ████████████████████████████████████████████████████████████ 100%+ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ PREMIUM SUMMARY │ │ +│ │ ───────────────────────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ Monthly: $450 Annual: $5,400 Budget: $12,000 (10%) │ │ +│ │ ███████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 45% │ │ +│ │ Using 45% of budget • $6,600 remaining │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────────────────────���──┐ │ +│ │ QUICK ACTIONS │ │ +│ │ │ │ +│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │ +│ │ │ + Add Policy │ │ 📊 View Gaps │ │ ⚙ Edit Targets │ │ │ +│ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Layout Options + +**Option A: Side-by-side (Current plan)** +``` +┌──────────────────────────────────┬────────────────────────────────────────────┐ +│ PERSON SELECTOR │ PROTECTION SCORE │ +│ 👤 John Tan │ ╭─────╮ │ +│ $120K/year │ │ 67% │ │ +│ │ ╰─────╯ │ +└──────────────────────────────────┴────────────────────────────────────────────┘ +``` + +**Option B: Stacked (Mobile-friendly)** +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ 👤 John Tan $120K/year │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ ╭─────────╮ │ +│ │ 67% │ │ +│ ╰─────────╯ │ +│ Partial Coverage │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Data Flow + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Guidelines │ │ Policies │ │ Coverage │ +│ Store │ │ (Phase 2) │ │ Dashboard │ +│ │ │ │ │ │ +│ • selectedPerson│────▶│ • Query by │────▶│ • Target vs │ +│ • annualIncome │ │ personId │ │ Actual │ +│ • multipliers │ │ • Sum coverage │ │ • Gap calc │ +│ • targets │ │ by category │ │ • Score % │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + +Phase 1: Guidelines Store → Dashboard (0% coverage, targets only) +Phase 2: + Policies → Dashboard (actual coverage shown) +``` + +--- + +## Protection Score Calculation + +``` +score = weightedAverage( + hospitalization: hasValidISP ? 100 : 0, weight: 0.25 + life_tpd: min(100, (current / target) * 100), weight: 0.35 + critical_illness: min(100, (current / target) * 100), weight: 0.25 + personal_accident: min(100, (current / target) * 100), weight: 0.15 +) +``` + +--- + +## Component Structure + +``` +CoverageDashboard +├── DashboardHeader +│ ├── Title "My Coverage" +│ ├── Edit Targets button +│ └── Reset button +├── PersonScoreSection (flex row) +│ ├── PersonCard +│ │ ├── Person name + color dot +│ │ ├── Annual income display +│ │ └── Change Person dropdown +│ └── ProtectionScoreGauge +│ ├── SVG circular progress +│ ├── Percentage text +│ └── Status message +├── CoverageCategoryList +│ └── CoverageCategoryRow × 4 +│ ├── Category icon + label +│ ├── Status badge (Covered/Gap/No Policy) +│ ├── Target/Current/Gap amounts +│ └── Progress bar +├── PremiumBudgetSection +│ ├── Budget info (monthly/annual) +│ ├── Progress bar +│ └── Remaining amount +└── QuickActionsSection + └── Add Policy button (prominent CTA) +``` + +--- + +## Files to Modify + +### 1. `frontend/src/types/insurance.ts` ✅ DONE +- Added `CoverageCategoryStatus` interface +- Added `CoverageStatus` interface +- Added `calculateProtectionScore()` function +- Added `buildCoverageStatus()` function + +### 2. `frontend/src/components/insurance/CoverageDashboard.tsx` (NEW) +Main dashboard component containing: +- `ProtectionScoreGauge` - SVG circular gauge +- `CoverageCategoryRow` - Individual category with progress bar +- `PremiumBudgetBar` - Budget utilization display +- `CoverageDashboard` - Main orchestrating component + +### 3. `frontend/src/components/insurance/tabs/GuidelinesTab.tsx` +- Replace `ConfiguredGuidelinesView` with import of `CoverageDashboard` + +--- + +## Implementation Notes + +**SVG Circular Gauge:** +- Use `stroke-dasharray` and `stroke-dashoffset` for progress +- Circle with radius ~45, stroke-width ~10 +- Background circle in gray, progress arc in color +- Animate with CSS transition on stroke-dashoffset + +**Progress Bars:** +- Simple div with width percentage +- Background: `bg-white/[0.1]` +- Fill: `bg-emerald-500` (covered), `bg-amber-500` (partial), `bg-slate-600` (empty) + +**Empty State:** +- All progress at 0% +- Protection score shows 0% +- Prominent CTA to add first policy +- Targets still visible so user knows what they're working toward + +--- + +## Verification + +1. Complete wizard → See coverage dashboard (empty state) +2. Dashboard shows targets with 0% +3. Circular gauge displays 0% with "Add policies" message +4. Person selector changes income and targets +5. TypeScript: `cd frontend && npx tsc --noEmit` + +--- + +## Questions for Review + +1. **Layout**: Side-by-side (Option A) or stacked (Option B) for person+score section? +2. **Empty state CTA**: Single prominent button or include "View Gaps" as secondary action? +3. **Category icons**: Use emojis (🏥🛡️💊⚡) or Lucide icons? diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..1f195064c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "enabledPlugins": { + "ralph-wiggum@claude-plugins-official": true + }, + "permissions": { + "allow": [ + "Bash(/Users/jitcorn/.claude/plugins/cache/claude-plugins-official/ralph-wiggum/*)" + ] + } +} diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 412cef9e6..99b80baf7 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -2,9 +2,9 @@ name: Claude Code on: issue_comment: - types: [created] + types: [created, edited] pull_request_review_comment: - types: [created] + types: [created, edited] issues: types: [opened, assigned] pull_request_review: @@ -19,11 +19,11 @@ jobs: (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) runs-on: ubuntu-latest permissions: - contents: read - pull-requests: read - issues: read + contents: write + pull-requests: write + issues: write id-token: write - actions: read # Required for Claude to read CI results on PRs + actions: read steps: - name: Checkout repository uses: actions/checkout@v4 @@ -32,19 +32,9 @@ jobs: - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@v1.0.43 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | actions: read - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' - diff --git a/.gitignore b/.gitignore index 58e79b846..3139a15c7 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ dist/ build/ .next/ .gocache/ +storybook-static/ # Logs *.log diff --git a/backend/cmd/server/handlers/persons_v2.go b/backend/cmd/server/handlers/persons_v2.go index 583b2a62b..393b51d4c 100644 --- a/backend/cmd/server/handlers/persons_v2.go +++ b/backend/cmd/server/handlers/persons_v2.go @@ -17,6 +17,7 @@ type personV2CreateInput struct { Gender string `json:"gender"` // Required: 'male' or 'female' for CPF LIFE calculations ResidencyStatus string `json:"residencyStatus"` // 'citizen' or 'pr' (PR year is computed from prGrantDate) PRGrantDate *string `json:"prGrantDate"` // Required if residencyStatus='pr', format: "2006-01-02" + Relationship string `json:"relationship"` // 'self', 'spouse', 'child', 'parent', 'sibling', 'other' } // personV2UpdateInput is the JSON-friendly input struct for updating a person. @@ -28,6 +29,7 @@ type personV2UpdateInput struct { Gender string `json:"gender"` // Optional: 'male' or 'female' ResidencyStatus string `json:"residencyStatus"` // 'citizen' or 'pr' PRGrantDate *string `json:"prGrantDate"` // Required if residencyStatus='pr', format: "2006-01-02" + Relationship string `json:"relationship"` // Optional: 'self', 'spouse', 'child', 'parent', 'sibling', 'other' } // PersonV2Handler serves person v2 endpoints. @@ -146,6 +148,7 @@ func (h *PersonV2Handler) HandleCreate(w http.ResponseWriter, r *http.Request) { Gender: input.Gender, ResidencyStatus: residencyStatus, PRGrantDate: prGrantDate, + Relationship: input.Relationship, } if input.DisplayColor != "" { person.DisplayColor = &input.DisplayColor @@ -274,6 +277,7 @@ func (h *PersonV2Handler) HandleUpdate(w http.ResponseWriter, r *http.Request, i Gender: gender, ResidencyStatus: input.ResidencyStatus, PRGrantDate: prGrantDate, + Relationship: input.Relationship, } if input.DisplayColor != "" { person.DisplayColor = &input.DisplayColor diff --git a/backend/internal/financial/repository/timeline.go b/backend/internal/financial/repository/timeline.go index e2dd2e9c6..987053d20 100644 --- a/backend/internal/financial/repository/timeline.go +++ b/backend/internal/financial/repository/timeline.go @@ -27,6 +27,7 @@ type UserSettings struct { GroupItemsByCategory bool `json:"groupItemsByCategory"` // Whether to group financial items by category in UI ChartPictureInPicture bool `json:"chartPictureInPicture"` // Whether to show mini chart when scrolled out of view DashboardLayout string `json:"dashboardLayout"` // Dashboard layout: stacked, chart-left, or chart-right + OnboardingCompleted bool `json:"onboardingCompleted"` // Whether the onboarding wizard has been completed or dismissed UpdatedAt time.Time `json:"updatedAt,omitempty"` } @@ -257,11 +258,12 @@ func (s *Store) GetUserSettings(ctx context.Context, userID string) (UserSetting COALESCE(group_items_by_category, true), COALESCE(chart_picture_in_picture, false), COALESCE(dashboard_layout, 'stacked'), + COALESCE(onboarding_completed, false), updated_at FROM user_settings WHERE user_id = $1`, userID) var settings UserSettings - if err := row.Scan(&settings.ID, &settings.UserID, &settings.StartingAge, &settings.TerminalAge, &settings.YearDisplayFormat, &settings.TimeResolution, &settings.CompoundingFrequency, &settings.AutoExecuteTools, &settings.GroupItemsByCategory, &settings.ChartPictureInPicture, &settings.DashboardLayout, &settings.UpdatedAt); err != nil { + if err := row.Scan(&settings.ID, &settings.UserID, &settings.StartingAge, &settings.TerminalAge, &settings.YearDisplayFormat, &settings.TimeResolution, &settings.CompoundingFrequency, &settings.AutoExecuteTools, &settings.GroupItemsByCategory, &settings.ChartPictureInPicture, &settings.DashboardLayout, &settings.OnboardingCompleted, &settings.UpdatedAt); err != nil { if errors.Is(err, sql.ErrNoRows) { return DefaultUserSettings, nil } @@ -273,8 +275,8 @@ func (s *Store) GetUserSettings(ctx context.Context, userID string) (UserSetting // UpsertUserSettings inserts or updates user settings. func (s *Store) UpsertUserSettings(ctx context.Context, userID string, settings UserSettings) (UserSettings, error) { row := s.db.QueryRowContext(ctx, ` - INSERT INTO user_settings (user_id, starting_age, terminal_age, year_display_format, time_resolution, compounding_frequency, auto_execute_tools, group_items_by_category, chart_picture_in_picture, dashboard_layout) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + INSERT INTO user_settings (user_id, starting_age, terminal_age, year_display_format, time_resolution, compounding_frequency, auto_execute_tools, group_items_by_category, chart_picture_in_picture, dashboard_layout, onboarding_completed) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (user_id) DO UPDATE SET starting_age = EXCLUDED.starting_age, terminal_age = EXCLUDED.terminal_age, @@ -285,6 +287,7 @@ func (s *Store) UpsertUserSettings(ctx context.Context, userID string, settings group_items_by_category = EXCLUDED.group_items_by_category, chart_picture_in_picture = EXCLUDED.chart_picture_in_picture, dashboard_layout = EXCLUDED.dashboard_layout, + onboarding_completed = EXCLUDED.onboarding_completed, updated_at = NOW() RETURNING id, user_id, starting_age, terminal_age, year_display_format, COALESCE(time_resolution, 'yearly'), @@ -293,10 +296,11 @@ func (s *Store) UpsertUserSettings(ctx context.Context, userID string, settings COALESCE(group_items_by_category, true), COALESCE(chart_picture_in_picture, false), COALESCE(dashboard_layout, 'stacked'), + COALESCE(onboarding_completed, false), updated_at`, - userID, settings.StartingAge, settings.TerminalAge, settings.YearDisplayFormat, settings.TimeResolution, settings.CompoundingFrequency, settings.AutoExecuteTools, settings.GroupItemsByCategory, settings.ChartPictureInPicture, settings.DashboardLayout) + userID, settings.StartingAge, settings.TerminalAge, settings.YearDisplayFormat, settings.TimeResolution, settings.CompoundingFrequency, settings.AutoExecuteTools, settings.GroupItemsByCategory, settings.ChartPictureInPicture, settings.DashboardLayout, settings.OnboardingCompleted) var updated UserSettings - if err := row.Scan(&updated.ID, &updated.UserID, &updated.StartingAge, &updated.TerminalAge, &updated.YearDisplayFormat, &updated.TimeResolution, &updated.CompoundingFrequency, &updated.AutoExecuteTools, &updated.GroupItemsByCategory, &updated.ChartPictureInPicture, &updated.DashboardLayout, &updated.UpdatedAt); err != nil { + if err := row.Scan(&updated.ID, &updated.UserID, &updated.StartingAge, &updated.TerminalAge, &updated.YearDisplayFormat, &updated.TimeResolution, &updated.CompoundingFrequency, &updated.AutoExecuteTools, &updated.GroupItemsByCategory, &updated.ChartPictureInPicture, &updated.DashboardLayout, &updated.OnboardingCompleted, &updated.UpdatedAt); err != nil { return UserSettings{}, err } return updated, nil diff --git a/backend/internal/financial_v2/repository/person.go b/backend/internal/financial_v2/repository/person.go index b89b73e01..401eab4af 100644 --- a/backend/internal/financial_v2/repository/person.go +++ b/backend/internal/financial_v2/repository/person.go @@ -12,7 +12,7 @@ func (s *Store) ListPersons(ctx context.Context, userID string) ([]Person, error query := ` SELECT id, user_id, name, display_color, is_included, date_of_birth, gender, residency_status, pr_grant_date, - created_at, updated_at + relationship, created_at, updated_at FROM persons WHERE user_id = $1 ORDER BY created_at ASC` @@ -31,7 +31,7 @@ func (s *Store) ListPersons(ctx context.Context, userID string) ([]Person, error if err := rows.Scan( &p.ID, &p.UserID, &p.Name, &p.DisplayColor, &p.IsIncluded, &p.DateOfBirth, &p.Gender, &p.ResidencyStatus, &p.PRGrantDate, - &p.CreatedAt, &p.UpdatedAt, + &p.Relationship, &p.CreatedAt, &p.UpdatedAt, ); err != nil { return nil, fmt.Errorf("failed to scan person: %w", err) } @@ -51,7 +51,7 @@ func (s *Store) ListPersonsWithStats(ctx context.Context, userID string) ([]Pers SELECT p.id, p.user_id, p.name, p.display_color, p.is_included, p.date_of_birth, p.gender, p.residency_status, p.pr_grant_date, - p.created_at, p.updated_at, + p.relationship, p.created_at, p.updated_at, COALESCE(income_counts.count, 0) as income_count, COALESCE(cpf_counts.count, 0) as cpf_count FROM persons p @@ -84,7 +84,7 @@ func (s *Store) ListPersonsWithStats(ctx context.Context, userID string) ([]Pers if err := rows.Scan( &p.ID, &p.UserID, &p.Name, &p.DisplayColor, &p.IsIncluded, &p.DateOfBirth, &p.Gender, &p.ResidencyStatus, &p.PRGrantDate, - &p.CreatedAt, &p.UpdatedAt, &p.IncomeCount, &p.CPFCount, + &p.Relationship, &p.CreatedAt, &p.UpdatedAt, &p.IncomeCount, &p.CPFCount, ); err != nil { return nil, fmt.Errorf("failed to scan person with stats: %w", err) } @@ -103,7 +103,7 @@ func (s *Store) GetPerson(ctx context.Context, userID, id string) (*Person, erro query := ` SELECT id, user_id, name, display_color, is_included, date_of_birth, gender, residency_status, pr_grant_date, - created_at, updated_at + relationship, created_at, updated_at FROM persons WHERE user_id = $1 AND id = $2` @@ -113,7 +113,7 @@ func (s *Store) GetPerson(ctx context.Context, userID, id string) (*Person, erro err := s.pool.QueryRow(ctx, query, userID, id).Scan( &p.ID, &p.UserID, &p.Name, &p.DisplayColor, &p.IsIncluded, &p.DateOfBirth, &p.Gender, &p.ResidencyStatus, &p.PRGrantDate, - &p.CreatedAt, &p.UpdatedAt, + &p.Relationship, &p.CreatedAt, &p.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, ErrNotFound @@ -130,11 +130,11 @@ func (s *Store) CreatePerson(ctx context.Context, userID string, p Person) (*Per query := ` INSERT INTO persons (user_id, name, display_color, is_included, date_of_birth, gender, residency_status, pr_grant_date, - created_at, updated_at) - VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7, $8, NOW(), NOW()) + relationship, created_at, updated_at) + VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7, $8, $9, NOW(), NOW()) RETURNING id, user_id, name, display_color, is_included, date_of_birth, gender, residency_status, pr_grant_date, - created_at, updated_at` + relationship, created_at, updated_at` displayColor := "" if p.DisplayColor != nil { @@ -153,7 +153,13 @@ func (s *Store) CreatePerson(ctx context.Context, userID string, p Person) (*Per gender = "male" } - args := []any{userID, p.Name, displayColor, true, p.DateOfBirth, gender, residencyStatus, p.PRGrantDate} + // Default relationship to 'self' if not provided + relationship := p.Relationship + if relationship == "" { + relationship = "self" + } + + args := []any{userID, p.Name, displayColor, true, p.DateOfBirth, gender, residencyStatus, p.PRGrantDate, relationship} logQuery(query, args) @@ -161,7 +167,7 @@ func (s *Store) CreatePerson(ctx context.Context, userID string, p Person) (*Per err := s.pool.QueryRow(ctx, query, args...).Scan( &created.ID, &created.UserID, &created.Name, &created.DisplayColor, &created.IsIncluded, &created.DateOfBirth, &created.Gender, &created.ResidencyStatus, &created.PRGrantDate, - &created.CreatedAt, &created.UpdatedAt, + &created.Relationship, &created.CreatedAt, &created.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to create person: %w", err) @@ -181,11 +187,12 @@ func (s *Store) UpdatePerson(ctx context.Context, userID, id string, p Person) ( gender = COALESCE(NULLIF($7, ''), gender), residency_status = COALESCE(NULLIF($8, ''), residency_status), pr_grant_date = $9, + relationship = COALESCE(NULLIF($10, ''), relationship), updated_at = NOW() WHERE user_id = $1 AND id = $2 RETURNING id, user_id, name, display_color, is_included, date_of_birth, gender, residency_status, pr_grant_date, - created_at, updated_at` + relationship, created_at, updated_at` displayColor := "" if p.DisplayColor != nil { @@ -198,7 +205,7 @@ func (s *Store) UpdatePerson(ctx context.Context, userID, id string, p Person) ( dateOfBirth = p.DateOfBirth } - args := []any{userID, id, p.Name, displayColor, p.IsIncluded, dateOfBirth, p.Gender, p.ResidencyStatus, p.PRGrantDate} + args := []any{userID, id, p.Name, displayColor, p.IsIncluded, dateOfBirth, p.Gender, p.ResidencyStatus, p.PRGrantDate, p.Relationship} logQuery(query, args) @@ -206,7 +213,7 @@ func (s *Store) UpdatePerson(ctx context.Context, userID, id string, p Person) ( err := s.pool.QueryRow(ctx, query, args...).Scan( &updated.ID, &updated.UserID, &updated.Name, &updated.DisplayColor, &updated.IsIncluded, &updated.DateOfBirth, &updated.Gender, &updated.ResidencyStatus, &updated.PRGrantDate, - &updated.CreatedAt, &updated.UpdatedAt, + &updated.Relationship, &updated.CreatedAt, &updated.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, ErrNotFound @@ -245,7 +252,7 @@ func (s *Store) TogglePersonIncluded(ctx context.Context, userID, id string) (*P WHERE user_id = $1 AND id = $2 RETURNING id, user_id, name, display_color, is_included, date_of_birth, gender, residency_status, pr_grant_date, - created_at, updated_at` + relationship, created_at, updated_at` logQuery(query, []any{userID, id}) @@ -253,7 +260,7 @@ func (s *Store) TogglePersonIncluded(ctx context.Context, userID, id string) (*P err := s.pool.QueryRow(ctx, query, userID, id).Scan( &updated.ID, &updated.UserID, &updated.Name, &updated.DisplayColor, &updated.IsIncluded, &updated.DateOfBirth, &updated.Gender, &updated.ResidencyStatus, &updated.PRGrantDate, - &updated.CreatedAt, &updated.UpdatedAt, + &updated.Relationship, &updated.CreatedAt, &updated.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, ErrNotFound diff --git a/backend/internal/financial_v2/repository/store.go b/backend/internal/financial_v2/repository/store.go index 1d053499e..46a4cbd47 100644 --- a/backend/internal/financial_v2/repository/store.go +++ b/backend/internal/financial_v2/repository/store.go @@ -257,6 +257,7 @@ type Person struct { Gender string `json:"gender"` // 'male' or 'female' - required for CPF LIFE calculations ResidencyStatus string `json:"residencyStatus"` // 'citizen' or 'pr' (PR year computed from prGrantDate) PRGrantDate *time.Time `json:"prGrantDate,omitempty"` + Relationship string `json:"relationship"` // 'self', 'spouse', 'child', 'parent', 'sibling', 'other' CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` // Stats populated by GetPersonsWithStats diff --git a/backend/migrations/202602040001_add_person_relationship.down.sql b/backend/migrations/202602040001_add_person_relationship.down.sql new file mode 100644 index 000000000..8435468e7 --- /dev/null +++ b/backend/migrations/202602040001_add_person_relationship.down.sql @@ -0,0 +1 @@ +ALTER TABLE persons DROP COLUMN IF EXISTS relationship; diff --git a/backend/migrations/202602040001_add_person_relationship.up.sql b/backend/migrations/202602040001_add_person_relationship.up.sql new file mode 100644 index 000000000..35f76d86d --- /dev/null +++ b/backend/migrations/202602040001_add_person_relationship.up.sql @@ -0,0 +1,8 @@ +-- Add relationship column to persons table for household role identification +-- Used by insurance coverage guidelines to determine spouse vs dependent vs self + +ALTER TABLE persons +ADD COLUMN relationship VARCHAR(20) DEFAULT 'self' NOT NULL +CHECK (relationship IN ('self', 'spouse', 'child', 'parent', 'sibling', 'other')); + +COMMENT ON COLUMN persons.relationship IS 'Relationship to primary household member (self/spouse/child/parent/sibling/other)'; diff --git a/backend/migrations/202602040002_add_onboarding_completed.down.sql b/backend/migrations/202602040002_add_onboarding_completed.down.sql new file mode 100644 index 000000000..914a22945 --- /dev/null +++ b/backend/migrations/202602040002_add_onboarding_completed.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_settings +DROP COLUMN IF EXISTS onboarding_completed; diff --git a/backend/migrations/202602040002_add_onboarding_completed.up.sql b/backend/migrations/202602040002_add_onboarding_completed.up.sql new file mode 100644 index 000000000..6c382f396 --- /dev/null +++ b/backend/migrations/202602040002_add_onboarding_completed.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_settings +ADD COLUMN onboarding_completed boolean DEFAULT false NOT NULL; diff --git a/conductor/product-guidelines.md b/conductor/product-guidelines.md new file mode 100644 index 000000000..4be6e4e40 --- /dev/null +++ b/conductor/product-guidelines.md @@ -0,0 +1,32 @@ +# Product Guidelines + +## Prose Style + +The language used throughout the application and in all communications should be clear, concise, and professional. Avoid jargon where possible, or explain it clearly if necessary. The tone should be informative and helpful, instilling confidence in the user regarding their financial decisions. + +## Brand Messaging + +Our core message revolves around empowering users with intelligent financial planning. Key themes to emphasize include: + +- **Intelligence:** Leveraging AI and LLMs to provide smart, data-driven insights. +- **Control:** Users maintain full control over their financial actions with transparent review and approval processes. +- **Transparency:** Clear explanations of AI-generated suggestions and their potential impact. +- **Ease of Use:** A seamless, intuitive chat-based interface that simplifies complex financial management. +- **Security:** Reassurance about the protection of sensitive financial data. + +## Visual Identity + +The visual design should convey modernity, trustworthiness, and approachability. + +- **Clean Interface:** A minimalist and uncluttered design to reduce cognitive load. +- **Intuitive Layout:** Easy navigation and clear presentation of information. +- **Consistent Branding:** Use a consistent color palette, typography, and iconography across all platforms and communications to build brand recognition. + +## Tone of Voice + +The application's tone should be: + +- **Helpful:** Guiding users through their financial journey. +- **Reassuring:** Providing a sense of security and confidence in their financial planning. +- **Objective:** Presenting information and options impartially. +- **Engaging:** Maintaining an interactive and responsive dialogue. diff --git a/conductor/product.md b/conductor/product.md new file mode 100644 index 000000000..890eb8e70 --- /dev/null +++ b/conductor/product.md @@ -0,0 +1,26 @@ +# Initial Concept + +A modern financial planning chat application with LLM-native tool calling architecture. + +## Product Vision + +To provide an intuitive and intelligent platform for financial planning, leveraging large language models to assist users in managing their assets, liabilities, and financial scenarios through an interactive chat interface. The system aims to simplify complex financial tasks and offer actionable insights with user approval. + +## Key Features + +- **LLM-Native Tool Calling:** Integrates advanced language models to interpret user requests and invoke appropriate financial tools dynamically. +- **Manual Action Review:** Ensures user control and transparency by requiring explicit approval for AI-generated financial actions before execution. +- **Session Management:** Maintains persistent conversation context and state across user sessions, allowing for a seamless and continuous financial planning experience. +- **Financial Tools:** Provides a suite of tools for users to create, update, and manage various financial elements, including assets, liabilities, and property scenarios. +- **API Versioning:** Implements a versioned REST API to ensure backward compatibility and future-proof the system's extensibility. + +## Target Audience + +Individuals and households seeking an interactive and intelligent solution to manage their personal finances, plan for the future, and understand their financial standing with the assistance of AI. + +## High-Level Goals + +- Enhance user engagement in financial planning through conversational AI. +- Increase accuracy and efficiency in managing personal financial data. +- Provide clear, actionable, and user-approved financial advice and actions. +- Ensure the system is scalable, secure, and easily maintainable. diff --git a/conductor/setup_state.json b/conductor/setup_state.json new file mode 100644 index 000000000..24df52004 --- /dev/null +++ b/conductor/setup_state.json @@ -0,0 +1 @@ +{"last_successful_step": "2.3_tech_stack"} \ No newline at end of file diff --git a/conductor/tech-stack.md b/conductor/tech-stack.md new file mode 100644 index 000000000..ed2fab127 --- /dev/null +++ b/conductor/tech-stack.md @@ -0,0 +1,50 @@ +# Technology Stack + +## Overview + +The project leverages a modern, distributed architecture designed for scalability, performance, and maintainability. + +## Backend + +- **Language:** Go (Golang) + - Chosen for its performance characteristics, concurrency model, and strong type safety, making it ideal for building robust API services. +- **Framework:** Standard Go libraries and potentially specialized HTTP routers (e.g., Gin, Echo, not explicitly stated but common for REST APIs in Go). +- **LLM Integration:** + - OpenAI GPT-4: For primary large language model capabilities and tool-calling. + - Anthropic Claude: Supported as an alternative or supplementary LLM provider. +- **Database Driver:** Likely `database/sql` with a PostgreSQL driver (e.g., `lib/pq` or `pgx`). + +## Frontend + +- **Framework/Library:** React 18 + - A declarative, component-based JavaScript library for building user interfaces, chosen for its efficiency and widespread adoption. +- **Language:** TypeScript + - Provides static typing to JavaScript, enhancing code quality, maintainability, and developer experience. +- **Styling:** Not explicitly stated, but common choices include CSS-in-JS, Tailwind CSS, or component libraries like Material UI. +- **Build Tool:** Likely Webpack or Vite (inferred from React 18 context). + +## Database + +- **Type:** Relational Database +- **Specific:** PostgreSQL + - A powerful, open-source object-relational database system known for its reliability, feature robustness, and performance. +- **ORM/Query Builder:** Potentially `GORM`, `sqlc`, or direct `database/sql` usage (not explicitly stated). + +## Development and Operations (DevOps) + +- **Containerization:** Docker & Docker Compose + - For consistent development environments and streamlined deployment. +- **Build Automation:** Makefile + - Used for managing common development tasks like dependency installation, building, testing, and running services. +- **Version Control:** Git + - Standard distributed version control system. + +## Testing + +- **Backend:** Go's built-in testing framework (`testing` package). +- **Frontend:** Likely React Testing Library, Jest, or similar. + +## API + +- **Style:** REST APIs +- **Documentation:** Swagger UI (inferred from `README.md` mentioning `/swagger/index.html`). diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts new file mode 100644 index 000000000..21d5a6631 --- /dev/null +++ b/frontend/.storybook/main.ts @@ -0,0 +1,37 @@ +import type { StorybookConfig } from '@storybook/react-webpack5' +import path from 'path' + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(ts|tsx)'], + addons: [ + '@storybook/addon-webpack5-compiler-swc', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@storybook/addon-themes', + ], + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, + typescript: { + reactDocgen: 'react-docgen-typescript', + reactDocgenTypescriptOptions: { + shouldExtractLiteralValuesFromEnum: true, + propFilter: (prop) => + prop.parent ? !/node_modules/.test(prop.parent.fileName) : true, + }, + }, + webpackFinal: async (webpackConfig) => { + // Path alias: @ -> src/ + if (webpackConfig.resolve) { + webpackConfig.resolve.alias = { + ...webpackConfig.resolve.alias, + '@': path.resolve(__dirname, '../src'), + } + } + + return webpackConfig + }, +} + +export default config diff --git a/frontend/.storybook/preview-head.html b/frontend/.storybook/preview-head.html new file mode 100644 index 000000000..349012710 --- /dev/null +++ b/frontend/.storybook/preview-head.html @@ -0,0 +1,14 @@ + + + + + diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts new file mode 100644 index 000000000..a9a414464 --- /dev/null +++ b/frontend/.storybook/preview.ts @@ -0,0 +1,38 @@ +import type { Preview } from '@storybook/react' +import { withThemeByClassName } from '@storybook/addon-themes' + +// Import the same CSS files the app uses so all design tokens are available +import '../src/styles/globals.css' +import '../src/index.css' + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + layout: 'centered', + backgrounds: { + default: 'dark', + values: [ + { name: 'dark', value: 'hsl(240 10% 3.9%)' }, + { name: 'light', value: 'hsl(0 0% 100%)' }, + { name: 'monet', value: '#FAF8F5' }, + ], + }, + }, + decorators: [ + withThemeByClassName({ + themes: { + light: '', + dark: 'dark', + monet: 'monet', + }, + defaultTheme: 'dark', + }), + ], +} + +export default preview diff --git a/frontend/e2e/insurance-coverage.spec.ts b/frontend/e2e/insurance-coverage.spec.ts new file mode 100644 index 000000000..3066e32b1 --- /dev/null +++ b/frontend/e2e/insurance-coverage.spec.ts @@ -0,0 +1,397 @@ +/** + * E2E Tests: Insurance Coverage Guidelines + * + * Tests the insurance planner wizard flow and field inputs: + * - Step 1: Person selection + income display + * - Step 2: Questionnaire sections (Hospitalization, Life/TPD, Critical Illness, PA, Self-Insurance) + * - Step 3: Summary review + * + * Uses serial mode since wizard state carries between tests. + */ +import { authenticatedTest } from './fixtures/auth' +import { expect } from '@playwright/test' + +authenticatedTest.describe('Insurance Coverage Guidelines', () => { + authenticatedTest.describe.configure({ mode: 'serial' }) + + authenticatedTest('should navigate to insurance planner', async ({ authenticatedPage }) => { + const page = authenticatedPage + + // Navigate directly to the insurance planner page + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Verify we're on the insurance planner page + await expect(page.getByText('Insurance Planner').first()).toBeVisible({ timeout: 10000 }) + + // The default tab should be "My Coverage" which shows the GuidelinesTab + await expect(page.getByText('My Coverage').first()).toBeVisible({ timeout: 5000 }) + }) + + authenticatedTest('Step 1: should show person selector and income', async ({ authenticatedPage }) => { + const page = authenticatedPage + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Verify Step 1 heading + await expect(page.getByText('Step 1 of 3')).toBeVisible({ timeout: 10000 }) + await expect(page.getByText('Who are we planning for?')).toBeVisible() + + // Verify person selector exists + await expect(page.getByText('Select person').first()).toBeVisible() + + // The PersonSelector should auto-select the first person + // Check that the annual income section appears + await expect(page.getByText(/annual income/i).first()).toBeVisible({ timeout: 5000 }) + + // Verify the Continue button exists + const continueBtn = page.getByRole('button', { name: /continue/i }) + await expect(continueBtn).toBeVisible() + }) + + authenticatedTest('Step 1: should allow changing person selection', async ({ authenticatedPage }) => { + const page = authenticatedPage + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Wait for step 1 to load + await expect(page.getByText('Step 1 of 3')).toBeVisible({ timeout: 10000 }) + + // Click the person selector dropdown to open it + const personSelector = page.locator('.relative').filter({ hasText: /select person/i }).first() + const dropdownTrigger = personSelector.locator('button').first() + + if (await dropdownTrigger.isVisible()) { + await dropdownTrigger.click() + + // Verify the dropdown opened - should show person options + await page.waitForTimeout(500) + + // Check that at least one person is in the dropdown + const personOptions = page.locator('button').filter({ hasText: /\w+/ }) + const optionCount = await personOptions.count() + expect(optionCount).toBeGreaterThan(0) + + // Close dropdown by pressing Escape + await page.keyboard.press('Escape') + } + }) + + authenticatedTest('Step 1: should navigate to Step 2 on Continue', async ({ authenticatedPage }) => { + const page = authenticatedPage + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Wait for step 1 + await expect(page.getByText('Step 1 of 3')).toBeVisible({ timeout: 10000 }) + + // Click continue to go to Step 2 + const continueBtn = page.getByRole('button', { name: /continue/i }) + await expect(continueBtn).toBeVisible({ timeout: 5000 }) + + // Only click if the button is enabled (income > 0) + const isDisabled = await continueBtn.getAttribute('disabled') + if (isDisabled === null) { + await continueBtn.click() + + // Verify we're now on Step 2 — should see questionnaire section pills + await expect(page.getByText('Hospitalization').first()).toBeVisible({ timeout: 5000 }) + } + }) + + authenticatedTest('Step 2 - Hospitalization: should show coverage options', async ({ authenticatedPage }) => { + const page = authenticatedPage + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Navigate to Step 2 + await expect(page.getByText('Step 1 of 3')).toBeVisible({ timeout: 10000 }) + const continueBtn = page.getByRole('button', { name: /continue/i }) + await expect(continueBtn).toBeVisible({ timeout: 5000 }) + const isDisabled = await continueBtn.getAttribute('disabled') + if (isDisabled !== null) return // Skip if can't proceed + + await continueBtn.click() + + // Verify Hospitalization section is active + await expect(page.getByText('Hospitalization').first()).toBeVisible({ timeout: 5000 }) + await expect(page.getByText(/hospitalization coverage/i).first()).toBeVisible({ timeout: 5000 }) + + // Should show Public/Private hospital options + await expect(page.getByText(/public/i).first()).toBeVisible() + await expect(page.getByText(/private/i).first()).toBeVisible() + + // Click "Private" option + const privateOption = page.locator('button').filter({ hasText: /private/i }).first() + await privateOption.click() + + // After selecting Private, should show room type options + await page.waitForTimeout(500) + + // Click Continue to advance to next sub-step or section + const nextBtn = page.getByRole('button', { name: /continue/i }) + if (await nextBtn.isVisible()) { + // Select a room type option if visible + const roomOptions = page.locator('button').filter({ hasText: /single|shared|ward/i }) + if (await roomOptions.first().isVisible()) { + await roomOptions.first().click() + } + await nextBtn.click() + } + }) + + authenticatedTest('Step 2 - Life/TPD: should show dependent selection and financial obligations', async ({ authenticatedPage }) => { + const page = authenticatedPage + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Navigate through Step 1 → Step 2 + await expect(page.getByText('Step 1 of 3')).toBeVisible({ timeout: 10000 }) + const step1Continue = page.getByRole('button', { name: /continue/i }) + await expect(step1Continue).toBeVisible({ timeout: 5000 }) + if ((await step1Continue.getAttribute('disabled')) !== null) return + await step1Continue.click() + + // Navigate through Hospitalization section + await expect(page.getByText('Hospitalization').first()).toBeVisible({ timeout: 5000 }) + + // Select Public hospital (simpler path) + const publicOption = page.locator('button').filter({ hasText: /public/i }).first() + if (await publicOption.isVisible()) { + await publicOption.click() + await page.waitForTimeout(300) + } + + // Select a ward class if shown + const wardOptions = page.locator('button').filter({ hasText: /class|ward/i }) + if (await wardOptions.first().isVisible()) { + await wardOptions.first().click() + await page.waitForTimeout(300) + } + + // Click Continue to go to Life/TPD + let nextBtn = page.getByRole('button', { name: /continue/i }) + if (await nextBtn.isVisible()) { + await nextBtn.click() + await page.waitForTimeout(300) + } + + // If there's another sub-step in hospitalization, advance again + nextBtn = page.getByRole('button', { name: /continue/i }) + if (await nextBtn.isVisible()) { + // Check if we're still in hospitalization + const lifeTpdVisible = await page.getByText('Life / TPD').first().isVisible().catch(() => false) + if (!lifeTpdVisible) { + await nextBtn.click() + await page.waitForTimeout(300) + } + } + + // Now we should be in Life/TPD section + // Check for dependent selection heading + const dependentLabel = page.getByText(/who financially depends on you/i) + const isLifeTpdSection = await dependentLabel.isVisible({ timeout: 5000 }).catch(() => false) + + if (isLifeTpdSection) { + // Verify dependent selection chips are shown + await expect(dependentLabel).toBeVisible() + + // Check if there are person chips to select as dependents + // These are buttons showing person names with checkboxes + const personChips = page.locator('button').filter({ hasText: /age \d+/i }) + const chipCount = await personChips.count() + + if (chipCount > 0) { + // Click the first dependent to select them + await personChips.first().click() + await page.waitForTimeout(300) + + // Verify selection summary appears + await expect(page.getByText(/dependent.*selected/i).first()).toBeVisible({ timeout: 3000 }) + + // Check that spouse selector appears after selecting a dependent + const spouseLabel = page.getByText(/spouse.*partner/i).first() + const spouseVisible = await spouseLabel.isVisible({ timeout: 3000 }).catch(() => false) + if (spouseVisible) { + await expect(spouseLabel).toBeVisible() + } + } + + // Click Continue to go to Financial obligations sub-step + nextBtn = page.getByRole('button', { name: /continue/i }) + if (await nextBtn.isVisible()) { + await nextBtn.click() + await page.waitForTimeout(300) + } + + // Check for financial obligations section + const financialLabel = page.getByText(/financial obligations/i).first() + const financialVisible = await financialLabel.isVisible({ timeout: 3000 }).catch(() => false) + + if (financialVisible) { + // Verify auto-populated financial data is shown (read-only) + const mortgageText = page.getByText(/mortgage/i).first() + await expect(mortgageText).toBeVisible({ timeout: 3000 }) + + // Verify "Future obligations" input exists (the only editable field) + const futureObligations = page.getByText(/future obligations/i).first() + const hasFutureObligations = await futureObligations.isVisible({ timeout: 3000 }).catch(() => false) + if (hasFutureObligations) { + await expect(futureObligations).toBeVisible() + } + } + } + }) + + authenticatedTest('Step 2 - Critical Illness: should show emergency fund options', async ({ authenticatedPage }) => { + const page = authenticatedPage + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Fast-track through Step 1 + await expect(page.getByText('Step 1 of 3')).toBeVisible({ timeout: 10000 }) + const step1Continue = page.getByRole('button', { name: /continue/i }) + if ((await step1Continue.getAttribute('disabled')) !== null) return + await step1Continue.click() + await page.waitForTimeout(500) + + // Navigate through sections by clicking Continue repeatedly + // We need to get past Hospitalization and Life/TPD to reach Critical Illness + for (let i = 0; i < 6; i++) { + const continueBtn = page.getByRole('button', { name: /continue/i }) + if (!(await continueBtn.isVisible({ timeout: 2000 }).catch(() => false))) break + + // Select any required option before continuing + // Check for option cards and select the first if none selected + const optionButtons = page.locator('button').filter({ hasText: /public|private|single|shared/i }) + if (await optionButtons.first().isVisible({ timeout: 500 }).catch(() => false)) { + await optionButtons.first().click() + await page.waitForTimeout(200) + } + + await continueBtn.click() + await page.waitForTimeout(500) + + // Check if we've reached Critical Illness + const ciVisible = await page.getByText(/emergency fund/i).first().isVisible({ timeout: 1000 }).catch(() => false) + if (ciVisible) break + } + + // Verify Critical Illness section content + const emergencyFund = page.getByText(/emergency fund/i).first() + const isCISection = await emergencyFund.isVisible({ timeout: 3000 }).catch(() => false) + if (isCISection) { + await expect(emergencyFund).toBeVisible() + } + }) + + authenticatedTest('Full wizard flow: should complete all steps', async ({ authenticatedPage }) => { + const page = authenticatedPage + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Step 1: Verify and proceed + await expect(page.getByText('Step 1 of 3')).toBeVisible({ timeout: 10000 }) + let continueBtn = page.getByRole('button', { name: /continue/i }) + await expect(continueBtn).toBeVisible({ timeout: 5000 }) + if ((await continueBtn.getAttribute('disabled')) !== null) { + // Can't proceed without income - test ends here + return + } + await continueBtn.click() + await page.waitForTimeout(500) + + // Step 2: Click through all questionnaire sections + // Navigate through each section by selecting options and clicking Continue + let maxAttempts = 20 // Safety limit + let attempts = 0 + + while (attempts < maxAttempts) { + attempts++ + + // Check if we've reached Step 3 (Summary) + const summaryVisible = await page.getByText(/review your coverage/i).first().isVisible({ timeout: 500 }).catch(() => false) + || await page.getByText(/your targets/i).first().isVisible({ timeout: 500 }).catch(() => false) + if (summaryVisible) break + + // Look for Continue button + continueBtn = page.getByRole('button', { name: /continue/i }) + const continueVisible = await continueBtn.isVisible({ timeout: 1000 }).catch(() => false) + + if (!continueVisible) { + // Maybe there's a "Complete" or "Finish" button + const completeBtn = page.getByRole('button', { name: /complete|finish|confirm/i }) + if (await completeBtn.isVisible({ timeout: 500 }).catch(() => false)) { + await completeBtn.click() + break + } + break + } + + // Before clicking Continue, try selecting required options + // Check if Continue is disabled - if so, we need to make a selection + const isDisabled = await continueBtn.getAttribute('disabled') + if (isDisabled !== null) { + // Find and click any unselected option card + const optionCards = page.locator('button').filter({ hasText: /public|private|single|shared|class|ward|basic|standard/i }) + if (await optionCards.first().isVisible({ timeout: 500 }).catch(() => false)) { + await optionCards.first().click() + await page.waitForTimeout(300) + } else { + // Try clicking any available person chip for dependent selection + const personChips = page.locator('button').filter({ hasText: /age \d+/i }) + if (await personChips.first().isVisible({ timeout: 500 }).catch(() => false)) { + await personChips.first().click() + await page.waitForTimeout(300) + } else { + // Can't proceed - break to avoid infinite loop + break + } + } + } + + await continueBtn.click() + await page.waitForTimeout(500) + } + + // Verify we reached either the summary or the configured view + const reachedEnd = await page.getByText(/review your coverage|your targets|coverage target/i).first() + .isVisible({ timeout: 5000 }).catch(() => false) + + // Log where we ended up for debugging + if (!reachedEnd) { + const pageContent = await page.textContent('body') + console.log('Did not reach summary. Current page contains:', pageContent?.substring(0, 500)) + } + }) + + authenticatedTest('PersonSelector: should show relationship labels', async ({ authenticatedPage }) => { + const page = authenticatedPage + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Wait for step 1 + await expect(page.getByText('Step 1 of 3')).toBeVisible({ timeout: 10000 }) + + // Open the PersonSelector dropdown + // Find the selector near "Select person" label + const selectorContainer = page.locator('div').filter({ hasText: /^select person$/i }).first() + const trigger = selectorContainer.locator('..').locator('button').first() + + if (await trigger.isVisible()) { + await trigger.click() + await page.waitForTimeout(500) + + // Check that the dropdown is open and shows person options + // Person options should show names and potentially relationship labels + const dropdownOptions = page.locator('button').filter({ hasText: /\w+/ }) + const count = await dropdownOptions.count() + + // At least the test account person should be visible + expect(count).toBeGreaterThan(0) + + // Close dropdown + await page.keyboard.press('Escape') + } + }) +}) diff --git a/frontend/package.json b/frontend/package.json index a4690f184..eee6ae15a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,8 @@ "lint": "next lint", "type-check": "tsc --noEmit", "test": "vitest", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build -o storybook-static", "generate:api": "swagger-typescript-api generate -p ../backend/cmd/server/docs/swagger.json -o src/types -n api.generated.ts --no-client" }, "dependencies": { @@ -50,6 +52,15 @@ }, "devDependencies": { "@playwright/test": "^1.57.0", + "@storybook/addon-essentials": "8.4.7", + "@storybook/addon-interactions": "8.4.7", + "@storybook/addon-themes": "8.4.7", + "@storybook/addon-webpack5-compiler-swc": "^4.0.2", + "@storybook/blocks": "8.4.7", + "@storybook/manager-api": "8.4.7", + "@storybook/react": "8.4.7", + "@storybook/react-webpack5": "8.4.7", + "@storybook/theming": "8.4.7", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -60,13 +71,19 @@ "@types/react-resizable": "^3.0.8", "@vitejs/plugin-react": "^5.1.1", "autoprefixer": "^10.4.16", + "css-loader": "^7.1.3", "eslint": "^8.53.0", "eslint-config-next": "^14.0.0", "grab": "^0.0.88", + "html-webpack-plugin": "^5.6.6", "jsdom": "^27.2.0", "postcss": "^8.4.31", + "postcss-loader": "^8.2.0", + "storybook": "8.4.7", + "style-loader": "^4.0.0", "swagger-typescript-api": "^13.2.16", "tailwindcss": "^3.3.5", + "ts-loader": "^9.5.4", "typescript": "^5.2.2", "vitest": "^4.0.13" } diff --git a/frontend/src/api/financial/persons.ts b/frontend/src/api/financial/persons.ts index 72f13c150..e26460584 100644 --- a/frontend/src/api/financial/persons.ts +++ b/frontend/src/api/financial/persons.ts @@ -1,5 +1,5 @@ import { apiClient } from '../client' -import type { Person, PersonCreatePayload, PersonUpdatePayload } from '@/types/person' +import type { Person, PersonCreatePayload, PersonUpdatePayload, Relationship } from '@/types/person' import type { ResidencyStatus } from '@/types/cpf' import type { Person as ApiPerson, @@ -21,6 +21,7 @@ function toPerson(data: ApiPerson): Person { gender: ((data as { gender?: string }).gender ?? 'male') as 'male' | 'female', residencyStatus: (data.residencyStatus ?? 'citizen') as ResidencyStatus, prGrantDate: data.prGrantDate ?? null, + relationship: ((data as { relationship?: string }).relationship ?? 'self') as Relationship, createdAt: data.createdAt ?? '', updatedAt: data.updatedAt ?? '', incomeCount: data.incomeCount ?? 0, @@ -48,13 +49,14 @@ export async function getPerson(id: string): Promise { * Create a new person */ export async function createPerson(payload: PersonCreatePayload): Promise { - const body: PersonV2CreateInput & { gender?: string } = { + const body: PersonV2CreateInput & { gender?: string; relationship?: string } = { name: payload.name, dateOfBirth: payload.dateOfBirth, residencyStatus: payload.residencyStatus, displayColor: payload.displayColor ?? undefined, prGrantDate: payload.prGrantDate ?? undefined, gender: payload.gender, + relationship: payload.relationship, } const data = await apiClient.post('/persons', body, { baseUrl: '/api/v2' }) return toPerson(data) @@ -64,13 +66,14 @@ export async function createPerson(payload: PersonCreatePayload): Promise { - const body: PersonV2UpdateInput = { + const body: PersonV2UpdateInput & { relationship?: string } = { name: payload.name, dateOfBirth: payload.dateOfBirth, residencyStatus: payload.residencyStatus, displayColor: payload.displayColor ?? undefined, prGrantDate: payload.prGrantDate ?? undefined, isIncluded: payload.isIncluded, + relationship: payload.relationship, } const data = await apiClient.put(`/persons/${id}`, body, { baseUrl: '/api/v2' }) return toPerson(data) diff --git a/frontend/src/api/financial/settings.ts b/frontend/src/api/financial/settings.ts index b5b1fa801..0b153b844 100644 --- a/frontend/src/api/financial/settings.ts +++ b/frontend/src/api/financial/settings.ts @@ -14,6 +14,7 @@ export async function getUserSettings(): Promise { groupItemsByCategory: data.groupItemsByCategory ?? true, chartPictureInPicture: data.chartPictureInPicture ?? false, dashboardLayout: data.dashboardLayout ?? 'stacked', + onboardingCompleted: data.onboardingCompleted ?? false, updatedAt: data.updatedAt, } } @@ -29,6 +30,7 @@ export async function updateUserSettings(settings: UserSettings): Promise {/* Header */} Welcome back - Sign in to continue to Assetra + Sign in to continue to WealthProject {/* Form Card */} diff --git a/frontend/src/app/insurance-planner/page.tsx b/frontend/src/app/insurance-planner/page.tsx index 0a9e3826a..481ed3770 100644 --- a/frontend/src/app/insurance-planner/page.tsx +++ b/frontend/src/app/insurance-planner/page.tsx @@ -1,34 +1,182 @@ 'use client' import { useState } from 'react' -import { ArrowLeft, Shield, X } from 'lucide-react' -import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { Shield, X } from 'lucide-react' +import clsx from 'clsx' import { InsuranceTabs, type InsuranceTabId, } from '@/components/insurance/InsuranceTabs' -import { OverviewTab } from '@/components/insurance/tabs/OverviewTab' import { PoliciesTab } from '@/components/insurance/tabs/PoliciesTab' +import { GuidelinesTab } from '@/components/insurance/tabs/GuidelinesTab' +import { JourneyTab } from '@/components/insurance/tabs/JourneyTab' +import { useColorScheme } from '@/stores' + +// ============================================================================ +// THEME-AWARE DESIGN SYSTEM +// Supports both dark mode and Monet (impressionist) mode +// ============================================================================ + +const monetColors = { + // Backgrounds - warm cream to pale blue like his canvases + bgCream: '#FAF8F5', + bgPaleBlue: '#F0F4F8', + bgWarmWhite: '#FFFEF9', + + // Primary - Wisteria/Lavender (from his garden paintings) + lavender: '#9B8BB4', + lavenderLight: '#C4B8D9', + lavenderDark: '#7A6B94', + + // Accent - Coral Rose (water lily pinks) + coralRose: '#E8A898', + coralRoseLight: '#F5D4CC', + coralRoseDark: '#D4847A', + + // Success - Sage Green (lily pads, gardens) + sage: '#7FB285', + sageLight: '#B5D4B8', + sageDark: '#5A8A5E', + + // Gold - Sunlight on water + sunlightGold: '#D4C5A9', + sunlightGoldLight: '#EDE6D8', + sunlightGoldDark: '#B8A87D', + + // Text + textPrimary: '#3D3D3D', + textSecondary: '#6B6B6B', + textMuted: '#9B9B9B', + + // Soft shadows + shadowSoft: 'rgba(155, 139, 180, 0.12)', + shadowMedium: 'rgba(155, 139, 180, 0.18)', +} + +const darkColors = { + // Backgrounds + bgPrimary: '#0a0a0a', + bgSecondary: '#111111', + bgTertiary: '#1a1a1a', + + // Primary - Purple accent + primary: '#a78bfa', + primaryLight: '#c4b5fd', + primaryDark: '#7c3aed', + + // Accent - Coral/Rose + accent: '#fb7185', + accentLight: '#fda4af', + accentDark: '#e11d48', + + // Success - Emerald + success: '#34d399', + successLight: '#6ee7b7', + successDark: '#059669', + + // Gold + gold: '#fbbf24', + goldLight: '#fcd34d', + goldDark: '#d97706', + + // Text + textPrimary: '#f1f5f9', + textSecondary: '#94a3b8', + textMuted: '#64748b', + + // Shadows + shadowSoft: 'rgba(0, 0, 0, 0.3)', + shadowMedium: 'rgba(0, 0, 0, 0.5)', +} + +// Painterly noise texture overlay (subtle canvas effect) +const canvasTexture = `url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")` // Embedded view component for use within Dashboard export function InsurancePlannerView({ onClose }: { onClose?: () => void }) { const [activeTab, setActiveTab] = useState('overview') + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' + const colors = isMonet ? monetColors : darkColors + + const handleNavigateToPolicy = () => { + setActiveTab('policies') + } return ( - + + {/* Canvas/noise texture overlay */} + + + {/* Ambient light effects */} + + {/* Header */} - - + + - - - + + + - + Insurance Planner - + Analyze scenarios and explore coverage options @@ -37,7 +185,12 @@ export function InsurancePlannerView({ onClose }: { onClose?: () => void }) { @@ -50,8 +203,9 @@ export function InsurancePlannerView({ onClose }: { onClose?: () => void }) { {/* Main Content - scrollable */} - - {activeTab === 'overview' && } + + {activeTab === 'overview' && } + {activeTab === 'journey' && } {activeTab === 'policies' && } @@ -60,37 +214,137 @@ export function InsurancePlannerView({ onClose }: { onClose?: () => void }) { // Standalone page for direct navigation export default function InsurancePlannerPage() { + const router = useRouter() const [activeTab, setActiveTab] = useState('overview') + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' + const colors = isMonet ? monetColors : darkColors + + const handleClose = () => { + router.push('/dashboard') + } + + const handleNavigateToPolicy = () => { + setActiveTab('policies') + } return ( - - {/* Header */} - - - - - - Dashboard - + + {/* Canvas/noise texture overlay */} + - + {/* Atmospheric light effects */} + + + - - - + {/* Header */} + + + + + + + - + Insurance Planner - + Analyze scenarios and explore coverage options + + + + @@ -98,11 +352,17 @@ export default function InsurancePlannerPage() { {/* Tab Navigation */} - {/* Main Content */} - - {activeTab === 'overview' && } - {activeTab === 'policies' && } + {/* Main Content - scrollable */} + + + {activeTab === 'overview' && } + {activeTab === 'journey' && } + {activeTab === 'policies' && } + ) } + +// Export colors for use in child components +export { monetColors, darkColors, canvasTexture } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 70c47f592..6a484a3ef 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -8,6 +8,7 @@ import { AuthProvider } from '@/components/auth/AuthProvider' import { AuthenticationGuard } from '@/components/auth/AuthGuard' import { PersonFilterProvider } from '@/contexts/PersonFilterContext' import { PersonsModalContainer } from '@/components/modals/PersonsModal' +import { ThemeProvider } from '@/components/providers/ThemeProvider' const geistSans = Geist({ subsets: ['latin'], @@ -29,7 +30,7 @@ export default function RootLayout({ return ( {process.env.NODE_ENV === 'development' && ( @@ -52,15 +53,17 @@ export default function RootLayout({ > )} - + - - - {children} - - - + + + + {children} + + + + @@ -70,6 +73,6 @@ export default function RootLayout({ } export const metadata = { - title: 'Financial Chat System', - description: 'AI-powered financial planning and chat system', + title: 'WealthProject', + description: 'Weath projection simulator', } diff --git a/frontend/src/components/auth/UserMenu.tsx b/frontend/src/components/auth/UserMenu.tsx index a27981b95..efb016ffb 100644 --- a/frontend/src/components/auth/UserMenu.tsx +++ b/frontend/src/components/auth/UserMenu.tsx @@ -4,13 +4,17 @@ import { useState, useEffect, useRef } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' import { LogOut, User, ChevronDown, Settings } from 'lucide-react' +import clsx from 'clsx' import { useSession, signOut } from '@/lib/auth-client' import { Button } from '@/components/ui/button' import { SettingsModal } from '@/components/modals/SettingsModal/SettingsModal' import { clearAllSensitiveStorage } from '@/hooks/useTaxReliefStorage' +import { useColorScheme } from '@/stores' export function UserMenu() { const { data: session, isPending } = useSession() + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' const [isOpen, setIsOpen] = useState(false) const [isSigningOut, setIsSigningOut] = useState(false) const [mounted, setMounted] = useState(false) @@ -56,7 +60,10 @@ export function UserMenu() { // Show loading skeleton on server and until mounted on client if (!mounted || isPending) { return ( - + ) } @@ -64,7 +71,11 @@ export function UserMenu() { return ( - + Sign In @@ -81,16 +92,14 @@ export function UserMenu() { setIsOpen(!isOpen)} - className={`flex items-center -gap-2 px-3 py-2 -rounded-md -hover:bg-gray-800 -transition-colors`} + className={clsx( + 'flex items-center gap-2 px-3 py-2 rounded-md transition-colors', + isMonet + ? 'hover:bg-[var(--monet-lavender)]/10' + : 'hover:bg-gray-800' + )} > - + {session.user.image ? ( )} - + {session.user.name || session.user.email} - + {isOpen && ( - - - + + + {session.user.name} - + {session.user.email} @@ -130,12 +156,12 @@ shadow-2xl`} style={{ isolation: 'isolate' }}> setIsOpen(false) setIsSettingsOpen(true) }} - className={`flex items-center -w-full -gap-2 px-4 py-2.5 -hover:bg-white/5 -text-sm text-slate-300 hover:text-slate-100 -transition-colors`} + className={clsx( + 'flex items-center w-full gap-2 px-4 py-2.5 text-sm transition-colors', + isMonet + ? 'text-[var(--monet-text-secondary)] hover:text-[var(--monet-text-primary)] hover:bg-[var(--monet-lavender)]/5' + : 'text-slate-300 hover:text-slate-100 hover:bg-white/5' + )} > Settings @@ -143,13 +169,12 @@ transition-colors`} {isSigningOut ? 'Signing out...' : 'Sign out'} diff --git a/frontend/src/components/chat/ChatHeader.tsx b/frontend/src/components/chat/ChatHeader.tsx index 4609fc5d1..3dc7323e4 100644 --- a/frontend/src/components/chat/ChatHeader.tsx +++ b/frontend/src/components/chat/ChatHeader.tsx @@ -24,7 +24,7 @@ shadow-glow`}> - Assetra Chat + WealthProject Chat {onCollapse && ( diff --git a/frontend/src/components/dashboard/Dashboard.tsx b/frontend/src/components/dashboard/Dashboard.tsx index 31e1310ac..3d0825d49 100644 --- a/frontend/src/components/dashboard/Dashboard.tsx +++ b/frontend/src/components/dashboard/Dashboard.tsx @@ -14,6 +14,8 @@ import { MiniChart } from './MiniChart' import { ResizableChartSection } from './ResizableChartSection' import { PropertyPlannerModal } from '@/components/modals/PropertyPlannerModal/PropertyPlannerModal' import { LayoutPreviewModal } from '@/components/modals/LayoutPreviewModal' +import { OnboardingWizardModal } from '@/components/modals/OnboardingWizardModal/OnboardingWizardModal' +import { PostResetChoiceModal } from '@/components/modals/PostResetChoiceModal' // Loading skeleton for feature modules function FeatureModuleLoading() { @@ -30,15 +32,12 @@ const CPFSimulationView = dynamic( { ssr: false, loading: FeatureModuleLoading } ) -const TaxPlannerV2View = dynamic( - () => import('@/app/tax-planner/page').then(mod => ({ default: mod.TaxPlannerV2View })), - { ssr: false, loading: FeatureModuleLoading } -) - const InsurancePlannerView = dynamic( () => import('@/app/insurance-planner/page').then(mod => ({ default: mod.InsurancePlannerView })), { ssr: false, loading: FeatureModuleLoading } ) + +import { usePersonsQuery } from '@/hooks/queries/usePersonsQuery' import { useTimeline } from '@/hooks/useTimeline' import { usePictureInPicture } from '@/hooks/usePictureInPicture' import { useScenarioEvents } from '@/hooks/useScenarioEvents' @@ -46,7 +45,7 @@ import { useWindowWidth } from '@/hooks/useWindowWidth' import { generateUUID } from '@/lib/utils' import { settingsApi } from '@/api/financial' import { QUERY_KEYS } from '@/lib/queryKeys' -import { useTimelineStore, useFeatureModulesStore } from '@/stores' +import { useTimelineStore, useFeatureModulesStore, useColorScheme } from '@/stores' import { useShallow } from 'zustand/react/shallow' export function Dashboard() { @@ -54,13 +53,12 @@ export function Dashboard() { const chatId = chatIdRef.current const queryClient = useQueryClient() const windowWidth = useWindowWidth() + const colorScheme = useColorScheme() // Feature modules state from Zustand store (batched with shallow comparison) const { showCPFView, closeCPFView, - showTaxPlanner, - closeTaxPlanner, showInsurancePlanner, closeInsurancePlanner, showPropertyPlanner, @@ -69,6 +67,11 @@ export function Dashboard() { closePropertyPlanner, showLayoutModal, closeLayoutModal, + showOnboardingWizard, + openOnboardingWizard, + closeOnboardingWizard, + showPostResetChoice, + closePostResetChoice, isChatCollapsed, isHistoryOpen, toggleChat, @@ -82,8 +85,6 @@ export function Dashboard() { useShallow((s) => ({ showCPFView: s.showCPFView, closeCPFView: s.closeCPFView, - showTaxPlanner: s.showTaxPlanner, - closeTaxPlanner: s.closeTaxPlanner, showInsurancePlanner: s.showInsurancePlanner, closeInsurancePlanner: s.closeInsurancePlanner, showPropertyPlanner: s.showPropertyPlanner, @@ -92,6 +93,11 @@ export function Dashboard() { closePropertyPlanner: s.closePropertyPlanner, showLayoutModal: s.showLayoutModal, closeLayoutModal: s.closeLayoutModal, + showOnboardingWizard: s.showOnboardingWizard, + openOnboardingWizard: s.openOnboardingWizard, + closeOnboardingWizard: s.closeOnboardingWizard, + showPostResetChoice: s.showPostResetChoice, + closePostResetChoice: s.closePostResetChoice, isChatCollapsed: s.isChatCollapsed, isHistoryOpen: s.isHistoryOpen, toggleChat: s.toggleChat, @@ -130,6 +136,36 @@ export function Dashboard() { } }, [userSettings?.dashboardLayout, initializeLayout]) + // ─── Onboarding wizard auto-trigger ───────────────────────────────────── + // Show wizard when: user has zero persons AND hasn't completed/dismissed onboarding. + // Uses a ref guard to prevent re-triggering during the same "session" (avoids race + // condition where the dismiss mutation hasn't round-tripped yet). + // The guard resets when onboardingCompleted flips back to false (e.g., after "Start Fresh"). + const { data: personsData } = usePersonsQuery() + const onboardingTriggeredRef = useRef(false) + + // Reset the guard when onboardingCompleted is explicitly set back to false + // (happens after "Delete All Data" / "Start Fresh") + useEffect(() => { + if (userSettings && !userSettings.onboardingCompleted) { + onboardingTriggeredRef.current = false + } + }, [userSettings?.onboardingCompleted]) + + useEffect(() => { + if ( + !onboardingTriggeredRef.current && + !showPostResetChoice && + personsData !== undefined && + userSettings !== undefined && + personsData.length === 0 && + !userSettings.onboardingCompleted + ) { + onboardingTriggeredRef.current = true + openOnboardingWizard() + } + }, [personsData, userSettings, showPostResetChoice, openOnboardingWizard]) + // Mutation for updating layout preference const updateLayoutMutation = useMutation({ mutationFn: (layout: typeof dashboardLayout) => @@ -193,32 +229,41 @@ export function Dashboard() { return () => window.removeEventListener('keydown', handleKeyDown) }, [toggleChat]) + // Theme-aware styles + const isMonet = colorScheme === 'monet' + return ( <> - - {/* Ambient background orbs */} - - - + + {/* Ambient background orbs - theme aware */} + + + {/* Main content - side by side layout */} @@ -232,19 +277,21 @@ opacity-20 blur-[80px]`} /> > {/* Chat panel */} - + {/* Collapsed sidebar */} @@ -286,27 +338,16 @@ h-screen gap-6 p-6`}> {showCPFView ? ( /* CPF Simulation View - takes over entire area */ - + - ) : showTaxPlanner ? ( - /* Tax Planner View - shows header + tax planner */ - <> - {/* Header bar only - no chart */} - - - - {/* Tax Planner content */} - - - - > ) : showInsurancePlanner ? ( /* Insurance Planner View - shows header + insurance planner */ <> @@ -315,10 +356,14 @@ bg-[#0a0a0a]/80`}> {/* Insurance Planner content */} - + > @@ -333,7 +378,7 @@ bg-[#0a0a0a]/80`}> {/* Side-by-side content area */} @@ -349,7 +394,7 @@ bg-[#0a0a0a]/80`}> {/* Cards section - compact mode */} - + @@ -378,7 +423,7 @@ bg-[#0a0a0a]/80`}> {/* Picture-in-Picture mini chart - disabled in side-by-side layouts */} - {showPiP && !showCPFView && !showTaxPlanner && !showInsurancePlanner && !isSideBySide && ( + {showPiP && !showCPFView && !showInsurancePlanner && !isSideBySide && ( currentLayout={dashboardLayout} onLayoutChange={handleLayoutChange} /> + + {/* Post-reset choice: Dashboard vs Wizard */} + { + closePostResetChoice() + // Mark onboarding completed so the auto-trigger doesn't reopen the wizard + onboardingTriggeredRef.current = true + if (userSettings) { + const updated = { ...userSettings, onboardingCompleted: true } + queryClient.setQueryData(QUERY_KEYS.settings.user, updated) + settingsApi.updateUserSettings(updated) + } + }} + onWizard={() => { + closePostResetChoice() + openOnboardingWizard() + }} + /> + + {/* Onboarding Wizard Modal */} + > ) } diff --git a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard.tsx b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard.tsx index 5b8cf0071..a6f6791a9 100644 --- a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard.tsx +++ b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard.tsx @@ -5,6 +5,7 @@ import type { ScenarioEvent } from '@/types/scenario' import type { CashAccount } from '@/types/financial' import type { PropertyLinkRecord } from '@/types/property' import type { IncomeAllocation } from '@/api/financial/incomes' +import { useColorScheme } from '@/stores' import { categoryConfig } from '../config' import { getItemId, sortItems } from '../utils' import { parseDecimal } from '../converters' @@ -140,6 +141,10 @@ export function CategoryCard({ compact = false, onCollapseChange, }: CategoryCardProps) { + // Theme + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' + // Start collapsed in compact mode (side-by-side layout) const [isCollapsed, setIsCollapsed] = useState(compact) @@ -232,8 +237,12 @@ export function CategoryCard({ return ( - {/* Collapsible content */} + {/* Collapsible content - Stitch style: no separate total section, items directly */} - + {/* Hide the large total section - Stitch shows total in header subtitle */} + {false && ( + + )} {/* List Items */} diff --git a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CPFContributionsSection.tsx b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CPFContributionsSection.tsx index 74d390f81..7abc0f15d 100644 --- a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CPFContributionsSection.tsx +++ b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CPFContributionsSection.tsx @@ -17,14 +17,14 @@ export function CPFContributionsSection({ const renderContributions = () => cpfContributionsRaw.map((item, index) => ( - Employee - {item.name.replace('CPF Contribution - ', '')} + Employee - {item.name.replace('CPF Contribution - ', '')} ({formatCurrency(parseDecimal(item.employeeContribution))}) /mo - Employer - {item.name.replace('CPF Contribution - ', '')} + Employer - {item.name.replace('CPF Contribution - ', '')} {formatCurrency(parseDecimal(item.employerContribution))} /mo @@ -40,7 +40,7 @@ export function CPFContributionsSection({ > - + CPF Refund - {refund.propertyName} diff --git a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CategoryCardHeader.tsx b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CategoryCardHeader.tsx index 8e9cbf803..de0f47d49 100644 --- a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CategoryCardHeader.tsx +++ b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CategoryCardHeader.tsx @@ -1,5 +1,8 @@ import { useState, useRef, useEffect } from 'react' -import { Plus, ArrowDownWideNarrow, Wallet, BarChart3, Shield, ChevronDown } from 'lucide-react' +import { Plus, Wallet, BarChart3, Shield, ChevronRight } from 'lucide-react' +import clsx from 'clsx' +import { useColorScheme } from '@/stores' +import { formatCurrency } from '@/lib/format' import { categoryConfig } from '../../config' import type { FinancialCategory } from '../../types' @@ -13,19 +16,24 @@ interface CategoryCardHeaderProps { onAddCpf?: () => void isCollapsed?: boolean onToggleCollapse?: () => void + /** Total amount to display as subtitle like Stitch */ + total?: number } export function CategoryCardHeader({ category, showMonthlyData, - sortDirection, - onToggleSortDirection, + sortDirection: _sortDirection, + onToggleSortDirection: _onToggleSortDirection, onAddItem, onAddInvestment, onAddCpf, isCollapsed = false, onToggleCollapse, + total, }: CategoryCardHeaderProps) { + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' const config = categoryConfig[category] const [showAssetMenu, setShowAssetMenu] = useState(false) const menuRef = useRef(null) @@ -57,39 +65,57 @@ export function CategoryCardHeader({ const showAssetDropdown = category === 'asset' && (onAddInvestment || onAddCpf) return ( - + + {/* Left side: Icon + Title/Total - clickable to expand */} - - + {/* Icon container - solid colored background with white icon like Stitch */} + + + + + {getTitle()} + {total !== undefined && ( + + {formatCurrency(total)} + + )} - {getTitle()} - {onToggleCollapse && ( - - )} - - - - - {showAssetDropdown ? ( + {/* Right side: Add button + Chevron */} + + {!isCollapsed && (showAssetDropdown ? ( setShowAssetMenu(!showAssetMenu)} - className="p-1.5 rounded-md hover:bg-white/5 text-slate-500 hover:text-slate-300 transition-colors" + onClick={(e) => { + e.stopPropagation() + setShowAssetMenu(!showAssetMenu) + }} + className={clsx( + "h-8 w-8 rounded-full flex items-center justify-center transition-all", + isMonet + ? "bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-700" + : "bg-white/[0.06] text-slate-400 hover:bg-white/[0.1] hover:text-slate-200" + )} type="button" title="Add Item" > @@ -114,13 +140,42 @@ export function CategoryCardHeader({ ) : ( { + e.stopPropagation() + onAddItem() + }} + className={clsx( + "h-8 w-8 rounded-full flex items-center justify-center transition-all", + isMonet + ? "bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-700" + : "bg-white/[0.06] text-slate-400 hover:bg-white/[0.1] hover:text-slate-200" + )} type="button" title="Add Item" > + ))} + + {/* Chevron at far right - Stitch style */} + {onToggleCollapse && ( + + + )} diff --git a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CategoryCardTotal.tsx b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CategoryCardTotal.tsx index 0847c1567..4ed5ee887 100644 --- a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CategoryCardTotal.tsx +++ b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CategoryCardTotal.tsx @@ -1,5 +1,7 @@ import { ArrowUpRight, ArrowDownRight } from 'lucide-react' +import clsx from 'clsx' import { formatCurrency } from '@/lib/format' +import { useColorScheme } from '@/stores' import type { FinancialCategory } from '../../types' interface CategoryCardTotalProps { @@ -13,27 +15,41 @@ export function CategoryCardTotal({ total, showMonthlyData, }: CategoryCardTotalProps) { + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' + // Mock trend (you may want to calculate real trends) const mockTrend = category === 'asset' ? 12.5 : category === 'income' ? 5.2 : category === 'liability' ? -2.1 : 1.2 const isPositiveTrend = mockTrend >= 0 const showMonthSuffix = showMonthlyData && (category === 'income' || category === 'expense') return ( - + - + {formatCurrency(total)} {showMonthSuffix && ( - /mo + /mo )} - + {isPositiveTrend ? : } {Math.abs(mockTrend)}% - vs last month + vs last month ) diff --git a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/InvestmentsIncomeSection.tsx b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/InvestmentsIncomeSection.tsx index d2174e474..4120e9f00 100644 --- a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/InvestmentsIncomeSection.tsx +++ b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/InvestmentsIncomeSection.tsx @@ -44,7 +44,7 @@ export function InvestmentsIncomeSection({ )) ) : ( - Allocated to investments + Allocated to investments {formatCurrency(displayAmount)} {showMonthlyData ? '/mo' : '/yr'} diff --git a/frontend/src/components/dashboard/FinancialDataManagement/components/CollapsibleSection.tsx b/frontend/src/components/dashboard/FinancialDataManagement/components/CollapsibleSection.tsx index 201f33eeb..3635ba289 100644 --- a/frontend/src/components/dashboard/FinancialDataManagement/components/CollapsibleSection.tsx +++ b/frontend/src/components/dashboard/FinancialDataManagement/components/CollapsibleSection.tsx @@ -191,8 +191,8 @@ export function CollapsibleItem({ : (isSelected ? "bg-white/[0.06]" : "hover:bg-white/[0.04]") )} > - - {name} + + {name} {icon && ( ('calendar') const { data: userSettings } = useQuery({ @@ -186,14 +189,25 @@ export function Header({ {/* Row 1: Title */} - Financial Data - {`${absoluteYear} (Age ${displayAge})`} + Financial Data + {`${absoluteYear} (Age ${displayAge})`} {/* Row 2: Timeline controls on left, Action buttons on right */} {/* Timeline navigation controls */} - + {resolution === 'monthly' && ( <> @@ -207,8 +221,9 @@ export function Header({ { value: 'annualized', label: 'Yearly' }, { value: 'monthly', label: 'Monthly' }, ]} + isMonet={isMonet} /> - + > )} @@ -227,11 +242,12 @@ export function Header({ className="w-16" onLabelClick={() => setYearDisplayMode(m => m === 'calendar' ? 'relative' : 'calendar')} labelTitle="Click to toggle year format" + isMonet={isMonet} /> {resolution === 'monthly' && ( <> - + > )} {shouldShowSlider && ( - + - - + + - + )} - {/* Action buttons */} - - {/* Tax Estimate Icon */} + {/* Action buttons - Cashflow & Family pills like Stitch */} + + {/* Cashflow Button */} - + + Tax Estimate - {/* Persons Filter Icon */} + {/* Family Button */} openPersonsModal?.()} - title="Manage Persons" - className={` - relative flex items-center justify-center - p-1.5 rounded-md - transition-all duration-200 - ${hasExcludedPersons - ? 'text-blue-400 hover:text-blue-300 hover:bg-blue-500/10' - : 'text-slate-400 hover:text-slate-300 hover:bg-white/[0.06]' - } - `} + title="Family" + className={clsx( + "relative flex items-center gap-2 px-4 py-2 rounded-xl", + "text-sm font-medium transition-all duration-200", + isMonet + ? "bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 shadow-sm" + : "bg-white/[0.02] border border-white/[0.08] text-slate-400 hover:text-slate-200 hover:bg-white/[0.04]", + hasExcludedPersons && "ring-2 ring-blue-500/30" + )} > - + + Persons {hasExcludedPersons && ( - + )} @@ -319,9 +354,10 @@ interface SelectFieldProps { className?: string onLabelClick?: () => void labelTitle?: string + isMonet?: boolean } -function SelectField({ label, id, value, disabled, onChange, options, className, onLabelClick, labelTitle }: SelectFieldProps) { +function SelectField({ label, id, value, disabled, onChange, options, className, onLabelClick, labelTitle, isMonet }: SelectFieldProps) { const [isOpen, setIsOpen] = useState(false) const containerRef = useRef(null) @@ -338,9 +374,16 @@ function SelectField({ label, id, value, disabled, onChange, options, className, const selectedOption = options.find(opt => opt.value === value) return ( - + !disabled && setIsOpen(!isOpen)} disabled={disabled} - className={` - flex items-center justify-between gap-2 w-full - appearance-none cursor-pointer - bg-transparent - text-sm font-medium text-white - focus:outline-none - disabled:opacity-50 disabled:cursor-not-allowed - ${isOpen ? 'text-blue-400' : ''} - `} + className={clsx( + "flex items-center justify-between gap-2 w-full", + "appearance-none cursor-pointer bg-transparent", + "text-sm font-medium focus:outline-none", + "disabled:opacity-50 disabled:cursor-not-allowed", + isMonet + ? (isOpen ? "text-blue-600" : "text-slate-700") + : (isOpen ? "text-blue-400" : "text-white") + )} > {selectedOption?.label ?? ''} - + {isOpen && ( - + {options.map((opt) => { const isSelected = opt.value === value @@ -389,19 +434,15 @@ function SelectField({ label, id, value, disabled, onChange, options, className, onChange(opt.value) setIsOpen(false) }} - className={` - w-full flex items-center gap-2 - px-3 py-1.5 - text-sm text-left - transition-all duration-150 - ${isSelected - ? 'bg-blue-500/15 text-white' - : 'text-slate-300 hover:bg-white/[0.05]' - } - `} + className={clsx( + "w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left transition-all duration-150", + isMonet + ? (isSelected ? "bg-blue-50 text-blue-700" : "text-slate-700 hover:bg-slate-50") + : (isSelected ? "bg-blue-500/15 text-white" : "text-slate-300 hover:bg-white/[0.05]") + )} > - {isSelected && } + {isSelected && } {opt.label} @@ -426,6 +467,7 @@ interface MonthSelectorProps { anchorCalendarMonth?: number | null onSelectMonth?: (month: number | null) => void isDisabled: boolean + isMonet?: boolean } function MonthSelector({ @@ -435,6 +477,7 @@ function MonthSelector({ anchorCalendarMonth, onSelectMonth, isDisabled, + isMonet, }: MonthSelectorProps) { const [isOpen, setIsOpen] = useState(false) const containerRef = useRef(null) @@ -459,41 +502,49 @@ function MonthSelector({ : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] return ( - - + + Month - + !isDisabled && setIsOpen(!isOpen)} disabled={isDisabled} - className={` - flex items-center justify-between gap-1 w-full - appearance-none cursor-pointer - bg-transparent - text-sm font-medium text-white - focus:outline-none - disabled:opacity-50 disabled:cursor-not-allowed - ${isOpen ? 'text-blue-400' : ''} - `} + className={clsx( + "flex items-center gap-1", + "appearance-none cursor-pointer bg-transparent", + "text-sm font-medium focus:outline-none", + "disabled:opacity-50 disabled:cursor-not-allowed", + isMonet + ? (isOpen ? "text-blue-600" : "text-slate-700") + : (isOpen ? "text-blue-400" : "text-white") + )} > - {MONTH_NAMES[safeCalendarMonth - 1]} - + {MONTH_NAMES[safeCalendarMonth - 1]} + {isOpen && ( - + {monthOptions.map((calendarMonth) => { const isSelected = calendarMonth === safeCalendarMonth @@ -508,21 +559,17 @@ function MonthSelector({ onSelectMonth?.(clampedCalendarMonth) setIsOpen(false) }} - className={` - w-full flex items-center gap-2 - px-3 py-1.5 - text-sm text-left - transition-all duration-150 - ${isMonthDisabled - ? 'opacity-50 cursor-not-allowed text-slate-500' - : isSelected - ? 'bg-blue-500/15 text-white' - : 'text-slate-300 hover:bg-white/[0.05]' - } - `} + className={clsx( + "w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left transition-all duration-150", + isMonthDisabled + ? "opacity-50 cursor-not-allowed text-slate-500" + : isMonet + ? (isSelected ? "bg-blue-50 text-blue-700" : "text-slate-700 hover:bg-slate-50") + : (isSelected ? "bg-blue-500/15 text-white" : "text-slate-300 hover:bg-white/[0.05]") + )} > - {isSelected && } + {isSelected && } {MONTH_NAMES[calendarMonth - 1]} diff --git a/frontend/src/components/dashboard/FinancialDataManagement/components/LineItem.tsx b/frontend/src/components/dashboard/FinancialDataManagement/components/LineItem.tsx index 5b7451041..10aa546c3 100644 --- a/frontend/src/components/dashboard/FinancialDataManagement/components/LineItem.tsx +++ b/frontend/src/components/dashboard/FinancialDataManagement/components/LineItem.tsx @@ -140,10 +140,10 @@ export const LineItem = memo(function LineItem({ )} > {/* Left side: name and person name */} - + {item.personName && ( - {item.personName} + {item.personName} )} {/* Accumulator star */} diff --git a/frontend/src/components/dashboard/FinancialDataManagement/components/SummaryCards.tsx b/frontend/src/components/dashboard/FinancialDataManagement/components/SummaryCards.tsx index dee131161..4ecae309e 100644 --- a/frontend/src/components/dashboard/FinancialDataManagement/components/SummaryCards.tsx +++ b/frontend/src/components/dashboard/FinancialDataManagement/components/SummaryCards.tsx @@ -1,4 +1,6 @@ +import clsx from 'clsx' import { formatCurrency } from '@/lib/format' +import { useColorScheme } from '@/stores' import type { MonthDetailResponseV2 } from '@/types/timeline' import { parseDecimal } from '../converters' @@ -15,13 +17,17 @@ export function SummaryCards({ hasV2Data, timelineMonthV2, }: SummaryCardsProps) { + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' + return ( - + ) @@ -29,16 +35,28 @@ export function SummaryCards({ interface NetWorthCardProps { netWorth: number + isMonet: boolean } -function NetWorthCard({ netWorth }: NetWorthCardProps) { +function NetWorthCard({ netWorth, isMonet }: NetWorthCardProps) { return ( - + - - Net Worth + + Net Worth - + {formatCurrency(netWorth)} @@ -49,20 +67,32 @@ interface SavingsCardProps { annualSavings: number hasV2Data: boolean timelineMonthV2?: MonthDetailResponseV2 + isMonet: boolean } -function SavingsCard({ annualSavings, hasV2Data, timelineMonthV2 }: SavingsCardProps) { +function SavingsCard({ annualSavings, hasV2Data, timelineMonthV2, isMonet }: SavingsCardProps) { const displaySavings = hasV2Data && timelineMonthV2 ? parseDecimal(timelineMonthV2.netSavings) : annualSavings return ( - + - - Savings + + Savings - + {formatCurrency(displaySavings)} diff --git a/frontend/src/components/dashboard/FinancialDataManagement/config.ts b/frontend/src/components/dashboard/FinancialDataManagement/config.ts index 2ab114dce..a69654905 100644 --- a/frontend/src/components/dashboard/FinancialDataManagement/config.ts +++ b/frontend/src/components/dashboard/FinancialDataManagement/config.ts @@ -1,4 +1,4 @@ -import { Wallet, TrendingUp, CreditCard, Activity, BarChart3 } from 'lucide-react' +import { Wallet, TrendingUp, CreditCard, Receipt, BarChart3 } from 'lucide-react' import type { FinancialCategory, CategoryConfig } from './types' export const categoryConfig: Record = { @@ -7,8 +7,14 @@ export const categoryConfig: Record = { emptyDescription: 'No assets added yet', icon: Wallet, accent: 'bg-emerald-500', + // Icon container styles - solid background with subtle shadow + iconBg: 'bg-emerald-500', + iconBgLight: 'bg-emerald-100', + iconColor: 'text-white', + iconColorLight: 'text-emerald-600', gradientBg: 'bg-gradient-to-br from-emerald-500/20 to-emerald-500/5 border-emerald-500/20', textColor: 'text-emerald-400', + textColorLight: 'text-emerald-600', progressColor: 'bg-emerald-500', singular: 'asset', plural: 'assets', @@ -18,8 +24,13 @@ export const categoryConfig: Record = { emptyDescription: 'No income added yet', icon: TrendingUp, accent: 'bg-blue-500', + iconBg: 'bg-blue-500', + iconBgLight: 'bg-blue-100', + iconColor: 'text-white', + iconColorLight: 'text-blue-600', gradientBg: 'bg-gradient-to-br from-blue-500/20 to-blue-500/5 border-blue-500/20', textColor: 'text-blue-400', + textColorLight: 'text-blue-600', progressColor: 'bg-blue-500', singular: 'income', plural: 'income', @@ -29,8 +40,13 @@ export const categoryConfig: Record = { emptyDescription: 'No liabilities added yet', icon: CreditCard, accent: 'bg-rose-500', + iconBg: 'bg-rose-500', + iconBgLight: 'bg-rose-100', + iconColor: 'text-white', + iconColorLight: 'text-rose-600', gradientBg: 'bg-gradient-to-br from-rose-500/20 to-rose-500/5 border-rose-500/20', textColor: 'text-rose-400', + textColorLight: 'text-rose-600', progressColor: 'bg-rose-500', singular: 'liability', plural: 'liabilities', @@ -38,10 +54,15 @@ export const categoryConfig: Record = { expense: { title: 'Annual Expenses', emptyDescription: 'No expenses added yet', - icon: Activity, + icon: Receipt, accent: 'bg-amber-500', + iconBg: 'bg-amber-500', + iconBgLight: 'bg-amber-100', + iconColor: 'text-white', + iconColorLight: 'text-amber-600', gradientBg: 'bg-gradient-to-br from-amber-500/20 to-amber-500/5 border-amber-500/20', textColor: 'text-amber-400', + textColorLight: 'text-amber-600', progressColor: 'bg-amber-500', singular: 'expense', plural: 'expenses', @@ -51,8 +72,13 @@ export const categoryConfig: Record = { emptyDescription: 'No investments added yet', icon: BarChart3, accent: 'bg-purple-500', + iconBg: 'bg-purple-500', + iconBgLight: 'bg-purple-100', + iconColor: 'text-white', + iconColorLight: 'text-purple-600', gradientBg: 'bg-gradient-to-br from-purple-500/20 to-purple-500/5 border-purple-500/20', textColor: 'text-purple-400', + textColorLight: 'text-purple-600', progressColor: 'bg-purple-500', singular: 'investment', plural: 'investments', diff --git a/frontend/src/components/dashboard/FinancialDataManagement/index.tsx b/frontend/src/components/dashboard/FinancialDataManagement/index.tsx index 52803e085..09daa9ce3 100644 --- a/frontend/src/components/dashboard/FinancialDataManagement/index.tsx +++ b/frontend/src/components/dashboard/FinancialDataManagement/index.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx' import { useFinancialData } from '@/hooks/useFinancialData' import { usePersonFilter } from '@/contexts/PersonFilterContext' import { useScenarioEvents } from '@/hooks/useScenarioEvents' -import { useTimelineStore, useTaxModeStore } from '@/stores' +import { useTimelineStore, useTaxModeStore, useColorScheme } from '@/stores' import { useCashAccountsQuery, useCreateCashAccountMutation, @@ -79,6 +79,10 @@ export function FinancialDataManagement({ showTaxMode: _showTaxMode = false, compact = false, }: FinancialDataManagementProps) { + // Theme + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' + // Get timeline selection state from Zustand store const selectedYear = useTimelineStore((s) => s.selectedYear) ?? 0 const selectedMonth = useTimelineStore((s) => s.selectedMonth) ?? undefined @@ -893,35 +897,41 @@ export function FinancialDataManagement({ <> { if (selectedItemId && (e.target as HTMLElement).closest('[data-line-item]') === null) { setSelectedItemId(null) } }} > - - - {/* Cashflow cards - always visible */} + {/* Main container - Stitch style */} - + + + {/* Financial data content */} + + {/* Summary cards at top - side by side */} + ({ openCPFView: s.openCPFView, openPropertyPlanner: s.openPropertyPlanner, - openTaxPlanner: s.openTaxPlanner, openInsurancePlanner: s.openInsurancePlanner, openLayoutModal: s.openLayoutModal, })) ) + // Get theme classes for consistent styling + const { classes } = useThemeClasses() + // Get timeline data from hook (React Query) const timeline = useTimeline({ resolution: 'monthly' }) const timelineYears = timeline.chartYears @@ -180,17 +183,15 @@ export function FinancialWorkspace({ }, []) return ( - + {/* Compact Header - hidden when chartOnly */} {!chartOnly && ( - + {/* Workspace */} @@ -199,55 +200,22 @@ shrink-0`}> {/* Glass pill control group */} - {/* Search */} - - - - - setIsProfileModalOpen(true)} - className={clsx( - "flex items-center justify-center", - "h-7 w-7", - "rounded-full", - "hover:bg-white/5", - "hover:text-slate-300 text-slate-500", - "disabled:opacity-60", - "transition", - )} + className={clsx(classes.toolbarButton.base, classes.toolbarButton.default)} title="Load a profile template" type="button" disabled={loadProfileMutation.isPending} > - {loadProfileMutation.isPending ? : } + {loadProfileMutation.isPending ? : } - + setIsModuleMenuOpen((prev) => !prev)} className={clsx( - "flex items-center gap-1.5", - "px-2 py-1", - "rounded-lg", - "hover:bg-white/5", - "font-medium hover:text-slate-200 text-[11px] text-slate-400", - "transition", + "flex items-center gap-1.5 px-2 py-1 rounded-lg font-medium text-[11px] transition", + classes.toolbarButton.active, )} type="button" > - + Modules @@ -282,41 +246,25 @@ text-[13px] text-slate-300`} className="fixed inset-0 z-[299]" onClick={() => setIsModuleMenuOpen(false)} /> - + {/* Property Planner */} { setIsModuleMenuOpen(false) openPropertyPlanner() }} - className={clsx( - "flex items-start gap-3", - "w-full", - "px-4 py-3", - "border-b border-white/[0.04]", - "hover:bg-white/5", - "text-left text-slate-200 text-sm", - "transition", - )} + className={clsx(classes.menuItem.base, classes.menuItem.withBorder, classes.menuItem.hover)} type="button" > - + Property Planner - Create and compare property purchase scenarios. + Create and compare property purchase scenarios. {/* CPF Simulation */} @@ -325,72 +273,15 @@ text-violet-400`}> setIsModuleMenuOpen(false) openCPFView() }} - className={clsx( - "flex items-start gap-3", - "w-full", - "px-4 py-3", - "border-b border-white/[0.04]", - "hover:bg-white/5", - "text-left text-slate-200 text-sm", - "transition", - )} + className={clsx(classes.menuItem.base, classes.menuItem.withBorder, classes.menuItem.hoverSage)} type="button" > - + CPF - Simulate balances, investments, and retirement. - - - {/* Coming Soon Modules */} - - - - - - - Vehicle Purchase - Coming soon - - - - {/* Tax Module */} - { - setIsModuleMenuOpen(false) - openTaxPlanner() - }} - className={clsx( - "flex items-start gap-3", - "w-full", - "px-4 py-3", - "border-b border-white/[0.04]", - "hover:bg-white/5", - "text-left text-slate-200 text-sm", - "transition", - )} - type="button" - > - - - - - Tax Planner - Singapore tax calculations and scenario planning. + Simulate balances, investments, and retirement. {/* Insurance Planner */} @@ -399,62 +290,64 @@ text-amber-400`}> setIsModuleMenuOpen(false) openInsurancePlanner() }} - className={clsx( - "flex items-start gap-3", - "w-full", - "px-4 py-3", - "hover:bg-white/5", - "text-left text-slate-200 text-sm", - "transition", - )} + className={clsx(classes.menuItem.base, classes.menuItem.withBorder, classes.menuItem.hover)} type="button" > - + Insurance Planner - Analyze coverage gaps and plan your protection. + Analyze coverage gaps and plan your protection. + {/* Coming Soon Modules */} + + + + + + + Vehicle Purchase + Coming soon + + + + + + + + + + Tax Projections + Coming soon + + + > )} - + {/* Layout toggle */} + {/* Color scheme toggle */} + + {/* Notification bell */} @@ -481,12 +374,8 @@ text-purple-400`}> {/* Chart Section */} {/* Chart Container - NetWorthProjection has its own header */} diff --git a/frontend/src/components/dashboard/TaxModePanel/TaxModeModal.tsx b/frontend/src/components/dashboard/TaxModePanel/TaxModeModal.tsx index 5960f0071..c38bb0ab7 100644 --- a/frontend/src/components/dashboard/TaxModePanel/TaxModeModal.tsx +++ b/frontend/src/components/dashboard/TaxModePanel/TaxModeModal.tsx @@ -15,9 +15,10 @@ import { import * as Tooltip from '@radix-ui/react-tooltip' import { clsx } from 'clsx' import { Modal } from '@/components/ui/Modal' -import { useTaxModeStore } from '@/stores' +import { useTaxModeStore, useColorScheme } from '@/stores' import { useTaxReliefStorage } from '@/hooks/useTaxReliefStorage' import { useIncomesQuery } from '@/hooks/queries/useIncomesQuery' +import { usePersonsQuery } from '@/hooks/queries/usePersonsQuery' import { numericStyles } from '@/lib/utils' import { CustomDropdown } from '@/components/modals/ScenarioEventModal/components/CustomDropdown' import { @@ -90,11 +91,17 @@ interface SegmentedControlProps { value: T onChange: (value: T) => void options: { value: T; label: string; icon?: React.ReactNode }[] + isMonet?: boolean } -function SegmentedControl({ value, onChange, options }: SegmentedControlProps) { +function SegmentedControl({ value, onChange, options, isMonet = false }: SegmentedControlProps) { return ( - + {options.map((option) => { const isActive = option.value === value return ( @@ -105,8 +112,8 @@ function SegmentedControl({ value, onChange, options }: Segmen className={clsx( 'flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-150', isActive - ? 'bg-white/[0.1] text-white shadow-sm' - : 'text-slate-500 hover:text-slate-300' + ? (isMonet ? 'bg-white text-[var(--monet-text-primary)] shadow-sm' : 'bg-white/[0.1] text-white shadow-sm') + : (isMonet ? 'text-[var(--monet-text-muted)] hover:text-[var(--monet-text-secondary)]' : 'text-slate-500 hover:text-slate-300') )} > {option.icon} @@ -121,16 +128,20 @@ function SegmentedControl({ value, onChange, options }: Segmen interface IncomeRowProps { income: Income annualAmount: number + isMonet?: boolean } -function IncomeRow({ income, annualAmount }: IncomeRowProps) { +function IncomeRow({ income, annualAmount, isMonet = false }: IncomeRowProps) { const taxType = mapCategoryToTaxType(income.category) return ( - + - {income.name} - {taxType} + {income.name} + {taxType} {formatCurrency(annualAmount)} @@ -142,9 +153,10 @@ function IncomeRow({ income, annualAmount }: IncomeRowProps) { interface ReliefRowProps { relief: TaxRelief onUpdate: (amount: number) => void + isMonet?: boolean } -function ReliefRow({ relief, onUpdate }: ReliefRowProps) { +function ReliefRow({ relief, onUpdate, isMonet = false }: ReliefRowProps) { const [isEditing, setIsEditing] = useState(false) const [inputValue, setInputValue] = useState(relief.claimedAmount.toString()) const isUsed = relief.claimedAmount > 0 @@ -159,13 +171,26 @@ function ReliefRow({ relief, onUpdate }: ReliefRowProps) { } return ( - + - + {relief.name} {relief.autoCalculated && ( - + Auto )} @@ -173,7 +198,10 @@ function ReliefRow({ relief, onUpdate }: ReliefRowProps) { - + @@ -182,19 +210,27 @@ function ReliefRow({ relief, onUpdate }: ReliefRowProps) { side="top" align="start" sideOffset={4} - className="z-[60] max-w-xs px-3 py-2 text-xs leading-relaxed text-slate-200 bg-[#1a1a1a] border border-white/[0.1] rounded-lg shadow-xl" + className={clsx( + 'z-[60] max-w-xs px-3 py-2 text-xs leading-relaxed rounded-lg shadow-xl', + isMonet + ? 'text-[var(--monet-text-secondary)] bg-white border border-[var(--monet-lavender)]/20' + : 'text-slate-200 bg-[#1a1a1a] border border-white/[0.1]' + )} > {reliefInfo.description} Learn more on IRAS - + @@ -210,21 +246,27 @@ function ReliefRow({ relief, onUpdate }: ReliefRowProps) { onBlur={handleBlur} onKeyDown={(e) => e.key === 'Enter' && handleBlur()} autoFocus - className="w-24 px-2 py-1 text-right text-sm rounded-lg bg-white/[0.03] border border-white/[0.06] text-white font-mono tabular-nums focus:outline-none focus:border-white/20 transition-colors" + className={clsx( + 'w-24 px-2 py-1 text-right text-sm rounded-lg font-mono tabular-nums focus:outline-none transition-colors', + isMonet + ? 'bg-white border border-[var(--monet-lavender)]/20 text-[var(--monet-text-primary)] focus:border-[var(--monet-lavender)]/40' + : 'bg-white/[0.03] border border-white/[0.06] text-white focus:border-white/20' + )} /> ) : ( setIsEditing(true)} className={clsx( numericStyles.medium, - 'transition-colors hover:text-amber-400', - !isUsed && 'text-slate-500' + 'transition-colors', + isMonet ? 'hover:text-amber-600' : 'hover:text-amber-400', + !isUsed && (isMonet ? 'text-[var(--monet-text-muted)]' : 'text-slate-500') )} > {formatCurrency(relief.claimedAmount)} )} - + / {formatCurrency(relief.maxAmount)} @@ -242,10 +284,18 @@ interface TaxModeModalProps { } export function TaxModeModal({ isOpen, onClose }: TaxModeModalProps) { + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' const residencyStatus = useTaxModeStore((s) => s.residencyStatus) const { loadReliefs, saveReliefs } = useTaxReliefStorage() const { data: incomes = [], isLoading: incomesLoading } = useIncomesQuery() + const { data: persons = [] } = usePersonsQuery() + + const getPersonLabel = (personId: PersonId) => { + const index = personId === 'person1' ? 0 : 1 + return persons[index]?.name || `Person ${index + 1}` + } const currentYear = new Date().getFullYear() const assessmentYear = currentYear + 1 @@ -355,26 +405,45 @@ export function TaxModeModal({ isOpen, onClose }: TaxModeModalProps) { - + {/* Header */} - + - + - Tax Estimate + Tax Estimate - YA {assessmentYear} + YA {assessmentYear} @@ -422,7 +491,12 @@ export function TaxModeModal({ isOpen, onClose }: TaxModeModalProps) { @@ -431,44 +505,47 @@ export function TaxModeModal({ isOpen, onClose }: TaxModeModalProps) { {/* Content - Two Column Layout */} {/* LEFT COLUMN - Inputs */} - + {/* Income Section */} - - Income (Annual) + + Income (Annual) setSelectedPerson(v as PersonId)} options={[ - { value: 'person1', label: 'Person 1' }, - { value: 'person2', label: 'Person 2' }, + { value: 'person1', label: getPersonLabel('person1') }, + { value: 'person2', label: getPersonLabel('person2') }, ]} minWidth="100px" /> {incomesLoading ? ( - Loading incomes... + Loading incomes... ) : selectedPersonIncomes.length === 0 ? ( - - No income assigned to {selectedPerson === 'person1' ? 'Person 1' : 'Person 2'} + + No income assigned to {getPersonLabel(selectedPerson)} ) : ( selectedPersonIncomes.map(({ income, annual }) => ( - + )) )} - + - Gross Income + Gross Income {formatCurrency(currentGrossIncome)} - CPF Deduction + CPF Deduction ({formatCurrency(cpfDeduction)}) @@ -478,16 +555,16 @@ export function TaxModeModal({ isOpen, onClose }: TaxModeModalProps) { - - Deductions & Reliefs + + Deductions & Reliefs - + {formatCurrency(totalReliefs)} / {formatCurrency(PERSONAL_RELIEF_CAP)} @@ -497,12 +574,13 @@ export function TaxModeModal({ isOpen, onClose }: TaxModeModalProps) { {usedReliefs.length > 0 && ( - Used + Used {usedReliefs.map((relief) => ( handleUpdateRelief(relief.id, amount)} + isMonet={isMonet} /> ))} @@ -510,12 +588,13 @@ export function TaxModeModal({ isOpen, onClose }: TaxModeModalProps) { {availableReliefs.length > 0 && ( - Available + Available {availableReliefs.map((relief) => ( handleUpdateRelief(relief.id, amount)} + isMonet={isMonet} /> ))} @@ -523,7 +602,7 @@ export function TaxModeModal({ isOpen, onClose }: TaxModeModalProps) { {totalReliefs >= PERSONAL_RELIEF_CAP && ( - + Relief cap of {formatCurrency(PERSONAL_RELIEF_CAP)} reached @@ -532,35 +611,38 @@ export function TaxModeModal({ isOpen, onClose }: TaxModeModalProps) { {/* RIGHT COLUMN - Results */} - + {/* Chargeable Income */} - - Chargeable Income + + Chargeable Income - Gross Income + Gross Income {formatCurrency(currentGrossIncome)} - CPF Deduction + CPF Deduction ({formatCurrency(cpfDeduction)}) - - Assessable Income + + Assessable Income {formatCurrency(currentGrossIncome - cpfDeduction)} - Reliefs + Reliefs ({formatCurrency(totalReliefs)}) - - Chargeable Income + + Chargeable Income {formatCurrency(taxResult.chargeableIncome)} @@ -568,19 +650,19 @@ export function TaxModeModal({ isOpen, onClose }: TaxModeModalProps) { {/* Tax */} - Tax Payable - + Tax Payable + ({formatCurrency(taxResult.taxPayable)}) - + {formatPercent(taxResult.effectiveRate)} effective | {formatPercent(taxResult.marginalRate)} marginal setTaxView(taxView === 'summary' ? 'by-bracket' : 'summary')} - className="text-xs text-slate-500 hover:text-slate-300 transition-colors" + className={clsx('text-xs transition-colors', isMonet ? 'text-[var(--monet-text-muted)] hover:text-[var(--monet-text-secondary)]' : 'text-slate-500 hover:text-slate-300')} > {taxView === 'summary' ? 'Show details' : 'Hide details'} @@ -594,16 +676,16 @@ export function TaxModeModal({ isOpen, onClose }: TaxModeModalProps) { exit={{ opacity: 0, height: 0 }} className="overflow-hidden" > - + {taxResult.taxBreakdown.map((bracket, idx) => ( - + {formatPercent(bracket.rate)} - {bracket.bracket} + {bracket.bracket} - + {formatCurrency(bracket.amount)} @@ -615,18 +697,18 @@ export function TaxModeModal({ isOpen, onClose }: TaxModeModalProps) { {/* Net Income */} - + - Net Income - + Net Income + {formatCurrency(taxResult.chargeableIncome - taxResult.taxPayable)} {/* Payment Method */} - + - Payment + Payment {paymentMethod === 'giro' && taxResult.taxPayable > 0 && ( - Monthly (12 instalments) + Monthly (12 instalments) ({formatCurrency(Math.ceil(taxResult.taxPayable / 12))}) diff --git a/frontend/src/components/dashboard/TaxModePanel/index.tsx b/frontend/src/components/dashboard/TaxModePanel/index.tsx index 493fec5e3..f124cb23a 100644 --- a/frontend/src/components/dashboard/TaxModePanel/index.tsx +++ b/frontend/src/components/dashboard/TaxModePanel/index.tsx @@ -16,6 +16,7 @@ import { clsx } from 'clsx' import { useTaxModeStore } from '@/stores' import { useTaxReliefStorage } from '@/hooks/useTaxReliefStorage' import { useIncomesQuery } from '@/hooks/queries/useIncomesQuery' +import { usePersonsQuery } from '@/hooks/queries/usePersonsQuery' import { numericStyles } from '@/lib/utils' import { CustomDropdown } from '@/components/modals/ScenarioEventModal/components/CustomDropdown' import { @@ -253,6 +254,13 @@ export function TaxModePanel({ fullWidth = false, hideToggle = false }: TaxModeP const { loadReliefs, saveReliefs } = useTaxReliefStorage() const { data: incomes = [], isLoading: incomesLoading } = useIncomesQuery() + const { data: persons = [] } = usePersonsQuery() + + // Person label helpers + const getPersonLabel = (personId: PersonId) => { + const index = personId === 'person1' ? 0 : 1 + return persons[index]?.name || `Person ${index + 1}` + } // Current year for tax calculation const currentYear = new Date().getFullYear() @@ -483,8 +491,8 @@ export function TaxModePanel({ fullWidth = false, hideToggle = false }: TaxModeP value={selectedPerson} onChange={(v) => setSelectedPerson(v as PersonId)} options={[ - { value: 'person1', label: 'Person 1' }, - { value: 'person2', label: 'Person 2' }, + { value: 'person1', label: getPersonLabel('person1') }, + { value: 'person2', label: getPersonLabel('person2') }, ]} minWidth="100px" /> @@ -494,7 +502,7 @@ export function TaxModePanel({ fullWidth = false, hideToggle = false }: TaxModeP Loading incomes... ) : selectedPersonIncomes.length === 0 ? ( - No income assigned to {selectedPerson === 'person1' ? 'Person 1' : 'Person 2'} + No income assigned to {getPersonLabel(selectedPerson)} ) : ( selectedPersonIncomes.map(({ income, annual }) => ( diff --git a/frontend/src/components/dashboard/projections/types.ts b/frontend/src/components/dashboard/projections/types.ts index d8156da10..b1b3f9f4a 100644 --- a/frontend/src/components/dashboard/projections/types.ts +++ b/frontend/src/components/dashboard/projections/types.ts @@ -5,11 +5,11 @@ export const AREA_ANIMATION_MS = 700 export const MARKER_BUFFER_MS = 400 export const chartColors = { - axis: '#aeb6c9', - grid: 'rgba(86, 91, 100, 0.6)', - gradientStart: '#4f81ff', - gradientEnd: 'rgba(59, 130, 246, 0.08)', - stroke: '#7db0ff', + axis: '#64748b', // slate-500 - darker for better contrast on light bg + grid: 'rgba(100, 116, 139, 0.12)', // subtle grid that works on both + gradientStart: 'rgba(74, 144, 217, 0.20)', + gradientEnd: 'rgba(74, 144, 217, 0.02)', + stroke: '#3B82F6', // blue-500 - vibrant line color } export type AxisMode = 'age' | 'year_number' | 'actual_year' diff --git a/frontend/src/components/insurance/ControlPointModal.tsx b/frontend/src/components/insurance/ControlPointModal.tsx new file mode 100644 index 000000000..dae0ee817 --- /dev/null +++ b/frontend/src/components/insurance/ControlPointModal.tsx @@ -0,0 +1,431 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { X, Shield, Heart, Zap } from 'lucide-react' +import { formatCoverageAmount } from '@/lib/coverage-journey-utils' +import type { CoverageControlPoint } from '@/types/insurance' + +// ============================================================================= +// Types +// ============================================================================= + +interface ControlPointModalProps { + isOpen: boolean + onClose: () => void + onSave: (data: { + age: number + lifeTpd: number | null + criticalIllness: number | null + personalAccident: number | null + reason?: string + }) => void + editingPoint?: CoverageControlPoint | null + recommendedValues?: { + lifeTpd: number + criticalIllness: number + personalAccident: number + } + minAge?: number + maxAge?: number + existingAges?: number[] +} + +// Monet colors +const monetColors = { + lavender: '#9B8BB4', + lavenderLight: '#C4B8D9', + lavenderDark: '#7A6B94', + coralRose: '#E8A898', + sage: '#7FB285', + sunlightGold: '#D4C5A9', + textPrimary: '#3D3D3D', + textSecondary: '#6B6B6B', + textMuted: '#9B9B9B', + bgCream: '#FAF8F5', + shadowSoft: 'rgba(155, 139, 180, 0.12)', + shadowMedium: 'rgba(155, 139, 180, 0.18)', +} + +// ============================================================================= +// Component +// ============================================================================= + +export function ControlPointModal({ + isOpen, + onClose, + onSave, + editingPoint, + recommendedValues = { lifeTpd: 600000, criticalIllness: 200000, personalAccident: 150000 }, + minAge = 25, + maxAge = 75, + existingAges = [], +}: ControlPointModalProps) { + // Form state + const [age, setAge] = useState(35) + const [lifeTpdEnabled, setLifeTpdEnabled] = useState(false) + const [lifeTpdValue, setLifeTpdValue] = useState('') + const [criticalIllnessEnabled, setCriticalIllnessEnabled] = useState(false) + const [criticalIllnessValue, setCriticalIllnessValue] = useState('') + const [personalAccidentEnabled, setPersonalAccidentEnabled] = useState(false) + const [personalAccidentValue, setPersonalAccidentValue] = useState('') + const [reason, setReason] = useState('') + + // Age validation + const isEditMode = !!editingPoint + const ageAlreadyExists = + !isEditMode && existingAges.includes(age) + + // Initialize form when editing + useEffect(() => { + if (editingPoint) { + setAge(editingPoint.age) + setLifeTpdEnabled(editingPoint.lifeTpd !== null) + setLifeTpdValue(editingPoint.lifeTpd?.toString() ?? '') + setCriticalIllnessEnabled(editingPoint.criticalIllness !== null) + setCriticalIllnessValue(editingPoint.criticalIllness?.toString() ?? '') + setPersonalAccidentEnabled(editingPoint.personalAccident !== null) + setPersonalAccidentValue(editingPoint.personalAccident?.toString() ?? '') + setReason(editingPoint.reason ?? '') + } else { + // Reset form for new point + setAge(35) + setLifeTpdEnabled(false) + setLifeTpdValue('') + setCriticalIllnessEnabled(false) + setCriticalIllnessValue('') + setPersonalAccidentEnabled(false) + setPersonalAccidentValue('') + setReason('') + } + }, [editingPoint, isOpen]) + + // Handle save + const handleSave = useCallback(() => { + if (ageAlreadyExists) return + + onSave({ + age, + lifeTpd: lifeTpdEnabled ? parseInt(lifeTpdValue, 10) || null : null, + criticalIllness: criticalIllnessEnabled + ? parseInt(criticalIllnessValue, 10) || null + : null, + personalAccident: personalAccidentEnabled + ? parseInt(personalAccidentValue, 10) || null + : null, + reason: reason.trim() || undefined, + }) + onClose() + }, [ + age, + ageAlreadyExists, + lifeTpdEnabled, + lifeTpdValue, + criticalIllnessEnabled, + criticalIllnessValue, + personalAccidentEnabled, + personalAccidentValue, + reason, + onSave, + onClose, + ]) + + // Handle escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + if (isOpen) { + document.addEventListener('keydown', handleEscape) + document.body.style.overflow = 'hidden' + } + return () => { + document.removeEventListener('keydown', handleEscape) + document.body.style.overflow = '' + } + }, [isOpen, onClose]) + + if (!isOpen) return null + + const modalContent = ( + { + if (e.target === e.currentTarget) onClose() + }} + > + {/* Backdrop */} + + + {/* Modal */} + + {/* Header */} + + + {isEditMode ? 'Edit Control Point' : 'Add Control Point'} + + + + + + + {/* Body */} + + {/* Age Input */} + + + Age + + + setAge(parseInt(e.target.value, 10))} + disabled={isEditMode} + className="flex-1 h-2 rounded-full appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" + style={{ + background: `linear-gradient(to right, ${monetColors.lavender} 0%, ${monetColors.lavender} ${((age - minAge) / (maxAge - minAge)) * 100}%, rgba(155, 139, 180, 0.2) ${((age - minAge) / (maxAge - minAge)) * 100}%, rgba(155, 139, 180, 0.2) 100%)`, + }} + /> + + {age} + + + {ageAlreadyExists && ( + + A control point already exists at this age + + )} + + + {/* Coverage Targets */} + + + Coverage Targets + + + {/* Life/TPD */} + } + iconColor={monetColors.sage} + label="Life / TPD" + enabled={lifeTpdEnabled} + onEnabledChange={setLifeTpdEnabled} + value={lifeTpdValue} + onValueChange={setLifeTpdValue} + recommendedValue={recommendedValues.lifeTpd} + /> + + {/* Critical Illness */} + } + iconColor="#3B82F6" + label="Critical Illness" + enabled={criticalIllnessEnabled} + onEnabledChange={setCriticalIllnessEnabled} + value={criticalIllnessValue} + onValueChange={setCriticalIllnessValue} + recommendedValue={recommendedValues.criticalIllness} + /> + + {/* Personal Accident */} + } + iconColor="#A855F7" + label="Personal Accident" + enabled={personalAccidentEnabled} + onEnabledChange={setPersonalAccidentEnabled} + value={personalAccidentValue} + onValueChange={setPersonalAccidentValue} + recommendedValue={recommendedValues.personalAccident} + /> + + + + {/* Reason */} + + + Reason (optional) + + setReason(e.target.value)} + placeholder="e.g., Kids become independent" + className="w-full px-3 py-2 rounded-xl text-sm transition-all duration-200 outline-none" + style={{ + background: 'rgba(255, 255, 255, 0.8)', + border: '1px solid rgba(155, 139, 180, 0.2)', + color: monetColors.textPrimary, + }} + /> + + + + {/* Footer */} + + + Cancel + + + {isEditMode ? 'Save Changes' : 'Add Control Point'} + + + + + ) + + // Use portal to render at document root + if (typeof document === 'undefined') return null + return createPortal(modalContent, document.body) +} + +// ============================================================================= +// Sub-component +// ============================================================================= + +function CoverageInput({ + icon, + iconColor, + label, + enabled, + onEnabledChange, + value, + onValueChange, + recommendedValue, +}: { + icon: React.ReactNode + iconColor: string + label: string + enabled: boolean + onEnabledChange: (enabled: boolean) => void + value: string + onValueChange: (value: string) => void + recommendedValue: number +}) { + return ( + + + {/* Toggle */} + onEnabledChange(!enabled)} + className="relative w-9 h-5 rounded-full transition-colors duration-200 shrink-0" + style={{ + background: enabled ? monetColors.lavender : 'rgba(155, 139, 180, 0.2)', + }} + > + + + + {/* Icon & Label */} + + {icon} + + {label} + + + + {/* Value Input or Recommended Badge */} + + {enabled ? ( + + + $ + + onValueChange(e.target.value)} + placeholder={Math.round(recommendedValue / 1000).toString() + 'K'} + className="w-24 px-2 py-1 rounded-lg text-sm text-right outline-none" + style={{ + background: 'rgba(155, 139, 180, 0.08)', + color: monetColors.textPrimary, + }} + /> + + ) : ( + + {formatCoverageAmount(recommendedValue)} (auto) + + )} + + + + ) +} diff --git a/frontend/src/components/insurance/ControlPointsPanel.tsx b/frontend/src/components/insurance/ControlPointsPanel.tsx new file mode 100644 index 000000000..7b519d9bd --- /dev/null +++ b/frontend/src/components/insurance/ControlPointsPanel.tsx @@ -0,0 +1,330 @@ +'use client' + +import { Plus, Pencil, Trash2, RotateCcw } from 'lucide-react' +import { cn } from '@/lib/utils' +import { formatCoverageAmount } from '@/lib/coverage-journey-utils' +import type { CoverageControlPoint, InterpolationMode } from '@/types/insurance' + +// ============================================================================= +// Types +// ============================================================================= + +interface ControlPointsPanelProps { + controlPoints: CoverageControlPoint[] + interpolationMode: InterpolationMode + showRecommendedBaseline: boolean + onAddPoint: () => void + onEditPoint: (point: CoverageControlPoint) => void + onDeletePoint: (id: string) => void + onInterpolationModeChange: (mode: InterpolationMode) => void + onShowBaselineChange: (show: boolean) => void + onResetAll: () => void + className?: string +} + +// Monet-inspired colors (matching insurance planner) +const monetColors = { + lavender: '#9B8BB4', + lavenderLight: '#C4B8D9', + lavenderDark: '#7A6B94', + coralRose: '#E8A898', + sage: '#7FB285', + sunlightGold: '#D4C5A9', + textPrimary: '#3D3D3D', + textSecondary: '#6B6B6B', + textMuted: '#9B9B9B', + shadowSoft: 'rgba(155, 139, 180, 0.12)', +} + +// ============================================================================= +// Component +// ============================================================================= + +export function ControlPointsPanel({ + controlPoints, + interpolationMode, + showRecommendedBaseline, + onAddPoint, + onEditPoint, + onDeletePoint, + onInterpolationModeChange, + onShowBaselineChange, + onResetAll, + className, +}: ControlPointsPanelProps) { + const sortedPoints = [...controlPoints].sort((a, b) => a.age - b.age) + + return ( + + {/* Header */} + + + + Control Points + + + {controlPoints.length === 0 + ? 'Add points to customize your coverage plan' + : `${controlPoints.length} point${controlPoints.length !== 1 ? 's' : ''} defined`} + + + + + Add Point + + + + {/* Control Points List */} + {sortedPoints.length > 0 ? ( + + {sortedPoints.map((point) => ( + onEditPoint(point)} + onDelete={() => onDeletePoint(point.id)} + /> + ))} + + ) : ( + + )} + + {/* Settings */} + {controlPoints.length > 0 && ( + + {/* Interpolation Mode */} + + + Interpolation + + + {(['linear', 'smooth', 'step'] as InterpolationMode[]).map((mode) => ( + onInterpolationModeChange(mode)} + className="px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-150" + style={{ + background: + interpolationMode === mode + ? 'white' + : 'transparent', + color: + interpolationMode === mode + ? monetColors.lavenderDark + : monetColors.textMuted, + boxShadow: + interpolationMode === mode + ? `0 1px 3px ${monetColors.shadowSoft}` + : 'none', + }} + > + {mode.charAt(0).toUpperCase() + mode.slice(1)} + + ))} + + + + {/* Show Baseline Toggle */} + + + Show recommended baseline + + onShowBaselineChange(!showRecommendedBaseline)} + className="relative w-10 h-5 rounded-full transition-colors duration-200" + style={{ + background: showRecommendedBaseline + ? monetColors.sage + : 'rgba(155, 139, 180, 0.2)', + }} + > + + + + + {/* Reset Button */} + + + Reset to recommended + + + )} + + ) +} + +// ============================================================================= +// Sub-components +// ============================================================================= + +function ControlPointCard({ + point, + onEdit, + onDelete, +}: { + point: CoverageControlPoint + onEdit: () => void + onDelete: () => void +}) { + const hasLifeTpd = point.lifeTpd !== null + const hasCriticalIllness = point.criticalIllness !== null + const hasPersonalAccident = point.personalAccident !== null + + return ( + + + + {/* Age badge */} + + + Age {point.age} + + + + {/* Coverage values */} + + {hasLifeTpd && ( + + Life/TPD:{' '} + {formatCoverageAmount(point.lifeTpd!)} + + )} + {hasCriticalIllness && ( + + CI:{' '} + {formatCoverageAmount(point.criticalIllness!)} + + )} + {hasPersonalAccident && ( + + PA:{' '} + {formatCoverageAmount(point.personalAccident!)} + + )} + {!hasLifeTpd && !hasCriticalIllness && !hasPersonalAccident && ( + + Using recommended values + + )} + + + {/* Reason */} + {point.reason && ( + + “{point.reason}” + + )} + + + {/* Actions */} + + + + + + + + + + + ) +} + +function EmptyState({ onAddPoint }: { onAddPoint: () => void }) { + return ( + + + + + + No control points yet + + + Add control points to define custom coveragetargets at specific ages + + + Add your first point + + + ) +} diff --git a/frontend/src/components/insurance/CoverageDashboard.tsx b/frontend/src/components/insurance/CoverageDashboard.tsx new file mode 100644 index 000000000..5bde5cc84 --- /dev/null +++ b/frontend/src/components/insurance/CoverageDashboard.tsx @@ -0,0 +1,635 @@ +'use client' + +import { useMemo } from 'react' +import { + Settings, + Plus, + CheckCircle2, + AlertTriangle, + Lock, + Stethoscope, + Shield, + Heart, + Zap, +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { formatCurrency } from '@/lib/format' +import { useColorScheme } from '@/stores' +import { getInsuranceTheme, type InsuranceTheme } from '@/lib/insurance-theme' +import { + useGuidelines, + useGuidelineTargets, + useGuidelinesActions, + useSelectedPersonId, +} from '@/stores/coverageGuidelinesStore' +import { + guidelineCoverageConfig, + type GuidelineCoverageType, + type CoverageCategoryStatus, + buildCoverageStatus, +} from '@/types/insurance' +import { usePersonFilter } from '@/contexts/PersonFilterContext' +import { PersonSelector } from '@/components/ui/PersonSelector' + +// ============================================================================ +// Helper to get status color from theme +// ============================================================================ + +function getStatusColor(score: number, isEmpty: boolean, theme: InsuranceTheme) { + if (isEmpty) return theme.textMuted + if (score >= 80) return theme.sage + if (score >= 50) return theme.sunlightGold + return theme.coralRose +} + +// ============================================================================ +// PROTECTION SCORE GAUGE +// ============================================================================ + +interface ProtectionScoreGaugeProps { + score: number + gapCount: number + isEmpty: boolean + compact?: boolean +} + +function ProtectionScoreGauge({ score, gapCount, isEmpty, compact = false }: ProtectionScoreGaugeProps) { + const colorScheme = useColorScheme() + const monetColors = getInsuranceTheme(colorScheme) + + const size = compact ? 80 : 140 + const strokeWidth = compact ? 8 : 12 + const radius = (size - strokeWidth) / 2 + const circumference = 2 * Math.PI * radius + const progress = isEmpty ? 0 : Math.min(100, Math.max(0, score)) + const strokeDashoffset = circumference - (progress / 100) * circumference + + const scoreColor = getStatusColor(score, isEmpty, monetColors) + const strokeColor = getStatusColor(score, isEmpty, monetColors) + + const getStatusText = () => { + if (isEmpty) return 'No policies yet' + if (score >= 80) return 'Well Protected' + if (score >= 50) return 'Partial Coverage' + return 'Needs Attention' + } + + if (compact) { + return ( + + + + + + + + {isEmpty ? '—' : `${Math.round(score)}%`} + + + + ) + } + + return ( + + + + + + + + + {isEmpty ? '—' : `${Math.round(score)}%`} + + + + + + {getStatusText()} + + {!isEmpty && gapCount > 0 && ( + + {gapCount} {gapCount === 1 ? 'gap' : 'gaps'} to address + + )} + {isEmpty && ( + + Add policies to see your score + + )} + + + ) +} + +// ============================================================================ +// COVERAGE CATEGORY ROW +// ============================================================================ + +const categoryIcons: Record = { + hospitalization: Stethoscope, + life_tpd: Shield, + critical_illness: Heart, + personal_accident: Zap, +} + +// Helper to get category colors from theme +function getCategoryColors(categoryType: GuidelineCoverageType, theme: InsuranceTheme) { + switch (categoryType) { + case 'hospitalization': + return { bg: `${theme.coralRose}20`, icon: theme.coralRose } + case 'life_tpd': + return { bg: `${theme.lavender}20`, icon: theme.lavender } + case 'critical_illness': + return { bg: `${theme.sage}20`, icon: theme.sage } + case 'personal_accident': + return { bg: `${theme.sunlightGold}30`, icon: theme.amber } + default: + return { bg: `${theme.lavender}20`, icon: theme.lavender } + } +} + +interface CoverageCategoryRowProps { + category: CoverageCategoryStatus + isLast?: boolean +} + +function CoverageCategoryRow({ category, isLast }: CoverageCategoryRowProps) { + const colorScheme = useColorScheme() + const monetColors = getInsuranceTheme(colorScheme) + + const config = guidelineCoverageConfig[category.category] + const Icon = categoryIcons[category.category] + const categoryColors = getCategoryColors(category.category, monetColors) + + const getStatusBadge = () => { + switch (category.status) { + case 'covered': + return ( + + + Covered + + ) + case 'partial': + return ( + + + Gap + + ) + case 'gap': + return ( + + + No Policy + + ) + default: + return null + } + } + + const getProgressColor = () => { + if (category.percentage >= 100) return monetColors.sage + if (category.percentage > 0) return monetColors.sunlightGold + return monetColors.textMuted + } + + const isHospitalization = category.category === 'hospitalization' + + return ( + + + + + + + + + {config.label} + + {getStatusBadge()} + + + {isHospitalization ? ( + <>Target: {category.label}> + ) : ( + <> + Target: {formatCurrency(category.target)} + {category.current > 0 && ( + <> · Current: {formatCurrency(category.current)}> + )} + > + )} + + + + + + + {category.percentage}% + + + + + + ) +} + +// ============================================================================ +// PREMIUM BUDGET BAR +// ============================================================================ + +interface PremiumBudgetBarProps { + budget: number + used: number + percentageUsed: number +} + +function PremiumBudgetBar({ budget, used, percentageUsed }: PremiumBudgetBarProps) { + const colorScheme = useColorScheme() + const monetColors = getInsuranceTheme(colorScheme) + + const remaining = Math.max(0, budget - used) + + const getProgressColor = () => { + if (percentageUsed > 100) return monetColors.coralRose + if (percentageUsed > 80) return monetColors.sunlightGold + return monetColors.sage + } + + return ( + + + + + Premium Budget + + + + + + Budget: {formatCurrency(budget)}/year + + + {formatCurrency(used)}/year + + + + + + + + + + {percentageUsed}% of budget used + + + {formatCurrency(remaining)} remaining + + + + ) +} + +// ============================================================================ +// MAIN COVERAGE DASHBOARD +// ============================================================================ + +interface CoverageDashboardProps { + onEditTargets?: () => void + onAddPolicy?: () => void +} + +export function CoverageDashboard({ onEditTargets, onAddPolicy }: CoverageDashboardProps) { + const colorScheme = useColorScheme() + const monetColors = getInsuranceTheme(colorScheme) + + const guidelines = useGuidelines() + const targets = useGuidelineTargets() + const selectedPersonId = useSelectedPersonId() + const { setSelectedPersonId } = useGuidelinesActions() + const { includedPersons } = usePersonFilter() + + const selectedPerson = includedPersons.find((p) => p.id === selectedPersonId) + + const coverageStatus = useMemo(() => { + return buildCoverageStatus( + guidelines, + selectedPersonId || '', + selectedPerson?.name || 'Unknown', + { + hospitalization: false, + life_tpd: 0, + critical_illness: 0, + personal_accident: 0, + }, + { + monthlyPremium: 0, + annualPremium: 0, + } + ) + }, [guidelines, selectedPersonId, selectedPerson?.name]) + + const isEmpty = coverageStatus.categories.every((c) => c.percentage === 0) + const showPersonTabs = includedPersons.length > 1 + + return ( + + + {/* Header */} + + + My Coverage + + + {onEditTargets && ( + + + Edit Targets + + )} + + + + {/* Person Tabs + Score Row */} + + + {showPersonTabs ? ( + id && setSelectedPersonId(id)} + variant={colorScheme === 'monet' ? 'monet' : 'dark'} + showCreate={false} + required + className="w-48" + /> + ) : ( + + {selectedPerson?.name || 'No person selected'} + + )} + + + + + + {isEmpty + ? 'No policies yet' + : coverageStatus.gapCount > 0 + ? `${coverageStatus.gapCount} ${coverageStatus.gapCount > 1 ? 'gaps' : 'gap'} to address` + : 'Fully covered'} + + + + + + {/* Stress Test Banner */} + + + + + + Stress Test + + + Coming Soon + + + + + Test your coverage against different scenarios + + + + {/* Coverage by Category */} + + + + + Coverage by Category + + + {coverageStatus.categories.map((category, index) => ( + + ))} + + + {/* Premium Budget */} + + + {/* Add Policy CTA */} + + + + Add Your First Policy + + + + + ) +} diff --git a/frontend/src/components/insurance/CoverageJourney.tsx b/frontend/src/components/insurance/CoverageJourney.tsx new file mode 100644 index 000000000..1efa6d2ac --- /dev/null +++ b/frontend/src/components/insurance/CoverageJourney.tsx @@ -0,0 +1,293 @@ +'use client' + +import { useMemo, useState } from 'react' +import { Shield, TrendingDown, AlertCircle, CheckCircle2 } from 'lucide-react' +import { cn } from '@/lib/utils' +import { numericStyles } from '@/lib/utils' +import { usePersonsQuery } from '@/hooks/queries/usePersonsQuery' +import { useQuestionnaireAutoPopulate } from '@/hooks/useQuestionnaireAutoPopulate' +import { usePersonFilter } from '@/contexts/PersonFilterContext' +import { + detectLifeStage, + generateCoverageProjection, + calculateMilestones, + calculateAge, + formatCoverageAmount, + type PersonCoverageContext, +} from '@/lib/coverage-journey-utils' +import { LifeStagePills, LifeStageIndicator } from './LifeStagePills' +import { CoverageNeedsCurve } from './CoverageNeedsCurve' +import { MilestoneAlerts } from './MilestoneAlerts' + +// ============================================================================= +// Types +// ============================================================================= + +interface CoverageJourneyProps { + className?: string +} + +interface CoverageComparisonRowProps { + category: string + icon: React.ReactNode + recommended: number + current: number + isAmount?: boolean +} + +// ============================================================================= +// Sub-Components +// ============================================================================= + +function CoverageComparisonRow({ + category, + icon, + recommended, + current, + isAmount = true, +}: CoverageComparisonRowProps) { + const gap = recommended - current + const isExcess = gap < 0 + const isOnTarget = gap === 0 + + const getStatus = () => { + if (isOnTarget) return { label: 'On Target', color: 'text-emerald-400', icon: CheckCircle2 } + if (isExcess) return { label: `+${formatCoverageAmount(Math.abs(gap))}`, color: 'text-emerald-400', icon: CheckCircle2 } + return { label: `Gap: ${formatCoverageAmount(gap)}`, color: 'text-amber-400', icon: AlertCircle } + } + + const status = getStatus() + const StatusIcon = status.icon + + return ( + + {/* Category */} + + {icon} + {category} + + + {/* Recommended */} + + + {isAmount ? formatCoverageAmount(recommended) : recommended} + + + + {/* Current */} + + + {isAmount ? formatCoverageAmount(current) : current} + + + + {/* Status */} + + + + {status.label} + + + + ) +} + +// ============================================================================= +// Main Component +// ============================================================================= + +export function CoverageJourney({ className }: CoverageJourneyProps) { + const { includedPersons } = usePersonFilter() + const { data: persons, isLoading: personsLoading } = usePersonsQuery() + + // Get first included person + const selectedPersonId = includedPersons[0]?.id ?? null + const selectedPerson = persons?.find(p => p.id === selectedPersonId) + + // Auto-populated financial data + const autoPopulated = useQuestionnaireAutoPopulate(selectedPersonId) + + // Selected age for exploration (defaults to current age) + const currentAge = selectedPerson ? calculateAge(selectedPerson.dateOfBirth) : 35 + const [selectedAge, setSelectedAge] = useState(currentAge) + + // Build context for calculations + const coverageContext: PersonCoverageContext = useMemo(() => { + // Get dependents from persons data + const dependents = (persons ?? []) + .filter(p => p.id !== selectedPersonId && p.isIncluded) + .map(p => ({ + name: p.name, + age: calculateAge(p.dateOfBirth), + })) + .filter(d => d.age < 22) // Only count dependents under 22 + + return { + age: currentAge, + annualIncome: autoPopulated.computed.primaryPersonIncome || 60000, // Default $60k + dependents, + mortgageBalance: autoPopulated.computed.totalMortgage, + mortgageEndYear: autoPopulated.computed.totalMortgage > 0 + ? new Date().getFullYear() + 25 // Assume 25-year mortgage + : null, + retirementAge: 65, + } + }, [currentAge, autoPopulated, persons, selectedPersonId]) + + // Calculate life stage + const lifeStage = useMemo(() => { + return detectLifeStage(currentAge, coverageContext.dependents) + }, [currentAge, coverageContext.dependents]) + + // Generate projections + const projections = useMemo(() => { + return generateCoverageProjection(coverageContext, 25, 75) + }, [coverageContext]) + + // Calculate milestones + const milestones = useMemo(() => { + return calculateMilestones(coverageContext) + }, [coverageContext]) + + // Get recommended coverage at selected age + const selectedProjection = useMemo(() => { + return projections.find(p => p.age === selectedAge) ?? projections.find(p => p.age === currentAge) + }, [projections, selectedAge, currentAge]) + + // Current year + const currentYear = new Date().getFullYear() + + // Loading state + if (personsLoading || autoPopulated.isLoading) { + return ( + + + + + + ) + } + + // No person selected + if (!selectedPerson) { + return ( + + + Select a person to view their coverage journey + + ) + } + + const isCurrentAge = selectedAge === currentAge + const yearsFromNow = selectedAge - currentAge + + return ( + + {/* Header */} + + + Coverage Journey + + How your insurance needs change over time + + + + + + {/* Life Stage Pills */} + + + Your Life Stage + + + + + {/* Coverage Needs Curve */} + + + Coverage Needs Over Time + + + + + {/* Coverage Comparison at Selected Age */} + + + + + At Age {selectedAge} + + + {isCurrentAge ? ( + Your current age + ) : yearsFromNow > 0 ? ( + {yearsFromNow} years from now + ) : ( + {Math.abs(yearsFromNow)} years ago + )} + + + {!isCurrentAge && ( + setSelectedAge(currentAge)} + className="text-xs text-slate-400 hover:text-slate-300 transition-colors" + > + ← Back to current + + )} + + + {selectedProjection && ( + + {/* Header row */} + + Category + Recommended + Current + Status + + + } + recommended={selectedProjection.recommendedLifeTpd} + current={0} // Will be from policies in future + /> + } + recommended={selectedProjection.recommendedCriticalIllness} + current={0} + /> + } + recommended={selectedProjection.recommendedPersonalAccident} + current={0} + /> + + )} + + + {/* Milestones */} + {milestones.length > 0 && ( + + + Upcoming Milestones + + + + )} + + ) +} diff --git a/frontend/src/components/insurance/CoverageLayers.tsx b/frontend/src/components/insurance/CoverageLayers.tsx new file mode 100644 index 000000000..ab45aff24 --- /dev/null +++ b/frontend/src/components/insurance/CoverageLayers.tsx @@ -0,0 +1,323 @@ +'use client' + +import { cn } from '@/lib/utils' + +// ============================================================================ +// MONET-INSPIRED COLORS FOR COVERAGE LAYERS +// Soft, painterly palette reflecting impressionist aesthetics +// ============================================================================ + +const monetLayerColors = { + // Coral Rose - for MediShield Life (government foundation) + coral: '#E8A898', + coralLight: '#F5D4CC', + + // Lavender Blue - for ISP Coverage (private enhancement) + lavender: '#9B8BB4', + lavenderLight: '#C4B8D9', + + // Sage - for Rider (additional protection) + sage: '#7FB285', + sageLight: '#B5D4B8', + + // Sunlight Gold - for Out-of-pocket (your contribution) + gold: '#D4C5A9', + goldLight: '#EDE6D8', + + // Text + textPrimary: '#3D3D3D', + textSecondary: '#6B6B6B', + textMuted: '#9B9B9B', + + // Card + cardBorder: 'rgba(155, 139, 180, 0.15)', +} + +interface CoverageLayerOption { + id: 'mshl_only' | 'mshl_isp' | 'mshl_isp_rider' + title: string + description: string + layers: { + label: string + sublabel?: string + color: 'coral' | 'lavender' | 'sage' | 'gold' + heightClass: string + }[] +} + +const coverageOptions: CoverageLayerOption[] = [ + { + id: 'mshl_only', + title: 'MediShield Life Only', + description: 'Basic coverage for B2/C wards', + layers: [ + { + label: 'Out-of-pocket payment', + color: 'gold', + heightClass: 'h-32', + }, + { + label: 'Paid by MediShield Life (MSHL)', + color: 'coral', + heightClass: 'h-20', + }, + ], + }, + { + id: 'mshl_isp', + title: 'MediShield Life + ISP', + description: 'Higher ward class coverage', + layers: [ + { + label: 'Out-of-pocket payment', + sublabel: 'Deductible + Co-payment at 10%', + color: 'gold', + heightClass: 'h-20', + }, + { + label: 'Integrated Shield Plan (IP) pays', + color: 'lavender', + heightClass: 'h-16', + }, + { + label: 'Paid by MediShield Life (MSHL)', + color: 'coral', + heightClass: 'h-16', + }, + ], + }, + { + id: 'mshl_isp_rider', + title: 'MediShield Life + ISP + Rider', + description: 'Maximum coverage', + layers: [ + { + label: 'Out-of-pocket payment', + sublabel: 'Smaller deductible + Co-payment 5-10%', + color: 'gold', + heightClass: 'h-12', + }, + { + label: 'Integrated Shield Plan Rider pays', + color: 'sage', + heightClass: 'h-12', + }, + { + label: 'Integrated Shield Plan (IP) pays', + color: 'lavender', + heightClass: 'h-14', + }, + { + label: 'Paid by MediShield Life (MSHL)', + color: 'coral', + heightClass: 'h-14', + }, + ], + }, +] + +// Monet-inspired color styles for each layer type +const getLayerStyle = (color: string): React.CSSProperties => { + switch (color) { + case 'coral': + return { + background: `linear-gradient(135deg, ${monetLayerColors.coral}, ${monetLayerColors.coralLight})`, + color: '#fff', + } + case 'lavender': + return { + background: `linear-gradient(135deg, ${monetLayerColors.lavender}, ${monetLayerColors.lavenderLight})`, + color: '#fff', + } + case 'sage': + return { + background: `linear-gradient(135deg, ${monetLayerColors.sage}, ${monetLayerColors.sageLight})`, + color: '#fff', + } + case 'gold': + return { + background: `linear-gradient(135deg, ${monetLayerColors.gold}, ${monetLayerColors.goldLight})`, + color: monetLayerColors.textPrimary, + } + default: + return {} + } +} + +interface CoverageLayersProps { + selectedOption?: 'mshl_only' | 'mshl_isp' | 'mshl_isp_rider' + onSelect?: (option: 'mshl_only' | 'mshl_isp' | 'mshl_isp_rider') => void + compact?: boolean +} + +export function CoverageLayers({ selectedOption, onSelect, compact = false }: CoverageLayersProps) { + return ( + + {/* Title - Impressionist style */} + + + Layers of Protection + + + Source: Health Insured SG + + + + {/* Coverage options grid */} + + {coverageOptions.map((option) => { + const isSelected = selectedOption === option.id + + return ( + onSelect?.(option.id)} + className={cn( + 'rounded-2xl p-4 text-left transition-all duration-300', + onSelect && 'cursor-pointer hover:scale-[1.02]', + !onSelect && 'cursor-default' + )} + style={{ + background: isSelected + ? `linear-gradient(135deg, ${monetLayerColors.sageLight}40, rgba(255,255,255,0.8))` + : 'rgba(255, 255, 255, 0.6)', + border: `1px solid ${isSelected ? monetLayerColors.sage : monetLayerColors.cardBorder}`, + boxShadow: isSelected + ? `0 8px 24px rgba(127, 178, 133, 0.2)` + : `0 4px 12px rgba(155, 139, 180, 0.08)`, + }} + > + {/* Option title */} + + + {option.title} + + {!compact && ( + + {option.description} + + )} + + + {/* Stacked layers visualization - soft, organic */} + + {option.layers.map((layer, idx) => ( + + + {layer.label} + + {layer.sublabel && !compact && ( + + {layer.sublabel} + + )} + + ))} + + + ) + })} + + + {/* Legend - Impressionist style */} + + + + + MediShield Life + + + + + + ISP Coverage + + + + + + Rider Coverage + + + + + + You Pay + + + + + ) +} + +/** + * Inline version for embedding in text/questionnaires + * Styled to match the Monet impressionist theme + */ +export function CoverageLayersInline() { + return ( + + + + ) +} diff --git a/frontend/src/components/insurance/CoverageNeedsCurve.tsx b/frontend/src/components/insurance/CoverageNeedsCurve.tsx new file mode 100644 index 000000000..aa60a2497 --- /dev/null +++ b/frontend/src/components/insurance/CoverageNeedsCurve.tsx @@ -0,0 +1,339 @@ +'use client' + +import { useMemo, useRef } from 'react' +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Filler, + Tooltip, + type ChartOptions, + type ChartData, +} from 'chart.js' +import { Line } from 'react-chartjs-2' +import { Shield, HeartPulse, Zap } from 'lucide-react' +import { cn } from '@/lib/utils' +import { + type CoverageProjectionYear, + type CoverageMilestone, + formatCoverageAmount, +} from '@/lib/coverage-journey-utils' + +// Register Chart.js components +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Filler, + Tooltip +) + +// ============================================================================= +// Types +// ============================================================================= + +interface CoverageNeedsCurveProps { + projections: CoverageProjectionYear[] + milestones: CoverageMilestone[] + currentAge: number + selectedAge: number + onAgeSelect: (age: number) => void + className?: string +} + +// ============================================================================= +// Chart Colors +// ============================================================================= + +const CHART_COLORS = { + lifeTpd: { + line: 'rgb(16, 185, 129)', // emerald-500 + fill: 'rgba(16, 185, 129, 0.15)', + }, + criticalIllness: { + line: 'rgb(59, 130, 246)', // blue-500 + fill: 'rgba(59, 130, 246, 0.1)', + }, + personalAccident: { + line: 'rgb(168, 85, 247)', // purple-500 + fill: 'rgba(168, 85, 247, 0.08)', + }, + grid: 'rgba(255, 255, 255, 0.04)', + axis: 'rgb(100, 116, 139)', // slate-500 + currentAge: 'rgba(16, 185, 129, 0.8)', + selectedAge: 'rgba(255, 255, 255, 0.3)', +} + +// ============================================================================= +// Component +// ============================================================================= + +export function CoverageNeedsCurve({ + projections, + milestones, + currentAge, + selectedAge, + onAgeSelect, + className, +}: CoverageNeedsCurveProps) { + const chartRef = useRef | null>(null) + + // Find current age index + const currentAgeIndex = useMemo(() => { + return projections.findIndex(p => p.age === currentAge) + }, [projections, currentAge]) + + // Chart data + const chartData: ChartData<'line'> = useMemo(() => { + const labels = projections.map(p => p.age.toString()) + + return { + labels, + datasets: [ + { + label: 'Life/TPD', + data: projections.map(p => p.recommendedLifeTpd), + borderColor: CHART_COLORS.lifeTpd.line, + backgroundColor: CHART_COLORS.lifeTpd.fill, + fill: true, + tension: 0.4, + pointRadius: 0, + pointHoverRadius: 6, + pointHoverBackgroundColor: CHART_COLORS.lifeTpd.line, + pointHoverBorderColor: '#fff', + pointHoverBorderWidth: 2, + borderWidth: 2, + }, + { + label: 'Critical Illness', + data: projections.map(p => p.recommendedCriticalIllness), + borderColor: CHART_COLORS.criticalIllness.line, + backgroundColor: CHART_COLORS.criticalIllness.fill, + fill: true, + tension: 0.4, + pointRadius: 0, + pointHoverRadius: 5, + pointHoverBackgroundColor: CHART_COLORS.criticalIllness.line, + pointHoverBorderColor: '#fff', + pointHoverBorderWidth: 2, + borderWidth: 1.5, + }, + { + label: 'Personal Accident', + data: projections.map(p => p.recommendedPersonalAccident), + borderColor: CHART_COLORS.personalAccident.line, + backgroundColor: CHART_COLORS.personalAccident.fill, + fill: true, + tension: 0.4, + pointRadius: 0, + pointHoverRadius: 4, + pointHoverBackgroundColor: CHART_COLORS.personalAccident.line, + pointHoverBorderColor: '#fff', + pointHoverBorderWidth: 2, + borderWidth: 1, + }, + ], + } + }, [projections]) + + // Chart options + const chartOptions: ChartOptions<'line'> = useMemo(() => { + return { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false, + }, + onClick: (_event, elements) => { + if (elements.length > 0) { + const index = elements[0].index + const age = projections[index]?.age + if (age !== undefined) { + onAgeSelect(age) + } + } + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: true, + backgroundColor: 'rgba(15, 23, 40, 0.95)', + titleColor: '#fff', + bodyColor: 'rgb(148, 163, 184)', + borderColor: 'rgba(255, 255, 255, 0.1)', + borderWidth: 1, + padding: 12, + cornerRadius: 8, + displayColors: true, + callbacks: { + title: (items) => { + if (items.length > 0) { + const age = projections[items[0].dataIndex]?.age + const isCurrent = age === currentAge + return `Age ${age}${isCurrent ? ' (Current)' : ''}` + } + return '' + }, + label: (context) => { + const value = context.raw as number + return ` ${context.dataset.label}: ${formatCoverageAmount(value)}` + }, + }, + }, + }, + scales: { + x: { + grid: { + color: CHART_COLORS.grid, + drawTicks: false, + }, + ticks: { + color: CHART_COLORS.axis, + font: { + size: 11, + }, + maxRotation: 0, + callback: function(_value, index) { + const age = projections[index]?.age + // Show every 5 years + if (age !== undefined && age % 5 === 0) { + return age + } + return '' + }, + }, + border: { + display: false, + }, + }, + y: { + grid: { + color: CHART_COLORS.grid, + drawTicks: false, + }, + ticks: { + color: CHART_COLORS.axis, + font: { + size: 11, + }, + callback: (value) => formatCoverageAmount(value as number), + maxTicksLimit: 6, + }, + border: { + display: false, + }, + beginAtZero: true, + }, + }, + } + }, [projections, currentAge, onAgeSelect]) + + // Calculate slider position + const minAge = projections[0]?.age ?? 25 + const maxAge = projections[projections.length - 1]?.age ?? 75 + const sliderPercent = ((selectedAge - minAge) / (maxAge - minAge)) * 100 + + return ( + + {/* Chart */} + + + + {/* Current age indicator */} + {currentAgeIndex >= 0 && ( + + + You + + + )} + + + {/* Legend */} + + + + + Life/TPD + + + + + Critical Illness + + + + + Personal Accident + + + + {/* Age Slider */} + + + Explore coverage at different ages + Age {selectedAge} + + + + {/* Track */} + + {/* Filled portion */} + + + {/* Milestone markers */} + {milestones.map((milestone, idx) => { + const milestonePercent = ((milestone.age - minAge) / (maxAge - minAge)) * 100 + if (milestonePercent < 0 || milestonePercent > 100) return null + + return ( + + ) + })} + + + {/* Slider Input */} + onAgeSelect(parseInt(e.target.value, 10))} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + + {/* Custom Thumb */} + + + {/* Age Labels */} + + {minAge} + {maxAge} + + + + + ) +} diff --git a/frontend/src/components/insurance/InsuranceTabs.tsx b/frontend/src/components/insurance/InsuranceTabs.tsx index 5afe76268..7b9fb581f 100644 --- a/frontend/src/components/insurance/InsuranceTabs.tsx +++ b/frontend/src/components/insurance/InsuranceTabs.tsx @@ -1,30 +1,60 @@ 'use client' import { cn } from '@/lib/utils' -import { - LayoutDashboard, - FileText, -} from 'lucide-react' +import { FileText, Shield, TrendingUp } from 'lucide-react' +import { useColorScheme } from '@/stores' -export type InsuranceTabId = - | 'overview' - | 'policies' +export type InsuranceTabId = 'overview' | 'journey' | 'policies' interface InsuranceTabsProps { activeTab: InsuranceTabId onTabChange: (tab: InsuranceTabId) => void } +// Monet color palette +const monetTabColors = { + lavender: '#9B8BB4', + lavenderLight: '#C4B8D9', + sage: '#7FB285', + textPrimary: '#3D3D3D', + textSecondary: '#6B6B6B', + textMuted: '#9B9B9B', +} + +// Dark mode color palette +const darkTabColors = { + primary: '#a78bfa', + primaryLight: '#c4b5fd', + success: '#34d399', + textPrimary: '#f1f5f9', + textSecondary: '#94a3b8', + textMuted: '#64748b', +} + const tabs: { id: InsuranceTabId; label: string; icon: React.ElementType }[] = [ - { id: 'overview', label: 'Overview', icon: LayoutDashboard }, + { id: 'overview', label: 'My Coverage', icon: Shield }, + { id: 'journey', label: 'Journey', icon: TrendingUp }, { id: 'policies', label: 'Policies', icon: FileText }, ] export function InsuranceTabs({ activeTab, onTabChange }: InsuranceTabsProps) { + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' + const colors = isMonet ? monetTabColors : darkTabColors + return ( - - - + + + {tabs.map((tab) => { const Icon = tab.icon const isActive = activeTab === tab.id @@ -34,25 +64,59 @@ export function InsuranceTabs({ activeTab, onTabChange }: InsuranceTabsProps) { key={tab.id} onClick={() => onTabChange(tab.id)} className={cn( - 'group relative flex items-center gap-2 px-4 py-3 text-sm font-medium transition-all duration-200', - isActive - ? 'text-white' - : 'text-slate-400 hover:text-slate-200' + 'group relative flex items-center gap-2.5 px-5 py-2.5 rounded-xl text-sm font-medium transition-all duration-300', + isActive && 'transform scale-[1.02]' )} + style={{ + background: isActive + ? isMonet + ? 'rgba(255, 255, 255, 0.85)' + : 'rgba(255, 255, 255, 0.08)' + : 'transparent', + color: isActive ? colors.textPrimary : colors.textMuted, + boxShadow: isActive + ? isMonet + ? '0 4px 20px rgba(155, 139, 180, 0.15), 0 2px 8px rgba(155, 139, 180, 0.1)' + : '0 4px 20px rgba(0, 0, 0, 0.3)' + : 'none', + }} aria-selected={isActive} role="tab" > - {tab.label} + + {tab.label} + - {/* Active indicator */} + {/* Subtle active indicator */} {isActive && ( - + + )} + + {/* Hover effect for inactive tabs */} + {!isActive && ( + )} ) diff --git a/frontend/src/components/insurance/LifeStagePills.tsx b/frontend/src/components/insurance/LifeStagePills.tsx new file mode 100644 index 000000000..ac0a919d3 --- /dev/null +++ b/frontend/src/components/insurance/LifeStagePills.tsx @@ -0,0 +1,121 @@ +'use client' + +import { cn } from '@/lib/utils' +import { + type LifeStage, + LIFE_STAGE_INFO, + getLifeStageEmoji, +} from '@/lib/coverage-journey-utils' + +// ============================================================================= +// Types +// ============================================================================= + +interface LifeStagePillsProps { + currentStage: LifeStage + onStageClick?: (stage: LifeStage) => void + showDescriptions?: boolean + className?: string +} + +// ============================================================================= +// Constants +// ============================================================================= + +const STAGE_ORDER: LifeStage[] = [ + 'young_professional', + 'new_parent', + 'growing_family', + 'empty_nester', + 'retired', +] + +// ============================================================================= +// Component +// ============================================================================= + +export function LifeStagePills({ + currentStage, + onStageClick, + showDescriptions = true, + className, +}: LifeStagePillsProps) { + const currentInfo = LIFE_STAGE_INFO[currentStage] + + return ( + + {/* Stage Pills */} + + {STAGE_ORDER.map((stage) => { + const info = LIFE_STAGE_INFO[stage] + const isCurrent = stage === currentStage + const emoji = getLifeStageEmoji(stage) + + return ( + onStageClick?.(stage)} + disabled={!onStageClick} + className={cn( + 'flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200', + isCurrent + ? 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30' + : 'bg-white/[0.03] text-slate-400 border border-white/[0.06] hover:bg-white/[0.05] hover:text-slate-300', + !onStageClick && 'cursor-default' + )} + > + {emoji} + {info.label} + {isCurrent && ( + + )} + + ) + })} + + + {/* Current Stage Description */} + {showDescriptions && ( + + + {getLifeStageEmoji(currentStage)} + + You're in: {currentInfo.label} + + + + {currentInfo.description} + + + )} + + ) +} + +// ============================================================================= +// Compact Variant (for header use) +// ============================================================================= + +interface LifeStageIndicatorProps { + stage: LifeStage + className?: string +} + +export function LifeStageIndicator({ stage, className }: LifeStageIndicatorProps) { + const info = LIFE_STAGE_INFO[stage] + const emoji = getLifeStageEmoji(stage) + + return ( + + {emoji} + {info.label} + + ) +} diff --git a/frontend/src/components/insurance/MilestoneAlerts.tsx b/frontend/src/components/insurance/MilestoneAlerts.tsx new file mode 100644 index 000000000..4c1c53909 --- /dev/null +++ b/frontend/src/components/insurance/MilestoneAlerts.tsx @@ -0,0 +1,230 @@ +'use client' + +import { Calendar, GraduationCap, Home, Sunset } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { CoverageMilestone } from '@/lib/coverage-journey-utils' + +// ============================================================================= +// Types +// ============================================================================= + +interface MilestoneAlertsProps { + milestones: CoverageMilestone[] + currentYear: number + className?: string +} + +// ============================================================================= +// Helpers +// ============================================================================= + +function getMilestoneIcon(category: CoverageMilestone['category']) { + switch (category) { + case 'dependent': + return GraduationCap + case 'debt': + return Home + case 'retirement': + return Sunset + default: + return Calendar + } +} + +function getMilestoneColor(category: CoverageMilestone['category']) { + switch (category) { + case 'dependent': + return { + bg: 'bg-blue-500/10', + border: 'border-blue-500/20', + icon: 'text-blue-400', + text: 'text-blue-400', + } + case 'debt': + return { + bg: 'bg-emerald-500/10', + border: 'border-emerald-500/20', + icon: 'text-emerald-400', + text: 'text-emerald-400', + } + case 'retirement': + return { + bg: 'bg-amber-500/10', + border: 'border-amber-500/20', + icon: 'text-amber-400', + text: 'text-amber-400', + } + default: + return { + bg: 'bg-slate-500/10', + border: 'border-slate-500/20', + icon: 'text-slate-400', + text: 'text-slate-400', + } + } +} + +function formatYearsFromNow(targetYear: number, currentYear: number): string { + const yearsAway = targetYear - currentYear + if (yearsAway <= 0) return 'Now' + if (yearsAway === 1) return 'In 1 year' + return `In ${yearsAway} years` +} + +// ============================================================================= +// Component +// ============================================================================= + +export function MilestoneAlerts({ + milestones, + currentYear, + className, +}: MilestoneAlertsProps) { + if (milestones.length === 0) { + return ( + + + No upcoming milestones detected + + + ) + } + + return ( + + {/* Timeline visual */} + + {/* Timeline line */} + + + {/* Timeline dots */} + + {/* Now marker */} + + + NOW + + + {/* Milestone markers */} + {milestones.slice(0, 3).map((milestone, idx) => { + const colors = getMilestoneColor(milestone.category) + const yearsAway = milestone.year - currentYear + const position = Math.min(90, (yearsAway / 30) * 100 + 10) + + return ( + + + + {milestone.year} + + + ) + })} + + + + {/* Milestone Cards */} + + {milestones.map((milestone, idx) => { + const Icon = getMilestoneIcon(milestone.category) + const colors = getMilestoneColor(milestone.category) + + return ( + + + {/* Icon */} + + + + + {/* Content */} + + + + {milestone.year} + + • + + {formatYearsFromNow(milestone.year, currentYear)} + + + + + {milestone.event} + + + + {milestone.description} + + + {/* Impact */} + + {milestone.impact} + + + + + ) + })} + + + ) +} + +// ============================================================================= +// Compact Variant (single line summary) +// ============================================================================= + +interface MilestoneSummaryProps { + milestones: CoverageMilestone[] + currentYear: number + className?: string +} + +export function MilestoneSummary({ + milestones, + currentYear, + className, +}: MilestoneSummaryProps) { + const nextMilestone = milestones[0] + + if (!nextMilestone) { + return null + } + + const colors = getMilestoneColor(nextMilestone.category) + const Icon = getMilestoneIcon(nextMilestone.category) + + return ( + + + + Next: {nextMilestone.event} + + • + + {formatYearsFromNow(nextMilestone.year, currentYear)} + + + ) +} diff --git a/frontend/src/components/insurance/modals/AddPolicyModal.tsx b/frontend/src/components/insurance/modals/AddPolicyModal.tsx index a41620924..f0c2117fc 100644 --- a/frontend/src/components/insurance/modals/AddPolicyModal.tsx +++ b/frontend/src/components/insurance/modals/AddPolicyModal.tsx @@ -4,10 +4,11 @@ import { useState } from 'react' import { X, Shield, Stethoscope, HeartHandshake, Heart, Accessibility, Check } from 'lucide-react' import { cn } from '@/lib/utils' import { Modal } from '@/components/ui/Modal' -import { CustomDropdown } from '@/components/modals/ScenarioEventModal/components/CustomDropdown' +import { useColorScheme } from '@/stores' +import { getInsuranceTheme } from '@/lib/insurance-theme' /** - * AddPolicyModal - Modal for adding insurance policies + * AddPolicyModal - Modal for adding insurance policies (Monet-styled) * * Flow: * 1. Select policy category (Life, Health, CI, LTC, PA) @@ -16,12 +17,25 @@ import { CustomDropdown } from '@/components/modals/ScenarioEventModal/component * 4. Toggle coverage items */ +// ============================================================================= +// Category Accent Colors (static - used for category icons) +// ============================================================================= + +const accentColors = { + lavender: '#9B8BB4', + sage: '#7FB285', + coralRose: '#E8A898', + sunlightGold: '#D4C5A9', + blue: '#7BA3C9', +} + type PolicyCategory = 'life' | 'health' | 'critical_illness' | 'long_term_care' | 'personal_accident' interface PolicyCategoryOption { id: PolicyCategory label: string icon: typeof Shield + color: string types: PolicyTypeOption[] } @@ -42,6 +56,7 @@ const policyCategories: PolicyCategoryOption[] = [ id: 'life', label: 'Life Protection', icon: Shield, + color: accentColors.lavender, types: [ { value: 'term_life', @@ -75,6 +90,7 @@ const policyCategories: PolicyCategoryOption[] = [ id: 'health', label: 'Health Products', icon: Stethoscope, + color: accentColors.sage, types: [ { value: 'isp', @@ -100,6 +116,7 @@ const policyCategories: PolicyCategoryOption[] = [ id: 'critical_illness', label: 'Critical Illness', icon: HeartHandshake, + color: accentColors.coralRose, types: [ { value: 'early_ci', @@ -130,6 +147,7 @@ const policyCategories: PolicyCategoryOption[] = [ id: 'long_term_care', label: 'Long-Term Care', icon: Heart, + color: accentColors.sunlightGold, types: [ { value: 'careshield', @@ -152,6 +170,7 @@ const policyCategories: PolicyCategoryOption[] = [ id: 'personal_accident', label: 'Personal Accident', icon: Accessibility, + color: '#6B8BB4', // Soft blue types: [ { value: 'pa', @@ -198,6 +217,10 @@ interface PolicyFormData { } export function AddPolicyModal({ isOpen, onClose, onSave }: AddPolicyModalProps) { + const colorScheme = useColorScheme() + const monetColors = getInsuranceTheme(colorScheme) + const isMonet = colorScheme === 'monet' + const [selectedCategory, setSelectedCategory] = useState(null) const [selectedType, setSelectedType] = useState('') const [provider, setProvider] = useState('aia') @@ -284,28 +307,70 @@ export function AddPolicyModal({ isOpen, onClose, onSave }: AddPolicyModalProps) return parseInt(num).toLocaleString() } + // Input style + const inputStyle: React.CSSProperties = { + background: monetColors.inputBg, + border: `1px solid ${monetColors.cardBorder}`, + color: monetColors.textPrimary, + } + + const inputClassName = "w-full px-3 py-2.5 rounded-xl text-sm focus:outline-none transition-all" + return ( + {/* Header */} - + - - + + - Add Policy - Add an insurance policy to your portfolio + + Add Policy + + + Add an insurance policy to your portfolio + @@ -315,7 +380,12 @@ export function AddPolicyModal({ isOpen, onClose, onSave }: AddPolicyModalProps) {/* Step 1: Category Selection */} - Policy Category + + Policy Category + {policyCategories.map((category) => { const Icon = category.icon @@ -325,15 +395,25 @@ export function AddPolicyModal({ isOpen, onClose, onSave }: AddPolicyModalProps) key={category.id} type="button" onClick={() => handleCategorySelect(category.id)} - className={cn( - 'flex flex-col items-center justify-center gap-2 p-3 rounded-xl border transition-all', - isSelected - ? 'bg-purple-500/15 border-purple-500/40 text-purple-400' - : 'bg-white/[0.02] border-white/[0.06] text-slate-400 hover:bg-white/[0.04] hover:border-white/[0.1]' - )} + className="flex flex-col items-center justify-center gap-2 p-3 rounded-xl transition-all duration-200 hover:scale-105" + style={{ + background: isSelected + ? `linear-gradient(135deg, ${category.color}20, ${category.color}10)` + : monetColors.surfaceBg, + border: `1px solid ${isSelected ? `${category.color}60` : monetColors.cardBorder}`, + boxShadow: isSelected ? `0 4px 12px ${category.color}20` : 'none', + }} > - - {category.label} + + + {category.label} + ) })} @@ -343,14 +423,35 @@ export function AddPolicyModal({ isOpen, onClose, onSave }: AddPolicyModalProps) {/* Step 2: Policy Type (only show if more than one option) */} {selectedCategoryData && selectedCategoryData.types.length > 1 && ( - Policy Type - ({ value: t.value, label: t.label }))} - minWidth="100%" - className="w-full" - /> + + Policy Type + + + {selectedCategoryData.types.map((type) => { + const isSelected = selectedType === type.value + return ( + handleTypeSelect(type.value)} + className="px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200" + style={{ + background: isSelected + ? `linear-gradient(135deg, ${selectedCategoryData.color}, ${selectedCategoryData.color}CC)` + : monetColors.cardBg, + color: isSelected ? 'white' : monetColors.textSecondary, + border: `1px solid ${isSelected ? 'transparent' : monetColors.cardBorder}`, + boxShadow: isSelected ? `0 2px 8px ${selectedCategoryData.color}30` : 'none', + }} + > + {type.label} + + ) + })} + )} @@ -360,23 +461,44 @@ export function AddPolicyModal({ isOpen, onClose, onSave }: AddPolicyModalProps) {/* Provider & Name */} - Provider - + Provider + + + onChange={(e) => setProvider(e.target.value)} + className={inputClassName} + style={{ + ...inputStyle, + cursor: 'pointer', + }} + > + {providerOptions.map((opt) => ( + + {opt.label} + + ))} + - Policy Name + + Policy Name + setPolicyName(e.target.value)} placeholder="e.g., AIA Term Plus" - className="w-full px-3 py-2 rounded-lg border border-white/[0.08] bg-white/[0.03] text-white text-sm placeholder:text-slate-600 focus:outline-none focus:border-purple-500/40" + className={inputClassName} + style={{ + ...inputStyle, + }} /> @@ -384,28 +506,50 @@ export function AddPolicyModal({ isOpen, onClose, onSave }: AddPolicyModalProps) {/* Sum Assured & Premium */} - Sum Assured + + Sum Assured + - $ + + $ + setSumAssured(e.target.value.replace(/[^\d]/g, ''))} placeholder="100,000" - className="w-full pl-7 pr-3 py-2 rounded-lg border border-white/[0.08] bg-white/[0.03] text-white text-sm placeholder:text-slate-600 focus:outline-none focus:border-purple-500/40" + className={cn(inputClassName, 'pl-7')} + style={inputStyle} /> - Monthly Premium + + Monthly Premium + - $ + + $ + setMonthlyPremium(e.target.value.replace(/[^\d]/g, ''))} placeholder="150" - className="w-full pl-7 pr-3 py-2 rounded-lg border border-white/[0.08] bg-white/[0.03] text-white text-sm placeholder:text-slate-600 focus:outline-none focus:border-purple-500/40" + className={cn(inputClassName, 'pl-7')} + style={inputStyle} /> @@ -414,7 +558,12 @@ export function AddPolicyModal({ isOpen, onClose, onSave }: AddPolicyModalProps) {/* Coverage Items */} {selectedTypeData && ( - Coverage + + Coverage + {selectedTypeData.coverageItems.map((item) => { const isEnabled = coverage[item.id] ?? item.defaultEnabled @@ -423,21 +572,26 @@ export function AddPolicyModal({ isOpen, onClose, onSave }: AddPolicyModalProps) key={item.id} type="button" onClick={() => toggleCoverage(item.id)} - className={cn( - 'w-full flex items-center justify-between px-4 py-3 rounded-xl border transition-all', - isEnabled - ? 'bg-emerald-500/10 border-emerald-500/30' - : 'bg-white/[0.02] border-white/[0.06] hover:bg-white/[0.04]' - )} + className="w-full flex items-center justify-between px-4 py-3 rounded-xl transition-all duration-200" + style={{ + background: isEnabled + ? `${monetColors.sage}15` + : monetColors.surfaceBg, + border: `1px solid ${isEnabled ? `${monetColors.sage}40` : monetColors.cardBorder}`, + }} > - + {item.label} {isEnabled && } @@ -450,13 +604,19 @@ export function AddPolicyModal({ isOpen, onClose, onSave }: AddPolicyModalProps) {/* Notes */} - Notes (optional) + + Notes (optional) + setNotes(e.target.value)} placeholder="Additional details about this policy..." rows={2} - className="w-full px-3 py-2 rounded-lg border border-white/[0.08] bg-white/[0.03] text-white text-sm placeholder:text-slate-600 focus:outline-none focus:border-purple-500/40 resize-none" + className={cn(inputClassName, 'resize-none')} + style={inputStyle} /> > @@ -464,11 +624,15 @@ export function AddPolicyModal({ isOpen, onClose, onSave }: AddPolicyModalProps) {/* Footer */} - + Cancel @@ -477,15 +641,23 @@ export function AddPolicyModal({ isOpen, onClose, onSave }: AddPolicyModalProps) onClick={handleSave} disabled={!selectedCategory || !selectedType} className={cn( - 'px-5 py-2 rounded-lg text-sm font-medium transition-all', - selectedCategory && selectedType - ? 'bg-purple-500 hover:bg-purple-600 text-white' - : 'bg-white/[0.05] text-slate-500 cursor-not-allowed' + 'px-5 py-2.5 rounded-xl text-sm font-medium transition-all duration-200', + selectedCategory && selectedType ? 'hover:scale-105' : 'opacity-50 cursor-not-allowed' )} + style={{ + background: + selectedCategory && selectedType + ? `linear-gradient(135deg, ${monetColors.lavender}, ${monetColors.lavenderDark})` + : 'rgba(155, 139, 180, 0.2)', + color: selectedCategory && selectedType ? 'white' : monetColors.textMuted, + boxShadow: + selectedCategory && selectedType ? `0 4px 12px ${monetColors.shadowSoft}` : 'none', + }} > Add Policy + ) } diff --git a/frontend/src/components/insurance/tabs/GuidelinesTab.tsx b/frontend/src/components/insurance/tabs/GuidelinesTab.tsx new file mode 100644 index 000000000..484bf6ddc --- /dev/null +++ b/frontend/src/components/insurance/tabs/GuidelinesTab.tsx @@ -0,0 +1,3231 @@ +'use client' + +import { useState, useMemo, useEffect } from 'react' +import * as Slider from '@radix-ui/react-slider' +import * as Tooltip from '@radix-ui/react-tooltip' +import { + Info, + Check, + AlertCircle, + ArrowRight, + ArrowLeft, + RotateCcw, + Building2, + Home, + Clock, + Briefcase, + Sparkles, + Plus, + User, + Edit3, +} from 'lucide-react' +import { formatCurrency } from '@/lib/format' +import { useColorScheme } from '@/stores' +import { getInsuranceTheme } from '@/lib/insurance-theme' +import { + useGuidelines, + useGuidelinesActions, + useGuidelineTargets, + useHasConfiguredGuidelines, + useSelectedPersonId, + useReferenceMode, + useQuestionnaireAnswers, + useQuestionnaireRecommendations, +} from '@/stores/coverageGuidelinesStore' +import type { ReferenceMode } from '@/stores/coverageGuidelinesStore' +import { + guidelineCoverageConfig, + wardClassConfig, + type GuidelineCoverageType, + type WardClass, +} from '@/types/insurance' +import { PersonSelector } from '@/components/ui/PersonSelector' +import { CustomDropdown } from '@/components/modals/ScenarioEventModal/components/CustomDropdown' +import { usePersonFilter } from '@/contexts/PersonFilterContext' +import { useIncomesQuery } from '@/hooks/queries/useIncomesQuery' +import { useExpensesQuery } from '@/hooks/queries/useExpensesQuery' +import { useQuestionnaireAutoPopulate } from '@/hooks/useQuestionnaireAutoPopulate' +import type { Frequency } from '@/types/financial' + +// Helper to calculate annual income from frequency +const frequencyMultipliers: Record = { + weekly: 52, + biweekly: 26, + monthly: 12, + quarterly: 4, + annual: 1, + one_time: 0, // One-time doesn't contribute to annual income calculation +} + +function calculateAnnualIncomeForPerson( + incomes: { personId?: string | null; amount: number; frequency: Frequency; endDate?: string }[], + personId: string +): number { + const currentYear = new Date().getFullYear() + return incomes + .filter((income) => { + // Must belong to selected person + if (income.personId !== personId) return false + // Must be active (no end date or end date in future) + if (income.endDate) { + const endYear = new Date(income.endDate).getFullYear() + if (endYear < currentYear) return false + } + return true + }) + .reduce((total, income) => { + const multiplier = frequencyMultipliers[income.frequency] || 0 + return total + income.amount * multiplier + }, 0) +} + +/** + * Calculate total annual expenses (household-level, no personId filtering). + * Only includes active expenses (no endDate or future endDate). + */ +function calculateAnnualExpenses( + expenses: { amount: number; frequency: Frequency; endDate?: string }[] +): number { + const currentYear = new Date().getFullYear() + return expenses + .filter((expense) => { + if (expense.endDate) { + const endYear = new Date(expense.endDate).getFullYear() + if (endYear < currentYear) return false + } + return true + }) + .reduce((total, expense) => { + const multiplier = frequencyMultipliers[expense.frequency] || 0 + return total + expense.amount * multiplier + }, 0) +} + +/** + * TODO(human): Format a coverage target amount as a multiplier of expenses. + * Decides whether to show annual or monthly comparison, + * how to round the multiplier, and what text to display. + */ +function formatExpenseMultiplier( + targetAmount: number, + annualExpenses: number +): string { + // Placeholder — human will implement this + if (annualExpenses <= 0) return 'Add expenses to see this comparison' + const multiplier = targetAmount / annualExpenses + return `≈ ${multiplier.toFixed(1)}× your annual expenses` +} + +// ============================================================================ +// REFERENCE MODE TOGGLE +// ============================================================================ + +function ReferenceModeToggle() { + const colorScheme = useColorScheme() + const monetWizard = getInsuranceTheme(colorScheme) + const referenceMode = useReferenceMode() + const { setReferenceMode } = useGuidelinesActions() + + const options: { value: ReferenceMode; label: string }[] = [ + { value: 'income', label: 'vs Income' }, + { value: 'expenses', label: 'vs Expenses' }, + ] + + return ( + + {options.map((option) => { + const isActive = referenceMode === option.value + return ( + setReferenceMode(option.value)} + className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-150" + style={{ + background: isActive ? `${monetWizard.lavender}18` : 'transparent', + color: isActive ? monetWizard.textPrimary : monetWizard.textMuted, + }} + > + {option.label} + + ) + })} + + ) +} + +// ============================================================================ +// WIZARD STEP INDICATOR (Monet Impressionist Style) +// ============================================================================ + +interface WizardStepIndicatorProps { + currentStep: number + totalSteps: number + stepLabels: string[] +} + +function WizardStepIndicator({ currentStep, totalSteps, stepLabels }: WizardStepIndicatorProps) { + const colorScheme = useColorScheme() + const monetWizard = getInsuranceTheme(colorScheme) + + return ( + + {Array.from({ length: totalSteps }).map((_, index) => { + const stepNumber = index + 1 + const isComplete = stepNumber < currentStep + const isCurrent = stepNumber === currentStep + + return ( + + + {/* Minimal step indicator */} + + {isComplete ? ( + + ) : ( + + {stepNumber} + + )} + + + {stepLabels[index]} + + + + {/* Thin connector line */} + {index < totalSteps - 1 && ( + + )} + + ) + })} + + ) +} + +// ============================================================================ +// WIZARD STEP 1: PERSON & INCOME +// ============================================================================ + +interface WizardStep1Props { + onNext: () => void +} + +function WizardStep1Income({ onNext }: WizardStep1Props) { + const colorScheme = useColorScheme() + const monetWizard = getInsuranceTheme(colorScheme) + + const guidelines = useGuidelines() + const selectedPersonId = useSelectedPersonId() + const { setAnnualIncome, setSelectedPersonId } = useGuidelinesActions() + const { includedPersons } = usePersonFilter() + const { data: incomes = [] } = useIncomesQuery() + + // Calculate annual income when person changes + const calculatedIncome = useMemo(() => { + if (!selectedPersonId) return 0 + return calculateAnnualIncomeForPerson(incomes, selectedPersonId) + }, [incomes, selectedPersonId]) + + // Auto-select first person if none selected and persons exist + useEffect(() => { + if (!selectedPersonId && includedPersons.length > 0) { + setSelectedPersonId(includedPersons[0].id) + } + }, [selectedPersonId, includedPersons, setSelectedPersonId]) + + // Update store when calculated income changes + useEffect(() => { + if (calculatedIncome > 0) { + setAnnualIncome(calculatedIncome) + } + }, [calculatedIncome, setAnnualIncome]) + + const selectedPerson = includedPersons.find((p) => p.id === selectedPersonId) + const hasNoIncome = selectedPersonId && calculatedIncome === 0 + const canProceed = guidelines.annualIncome > 0 + + return ( + + {/* Header - minimal */} + + + Step 1 of 3 + + + Who are we planning for? + + + Select a person to calculate their coverage needs + + + + {/* Person selector - glass card */} + + + Select person + + + + {/* Income display */} + {selectedPersonId && ( + + + + Annual income + + {calculatedIncome > 0 && ( + + Auto-detected + + )} + + + {formatCurrency(calculatedIncome)} + + + {hasNoIncome && ( + + + + No income found for {selectedPerson?.name}. Add income in Financial Data first. + + + )} + + )} + + + {/* Info box - subtle */} + + + + Coverage targets are calculated as multiples of income (e.g., 10× for life insurance). + + + + {/* Next button - pill shape */} + + Continue + + + + ) +} + +// ============================================================================ +// WIZARD STEP 2: COVERAGE MULTIPLIERS +// ============================================================================ + +interface WizardStep2Props { + onNext: () => void + onBack: () => void +} + +// Educational tooltips for each coverage type +const coverageEducation: Record = { + hospitalization: { + title: 'Why Hospitalization Coverage?', + points: [ + 'MediShield Life only covers B2/C wards with high co-pay', + 'ISP (Integrated Shield Plan) covers higher ward classes', + 'Riders reduce your out-of-pocket costs (but new rules apply from Apr 2026)', + 'Hospital bills can easily exceed $100K for major surgeries', + ], + extra: [ + '── Ward Classes ──', + 'Class A: Single room, air-con, private bathroom. Highest premiums.', + 'Class B1: 4-bed room, air-con. Good balance of comfort and cost.', + 'Class B2+: 6-bed room, air-con. Affordable with decent comfort.', + 'Class C: 8+ bed room, fan-cooled. Lowest premiums, covered by MediShield Life.', + '── About Riders ──', + 'A rider is an add-on to your ISP that reduces co-payment and deductible.', + 'Without rider: You pay deductible ($1,500-$3,500) + 5-10% of remaining bill.', + 'With rider: Reduced but not zero out-of-pocket for covered treatments.', + '── NEW RULES (Apr 2026) ──', + '⚠️ From April 2026, new riders cannot fully cover deductibles.', + 'New riders will be ~30% cheaper but require minimum $1,500-$3,500 deductible.', + 'Co-pay remains 5% of bill after deductible, but cap doubles to $6,000/year.', + 'Existing policies bought before Nov 2025 remain unchanged.', + 'Policies bought Nov 2025 - Mar 2026 convert after Apr 2028.', + ], + }, + life_tpd: { + title: 'Why Life/TPD Coverage?', + points: [ + '10× income replaces earnings for ~10 years', + 'Covers mortgage, children\'s education, daily expenses', + 'TPD (Total Permanent Disability) pays if you can\'t work', + 'DPS ($70K) from CPF is often insufficient alone', + ], + }, + critical_illness: { + title: 'Why Critical Illness Coverage?', + points: [ + 'Pays lump sum on diagnosis (cancer, heart attack, stroke)', + '5× income covers treatment + income loss during recovery', + '1 in 4 Singaporeans will develop cancer by age 75', + 'Covers expenses not covered by hospitalization plans', + ], + }, + personal_accident: { + title: 'Why Personal Accident Coverage?', + points: [ + 'Covers accidental injuries and death', + 'Often includes fractures, burns, disabilities', + 'Usually very affordable premiums', + 'Complements life insurance for accident scenarios', + ], + }, +} + +function CoverageMultiplierCard({ + coverageType, + showEducation = false, + reasoning, + showInputs = false, + annualExpenses = 0, +}: { + coverageType: GuidelineCoverageType + showEducation?: boolean + reasoning?: string + showInputs?: boolean + annualExpenses?: number +}) { + const colorScheme = useColorScheme() + const monetWizard = getInsuranceTheme(colorScheme) + + const guidelines = useGuidelines() + const targets = useGuidelineTargets() + const referenceMode = useReferenceMode() + const answers = useQuestionnaireAnswers() + const { + setMultiplier, + toggleCoverage, + setHospitalizationPreferences, + setLifeTpdAnswers, + setCriticalIllnessAnswers, + setPersonalAccidentAnswers, + applyQuestionnaireRecommendations, + } = useGuidelinesActions() + + const config = guidelineCoverageConfig[coverageType] + const coverage = guidelines.coverages[coverageType] + const education = coverageEducation[coverageType] + + const isHospitalization = coverageType === 'hospitalization' + const multiplier = isHospitalization ? 0 : ((coverage as { incomeMultiplier: number }).incomeMultiplier ?? 1) + const targetAmount = isHospitalization ? 0 : (targets[coverageType as keyof typeof targets] ?? 0) + + // Subtle accent colors for active state + const accentColorMap: Record = { + emerald: monetWizard.sage, + blue: monetWizard.blue, + purple: monetWizard.purple, + amber: monetWizard.amber, + } + + const accent = accentColorMap[config.color] || monetWizard.lavender + + // Helper to parse currency input (removes $, commas, and other non-numeric chars except . and -) + const parseCurrency = (value: string): number => { + const cleaned = value.replace(/[^0-9.\-]/g, '') + const num = parseFloat(cleaned) + return isNaN(num) ? 0 : Math.round(num) + } + + // Helper to format number for input display + const formatInputCurrency = (value: number): string => { + if (value === 0) return '' + return Math.round(value).toLocaleString() + } + + // Recompute recommendations when inputs change + const handleInputChange = () => { + // Small delay to allow state to update + setTimeout(() => applyQuestionnaireRecommendations(), 0) + } + + return ( + + {/* Header - horizontal layout */} + + + + + + {config.label} + + {!config.isRequired && ( + + Optional + + )} + {/* Learn More Tooltip */} + {showEducation && ( + + + + + + + + + + + {education.title} + + + {education.points.map((point, i) => ( + + · + {point} + + ))} + + + + + + + )} + + + {config.description} + + + + + {/* Sleek toggle */} + toggleCoverage(coverageType)} + className="relative h-5 w-10 rounded-full transition-all duration-300 shrink-0" + style={{ + background: coverage.isEnabled + ? accent + : `${monetWizard.lavender}25`, + }} + > + + + + + {/* Content - when enabled */} + {coverage.isEnabled && ( + + {isHospitalization ? ( + // Hospitalization: minimal ward selection + + + {(['A', 'B1', 'B2_plus', 'C'] as WardClass[]).map((ward) => { + const wardConfig = wardClassConfig[ward] + const isSelected = + guidelines.coverages.hospitalization.preferredWardClass === ward + + return ( + setHospitalizationPreferences({ preferredWardClass: ward })} + className="flex-1 py-2.5 rounded-xl text-xs font-medium transition-all duration-200" + style={{ + background: isSelected ? accent : monetWizard.surfaceBg, + color: isSelected ? 'white' : monetWizard.textSecondary, + boxShadow: isSelected ? `0 2px 8px ${accent}30` : 'none', + }} + > + {wardConfig.label} + + ) + })} + + + {/* Rider toggle - inline */} + + + Include rider + + + setHospitalizationPreferences({ + recommendsRider: !guidelines.coverages.hospitalization.recommendsRider, + }) + } + className="relative h-4 w-8 rounded-full transition-all duration-300" + style={{ + background: guidelines.coverages.hospitalization.recommendsRider + ? accent + : `${monetWizard.lavender}25`, + }} + > + + + + + ) : ( + // Income-based: editable inputs layout + + {/* Editable Inputs - type-specific */} + {showInputs && coverageType === 'life_tpd' && ( + + + Your situation + + + + + Dependents + + { + setLifeTpdAnswers({ dependentCount: parseInt(e.target.value) || 0 }) + handleInputChange() + }} + className="w-full mt-1 px-2 py-1.5 rounded-lg text-xs font-mono tabular-nums bg-transparent border focus:outline-none focus:ring-1" + style={{ + color: monetWizard.textPrimary, + borderColor: monetWizard.cardBorder, + }} + /> + + + + Years to support + + { + setLifeTpdAnswers({ yearsUntilIndependent: parseInt(e.target.value) || 0 }) + handleInputChange() + }} + className="w-full mt-1 px-2 py-1.5 rounded-lg text-xs font-mono tabular-nums bg-transparent border focus:outline-none focus:ring-1" + style={{ + color: monetWizard.textPrimary, + borderColor: monetWizard.cardBorder, + }} + /> + + + + Mortgage ($) + + { + setLifeTpdAnswers({ mortgageBalance: parseCurrency(e.target.value) }) + handleInputChange() + }} + placeholder="0" + className="w-full mt-1 px-2 py-1.5 rounded-lg text-xs font-mono tabular-nums bg-transparent border focus:outline-none focus:ring-1" + style={{ + color: monetWizard.textPrimary, + borderColor: monetWizard.cardBorder, + }} + /> + + + + Other debts ($) + + { + setLifeTpdAnswers({ otherDebts: parseCurrency(e.target.value) }) + handleInputChange() + }} + placeholder="0" + className="w-full mt-1 px-2 py-1.5 rounded-lg text-xs font-mono tabular-nums bg-transparent border focus:outline-none focus:ring-1" + style={{ + color: monetWizard.textPrimary, + borderColor: monetWizard.cardBorder, + }} + /> + + + + Existing assets ($) + + { + setLifeTpdAnswers({ existingAssets: parseCurrency(e.target.value) }) + handleInputChange() + }} + placeholder="0" + className="w-full mt-1 px-2 py-1.5 rounded-lg text-xs font-mono tabular-nums bg-transparent border focus:outline-none focus:ring-1" + style={{ + color: monetWizard.textPrimary, + borderColor: monetWizard.cardBorder, + }} + /> + + + + )} + + {showInputs && coverageType === 'critical_illness' && ( + + + Your situation + + + + + Emergency fund (months) + + { + setCriticalIllnessAnswers({ emergencyFundMonths: parseInt(e.target.value) || 0 }) + handleInputChange() + }} + className="w-full mt-1 px-2 py-1.5 rounded-lg text-xs font-mono tabular-nums bg-transparent border focus:outline-none focus:ring-1" + style={{ + color: monetWizard.textPrimary, + borderColor: monetWizard.cardBorder, + }} + /> + + + + Recovery period (months) + + { + setCriticalIllnessAnswers({ expectedRecoveryMonths: parseInt(e.target.value) || 0 }) + handleInputChange() + }} + className="w-full mt-1 px-2 py-1.5 rounded-lg text-xs font-mono tabular-nums bg-transparent border focus:outline-none focus:ring-1" + style={{ + color: monetWizard.textPrimary, + borderColor: monetWizard.cardBorder, + }} + /> + + + + Monthly expenses ($) + + { + setCriticalIllnessAnswers({ monthlyExpenses: parseCurrency(e.target.value) }) + handleInputChange() + }} + placeholder="0" + className="w-full mt-1 px-2 py-1.5 rounded-lg text-xs font-mono tabular-nums bg-transparent border focus:outline-none focus:ring-1" + style={{ + color: monetWizard.textPrimary, + borderColor: monetWizard.cardBorder, + }} + /> + + + + Existing coverage ($) + + { + setCriticalIllnessAnswers({ existingCiCoverage: parseCurrency(e.target.value) }) + handleInputChange() + }} + placeholder="0" + className="w-full mt-1 px-2 py-1.5 rounded-lg text-xs font-mono tabular-nums bg-transparent border focus:outline-none focus:ring-1" + style={{ + color: monetWizard.textPrimary, + borderColor: monetWizard.cardBorder, + }} + /> + + + + )} + + {showInputs && coverageType === 'personal_accident' && ( + + + Your situation + + + + + Occupation risk + + { + setPersonalAccidentAnswers({ occupationRisk: value as 'low' | 'medium' | 'high' }) + handleInputChange() + }} + options={[ + { value: 'low', label: 'Low (Office)' }, + { value: 'medium', label: 'Medium (Field)' }, + { value: 'high', label: 'High (Manual)' }, + ]} + minWidth="100%" + variant="monet" + /> + + + + Existing PA coverage ($) + + { + setPersonalAccidentAnswers({ existingPaCoverage: parseCurrency(e.target.value) }) + handleInputChange() + }} + placeholder="0" + className="w-full mt-1 px-2 py-1.5 rounded-lg text-xs font-mono tabular-nums bg-transparent border focus:outline-none focus:ring-1" + style={{ + color: monetWizard.textPrimary, + borderColor: monetWizard.cardBorder, + }} + /> + + + + )} + + {/* Target amount - editable input */} + + + + Coverage target + + + + + $ + { + const newTarget = parseCurrency(e.target.value) + const income = guidelines.annualIncome || 1 + const newMultiplier = Math.max(1, Math.min(20, Math.round(newTarget / income))) + setMultiplier(coverageType, newMultiplier) + }} + className="text-2xl font-light font-mono tabular-nums bg-transparent border-b-2 focus:outline-none transition-colors" + style={{ + color: accent, + borderColor: `${accent}30`, + width: `${Math.max(3, String(targetAmount).length) + 1}ch`, + }} + /> + + + {referenceMode === 'expenses' + ? formatExpenseMultiplier(targetAmount as number, annualExpenses) + : `≈ ${multiplier}× your annual income`} + + + + )} + + {/* Reasoning display - shows why this coverage was recommended */} + {reasoning && ( + + + + {reasoning} + + + )} + + )} + + ) +} + +// ============================================================================ +// GUIDED QUESTIONNAIRE COMPONENTS +// First-principles approach to coverage recommendations +// ============================================================================ + +type QuestionnaireSection = 'hospitalization' | 'life_tpd' | 'critical_illness' | 'personal_accident' | 'self_insurance' + +const sectionOrder: QuestionnaireSection[] = [ + 'hospitalization', + 'life_tpd', + 'critical_illness', + 'personal_accident', + 'self_insurance', +] + +const sectionConfig: Record = { + hospitalization: { title: 'Hospitalization', emoji: '🏥', color: 'emerald' }, + life_tpd: { title: 'Life / TPD', emoji: '😇', color: 'blue' }, + critical_illness: { title: 'Critical Illness', emoji: '🩺', color: 'purple' }, + personal_accident: { title: 'Personal Accident', emoji: '🚗', color: 'amber' }, + self_insurance: { title: 'Self-Insurance', emoji: '💰', color: 'slate' }, +} + +// Option card component for single/multiple choice questions (Monet style) +function OptionCard({ + isSelected, + onClick, + icon, + title, + description, + color = 'emerald', +}: { + isSelected: boolean + onClick: () => void + icon: React.ReactNode + title: string + description: string + color?: string +}) { + const colorScheme = useColorScheme() + const monetWizard = getInsuranceTheme(colorScheme) + + // Monet-inspired color mapping + // Hex alpha: 18 ≈ 9%, 30 ≈ 19%, 60 ≈ 38% + const colorStyles: Record = { + emerald: { + bg: `${monetWizard.sage}18`, + border: `${monetWizard.sage}60`, + accent: monetWizard.sage, + }, + blue: { + bg: `${monetWizard.blue}18`, + border: `${monetWizard.blue}60`, + accent: monetWizard.blue, + }, + purple: { + bg: `${monetWizard.purple}18`, + border: `${monetWizard.purple}60`, + accent: monetWizard.purple, + }, + amber: { + bg: `${monetWizard.amber}18`, + border: `${monetWizard.amber}60`, + accent: monetWizard.amber, + }, + } + + const styles = colorStyles[color] || colorStyles.emerald + + return ( + + + + {icon} + + + + {title} + + + {description} + + + {isSelected && ( + + + + )} + + + ) +} + +// Number input with label (Monet style) +function NumberInput({ + label, + value, + onChange, + placeholder = '0', + prefix = '$', + helpText, + preFilled, + preFilledSource, +}: { + label: string + value: number + onChange: (value: number) => void + placeholder?: string + prefix?: string + helpText?: string + preFilled?: boolean + preFilledSource?: string +}) { + const colorScheme = useColorScheme() + const monetWizard = getInsuranceTheme(colorScheme) + + return ( + + + + {label} + + {preFilled && value > 0 && ( + + {preFilledSource || 'Pre-filled'} + + )} + + + {prefix && ( + + {prefix} + + )} + onChange(parseFloat(e.target.value) || 0)} + placeholder={placeholder} + className="w-full rounded-xl text-sm py-3.5 transition-all duration-200" + style={{ + background: monetWizard.inputBg, + border: `1px solid ${preFilled && value > 0 ? monetWizard.sage + '50' : monetWizard.cardBorder}`, + color: monetWizard.textPrimary, + paddingLeft: prefix ? '2rem' : '1rem', + paddingRight: '1rem', + }} + /> + + {helpText && ( + + {helpText} + + )} + + ) +} + +// Section progress indicator (Monet style - flowing dots) +function SectionProgressDots({ current, total }: { current: number; total: number }) { + const colorScheme = useColorScheme() + const monetWizard = getInsuranceTheme(colorScheme) + + return ( + + {Array.from({ length: total }).map((_, i) => ( + + ))} + + ) +} + +// HOSPITALIZATION QUESTIONNAIRE +function HospitalizationQuestionnaire({ + onNext, + onBack, +}: { + onNext: () => void + onBack: () => void +}) { + const colorScheme = useColorScheme() + const monetWizard = getInsuranceTheme(colorScheme) + + const answers = useQuestionnaireAnswers() + const { setHospitalizationAnswers } = useGuidelinesActions() + const [subStep, setSubStep] = useState(0) + + const hospitalPref = answers.hospitalization.hospitalPreference + + // Sub-step 0: Public vs Private choice + // Sub-step 1: Follow-up based on choice + const totalSubSteps = hospitalPref ? 2 : 1 + + const canProceed = subStep === 0 ? hospitalPref !== null : true + + const handleNext = () => { + if (subStep < totalSubSteps - 1) { + setSubStep(subStep + 1) + } else { + onNext() + } + } + + const handleBack = () => { + if (subStep > 0) { + setSubStep(subStep - 1) + } else { + onBack() + } + } + + return ( + + + + {/* Header with inline info button */} + + + + Hospitalization Coverage + + + + + + + + + + + + + Private vs Public + + + • Private: Choose your specialist, shorter wait (days vs months) + • Public: All ward classes (A, B1, B2+, C), government subsidies + • Public Class A/B1 offers similar comfort at lower cost + + + + + MOH Rules from April 2026 + + + New IP riders can no longer fully cover deductibles ($1,500–$3,500 minimum out-of-pocket). + Existing policies bought before Nov 2025 are unaffected. + + + + + + + + + + {subStep === 0 + ? 'Where would you prefer to be treated?' + : hospitalPref === 'private' + ? 'Which room type do you prefer?' + : 'Which ward class suits you best?'} + + + + {subStep === 0 ? ( + // Step 0: Clean Public vs Private choice + + setHospitalizationAnswers({ hospitalPreference: 'private' })} + icon={} + title="Private Hospital" + description="Choose your specialist, shorter wait times. Higher premiums." + color="blue" + /> + setHospitalizationAnswers({ hospitalPreference: 'public' })} + icon={} + title="Public Hospital" + description="Government subsidies, same medical quality. Lower premiums." + color="emerald" + /> + + ) : hospitalPref === 'private' ? ( + // Step 1 (Private): Room type preference + + setHospitalizationAnswers({ willingToPayPremium: true })} + icon={} + title="Single Room" + description="Private bathroom, full choice of doctors. ~$3,500 deductible from 2026." + color="amber" + /> + setHospitalizationAnswers({ willingToPayPremium: false })} + icon={} + title="Shared Room (2-4 beds)" + description="Air-conditioned with shared facilities. Lower premiums than single room." + color="emerald" + /> + + + From Apr 2026: Minimum $3,500 deductible + 5% co-pay (capped at $6K/year) + + + ) : ( + // Step 1 (Public): Ward class preference + + setHospitalizationAnswers({ willingToPayPremium: true, comfortableWithWait: false })} + icon={} + title="Class A / B1" + description="Single room or 4-bed with air-con. Requires ISP upgrade. ~$2,500-3,500 deductible." + color="blue" + /> + setHospitalizationAnswers({ willingToPayPremium: false, comfortableWithWait: false })} + icon={} + title="Class B2+" + description="Air-con 6-bed rooms. Good subsidies. Requires ISP upgrade. ~$2,000 deductible." + color="emerald" + /> + setHospitalizationAnswers({ willingToPayPremium: false, comfortableWithWait: true })} + icon={} + title="Class C" + description="Fan-cooled open ward. Maximum subsidies. Covered by MediShield Life alone." + color="emerald" + /> + + + MediShield Life covers Class B2/C. Class A/B1 needs an Integrated Shield Plan (ISP). + + + )} + + {/* Navigation - Monet style */} + + + + Back + + + Continue + + + + + ) +} + +// LIFE/TPD QUESTIONNAIRE +function LifeTpdQuestionnaire({ + onNext, + onBack, +}: { + onNext: () => void + onBack: () => void +}) { + const colorScheme = useColorScheme() + const monetWizard = getInsuranceTheme(colorScheme) + + const answers = useQuestionnaireAnswers() + const guidelines = useGuidelines() + const { setLifeTpdAnswers } = useGuidelinesActions() + const [subStep, setSubStep] = useState(0) + const selectedPersonId = useSelectedPersonId() + const autoPopulate = useQuestionnaireAutoPopulate(selectedPersonId) + const { includedPersons } = usePersonFilter() + const { data: incomes = [] } = useIncomesQuery() + + // Other persons (excluding primary) as potential dependents + const otherPersons = useMemo(() => { + return includedPersons + .filter((p) => p.id !== selectedPersonId) + .map((p) => { + const today = new Date() + const birth = new Date(p.dateOfBirth) + let age = today.getFullYear() - birth.getFullYear() + const monthDiff = today.getMonth() - birth.getMonth() + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) { + age-- + } + return { ...p, age: Math.max(0, age) } + }) + }, [includedPersons, selectedPersonId]) + + // Derive dependent count and youngest age from selected person IDs + const handleDependentSelection = (personId: string) => { + const currentIds = answers.lifeTpd.dependentPersonIds || [] + const isSelected = currentIds.includes(personId) + const newIds = isSelected + ? currentIds.filter((id) => id !== personId) + : [...currentIds, personId] + + // Calculate youngest age from selected dependents + const selectedDependents = otherPersons.filter((p) => newIds.includes(p.id)) + const youngestAge = selectedDependents.length > 0 + ? Math.min(...selectedDependents.map((p) => p.age)) + : null + const yearsUntilIndependent = youngestAge !== null ? Math.max(0, 22 - youngestAge) : 0 + + // If the deselected person was the spouse, clear spouse too + const spouseCleared = isSelected && personId === answers.lifeTpd.spousePersonId + ? { spousePersonId: null, spouseHasIncome: false, spouseIncome: 0 } + : {} + + setLifeTpdAnswers({ + dependentPersonIds: newIds, + dependentCount: newIds.length, + youngestDependentAge: youngestAge, + yearsUntilIndependent, + ...spouseCleared, + }) + } + + // Auto-calculate spouse income when spouse is selected + const spouseIncome = useMemo(() => { + if (!answers.lifeTpd.spousePersonId) return 0 + return calculateAnnualIncomeForPerson(incomes, answers.lifeTpd.spousePersonId) + }, [incomes, answers.lifeTpd.spousePersonId]) + + const totalSubSteps = 2 // Dependents, then finances + + const handleNext = () => { + if (subStep < totalSubSteps - 1) { + setSubStep(subStep + 1) + } else { + onNext() + } + } + + const handleBack = () => { + if (subStep > 0) { + setSubStep(subStep - 1) + } else { + onBack() + } + } + + // Calculate recommended coverage based on answers + financial data + const incomeReplacement = answers.lifeTpd.dependentCount > 0 + ? guidelines.annualIncome * answers.lifeTpd.yearsUntilIndependent + : 0 + const sanitizedObligations = Math.max(0, answers.lifeTpd.futureObligations) + const totalNeeded = incomeReplacement + + autoPopulate.computed.totalMortgage + + autoPopulate.computed.totalOtherDebts + + sanitizedObligations - + autoPopulate.computed.totalAssets + + return ( + + + + {/* Header - Monet style */} + + + Life / TPD Coverage + + + {subStep === 0 + ? 'Who depends on your income?' + : 'What financial obligations need to be covered?'} + + + + {subStep === 0 ? ( + // Step 0: Dependents — select from persons list + + + + Who financially depends on you? + + + {otherPersons.length > 0 ? ( + + {otherPersons.map((person) => { + const isSelected = (answers.lifeTpd.dependentPersonIds || []).includes(person.id) + return ( + handleDependentSelection(person.id)} + className="w-full flex items-center justify-between rounded-xl px-4 py-3 transition-all duration-200" + style={{ + background: isSelected ? `${monetWizard.blue}18` : monetWizard.surfaceBg, + border: `1px solid ${isSelected ? `${monetWizard.blue}60` : monetWizard.cardBorder}`, + }} + > + + + {person.name.charAt(0).toUpperCase()} + + + + {person.name} + + + Age {person.age} + {person.relationship && person.relationship !== 'self' + ? ` · ${person.relationship.charAt(0).toUpperCase() + person.relationship.slice(1)}` + : person.age < 18 ? ' · Child' : person.age >= 65 ? ' · Elderly' : ''} + + + + + {isSelected && } + + + ) + })} + + ) : ( + + + No other persons added. Add family members in the main app to select them here. + + + )} + + {(answers.lifeTpd.dependentPersonIds || []).length > 0 && ( + + {answers.lifeTpd.dependentCount} dependent{answers.lifeTpd.dependentCount !== 1 ? 's' : ''} selected + {answers.lifeTpd.youngestDependentAge !== null && ( + <> · Youngest age {answers.lifeTpd.youngestDependentAge} · {answers.lifeTpd.yearsUntilIndependent} years until independent> + )} + + )} + + + {(answers.lifeTpd.dependentPersonIds || []).length > 0 && ( + + + Spouse / Partner (has own income) + + { + const hasIncome = personId ? calculateAnnualIncomeForPerson(incomes, personId) > 0 : false + const income = personId ? calculateAnnualIncomeForPerson(incomes, personId) : 0 + setLifeTpdAnswers({ + spousePersonId: personId, + spouseHasIncome: hasIncome, + spouseIncome: income, + }) + }} + placeholder="None" + variant={colorScheme === 'monet' ? 'monet' : 'dark'} + showCreate={false} + excludePersonIds={[ + ...(selectedPersonId ? [selectedPersonId] : []), + ...otherPersons.filter((p) => p.relationship === 'child').map((p) => p.id), + ]} + /> + + {answers.lifeTpd.spousePersonId + ? spouseIncome > 0 + ? `Annual income: ${formatCurrency(spouseIncome)} — reduces coverage needed` + : 'No income found for this person' + : 'Select if spouse/partner has their own income — this reduces coverage needed'} + + + )} + + {(answers.lifeTpd.dependentPersonIds || []).length === 0 && otherPersons.length > 0 && ( + + + No dependents? You may only need minimal coverage for final expenses + (funeral costs, outstanding debts). Consider if this changes in the future. + + + )} + + ) : ( + // Step 1: Financial obligations + + {/* Auto-fetched financial summary */} + + + + + From your financial data + + + + + + Outstanding mortgage + + + {formatCurrency(autoPopulate.computed.totalMortgage)} + + + + + + Other debts + + + {formatCurrency(autoPopulate.computed.totalOtherDebts)} + + + + + + Existing assets + + + {formatCurrency(autoPopulate.computed.totalAssets)} + + + + {!autoPopulate.sources.liabilities && !autoPopulate.sources.assets && ( + + No financial data found. Add liabilities and assets in the main app for automatic calculation. + + )} + + + setLifeTpdAnswers({ futureObligations: Math.max(0, value) })} + helpText="University education in Singapore costs ~$50-100K per child" + /> + + {/* Calculated recommendation - Monet style */} + + + Calculated coverage needed: + + {formatCurrency(Math.max(0, totalNeeded))} + + + + = {formatCurrency(incomeReplacement)} (income replacement) + + {formatCurrency(autoPopulate.computed.totalMortgage + autoPopulate.computed.totalOtherDebts)} (debts) + + {formatCurrency(sanitizedObligations)} (obligations) - + {formatCurrency(autoPopulate.computed.totalAssets)} (assets) + + + + )} + + {/* Navigation - Monet style */} + + + + Back + + + Continue + + + + + ) +} + +// CRITICAL ILLNESS QUESTIONNAIRE +function CriticalIllnessQuestionnaire({ + onNext, + onBack, +}: { + onNext: () => void + onBack: () => void +}) { + const colorScheme = useColorScheme() + const monetWizard = getInsuranceTheme(colorScheme) + + const answers = useQuestionnaireAnswers() + const guidelines = useGuidelines() + const { setCriticalIllnessAnswers } = useGuidelinesActions() + + const monthlyIncome = guidelines.annualIncome / 12 + const monthlyExpenses = answers.criticalIllness.monthlyExpenses || monthlyIncome * 0.7 + + // Calculate recommendation + const totalNeeded = (monthlyExpenses * answers.criticalIllness.expectedRecoveryMonths) - + (answers.criticalIllness.emergencyFundMonths * monthlyExpenses) - + answers.criticalIllness.existingCiCoverage + + return ( + + {/* Header - Monet style */} + + + Critical Illness Coverage + + + Can you survive financially during a long recovery period? + + + + + + + If diagnosed with a critical illness, how long would you need to recover? + + + + + { + const raw = e.target.value + if (raw === '') { + setCriticalIllnessAnswers({ expectedRecoveryMonths: 0 }) + return + } + const val = parseInt(raw, 10) + if (!isNaN(val) && val >= 0) { + setCriticalIllnessAnswers({ expectedRecoveryMonths: val }) + } + }} + onBlur={(e) => { + const val = parseInt(e.target.value, 10) + if (isNaN(val) || val < 1) { + setCriticalIllnessAnswers({ expectedRecoveryMonths: 12 }) + } + }} + className="w-20 text-2xl font-semibold tabular-nums text-center rounded-lg focus:outline-none focus:ring-2 transition-all" + style={{ + color: monetWizard.textPrimary, + background: monetWizard.cardBg, + border: `1px solid ${monetWizard.cardBorder}`, + }} + /> + months + + + {answers.criticalIllness.expectedRecoveryMonths <= 6 + ? 'mild' + : answers.criticalIllness.expectedRecoveryMonths <= 12 + ? 'typical' + : answers.criticalIllness.expectedRecoveryMonths <= 24 + ? 'serious' + : 'severe'} + + + setCriticalIllnessAnswers({ expectedRecoveryMonths: value[0] })} + aria-label="Recovery months" + > + + + + + + + 3 mo + 48 mo+ + + + + + setCriticalIllnessAnswers({ monthlyExpenses: value })} + placeholder={Math.round(monthlyIncome * 0.7).toString()} + helpText={`Default estimate: 70% of monthly income (${formatCurrency(Math.round(monthlyIncome * 0.7))})`} + /> + + + + How many months can your emergency fund cover? + + + + + { + const raw = e.target.value + if (raw === '') { + setCriticalIllnessAnswers({ emergencyFundMonths: 0 }) + return + } + const val = parseInt(raw, 10) + if (!isNaN(val) && val >= 0) { + setCriticalIllnessAnswers({ emergencyFundMonths: val }) + } + }} + onBlur={(e) => { + const val = parseInt(e.target.value, 10) + if (isNaN(val) || val < 0) { + setCriticalIllnessAnswers({ emergencyFundMonths: 0 }) + } + }} + className="w-20 text-2xl font-semibold tabular-nums text-center rounded-lg focus:outline-none focus:ring-2 transition-all" + style={{ + color: monetWizard.textPrimary, + background: monetWizard.cardBg, + border: `1px solid ${monetWizard.cardBorder}`, + }} + /> + months + + {answers.criticalIllness.emergencyFundMonths >= 6 && ( + + {answers.criticalIllness.emergencyFundMonths >= 12 ? 'excellent' : 'good'} + + )} + + setCriticalIllnessAnswers({ emergencyFundMonths: value[0] })} + aria-label="Emergency fund months" + > + + + + + + + 0 mo + 24 mo+ + + + + + setCriticalIllnessAnswers({ existingCiCoverage: value })} + placeholder="0" + helpText="Check your employment benefits and existing policies" + /> + + {/* Calculated recommendation - Monet style */} + + + Recommended CI coverage: + + {formatCurrency(Math.max(0, totalNeeded))} + + + + = ({formatCurrency(monthlyExpenses)} × {answers.criticalIllness.expectedRecoveryMonths} months) - + ({answers.criticalIllness.emergencyFundMonths} months emergency fund) - + ({formatCurrency(answers.criticalIllness.existingCiCoverage)} existing) + + + + + + Key insight: Critical illness coverage + replaces income during recovery. The payout is a lump sum, not monthly payments, + so you have flexibility in how you use it. + + + + + {/* Navigation - Monet style */} + + + + Back + + + Continue + + + + + ) +} + +// PERSONAL ACCIDENT QUESTIONNAIRE +function PersonalAccidentQuestionnaire({ + onNext, + onBack, +}: { + onNext: () => void + onBack: () => void +}) { + const colorScheme = useColorScheme() + const monetWizard = getInsuranceTheme(colorScheme) + + const answers = useQuestionnaireAnswers() + const { setPersonalAccidentAnswers } = useGuidelinesActions() + + return ( + + {/* Header - Monet style */} + + + Personal Accident Coverage + + + How risky is your lifestyle and occupation? + + + + + + + What's your occupation risk level? + + + setPersonalAccidentAnswers({ occupationRisk: 'low' })} + icon={} + title="Low Risk (Office/Desk job)" + description="Professional services, administrative, work-from-home" + color="emerald" + /> + setPersonalAccidentAnswers({ occupationRisk: 'medium' })} + icon={} + title="Medium Risk (Field work/Healthcare)" + description="Sales, healthcare workers, teachers, light manual work" + color="amber" + /> + setPersonalAccidentAnswers({ occupationRisk: 'high' })} + icon={} + title="High Risk (Manual labor/Dangerous)" + description="Construction, delivery riders, machinery operators" + color="amber" + /> + + + + setPersonalAccidentAnswers({ existingPaCoverage: value })} + placeholder="0" + /> + + + {/* Navigation - Monet style */} + + + + Back + + + Continue + + + + + ) +} + +// SELF-INSURANCE QUESTIONNAIRE +function SelfInsuranceQuestionnaire({ + onNext, + onBack, +}: { + onNext: () => void + onBack: () => void +}) { + const colorScheme = useColorScheme() + const monetWizard = getInsuranceTheme(colorScheme) + + const answers = useQuestionnaireAnswers() + const { setSelfInsuranceAnswers, applyQuestionnaireRecommendations } = useGuidelinesActions() + const recommendations = useQuestionnaireRecommendations() + const selectedPersonId = useSelectedPersonId() + const autoPopulate = useQuestionnaireAutoPopulate(selectedPersonId) + + const handleContinue = () => { + // Apply all questionnaire recommendations to the guidelines + applyQuestionnaireRecommendations() + onNext() + } + + return ( + + {/* Header - Monet style */} + + + Self-Insurance Capability + + + Can your assets cover some risks, reducing the need for insurance? + + + + + setSelfInsuranceAnswers({ liquidNetWorth: value })} + placeholder="0" + helpText="Assets you could access within 1-3 months if needed" + preFilled={autoPopulate.sources.assets} + preFilledSource="From assets" + /> + + + setSelfInsuranceAnswers({ willingToSelfInsure: !answers.selfInsurance.willingToSelfInsure })} + className="relative h-6 w-11 rounded-full transition-colors shrink-0" + style={{ + background: answers.selfInsurance.willingToSelfInsure ? monetWizard.sage : monetWizard.cardBorder, + }} + > + + + + Willing to use assets to cover insurance gaps + This can reduce your recommended coverage amounts + + + + {answers.selfInsurance.willingToSelfInsure && ( + setSelfInsuranceAnswers({ selfInsuranceThreshold: value })} + placeholder="100000" + helpText="Assets above this threshold can offset insurance needs" + /> + )} + + {/* Preview of recommendations - Monet style */} + + Based on your answers, we recommend: + + + Hospitalization + + {wardClassConfig[recommendations.hospitalization.wardClass].label} + {recommendations.hospitalization.rider && ' + Rider'} + + + + Life / TPD + {formatCurrency(recommendations.lifeTpd)} + + + Critical Illness + {formatCurrency(recommendations.criticalIllness)} + + + Personal Accident + {formatCurrency(recommendations.personalAccident)} + + + + + + + Remember: These are personalized recommendations based on your + specific situation. You can adjust them in the next step. + + + + + {/* Navigation - Monet style */} + + + + Back + + + Apply Recommendations + + + + + ) +} + +// MAIN WIZARD STEP 2 - Guided Questionnaire +function WizardStep2Questionnaire({ onNext, onBack }: WizardStep2Props) { + const colorScheme = useColorScheme() + const monetWizard = getInsuranceTheme(colorScheme) + + const [currentSection, setCurrentSection] = useState('hospitalization') + const [hasAutoPopulated, setHasAutoPopulated] = useState(false) + + const selectedPersonId = useSelectedPersonId() + const { setLifeTpdAnswers, setCriticalIllnessAnswers, setSelfInsuranceAnswers } = useGuidelinesActions() + const autoPopulateData = useQuestionnaireAutoPopulate(selectedPersonId) + + // Auto-populate questionnaire data on first mount + useEffect(() => { + if (!hasAutoPopulated && !autoPopulateData.isLoading) { + // Only populate if we have actual data from the app + const { lifeTpd, criticalIllness, selfInsurance, sources } = autoPopulateData + + // Populate Life/TPD answers if we have relevant data + if (sources.persons || sources.liabilities || sources.assets) { + setLifeTpdAnswers({ + ...lifeTpd, + // Keep futureObligations as user input since we can't infer it + }) + } + + // Populate Critical Illness answers if we have income data + if (sources.income || sources.assets) { + setCriticalIllnessAnswers(criticalIllness) + } + + // Populate Self Insurance answers if we have asset data + if (sources.assets) { + setSelfInsuranceAnswers(selfInsurance) + } + + setHasAutoPopulated(true) + } + }, [ + hasAutoPopulated, + autoPopulateData, + setLifeTpdAnswers, + setCriticalIllnessAnswers, + setSelfInsuranceAnswers, + ]) + + const currentIndex = sectionOrder.indexOf(currentSection) + + const handleSectionNext = () => { + if (currentIndex < sectionOrder.length - 1) { + setCurrentSection(sectionOrder[currentIndex + 1]) + } else { + onNext() + } + } + + const handleSectionBack = () => { + if (currentIndex > 0) { + setCurrentSection(sectionOrder[currentIndex - 1]) + } else { + onBack() + } + } + + return ( + + {/* Section indicator - minimal pill style */} + + {sectionOrder.map((section, i) => { + const config = sectionConfig[section] + const isCurrent = section === currentSection + const isPast = i < currentIndex + + return ( + + + {config.title} + + + ) + })} + + + {/* Render current section questionnaire */} + {currentSection === 'hospitalization' && ( + + )} + {currentSection === 'life_tpd' && ( + + )} + {currentSection === 'critical_illness' && ( + + )} + {currentSection === 'personal_accident' && ( + + )} + {currentSection === 'self_insurance' && ( + + )} + + ) +} + +// ============================================================================ +// WIZARD STEP 3: SUMMARY & BUDGET +// ============================================================================ + +interface WizardStep3Props { + onComplete: () => void + onBack: () => void +} + +function WizardStep3Summary({ onComplete, onBack }: WizardStep3Props) { + const colorScheme = useColorScheme() + const monetWizard = getInsuranceTheme(colorScheme) + + const guidelines = useGuidelines() + const targets = useGuidelineTargets() + const { setMaxPremiumPercentage } = useGuidelinesActions() + + const percentage = Math.round(guidelines.maxPremiumPercentage * 100) + + const coverageTypes: GuidelineCoverageType[] = [ + 'hospitalization', + 'life_tpd', + 'critical_illness', + 'personal_accident', + ] + + // Calculate slider fill percentage for gradient + const sliderFillPercent = ((percentage - 5) / 15) * 100 + + return ( + + {/* Header - elegant Monet style */} + + + + + + Review your guidelines + + + Here's a summary of your coverage targets. You can always adjust these later. + + + + {/* Summary card - glass morphism */} + + + Annual Income + + {formatCurrency(guidelines.annualIncome)} + + + + + {coverageTypes.map((type) => { + const config = guidelineCoverageConfig[type] + const coverage = guidelines.coverages[type] + + if (!coverage.isEnabled) { + return ( + + {config.shortLabel} + + Disabled + + + ) + } + + const isHospitalization = type === 'hospitalization' + + return ( + + {config.shortLabel} + + {isHospitalization + ? `${wardClassConfig[guidelines.coverages.hospitalization.preferredWardClass].label}${ + guidelines.coverages.hospitalization.recommendsRider ? ' + Rider' : '' + }` + : formatCurrency(targets[type as keyof typeof targets] as number)} + + + ) + })} + + + + {/* Premium budget - refined glass card */} + + + + + Maximum Premium Budget + + + % of income you're willing to spend on insurance + + + + {percentage}% + + + + {/* Custom slider with Monet styling */} + + setMaxPremiumPercentage(parseInt(e.target.value) / 100)} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" + /> + + + + + + 5% + 10% + 15% + 20% + + + + + + Max annual premiums + + + ≤ {formatCurrency(targets.maxAnnualPremium)} + + + + ≈ {formatCurrency(Math.round(targets.maxAnnualPremium / 12))}/month + + + + + {/* Navigation - sleek pill buttons */} + + + Back + + + + Save My Guidelines + + + + ) +} + +// ============================================================================ +// CONFIGURED VIEW COMPONENTS +// ============================================================================ + +/** + * Person selector dropdown for guidelines + */ +function PersonSelectorBar() { + const colorScheme = useColorScheme() + const monetWizard = getInsuranceTheme(colorScheme) + const selectedPersonId = useSelectedPersonId() + const { setSelectedPersonId } = useGuidelinesActions() + + return ( + + + + Coverage for + + + + ) +} + +// ============================================================================ +// CONFIGURED VIEW (Edit mode - after wizard completion) +// ============================================================================ + +interface ConfiguredGuidelinesViewProps { + onAddPolicy?: () => void +} + +function ConfiguredGuidelinesView({ onAddPolicy }: ConfiguredGuidelinesViewProps) { + const colorScheme = useColorScheme() + const monetWizard = getInsuranceTheme(colorScheme) + + const guidelines = useGuidelines() + const targets = useGuidelineTargets() + const recommendations = useQuestionnaireRecommendations() + const referenceMode = useReferenceMode() + const selectedPersonId = useSelectedPersonId() + const { setMaxPremiumPercentage, resetToDefaults, unmarkAsConfigured, setLifeTpdAnswers } = useGuidelinesActions() + const [showResetConfirm, setShowResetConfirm] = useState(false) + + // Fetch expenses for "vs Expenses" mode + const { data: expenses = [] } = useExpensesQuery() + const annualExpenses = useMemo(() => calculateAnnualExpenses(expenses), [expenses]) + + // Keep mortgage/assets in sync with current financial data + const autoPopulateData = useQuestionnaireAutoPopulate(selectedPersonId) + useEffect(() => { + if (!autoPopulateData.isLoading && (autoPopulateData.sources.liabilities || autoPopulateData.sources.assets)) { + setLifeTpdAnswers({ + mortgageBalance: autoPopulateData.computed.totalMortgage, + otherDebts: autoPopulateData.computed.totalOtherDebts, + existingAssets: autoPopulateData.computed.totalAssets, + }) + } + }, [ + autoPopulateData.isLoading, + autoPopulateData.computed.totalMortgage, + autoPopulateData.computed.totalOtherDebts, + autoPopulateData.computed.totalAssets, + autoPopulateData.sources.liabilities, + autoPopulateData.sources.assets, + setLifeTpdAnswers, + ]) + + const percentage = Math.round(guidelines.maxPremiumPercentage * 100) + + const coverageTypes: GuidelineCoverageType[] = [ + 'hospitalization', + 'life_tpd', + 'critical_illness', + 'personal_accident', + ] + + return ( + + + {/* Header - minimal and elegant */} + + + + Coverage Settings + + + My Coverage Targets + + + + + + Edit Answers + + setShowResetConfirm(true)} + className="flex items-center gap-2 px-4 py-2 text-xs font-medium uppercase tracking-wider transition-all duration-300 hover:opacity-70" + style={{ color: monetWizard.textMuted }} + > + + Reset + + {onAddPolicy && ( + + + Add Policy + + )} + + + + {/* Reset confirmation - sleek inline alert */} + {showResetConfirm && ( + + + Reset all guidelines to default values? + + + setShowResetConfirm(false)} + className="px-4 py-1.5 text-xs font-medium uppercase tracking-wider transition-all" + style={{ color: monetWizard.textMuted }} + > + Cancel + + { + resetToDefaults() + setShowResetConfirm(false) + }} + className="px-4 py-1.5 rounded-full text-xs font-medium text-white transition-all" + style={{ background: monetWizard.amber }} + > + Confirm + + + + )} + + {/* Person selector */} + + + + {/* Coverage cards - 8 columns */} + + {coverageTypes.map((type) => { + // Map coverage type to reasoning key + const reasoningKey = type === 'life_tpd' ? 'lifeTpd' : type === 'critical_illness' ? 'criticalIllness' : type === 'personal_accident' ? 'personalAccident' : type + const reasoning = recommendations.reasoning[reasoningKey as keyof typeof recommendations.reasoning] + return ( + + ) + })} + + + {/* Summary sidebar - 4 columns, sticky */} + + + {/* Summary card - glass effect */} + + + + Your Targets + + + + + {coverageTypes.map((type) => { + const config = guidelineCoverageConfig[type] + const coverage = guidelines.coverages[type] + + if (!coverage.isEnabled) return null + + const isHospitalization = type === 'hospitalization' + + return ( + + + {config.shortLabel} + + + + {isHospitalization + ? wardClassConfig[guidelines.coverages.hospitalization.preferredWardClass] + .label + : formatCurrency(targets[type as keyof typeof targets] as number)} + + {!isHospitalization && referenceMode === 'expenses' && ( + + {formatExpenseMultiplier( + targets[type as keyof typeof targets] as number, + annualExpenses + )} + + )} + + + ) + })} + + + + {/* Premium budget - refined slider */} + + + + Premium Budget + + + {percentage}% + + + + {/* Custom sleek slider track */} + + + setMaxPremiumPercentage(parseInt(e.target.value) / 100)} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + + + + 5% + 20% + + + + + {formatCurrency(targets.maxAnnualPremium)} + + + per year max + + + + + {/* Last updated - subtle */} + + Updated{' '} + {new Date(guidelines.updatedAt).toLocaleDateString('en-SG', { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + + + + + + + ) +} + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +interface GuidelinesTabProps { + onNavigateToPolicy?: () => void +} + +export function GuidelinesTab({ onNavigateToPolicy }: GuidelinesTabProps) { + const hasConfigured = useHasConfiguredGuidelines() + const { markAsConfigured } = useGuidelinesActions() + const [wizardStep, setWizardStep] = useState(1) + + // If configured, show the editable guidelines view directly + if (hasConfigured) { + return + } + + // Wizard flow for first-time setup + const stepLabels = ['Income', 'Questions', 'Review'] + + const handleComplete = () => { + markAsConfigured() + } + + return ( + + {/* Wizard step indicator */} + + + {/* Wizard steps */} + {wizardStep === 1 && setWizardStep(2)} />} + {wizardStep === 2 && ( + setWizardStep(3)} + onBack={() => setWizardStep(1)} + /> + )} + {wizardStep === 3 && ( + setWizardStep(2)} /> + )} + + ) +} diff --git a/frontend/src/components/insurance/tabs/JourneyTab.tsx b/frontend/src/components/insurance/tabs/JourneyTab.tsx new file mode 100644 index 000000000..96f91350a --- /dev/null +++ b/frontend/src/components/insurance/tabs/JourneyTab.tsx @@ -0,0 +1,956 @@ +'use client' + +import { useMemo, useState, useRef, useCallback } from 'react' +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Filler, + Tooltip, + type ChartOptions, + type ChartData, +} from 'chart.js' +import { Line } from 'react-chartjs-2' +import { Shield, HeartPulse, Zap, AlertCircle, CheckCircle2, Calendar, GraduationCap, Home, Sunset } from 'lucide-react' +import { cn } from '@/lib/utils' +import { usePersonsQuery } from '@/hooks/queries/usePersonsQuery' +import { useQuestionnaireAutoPopulate } from '@/hooks/useQuestionnaireAutoPopulate' +import { usePersonFilter } from '@/contexts/PersonFilterContext' +import { useColorScheme } from '@/stores' +import { PersonSelector } from '@/components/ui/PersonSelector' +import { getInsuranceTheme } from '@/lib/insurance-theme' +import { + generateCoverageProjection, + calculateMilestones, + calculateAge, + formatCoverageAmount, + type PersonCoverageContext, + type CoverageMilestone, + type CoverageProjectionYear, +} from '@/lib/coverage-journey-utils' + +// Register Chart.js components +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Filler, + Tooltip +) + +// ============================================================================= +// Types +// ============================================================================= + +interface JourneyTabProps { + className?: string +} + +// ============================================================================= +// Coverage Comparison (Light Theme) +// ============================================================================= + +function CoverageComparisonLight({ + recommendedLifeTpd, + recommendedCriticalIllness, + recommendedPersonalAccident, +}: { + recommendedLifeTpd: number + recommendedCriticalIllness: number + recommendedPersonalAccident: number +}) { + const colorScheme = useColorScheme() + const monetColors = getInsuranceTheme(colorScheme) + + const categories = [ + { name: 'Life/TPD', icon: Shield, recommended: recommendedLifeTpd, current: 0 }, + { name: 'Critical Illness', icon: HeartPulse, recommended: recommendedCriticalIllness, current: 0 }, + { name: 'Personal Accident', icon: Zap, recommended: recommendedPersonalAccident, current: 0 }, + ] + + return ( + + {/* Header */} + + + Category + + + Recommended + + + Current + + + Status + + + + {/* Rows */} + {categories.map((cat) => { + const Icon = cat.icon + const gap = cat.recommended - cat.current + const hasGap = gap > 0 + + return ( + + + + + {cat.name} + + + + + {formatCoverageAmount(cat.recommended)} + + + + + {formatCoverageAmount(cat.current)} + + + + {hasGap ? ( + <> + + + Gap: {formatCoverageAmount(gap)} + + > + ) : ( + <> + + + On Target + + > + )} + + + ) + })} + + ) +} + +// ============================================================================= +// Milestone Cards (Light Theme) +// ============================================================================= + +function getMilestoneIcon(category: CoverageMilestone['category']) { + switch (category) { + case 'dependent': + return GraduationCap + case 'debt': + return Home + case 'retirement': + return Sunset + default: + return Calendar + } +} + +function getMilestoneColor(category: CoverageMilestone['category'], theme: ReturnType, isMonet: boolean) { + switch (category) { + case 'dependent': + return { + bg: isMonet ? 'rgba(59, 130, 246, 0.12)' : 'rgba(59, 130, 246, 0.15)', + border: 'rgba(59, 130, 246, 0.25)', + text: '#3B82F6', + } + case 'debt': + return { + bg: isMonet ? `${theme.sage}18` : `${theme.sage}20`, + border: `${theme.sage}35`, + text: theme.sage, + } + case 'retirement': + return { + bg: isMonet ? `${theme.sunlightGold}25` : `${theme.amber}20`, + border: isMonet ? `${theme.sunlightGold}40` : `${theme.amber}35`, + text: isMonet ? '#8A7A5A' : theme.amber, + } + default: + return { + bg: `${theme.lavender}15`, + border: `${theme.lavender}25`, + text: theme.lavender, + } + } +} + +function MilestoneAlertsLight({ + milestones, + currentYear, +}: { + milestones: CoverageMilestone[] + currentYear: number +}) { + const colorScheme = useColorScheme() + const monetColors = getInsuranceTheme(colorScheme) + const isMonet = colorScheme === 'monet' + + if (milestones.length === 0) { + return ( + + + No upcoming milestones detected + + + ) + } + + return ( + + {milestones.map((milestone, idx) => { + const Icon = getMilestoneIcon(milestone.category) + const colors = getMilestoneColor(milestone.category, monetColors, isMonet) + const yearsAway = milestone.year - currentYear + + return ( + + + + + + + + + + {milestone.year} + + • + + {yearsAway === 1 ? 'In 1 year' : `In ${yearsAway} years`} + + + + + {milestone.event} + + + + {milestone.description} + + + + {milestone.impact} + + + + + ) + })} + + ) +} + +// ============================================================================= +// Coverage Chart (Light Theme) - Interactive with Draggable Age Marker +// ============================================================================= + +function getChartColors(theme: ReturnType) { + return { + lifeTpd: { + line: theme.lavender, + fill: 'rgba(155, 139, 180, 0.2)', + }, + criticalIllness: { + line: theme.sage, + fill: 'rgba(127, 178, 133, 0.15)', + }, + personalAccident: { + line: theme.coralRose, + fill: 'rgba(232, 168, 152, 0.1)', + }, + grid: 'rgba(155, 139, 180, 0.1)', + axis: theme.textMuted, + } +} + +interface CoverageChartLightProps { + projections: CoverageProjectionYear[] + milestones: CoverageMilestone[] + currentAge: number + selectedAge: number + onAgeSelect: (age: number) => void +} + +function CoverageChartLight({ + projections, + milestones, + currentAge, + selectedAge, + onAgeSelect, +}: CoverageChartLightProps) { + const colorScheme = useColorScheme() + const monetColors = getInsuranceTheme(colorScheme) + const CHART_COLORS_LIGHT = getChartColors(monetColors) + + const chartRef = useRef | null>(null) + const containerRef = useRef(null) + + // Drag state for the age marker + const [isDraggingAge, setIsDraggingAge] = useState(false) + + // Calculate chart area bounds + const getChartArea = useCallback(() => { + const chart = chartRef.current + if (!chart) return null + return chart.chartArea + }, []) + + // Convert pixel X position to age + const pixelToAge = useCallback((pixelX: number): number => { + const chart = chartRef.current + const chartArea = getChartArea() + if (!chart || !chartArea) return selectedAge + + // Calculate the relative position within the chart area + const relativeX = Math.max(0, Math.min(1, (pixelX - chartArea.left) / (chartArea.right - chartArea.left))) + + // Map to age range + const minAge = projections[0]?.age ?? 25 + const maxAge = projections[projections.length - 1]?.age ?? 75 + const age = Math.round(minAge + relativeX * (maxAge - minAge)) + + return Math.max(minAge, Math.min(maxAge, age)) + }, [projections, selectedAge, getChartArea]) + + // Calculate position of selected age marker + const selectedAgeIndex = useMemo(() => { + return projections.findIndex(p => p.age === selectedAge) + }, [projections, selectedAge]) + + const currentAgeIndex = useMemo(() => { + return projections.findIndex(p => p.age === currentAge) + }, [projections, currentAge]) + + const chartData: ChartData<'line'> = useMemo(() => { + const labels = projections.map(p => p.age.toString()) + + return { + labels, + datasets: [ + { + label: 'Life/TPD', + data: projections.map(p => p.recommendedLifeTpd), + borderColor: CHART_COLORS_LIGHT.lifeTpd.line, + backgroundColor: CHART_COLORS_LIGHT.lifeTpd.fill, + fill: true, + tension: 0.4, + pointRadius: 0, + pointHoverRadius: 6, + pointHoverBackgroundColor: CHART_COLORS_LIGHT.lifeTpd.line, + pointHoverBorderColor: '#fff', + pointHoverBorderWidth: 2, + borderWidth: 2, + }, + { + label: 'Critical Illness', + data: projections.map(p => p.recommendedCriticalIllness), + borderColor: CHART_COLORS_LIGHT.criticalIllness.line, + backgroundColor: CHART_COLORS_LIGHT.criticalIllness.fill, + fill: true, + tension: 0.4, + pointRadius: 0, + pointHoverRadius: 5, + pointHoverBackgroundColor: CHART_COLORS_LIGHT.criticalIllness.line, + pointHoverBorderColor: '#fff', + pointHoverBorderWidth: 2, + borderWidth: 1.5, + }, + { + label: 'Personal Accident', + data: projections.map(p => p.recommendedPersonalAccident), + borderColor: CHART_COLORS_LIGHT.personalAccident.line, + backgroundColor: CHART_COLORS_LIGHT.personalAccident.fill, + fill: true, + tension: 0.4, + pointRadius: 0, + pointHoverRadius: 4, + pointHoverBackgroundColor: CHART_COLORS_LIGHT.personalAccident.line, + pointHoverBorderColor: '#fff', + pointHoverBorderWidth: 2, + borderWidth: 1, + }, + ], + } + }, [projections]) + + // Handle mouse down on the age marker to start dragging + const handleMarkerMouseDown = useCallback((event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + setIsDraggingAge(true) + }, []) + + // Handle mouse move during drag + const handleMouseMove = useCallback((event: React.MouseEvent) => { + if (!isDraggingAge) return + + const rect = event.currentTarget.getBoundingClientRect() + const x = event.clientX - rect.left + const newAge = pixelToAge(x) + + if (newAge !== selectedAge) { + onAgeSelect(newAge) + } + }, [isDraggingAge, pixelToAge, selectedAge, onAgeSelect]) + + // Handle mouse up - stop dragging + const handleMouseUp = useCallback(() => { + setIsDraggingAge(false) + }, []) + + // Handle mouse leave - stop dragging + const handleMouseLeave = useCallback(() => { + if (isDraggingAge) { + setIsDraggingAge(false) + } + }, [isDraggingAge]) + + // Handle click on chart to select age + const handleChartClick = useCallback((event: React.MouseEvent) => { + if (isDraggingAge) return + + const rect = event.currentTarget.getBoundingClientRect() + const x = event.clientX - rect.left + const newAge = pixelToAge(x) + onAgeSelect(newAge) + }, [isDraggingAge, pixelToAge, onAgeSelect]) + + const chartOptions: ChartOptions<'line'> = useMemo(() => { + return { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: !isDraggingAge, + backgroundColor: monetColors.cardBgHover, + titleColor: monetColors.textPrimary, + bodyColor: monetColors.textSecondary, + borderColor: monetColors.cardBorder, + borderWidth: 1, + padding: 12, + cornerRadius: 8, + displayColors: true, + callbacks: { + title: (items) => { + if (items.length > 0) { + const age = projections[items[0].dataIndex]?.age + const isCurrent = age === currentAge + return `Age ${age}${isCurrent ? ' (Current)' : ''}` + } + return '' + }, + label: (context) => { + const value = context.raw as number + return ` ${context.dataset.label}: ${formatCoverageAmount(value)}` + }, + }, + }, + }, + scales: { + x: { + grid: { + color: CHART_COLORS_LIGHT.grid, + drawTicks: false, + }, + ticks: { + color: CHART_COLORS_LIGHT.axis, + font: { + size: 11, + }, + maxRotation: 0, + callback: function(_value, index) { + const age = projections[index]?.age + if (age !== undefined && age % 10 === 0) { + return age + } + return '' + }, + }, + border: { + display: false, + }, + }, + y: { + grid: { + color: CHART_COLORS_LIGHT.grid, + drawTicks: false, + }, + ticks: { + color: CHART_COLORS_LIGHT.axis, + font: { + size: 11, + }, + callback: (value) => formatCoverageAmount(value as number), + maxTicksLimit: 5, + }, + border: { + display: false, + }, + beginAtZero: true, + }, + }, + } + }, [projections, currentAge, isDraggingAge]) + + const isCurrentAge = selectedAge === currentAge + + return ( + + {/* Header with selected age display */} + + + + Viewing age + + + {selectedAge} + {isCurrentAge && ' (You)'} + + + + Drag the marker to explore + + + + {/* Chart with drag handling */} + + + + {/* Current age indicator (static) */} + {currentAgeIndex >= 0 && selectedAge !== currentAge && ( + + + Current + + + )} + + {/* Selected age marker (draggable) */} + {selectedAgeIndex >= 0 && ( + + {/* Drag handle at top */} + + + Age {selectedAge} + + {/* Arrow pointing down */} + + + + {/* Drag affordance dots on the line */} + + + + + + + )} + + {/* Dragging indicator */} + {isDraggingAge && ( + + )} + + {/* Milestone markers on chart */} + {milestones.map((milestone, idx) => { + const minAge = projections[0]?.age ?? 25 + const maxAge = projections[projections.length - 1]?.age ?? 75 + + // Skip milestones outside the chart range + if (milestone.age < minAge || milestone.age > maxAge) return null + + const positionPercent = ((milestone.age - minAge) / (maxAge - minAge)) * 100 + const Icon = getMilestoneIcon(milestone.category) + const colors = getMilestoneColor(milestone.category, monetColors, true) + + return ( + + + + + + ) + })} + + + {/* Legend */} + + + + + Life/TPD + + + + + Critical Illness + + + + + Personal Accident + + + + ) +} + +// ============================================================================= +// Main Component +// ============================================================================= + +export function JourneyTab({ className }: JourneyTabProps) { + const colorScheme = useColorScheme() + const monetColors = getInsuranceTheme(colorScheme) + const isMonet = colorScheme === 'monet' + + const { includedPersons } = usePersonFilter() + const { data: persons, isLoading: personsLoading } = usePersonsQuery() + + // Person selection state - default to first included person + const [selectedPersonId, setSelectedPersonId] = useState(null) + + // Initialize/update selected person when includedPersons changes + const effectivePersonId = selectedPersonId && includedPersons.some(p => p.id === selectedPersonId) + ? selectedPersonId + : includedPersons[0]?.id ?? null + + const selectedPerson = persons?.find(p => p.id === effectivePersonId) + + const autoPopulated = useQuestionnaireAutoPopulate(effectivePersonId) + + const currentAge = selectedPerson ? calculateAge(selectedPerson.dateOfBirth) : 35 + const [selectedAge, setSelectedAge] = useState(currentAge) + + const coverageContext: PersonCoverageContext = useMemo(() => { + const dependents = (persons ?? []) + .filter(p => p.id !== effectivePersonId && p.isIncluded) + .map(p => ({ + name: p.name, + age: calculateAge(p.dateOfBirth), + })) + .filter(d => d.age < 22) + + return { + age: currentAge, + annualIncome: autoPopulated.computed.primaryPersonIncome || 60000, + dependents, + mortgageBalance: autoPopulated.computed.totalMortgage, + mortgageEndYear: autoPopulated.computed.totalMortgage > 0 + ? new Date().getFullYear() + 25 + : null, + retirementAge: 65, + } + }, [currentAge, autoPopulated, persons, effectivePersonId]) + + // Generate projections + const projections = useMemo(() => { + return generateCoverageProjection(coverageContext, 25, 75) + }, [coverageContext]) + + const milestones = useMemo(() => { + return calculateMilestones(coverageContext) + }, [coverageContext]) + + const selectedProjection = useMemo(() => { + return projections.find(p => p.age === selectedAge) ?? projections.find(p => p.age === currentAge) + }, [projections, selectedAge, currentAge]) + + const currentYear = new Date().getFullYear() + const isCurrentAge = selectedAge === currentAge + const yearsFromNow = selectedAge - currentAge + + if (personsLoading || autoPopulated.isLoading) { + return ( + + + + + + ) + } + + if (!selectedPerson) { + return ( + + + + Select a person to view their coverage journey + + + ) + } + + return ( + + {/* Header */} + + + + Coverage Journey + + + How your insurance needs change over time + + + + {/* Person Selector */} + {includedPersons.length > 1 && ( + setSelectedPersonId(id)} + variant={isMonet ? 'monet' : 'dark'} + showCreate={false} + required + className="w-48" + /> + )} + {includedPersons.length === 1 && selectedPerson && ( + + + + {selectedPerson.name} + + + )} + + + {/* Coverage Needs Chart */} + + + Coverage Needs Over Time + + + + + + + {/* Coverage Comparison + Milestones - Side by Side */} + + {/* Coverage Comparison */} + + + + + At Age {selectedAge} + + + {isCurrentAge ? ( + Your current age + ) : yearsFromNow > 0 ? ( + {yearsFromNow} years from now + ) : ( + {Math.abs(yearsFromNow)} years ago + )} + + + {!isCurrentAge && ( + setSelectedAge(currentAge)} + className="text-xs transition-colors" + style={{ color: monetColors.lavender }} + > + ← Back to current + + )} + + + {selectedProjection && ( + + )} + + + {/* Milestones */} + {milestones.length > 0 && ( + + + Upcoming Milestones + + + + )} + + + ) +} diff --git a/frontend/src/components/insurance/tabs/PoliciesTab.tsx b/frontend/src/components/insurance/tabs/PoliciesTab.tsx index e9f536893..e874ca19a 100644 --- a/frontend/src/components/insurance/tabs/PoliciesTab.tsx +++ b/frontend/src/components/insurance/tabs/PoliciesTab.tsx @@ -1,26 +1,43 @@ 'use client' import { useState } from 'react' -import { FileText, Plus } from 'lucide-react' +import { Plus, Shield } from 'lucide-react' import { AddPolicyModal } from '../modals/AddPolicyModal' +import { useColorScheme } from '@/stores' +import { getInsuranceTheme } from '@/lib/insurance-theme' export function PoliciesTab() { const [isModalOpen, setIsModalOpen] = useState(false) + const colorScheme = useColorScheme() + const monetColors = getInsuranceTheme(colorScheme) + const isMonet = colorScheme === 'monet' return ( - + {/* Header */} - Your Policies - + + Your Policies + + Manage all your insurance policies in one place setIsModalOpen(true)} - className="flex items-center gap-2 rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-600 px-4 py-2.5 text-sm font-medium text-white shadow-lg shadow-emerald-500/20 transition-all hover:shadow-emerald-500/30" + className="flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium text-white transition-all duration-200 hover:scale-105" + style={{ + background: `linear-gradient(135deg, ${monetColors.lavender}, ${monetColors.lavenderDark})`, + boxShadow: `0 4px 16px ${monetColors.shadowSoft}`, + }} > Add Policy @@ -28,24 +45,59 @@ export function PoliciesTab() { {/* Empty State */} - - - + + + - No policies yet - - Add your insurance policies to track coverage and analyze gaps + + No policies yet + + + Add your insurance policies to track coverage and analyze gaps in your protection setIsModalOpen(true)} - className="mt-6 flex items-center gap-2 rounded-xl border border-white/[0.1] bg-white/[0.03] px-4 py-2.5 text-sm font-medium text-white transition-all hover:bg-white/[0.05]" + className="mt-6 flex items-center gap-2 rounded-xl px-5 py-3 text-sm font-medium transition-all duration-200 hover:scale-105" + style={{ + background: monetColors.cardBgHover, + border: `1px solid ${monetColors.lavender}40`, + color: monetColors.lavenderDark, + boxShadow: `0 2px 12px ${monetColors.shadowSoft}`, + }} > Add Your First Policy + {/* Future: Policy List would go here */} + {/* Each policy card would use the Monet glass card pattern: + - background: 'rgba(255, 255, 255, 0.6)' + - border: '1px solid rgba(155, 139, 180, 0.15)' + - boxShadow: monetColors.shadowSoft + - rounded-2xl with hover scale effect + */} + {/* Add Policy Modal */} - Join{' '} - 1,000+ Singaporeans{' '} - mapping their financial futures - + /> {/* Feature Cards */} - © {new Date().getFullYear()} Assetra. All rights reserved. - - - Built for Singaporeans, by Singaporeans + © {new Date().getFullYear()} WealthProject. All rights reserved. diff --git a/frontend/src/components/landing-v2/DemoSection.tsx b/frontend/src/components/landing-v2/DemoSection.tsx index 6525c3315..651632a7e 100644 --- a/frontend/src/components/landing-v2/DemoSection.tsx +++ b/frontend/src/components/landing-v2/DemoSection.tsx @@ -347,10 +347,10 @@ export function DemoSection() { className="mt-8 text-center" > - What takes hours in spreadsheets, + Tired of complicated spreadsheet formulas? - Assetra does in seconds. + Achieve clarity without the complexity with WealthProject. diff --git a/frontend/src/components/landing-v2/HeroSection.tsx b/frontend/src/components/landing-v2/HeroSection.tsx index e746c5129..d95ed9fe3 100644 --- a/frontend/src/components/landing-v2/HeroSection.tsx +++ b/frontend/src/components/landing-v2/HeroSection.tsx @@ -36,19 +36,6 @@ export function HeroSection() { className="relative z-10 max-w-5xl mx-auto text-center" style={parallaxStyle} > - {/* Badge */} - - - - Financial Planning for Singapore - - - {/* Main Headline */} - Trusted by{' '} - 1,000+ Singaporeans{' '} - planning their financial future + > - - - {/* Scroll Indicator */} - - - Scroll to explore - - - - - + ) } diff --git a/frontend/src/components/landing-v2/Navigation.tsx b/frontend/src/components/landing-v2/Navigation.tsx index ce60e5089..b02d50aa5 100644 --- a/frontend/src/components/landing-v2/Navigation.tsx +++ b/frontend/src/components/landing-v2/Navigation.tsx @@ -66,7 +66,7 @@ export function Navigation() { A - Assetra + WealthProject {/* Desktop Navigation */} diff --git a/frontend/src/components/landing-v2/PricingSection.tsx b/frontend/src/components/landing-v2/PricingSection.tsx index 5eb444a99..89fff578b 100644 --- a/frontend/src/components/landing-v2/PricingSection.tsx +++ b/frontend/src/components/landing-v2/PricingSection.tsx @@ -6,58 +6,24 @@ import { motion, useInView } from 'framer-motion' import { Check, Sparkles } from 'lucide-react' import { SpotlightCard } from './SpotlightCard' -const plans = [ - { - name: 'Free', - price: '$0', - period: 'forever', - description: 'Perfect for getting started with financial planning', - features: [ - '5-year net worth projection', - 'Basic scenario modeling', - 'CPF calculator', - 'Single user', - ], - cta: 'Get Started', - href: '/dashboard', - popular: false, - }, - { - name: 'Pro', - price: '$9', - period: '/month', - description: 'For serious planners who want the full picture', - features: [ - '20-year net worth projection', - 'Unlimited scenarios', - 'Advanced CPF strategies', - 'Goal tracking & milestones', - 'AI-powered insights', - 'Export to PDF/Excel', - 'Priority support', - ], - cta: 'Start Free Trial', - href: '/dashboard', - popular: true, - }, - { - name: 'Family', - price: '$19', - period: '/month', - description: 'Plan together with your partner or family', - features: [ - 'Everything in Pro', - 'Up to 4 family members', - 'Joint finances view', - 'Family goal tracking', - 'Shared scenarios', - 'Legacy planning tools', - ], - cta: 'Start Free Trial', - href: '/dashboard', - popular: false, - }, -] +const plan = { + name: 'Full Access', + price: '$0', + period: 'during trial', + description: 'Everything included while we\'re in early access. No credit card required.', + features: [ + '20-year net worth projection', + 'Unlimited scenarios', + 'Advanced CPF strategies', + 'Goal tracking & milestones', + 'AI-powered insights', + 'Export to PDF/Excel', + 'Up to 4 family members', + 'Joint finances view', + ], + cta: 'Get Started Free', + href: '/dashboard', +} export function PricingSection() { const sectionRef = useRef(null) @@ -89,81 +55,67 @@ export function PricingSection() { pricing - Start free, upgrade when you need more. No hidden fees. + Full access to everything during our trial period. No credit card required. - {/* Pricing Cards */} - - {plans.map((plan, index) => ( - - - {/* Popular badge */} - {plan.popular && ( - - - - - Most Popular - - - - )} + {/* Pricing Card */} + + + + {/* Trial badge */} + + + + + Early Access + + + - - {/* Plan name */} - - {plan.name} - + + {/* Plan name */} + + {plan.name} + - {/* Price */} - - - {plan.price} - - {plan.period} - + {/* Price */} + + + {plan.price} + + {plan.period} + - {/* Description */} - - {plan.description} - + {/* Description */} + + {plan.description} + - {/* Features */} - - {plan.features.map((feature) => ( - - - {feature} - - ))} - + {/* Features */} + + {plan.features.map((feature) => ( + + + {feature} + + ))} + - {/* CTA Button */} - - {plan.cta} - - - - - ))} + {/* CTA Button */} + + {plan.cta} + + + + {/* FAQ or note */} @@ -173,8 +125,6 @@ export function PricingSection() { transition={{ duration: 0.6, delay: 0.5 }} className="mt-12 text-center text-sm text-[#8A8F98]" > - All plans include a 14-day free trial. Cancel anytime. - Questions?{' '} Get in touch diff --git a/frontend/src/components/landing-v2/SingaporeSection.tsx b/frontend/src/components/landing-v2/SingaporeSection.tsx index 2cf276ab5..76ca1d8da 100644 --- a/frontend/src/components/landing-v2/SingaporeSection.tsx +++ b/frontend/src/components/landing-v2/SingaporeSection.tsx @@ -201,7 +201,7 @@ export function SingaporeSection() { transition={{ duration: 0.8, delay: 0.2 }} className="mb-16" > - + - - {label} - {required && *} - - - {hint && !error && {hint}} - {error && {error}} - - ) -} +/** + * Re-export FormField from the shared UI component. + * This file is kept for backwards compatibility with existing imports. + */ +export { FormField, type FormFieldProps } from '@/components/ui/FormField' diff --git a/frontend/src/components/modals/LayoutPreviewModal/LayoutPreviewItem.tsx b/frontend/src/components/modals/LayoutPreviewModal/LayoutPreviewItem.tsx index 4ade1835c..3548a1e91 100644 --- a/frontend/src/components/modals/LayoutPreviewModal/LayoutPreviewItem.tsx +++ b/frontend/src/components/modals/LayoutPreviewModal/LayoutPreviewItem.tsx @@ -5,9 +5,10 @@ interface LayoutPreviewItemProps { option: LayoutOption isSelected: boolean onSelect: (id: DashboardLayout) => void + isMonet?: boolean } -export function LayoutPreviewItem({ option, isSelected, onSelect }: LayoutPreviewItemProps) { +export function LayoutPreviewItem({ option, isSelected, onSelect, isMonet = false }: LayoutPreviewItemProps) { return ( {/* Layout visualization */} @@ -25,12 +30,14 @@ export function LayoutPreviewItem({ option, isSelected, onSelect }: LayoutPrevie className={clsx( 'w-32 h-24 rounded-lg overflow-hidden', 'border', - isSelected ? 'border-blue-500/30' : 'border-white/[0.08]' + isSelected + ? isMonet ? 'border-[var(--monet-lavender)]/30' : 'border-blue-500/30' + : isMonet ? 'border-[var(--monet-lavender)]/15' : 'border-white/[0.08]' )} > - {option.id === 'stacked' && } - {option.id === 'chart-left' && } - {option.id === 'chart-right' && } + {option.id === 'stacked' && } + {option.id === 'chart-left' && } + {option.id === 'chart-right' && } {/* Label and description */} @@ -38,106 +45,129 @@ export function LayoutPreviewItem({ option, isSelected, onSelect }: LayoutPrevie {option.label} - {option.description} + {option.description} ) } -function StackedPreview({ isSelected }: { isSelected: boolean }) { +function StackedPreview({ isSelected, isMonet }: { isSelected: boolean; isMonet: boolean }) { + const bgColor = isMonet ? 'bg-slate-100' : 'bg-[#0a0a0a]' + const chartBg = isSelected + ? isMonet ? 'bg-[var(--monet-lavender)]/20' : 'bg-blue-500/20' + : isMonet ? 'bg-[var(--monet-lavender)]/10' : 'bg-white/[0.06]' + const cardBg = isSelected + ? isMonet ? 'bg-[var(--monet-lavender)]/10' : 'bg-blue-500/10' + : isMonet ? 'bg-[var(--monet-lavender)]/5' : 'bg-white/[0.03]' + const strokeColor = isSelected + ? isMonet ? 'rgba(155, 139, 180, 0.6)' : 'rgba(59, 130, 246, 0.5)' + : isMonet ? 'rgba(155, 139, 180, 0.3)' : 'rgba(255, 255, 255, 0.15)' + return ( - + {/* Chart area - top */} - + {/* Chart line visualization */} {/* Cards area - bottom */} - - + + - - + + ) } -function ChartLeftPreview({ isSelected }: { isSelected: boolean }) { +function ChartLeftPreview({ isSelected, isMonet }: { isSelected: boolean; isMonet: boolean }) { + const bgColor = isMonet ? 'bg-slate-100' : 'bg-[#0a0a0a]' + const chartBg = isSelected + ? isMonet ? 'bg-[var(--monet-lavender)]/20' : 'bg-blue-500/20' + : isMonet ? 'bg-[var(--monet-lavender)]/10' : 'bg-white/[0.06]' + const cardBg = isSelected + ? isMonet ? 'bg-[var(--monet-lavender)]/10' : 'bg-blue-500/10' + : isMonet ? 'bg-[var(--monet-lavender)]/5' : 'bg-white/[0.03]' + const strokeColor = isSelected + ? isMonet ? 'rgba(155, 139, 180, 0.6)' : 'rgba(59, 130, 246, 0.5)' + : isMonet ? 'rgba(155, 139, 180, 0.3)' : 'rgba(255, 255, 255, 0.15)' + return ( - + {/* Chart area - left */} - + {/* Cards area - right */} - - + + - - + + ) } -function ChartRightPreview({ isSelected }: { isSelected: boolean }) { +function ChartRightPreview({ isSelected, isMonet }: { isSelected: boolean; isMonet: boolean }) { + const bgColor = isMonet ? 'bg-slate-100' : 'bg-[#0a0a0a]' + const chartBg = isSelected + ? isMonet ? 'bg-[var(--monet-lavender)]/20' : 'bg-blue-500/20' + : isMonet ? 'bg-[var(--monet-lavender)]/10' : 'bg-white/[0.06]' + const cardBg = isSelected + ? isMonet ? 'bg-[var(--monet-lavender)]/10' : 'bg-blue-500/10' + : isMonet ? 'bg-[var(--monet-lavender)]/5' : 'bg-white/[0.03]' + const strokeColor = isSelected + ? isMonet ? 'rgba(155, 139, 180, 0.6)' : 'rgba(59, 130, 246, 0.5)' + : isMonet ? 'rgba(155, 139, 180, 0.3)' : 'rgba(255, 255, 255, 0.15)' + return ( - + {/* Cards area - left */} - - + + - - + + {/* Chart area - right */} - + @@ -146,12 +176,15 @@ function ChartRightPreview({ isSelected }: { isSelected: boolean }) { ) } -function CardGridPattern() { +function CardGridPattern({ isMonet }: { isMonet: boolean }) { + const lineColor = isMonet ? 'bg-[var(--monet-lavender)]/20' : 'bg-white/[0.08]' + const lineColorLight = isMonet ? 'bg-[var(--monet-lavender)]/10' : 'bg-white/[0.04]' + return ( - - - + + + ) } diff --git a/frontend/src/components/modals/LayoutPreviewModal/LayoutPreviewModal.tsx b/frontend/src/components/modals/LayoutPreviewModal/LayoutPreviewModal.tsx index e268a4eb7..88a1cf8ea 100644 --- a/frontend/src/components/modals/LayoutPreviewModal/LayoutPreviewModal.tsx +++ b/frontend/src/components/modals/LayoutPreviewModal/LayoutPreviewModal.tsx @@ -6,6 +6,7 @@ import clsx from 'clsx' import { Modal } from '@/components/ui/Modal' import { LayoutPreviewItem } from './LayoutPreviewItem' import { LAYOUT_OPTIONS, type DashboardLayout } from './layoutTypes' +import { useColorScheme } from '@/stores' interface LayoutPreviewModalProps { isOpen: boolean @@ -20,6 +21,9 @@ export function LayoutPreviewModal({ currentLayout, onLayoutChange, }: LayoutPreviewModalProps) { + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' + const handleLayoutSelect = (layout: DashboardLayout) => { onLayoutChange(layout) onClose() @@ -29,28 +33,35 @@ export function LayoutPreviewModal({ {/* Header */} - - Choose Layout + + Choose Layout @@ -66,12 +77,16 @@ export function LayoutPreviewModal({ option={option} isSelected={currentLayout === option.id} onSelect={handleLayoutSelect} + isMonet={isMonet} /> ))} {/* Helper text */} - + Side-by-side layouts require a minimum screen width of 1280px diff --git a/frontend/src/components/modals/OnboardingWizardModal/OnboardingWizardModal.tsx b/frontend/src/components/modals/OnboardingWizardModal/OnboardingWizardModal.tsx new file mode 100644 index 000000000..82a5d227d --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/OnboardingWizardModal.tsx @@ -0,0 +1,222 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query' +import { X, AlertTriangle } from 'lucide-react' +import { Modal } from '@/components/ui/Modal' +import { cn } from '@/lib/utils' +import { useColorScheme } from '@/stores' +import { settingsApi } from '@/api/financial' +import { QUERY_KEYS } from '@/lib/queryKeys' +import type { UserSettings } from '@/types/financial' +import { OnboardingWizardView } from './OnboardingWizardView' + +interface OnboardingWizardModalProps { + isOpen: boolean + onClose: () => void +} + +export function OnboardingWizardModal({ isOpen, onClose }: OnboardingWizardModalProps) { + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' + const queryClient = useQueryClient() + const [showExitConfirm, setShowExitConfirm] = useState(false) + const showExitConfirmRef = useRef(false) + + // Keep ref in sync so the keydown listener always sees current state + useEffect(() => { + showExitConfirmRef.current = showExitConfirm + }, [showExitConfirm]) + + // Intercept Escape at the document level before the Modal's handler + // When confirmation is showing, Escape should dismiss it (not close the modal) + useEffect(() => { + if (!isOpen) return + + const handleEscapeIntercept = (e: KeyboardEvent) => { + if (e.key === 'Escape' && showExitConfirmRef.current) { + e.stopImmediatePropagation() + setShowExitConfirm(false) + } + } + + // Use capture phase to fire before the Modal's listener + document.addEventListener('keydown', handleEscapeIntercept, true) + return () => document.removeEventListener('keydown', handleEscapeIntercept, true) + }, [isOpen]) + + // Get current settings for the mutation + const { data: userSettings } = useQuery({ + queryKey: QUERY_KEYS.settings.user, + queryFn: () => settingsApi.getUserSettings(), + staleTime: 5 * 60 * 1000, + }) + + // Mark onboarding as completed + const markCompletedMutation = useMutation({ + mutationFn: () => { + if (!userSettings) throw new Error('Settings not loaded') + return settingsApi.updateUserSettings({ + ...userSettings, + onboardingCompleted: true, + }) + }, + onMutate: () => { + // Optimistically update the cache so Dashboard's auto-trigger useEffect + // immediately sees onboardingCompleted: true, preventing re-trigger on refresh + const currentSettings = queryClient.getQueryData(QUERY_KEYS.settings.user) + if (currentSettings) { + queryClient.setQueryData(QUERY_KEYS.settings.user, { + ...currentSettings, + onboardingCompleted: true, + }) + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.settings.user }) + // Also invalidate all financial data to pick up newly created items + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.financial.all }) + }, + }) + + // Intercept close attempts (Escape, X button, overlay click) — show confirmation + const handleCloseAttempt = useCallback(() => { + setShowExitConfirm(true) + }, []) + + // Confirm exit — actually close and mark completed + const handleConfirmExit = useCallback(() => { + setShowExitConfirm(false) + markCompletedMutation.mutate() + onClose() + }, [markCompletedMutation, onClose]) + + // Cancel exit — dismiss the confirmation and stay in wizard + const handleCancelExit = useCallback(() => { + setShowExitConfirm(false) + }, []) + + // Handle skip/dismiss — explicit action, no confirmation needed + const handleSkipSetup = useCallback(() => { + markCompletedMutation.mutate() + onClose() + }, [markCompletedMutation, onClose]) + + // Handle wizard completion (from summary step) — no confirmation needed + const handleComplete = useCallback(() => { + markCompletedMutation.mutate() + onClose() + }, [markCompletedMutation, onClose]) + + return ( + + {/* Header */} + + + Quick Start: Creating a financial plan + + + + + + + {/* Content */} + + + + + {/* Exit Confirmation Overlay */} + {showExitConfirm && ( + + + + + + + + + Exit setup? + + + Your progress won't be saved. You can always set up your financial plan later from the dashboard. + + + + + + + Continue Setup + + + Exit Setup + + + + + )} + + ) +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/OnboardingWizardView.tsx b/frontend/src/components/modals/OnboardingWizardModal/OnboardingWizardView.tsx new file mode 100644 index 000000000..f31e82501 --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/OnboardingWizardView.tsx @@ -0,0 +1,267 @@ +'use client' + +import { useState, useCallback } from 'react' +import { FormProvider } from 'react-hook-form' +import { AnimatePresence, motion } from 'framer-motion' +import { useOnboardingForm } from './hooks/useOnboardingForm' +import { useOnboardingSubmit } from './hooks/useOnboardingSubmit' +import { useLoadWizardProfile } from './hooks/useLoadWizardProfile' +import { StepIndicator } from './components/StepIndicator' +import { PersonalInfoStep } from './components/PersonalInfoStep' +import { IncomeExpensesStep } from './components/IncomeExpensesStep' +import { AssetsLiabilitiesStep } from './components/AssetsLiabilitiesStep' +import { CpfAccountsStep } from './components/CpfAccountsStep' +import { SummaryStep } from './components/SummaryStep' +import { LoadSampleDataButton } from './components/LoadSampleDataButton' +import type { StepStatus } from './types' + +interface OnboardingWizardViewProps { + onClose: () => void + onSkipSetup: () => void + isMonet: boolean +} + +const STEP_ANIMATION = { + initial: { opacity: 0, x: 40 }, + animate: { opacity: 1, x: 0 }, + exit: { opacity: 0, x: -40 }, + transition: { duration: 0.25, ease: 'easeInOut' as const }, +} + +const STEP_ANIMATION_BACK = { + initial: { opacity: 0, x: -40 }, + animate: { opacity: 1, x: 0 }, + exit: { opacity: 0, x: 40 }, + transition: { duration: 0.25, ease: 'easeInOut' as const }, +} + +export function OnboardingWizardView({ onClose, onSkipSetup, isMonet }: OnboardingWizardViewProps) { + const { form } = useOnboardingForm() + const [currentStepIndex, setCurrentStepIndex] = useState(0) + const [stepStatuses, setStepStatuses] = useState(['active', 'pending', 'pending', 'pending']) + const [animationDirection, setAnimationDirection] = useState<'forward' | 'back'>('forward') + const [showSummary, setShowSummary] = useState(false) + + const { submitStep, isSubmitting, submissionError } = useOnboardingSubmit(form) + const { loadProfile } = useLoadWizardProfile() + + // ─── Load sample profile ────────────────────────────────────────────────── + + const handleLoadProfile = useCallback((profileId: string) => { + const success = loadProfile(profileId, form) + if (success) { + // Reset wizard navigation to step 0 with all steps active + setStepStatuses(['active', 'pending', 'pending', 'pending']) + setCurrentStepIndex(0) + setAnimationDirection('forward') + setShowSummary(false) + } + }, [loadProfile, form]) + + // ─── Navigation ───────────────────────────────────────────────────────────── + + const goToStep = useCallback((targetIndex: number) => { + setAnimationDirection(targetIndex > currentStepIndex ? 'forward' : 'back') + setCurrentStepIndex(targetIndex) + setStepStatuses(prev => { + const next = [...prev] + next[targetIndex] = 'active' + return next + }) + }, [currentStepIndex]) + + const handleNext = useCallback(async () => { + // Validate and submit current step + const success = await submitStep(currentStepIndex) + if (!success) return + + // Mark current step completed + setStepStatuses(prev => { + const next = [...prev] + next[currentStepIndex] = 'completed' + return next + }) + + if (currentStepIndex < 3) { + // Advance to next step + setAnimationDirection('forward') + const nextIndex = currentStepIndex + 1 + setCurrentStepIndex(nextIndex) + setStepStatuses(prev => { + const next = [...prev] + next[nextIndex] = 'active' + return next + }) + } else { + // Last step → show summary + setShowSummary(true) + } + }, [currentStepIndex, submitStep]) + + const handleBack = useCallback(() => { + if (currentStepIndex > 0) { + setAnimationDirection('back') + setCurrentStepIndex(currentStepIndex - 1) + } + }, [currentStepIndex]) + + + const handleStepClick = useCallback((stepIndex: number) => { + goToStep(stepIndex) + }, [goToStep]) + + // ─── Render ───────────────────────────────────────────────────────────────── + + const animation = animationDirection === 'forward' ? STEP_ANIMATION : STEP_ANIMATION_BACK + + const stepComponents = [ + , + , + , + , + ] + + const handleBackFromSummary = useCallback(() => { + setShowSummary(false) + // Go back to the last step (CPF Accounts) + setAnimationDirection('back') + setCurrentStepIndex(3) + setStepStatuses(prev => { + const next = [...prev] + next[3] = 'active' + return next + }) + }, []) + + const handleStepClickFromSummary = useCallback((stepIndex: number) => { + setShowSummary(false) + setAnimationDirection('back') + setCurrentStepIndex(stepIndex) + setStepStatuses(prev => { + const next = [...prev] + next[stepIndex] = 'active' + return next + }) + }, []) + + if (showSummary) { + return ( + + + {/* Step Indicator — still visible so users can click back */} + + + + + + + + + ) + } + + return ( + + + {/* Step Indicator */} + + + {/* Divider */} + + + {/* Step Content */} + + + + {stepComponents[currentStepIndex]} + + + + {/* Error message */} + {submissionError && ( + + {submissionError} + + )} + + + {/* Footer */} + + + {/* Left side: Skip Setup + Load Sample */} + + + Skip Setup + + · + + + + {/* Right side: Back + Next */} + + {currentStepIndex > 0 && ( + + ← Back + + )} + + {isSubmitting ? 'Saving...' : currentStepIndex === 3 ? 'Finish \u2192' : 'Next \u2192'} + + + + + + + ) +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/components/AssetRow.tsx b/frontend/src/components/modals/OnboardingWizardModal/components/AssetRow.tsx new file mode 100644 index 000000000..71a0d2762 --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/components/AssetRow.tsx @@ -0,0 +1,193 @@ +import { useFormContext } from 'react-hook-form' +import { Trash2 } from 'lucide-react' +import { motion } from 'framer-motion' +import { cn } from '@/lib/utils' +import { formatCurrency } from '@/lib/format' +import { CurrencyInput } from '@/components/ui/CurrencyInput' +import { CustomDropdown } from '@/components/modals/ScenarioEventModal/components/CustomDropdown' +import type { OnboardingFormData } from '../types' +import { ASSET_CATEGORY_LABELS } from '../types' +import { ASSET_CATEGORY_ICONS } from './incomeExpensesConstants' + +const ASSET_CATEGORY_OPTIONS = Object.entries(ASSET_CATEGORY_LABELS).map(([value, label]) => ({ + value, + label: `${ASSET_CATEGORY_ICONS[value] ?? ''} ${label}`, +})) + +interface AssetRowProps { + fieldIndex: number + isExpanded: boolean + onToggle: () => void + onRemove: () => void + isMonet: boolean +} + +export function AssetRow({ + fieldIndex, + isExpanded, + onToggle, + onRemove, + isMonet, +}: AssetRowProps) { + const { watch, setValue, register, formState: { errors } } = useFormContext() + const asset = watch(`assets.${fieldIndex}`) + const fieldErrors = errors.assets?.[fieldIndex] + + if (!asset) return null + + const categoryIcon = ASSET_CATEGORY_ICONS[asset.category] ?? '📦' + const categoryLabel = ASSET_CATEGORY_LABELS[asset.category] ?? asset.category + + const getInputClass = (hasError?: boolean) => cn( + 'w-full py-2 px-3 rounded-lg text-sm transition-colors focus:outline-none', + hasError + ? 'border border-rose-500/40 bg-rose-500/5 text-white placeholder:text-slate-600 focus:border-rose-500/60' + : isMonet + ? 'bg-[var(--monet-lavender)]/5 border border-[var(--monet-lavender)]/15 text-[var(--monet-text-primary)] placeholder:text-[var(--monet-text-muted)] focus:border-[var(--monet-sage)]/40' + : 'bg-white/[0.03] border border-white/[0.06] text-white placeholder:text-slate-600 focus:border-blue-500/30 focus:bg-blue-500/[0.06]' + ) + + // ─── Collapsed row ──────────────────────────────────────────────────────── + const hasAnyError = !!fieldErrors + const displayName = asset.name || 'Untitled' + const displayAmount = asset.currentValue > 0 ? formatCurrency(asset.currentValue) : '—' + + if (!isExpanded) { + return ( + + {categoryIcon} + + {displayName} · {categoryLabel} + + + {displayAmount} + + { e.stopPropagation(); onRemove() }} + className={cn( + 'flex-shrink-0 p-1 rounded-lg transition-all opacity-0 group-hover:opacity-100', + isMonet + ? 'text-[var(--monet-text-muted)] hover:text-rose-500 hover:bg-rose-50' + : 'text-slate-600 hover:text-rose-400 hover:bg-rose-500/10' + )} + > + + + + ) + } + + // ─── Expanded row ───────────────────────────────────────────────────────── + return ( + + + {/* Row 1: Name + Current Value */} + + + + Name + + + + + + Current Value + + setValue(`assets.${fieldIndex}.currentValue`, val)} + size="sm" + /> + + + + {/* Row 2: Category + Growth Rate */} + + + + Category + + setValue(`assets.${fieldIndex}.category`, val as any)} + options={ASSET_CATEGORY_OPTIONS} + variant={isMonet ? 'monet' : 'dark'} + minWidth="100%" + /> + + + + Annual Growth Rate + + setValue(`assets.${fieldIndex}.growthRate`, val)} + isPercentage + size="sm" + /> + + + + {/* Collapse / Delete actions */} + + + Done + + + + Remove + + + + + ) +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/components/AssetsLiabilitiesStep.tsx b/frontend/src/components/modals/OnboardingWizardModal/components/AssetsLiabilitiesStep.tsx new file mode 100644 index 000000000..e5b516a4e --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/components/AssetsLiabilitiesStep.tsx @@ -0,0 +1,206 @@ +import { useState } from 'react' +import { useFormContext, useFieldArray } from 'react-hook-form' +import { Plus } from 'lucide-react' +import { AnimatePresence, motion } from 'framer-motion' +import { cn } from '@/lib/utils' +import { generateUUID } from '@/lib/utils' +import { formatCurrency } from '@/lib/format' +import type { OnboardingFormData, OnboardingAsset, OnboardingLiability } from '../types' +import { AssetRow } from './AssetRow' +import { LiabilityRow } from './LiabilityRow' + +interface AssetsLiabilitiesStepProps { + isMonet: boolean +} + +function createEmptyAsset(): OnboardingAsset { + return { + tempId: generateUUID(), + serverId: null, + name: '', + category: 'cash_savings', + currentValue: 0, + growthRate: 3, + } +} + +function createEmptyLiability(): OnboardingLiability { + return { + tempId: generateUUID(), + serverId: null, + name: '', + category: 'mortgage', + currentBalance: 0, + interestRateApr: 0, + minimumPayment: 0, + } +} + +const rowAnimation = { + initial: { opacity: 0, y: -8 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: 8, transition: { duration: 0.15 } }, + transition: { duration: 0.2 }, +} + +export function AssetsLiabilitiesStep({ isMonet }: AssetsLiabilitiesStepProps) { + const { watch, control } = useFormContext() + const { fields: assetFields, append: appendAsset, remove: removeAsset } = useFieldArray({ control, name: 'assets' }) + const { fields: liabilityFields, append: appendLiability, remove: removeLiability } = useFieldArray({ control, name: 'liabilities' }) + + const assets = watch('assets') + const liabilities = watch('liabilities') + + // ─── Accordion state ──────────────────────────────────────────────────── + const [expandedRowId, setExpandedRowId] = useState(null) + + const assetTotal = assets.reduce((sum, a) => sum + (a.currentValue ?? 0), 0) + const liabilityTotal = liabilities.reduce((sum, l) => sum + (l.currentBalance ?? 0), 0) + + // ─── Add handlers (new rows start expanded) ──────────────────────────── + const handleAddAsset = () => { + appendAsset(createEmptyAsset()) + setExpandedRowId(`asset-${assetFields.length}`) + } + + const handleAddLiability = () => { + appendLiability(createEmptyLiability()) + setExpandedRowId(`liability-${liabilityFields.length}`) + } + + const handleRemoveAsset = (index: number) => { + if (expandedRowId === `asset-${index}`) setExpandedRowId(null) + removeAsset(index) + } + + const handleRemoveLiability = (index: number) => { + if (expandedRowId === `liability-${index}`) setExpandedRowId(null) + removeLiability(index) + } + + const addButtonClass = cn( + 'flex items-center gap-1.5 text-xs font-medium transition-colors mt-3', + isMonet ? 'text-[var(--monet-sage)]' : 'text-emerald-400 hover:text-emerald-300' + ) + + return ( + + {/* ─── Assets ────────────────────────────────────────────────────────── */} + + + + Assets + + + {formatCurrency(assetTotal)} + + + + What you own — savings, investments, property, etc. + + + + + {assetFields.map((field, index) => ( + + + setExpandedRowId( + expandedRowId === `asset-${index}` ? null : `asset-${index}` + ) + } + onRemove={() => handleRemoveAsset(index)} + isMonet={isMonet} + /> + + ))} + + + {/* Empty state */} + {assetFields.length === 0 && ( + + No assets added yet. Add your savings or investments to track your net worth. + + )} + + + + + Add asset + + + + {/* ─── Liabilities ───────────────────────────────────────────────────── */} + + + + Liabilities + + + ({formatCurrency(liabilityTotal)}) + + + + What you owe — mortgages, loans, credit cards, etc. + + + + + {liabilityFields.map((field, index) => ( + + + setExpandedRowId( + expandedRowId === `liability-${index}` ? null : `liability-${index}` + ) + } + onRemove={() => handleRemoveLiability(index)} + isMonet={isMonet} + /> + + ))} + + + {/* Empty state */} + {liabilityFields.length === 0 && ( + + No liabilities added yet. Track your debts for a complete financial picture. + + )} + + + + + Add liability + + + + + ) +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/components/CpfAccountsStep.tsx b/frontend/src/components/modals/OnboardingWizardModal/components/CpfAccountsStep.tsx new file mode 100644 index 000000000..09ea8f2f6 --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/components/CpfAccountsStep.tsx @@ -0,0 +1,201 @@ +import { useEffect } from 'react' +import { useFormContext } from 'react-hook-form' +import { Info } from 'lucide-react' +import { cn } from '@/lib/utils' +import { generateUUID } from '@/lib/utils' +import { CurrencyInput } from '@/components/ui/CurrencyInput' +import type { OnboardingFormData } from '../types' + +interface CpfAccountsStepProps { + isMonet: boolean +} + +export function CpfAccountsStep({ isMonet }: CpfAccountsStepProps) { + const { watch, setValue } = useFormContext() + const persons = watch('persons') + const cpfAccounts = watch('cpfAccounts') + + // Filter to only citizen/PR persons + const eligiblePersons = persons.filter( + (p) => p.residencyStatus === 'citizen' || p.residencyStatus === 'pr' + ) + + // Auto-populate CPF entries for eligible persons on mount + useEffect(() => { + const existingPersonTempIds = new Set(cpfAccounts.map((c) => c.personTempId)) + const newEntries = eligiblePersons + .filter((p) => !existingPersonTempIds.has(p.tempId)) + .map((p) => ({ + tempId: generateUUID(), + serverId: null, + personTempId: p.tempId, + oaBalance: 0, + saBalance: 0, + maBalance: 0, + raBalance: 0, + })) + + if (newEntries.length > 0) { + setValue('cpfAccounts', [...cpfAccounts, ...newEntries]) + } + // Only run on mount or when persons change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [eligiblePersons.length]) + + const labelClass = cn( + 'text-xs font-medium mb-1.5', + isMonet ? 'text-[var(--monet-text-secondary)]' : 'text-slate-400' + ) + + // ─── No eligible persons ────────────────────────────────────────────────── + + if (eligiblePersons.length === 0) { + return ( + + + + + + Not Applicable + + + CPF accounts are only applicable for Singapore Citizens and Permanent Residents. + None of your household members have Citizen or PR residency status. + + + ) + } + + // ─── CPF balance entry cards ────────────────────────────────────────────── + + return ( + + + + CPF Account Balances + + + Enter your current CPF balances (check cpf.gov.sg) + + + + {eligiblePersons.map((person) => { + const cpfIndex = cpfAccounts.findIndex((c) => c.personTempId === person.tempId) + if (cpfIndex === -1) return null + + const statusLabel = person.residencyStatus === 'pr' + ? `PR${person.prGrantDate ? ` since ${new Date(person.prGrantDate).getFullYear()}` : ''}` + : 'Citizen' + + return ( + + {/* Person header */} + + + + {person.name || 'Unnamed'} + + + ({statusLabel}) + + + + {/* 2x2 grid of CPF accounts */} + + + Ordinary Account (OA) + setValue(`cpfAccounts.${cpfIndex}.oaBalance`, val)} + size="sm" + /> + + + + Special Account (SA) + setValue(`cpfAccounts.${cpfIndex}.saBalance`, val)} + size="sm" + /> + + + + MediSave Account (MA) + setValue(`cpfAccounts.${cpfIndex}.maBalance`, val)} + size="sm" + /> + + + + Retirement Account (RA) + setValue(`cpfAccounts.${cpfIndex}.raBalance`, val)} + size="sm" + /> + + + + {/* RA info note */} + + + RA is created at age 55. Leave as $0 if you're under 55. + + + ) + })} + + ) +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/components/DefaultAssumptions.tsx b/frontend/src/components/modals/OnboardingWizardModal/components/DefaultAssumptions.tsx new file mode 100644 index 000000000..108aa2750 --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/components/DefaultAssumptions.tsx @@ -0,0 +1,91 @@ +import { Settings } from 'lucide-react' +import { cn } from '@/lib/utils' +import { CurrencyInput } from '@/components/ui/CurrencyInput' +import { CustomDropdown } from '@/components/modals/ScenarioEventModal/components/CustomDropdown' +import { FREQUENCY_LABELS } from '../types' + +const FREQUENCY_OPTIONS = Object.entries(FREQUENCY_LABELS).map(([value, label]) => ({ value, label })) + +interface DefaultAssumptionsProps { + incomeGrowthDefault: number + setIncomeGrowthDefault: (v: number) => void + expenseGrowthDefault: number + setExpenseGrowthDefault: (v: number) => void + frequencyDefault: string + setFrequencyDefault: (v: string) => void + isMonet: boolean +} + +export function DefaultAssumptions({ + incomeGrowthDefault, + setIncomeGrowthDefault, + expenseGrowthDefault, + setExpenseGrowthDefault, + frequencyDefault, + setFrequencyDefault, + isMonet, +}: DefaultAssumptionsProps) { + return ( + + + + + Defaults + + + + {/* Income growth */} + + + Income growth: + + + + + + + {/* Expense inflation */} + + + Expense inflation: + + + + + + + {/* Frequency */} + + + Frequency: + + + + + + + ) +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/components/ExpenseRow.tsx b/frontend/src/components/modals/OnboardingWizardModal/components/ExpenseRow.tsx new file mode 100644 index 000000000..043d55a5f --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/components/ExpenseRow.tsx @@ -0,0 +1,229 @@ +import { useFormContext } from 'react-hook-form' +import { Trash2 } from 'lucide-react' +import { motion } from 'framer-motion' +import { cn } from '@/lib/utils' +import { CurrencyInput } from '@/components/ui/CurrencyInput' +import { CustomDropdown } from '@/components/modals/ScenarioEventModal/components/CustomDropdown' +import type { OnboardingFormData } from '../types' +import { + EXPENSE_CATEGORY_LABELS, + FREQUENCY_LABELS, +} from '../types' +import { EXPENSE_CATEGORY_ICONS } from './incomeExpensesConstants' +import { formatCollapsedLabel, formatCollapsedAmount } from './collapsedSummaryUtils' + +const EXPENSE_CATEGORY_OPTIONS = Object.entries(EXPENSE_CATEGORY_LABELS).map(([value, label]) => ({ + value, + label: `${EXPENSE_CATEGORY_ICONS[value] ?? ''} ${label}`, +})) + +const FREQUENCY_OPTIONS = Object.entries(FREQUENCY_LABELS).map(([value, label]) => ({ value, label })) + +interface ExpenseRowProps { + fieldIndex: number + isExpanded: boolean + onToggle: () => void + onRemove: () => void + isMonet: boolean + defaults: { growthRate: number; frequency: string } +} + +export function ExpenseRow({ + fieldIndex, + isExpanded, + onToggle, + onRemove, + isMonet, + defaults, +}: ExpenseRowProps) { + const { watch, setValue, register, formState: { errors } } = useFormContext() + const expense = watch(`expenses.${fieldIndex}`) + const fieldErrors = errors.expenses?.[fieldIndex] + + if (!expense) return null + + const categoryIcon = EXPENSE_CATEGORY_ICONS[expense.category] ?? '📦' + const categoryLabel = EXPENSE_CATEGORY_LABELS[expense.category] ?? expense.category + + const getInputClass = (hasError?: boolean) => cn( + 'w-full py-2 px-3 rounded-lg text-sm transition-colors focus:outline-none', + hasError + ? 'border border-rose-500/40 bg-rose-500/5 text-white placeholder:text-slate-600 focus:border-rose-500/60' + : isMonet + ? 'bg-[var(--monet-lavender)]/5 border border-[var(--monet-lavender)]/15 text-[var(--monet-text-primary)] placeholder:text-[var(--monet-text-muted)] focus:border-[var(--monet-sage)]/40' + : 'bg-white/[0.03] border border-white/[0.06] text-white placeholder:text-slate-600 focus:border-blue-500/30 focus:bg-blue-500/[0.06]' + ) + + const matchesDefault = (field: 'growthRate' | 'frequency') => { + if (field === 'growthRate') return expense.growthRate === defaults.growthRate + return expense.frequency === defaults.frequency + } + + // ─── Collapsed row ──────────────────────────────────────────────────────── + const hasAnyError = !!fieldErrors + + if (!isExpanded) { + return ( + + {categoryIcon} + + {formatCollapsedLabel(expense.name, categoryLabel)} + + + {formatCollapsedAmount(expense.amount, expense.frequency)} + + { e.stopPropagation(); onRemove() }} + className={cn( + 'flex-shrink-0 p-1 rounded-lg transition-all opacity-0 group-hover:opacity-100', + isMonet + ? 'text-[var(--monet-text-muted)] hover:text-rose-500 hover:bg-rose-50' + : 'text-slate-600 hover:text-rose-400 hover:bg-rose-500/10' + )} + > + + + + ) + } + + // ─── Expanded row ───────────────────────────────────────────────────────── + return ( + + + {/* Row 1: Name + Amount */} + + + + Name + + + + + + Amount + + setValue(`expenses.${fieldIndex}.amount`, val)} + size="sm" + /> + + + + {/* Row 2: Frequency + Category */} + + + + Frequency + {matchesDefault('frequency') && ( + (default) + )} + + setValue(`expenses.${fieldIndex}.frequency`, val as any)} + options={FREQUENCY_OPTIONS} + variant={isMonet ? 'monet' : 'dark'} + minWidth="100%" + /> + + + + Category + + setValue(`expenses.${fieldIndex}.category`, val as any)} + options={EXPENSE_CATEGORY_OPTIONS} + variant={isMonet ? 'monet' : 'dark'} + minWidth="100%" + /> + + + + {/* Row 3: Growth Rate */} + + + Annual Inflation + {matchesDefault('growthRate') && ( + (default) + )} + + setValue(`expenses.${fieldIndex}.growthRate`, val)} + isPercentage + size="sm" + /> + + + {/* Collapse / Delete actions */} + + + Done + + + + Remove + + + + + ) +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/components/IncomeExpensesStep.tsx b/frontend/src/components/modals/OnboardingWizardModal/components/IncomeExpensesStep.tsx new file mode 100644 index 000000000..aea2910fb --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/components/IncomeExpensesStep.tsx @@ -0,0 +1,270 @@ +import { useState } from 'react' +import { useFormContext, useFieldArray } from 'react-hook-form' +import { Plus } from 'lucide-react' +import { AnimatePresence, motion } from 'framer-motion' +import { cn } from '@/lib/utils' +import { formatCurrency } from '@/lib/format' +import { createDefaultIncome, createDefaultExpense } from '../hooks/useOnboardingForm' +import type { OnboardingFormData } from '../types' +import { RELATIONSHIP_LABELS } from '../types' +import { IncomeRow } from './IncomeRow' +import { ExpenseRow } from './ExpenseRow' + +interface IncomeExpensesStepProps { + isMonet: boolean +} + +const ROLE_COLORS: Record = { + self: '#10b981', + spouse: '#3b82f6', + child: '#8b5cf6', + parent: '#f59e0b', + sibling: '#06b6d4', + other: '#ec4899', +} + +const rowAnimation = { + initial: { opacity: 0, y: -8 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: 8, transition: { duration: 0.15 } }, + transition: { duration: 0.2 }, +} + +export function IncomeExpensesStep({ isMonet }: IncomeExpensesStepProps) { + const { watch, control } = useFormContext() + const { fields: incomeFields, append: appendIncome, remove: removeIncome } = useFieldArray({ control, name: 'incomes' }) + const { fields: expenseFields, append: appendExpense, remove: removeExpense } = useFieldArray({ control, name: 'expenses' }) + + const persons = watch('persons') + const incomes = watch('incomes') + const expenses = watch('expenses') + + // ─── Accordion state ──────────────────────────────────────────────────── + const [expandedRowId, setExpandedRowId] = useState(null) + + // ─── Default assumptions for new rows ────────────────────────────────── + const incomeGrowthDefault = 3 + const expenseGrowthDefault = 2 + const frequencyDefault = 'monthly' + + // ─── Monthly equivalent helper ────────────────────────────────────────── + const toMonthly = (amount: number, frequency: string) => + frequency === 'annual' ? amount / 12 + : frequency === 'quarterly' ? amount / 3 + : frequency === 'weekly' ? amount * 4.33 + : frequency === 'biweekly' ? amount * 2.17 + : amount + + const incomeTotal = incomes.reduce((sum, inc) => sum + toMonthly(inc.amount, inc.frequency), 0) + const expenseTotal = expenses.reduce((sum, exp) => sum + toMonthly(exp.amount, exp.frequency), 0) + + // ─── Group incomes by person ──────────────────────────────────────────── + const incomesByPerson = persons.map((person) => { + const indices: number[] = [] + incomeFields.forEach((_field, index) => { + if (incomes[index]?.personTempId === person.tempId) { + indices.push(index) + } + }) + return { person, indices } + }) + + const isPersonEligibleForCpf = (personTempId: string): boolean => { + const person = persons.find(p => p.tempId === personTempId) + return person?.residencyStatus === 'citizen' || person?.residencyStatus === 'pr' + } + + // ─── Add handlers (new rows start expanded) ──────────────────────────── + const handleAddIncome = (personTempId: string) => { + const newIncome = createDefaultIncome(personTempId, { + growthRate: incomeGrowthDefault, + frequency: frequencyDefault as any, + }) + appendIncome(newIncome) + // New row at end of incomes array → will be the last index + setExpandedRowId(`income-${incomeFields.length}`) + } + + const handleAddExpense = () => { + const newExpense = createDefaultExpense({ + growthRate: expenseGrowthDefault, + frequency: frequencyDefault as any, + }) + appendExpense(newExpense) + setExpandedRowId(`expense-${expenseFields.length}`) + } + + const handleRemoveIncome = (index: number) => { + if (expandedRowId === `income-${index}`) setExpandedRowId(null) + removeIncome(index) + } + + const handleRemoveExpense = (index: number) => { + if (expandedRowId === `expense-${index}`) setExpandedRowId(null) + removeExpense(index) + } + + const addButtonClass = cn( + 'flex items-center gap-1.5 text-xs font-medium transition-colors mt-3', + isMonet ? 'text-[var(--monet-sage)]' : 'text-emerald-400 hover:text-emerald-300' + ) + + return ( + + {/* ─── Income Sources ────────────────────────────────────────────────── */} + + + + Income Sources + + + {formatCurrency(incomeTotal)}/mo + + + + Salaries, bonuses, rental income, etc. + + + + {incomesByPerson.map(({ person, indices }) => { + const roleColor = ROLE_COLORS[person.relationship] ?? ROLE_COLORS.other + const personName = person.name || 'Unnamed' + const personLabel = person.relationship === 'self' + ? personName + : `${personName} (${RELATIONSHIP_LABELS[person.relationship] || 'Member'})` + const cpfEligible = isPersonEligibleForCpf(person.tempId) + + return ( + + {/* Person header */} + + + + {personLabel} + + + + {/* Income rows */} + + + {indices.map((fieldIndex) => ( + + + setExpandedRowId( + expandedRowId === `income-${fieldIndex}` ? null : `income-${fieldIndex}` + ) + } + onRemove={() => handleRemoveIncome(fieldIndex)} + cpfEligible={cpfEligible} + isMonet={isMonet} + defaults={{ growthRate: incomeGrowthDefault, frequency: frequencyDefault }} + /> + + ))} + + + {/* Empty state */} + {indices.length === 0 && ( + + No income sources yet. Add your salary or other income to get started. + + )} + + + {/* Add button per person */} + handleAddIncome(person.tempId)} + className={addButtonClass} + > + + Add income for {personName} + + + ) + })} + + + + {/* ─── Recurring Expenses ────────────────────────────────────────────── */} + + + + Recurring Expenses + + + {formatCurrency(expenseTotal)}/mo + + + + Housing, transport, food, utilities, etc. + + + + + {expenseFields.map((field, index) => ( + + + setExpandedRowId( + expandedRowId === `expense-${index}` ? null : `expense-${index}` + ) + } + onRemove={() => handleRemoveExpense(index)} + isMonet={isMonet} + defaults={{ growthRate: expenseGrowthDefault, frequency: frequencyDefault }} + /> + + ))} + + + {/* Empty state */} + {expenseFields.length === 0 && ( + + No expenses added yet. Tracking spending helps build an accurate plan. + + )} + + + + + Add expense + + + + ) +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/components/IncomeRow.tsx b/frontend/src/components/modals/OnboardingWizardModal/components/IncomeRow.tsx new file mode 100644 index 000000000..59dab8547 --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/components/IncomeRow.tsx @@ -0,0 +1,350 @@ +import { useFormContext } from 'react-hook-form' +import { Trash2, Info } from 'lucide-react' +import { AnimatePresence, motion } from 'framer-motion' +import { cn } from '@/lib/utils' +import { CurrencyInput } from '@/components/ui/CurrencyInput' +import { CustomDropdown } from '@/components/modals/ScenarioEventModal/components/CustomDropdown' +import type { OnboardingFormData } from '../types' +import { + INCOME_CATEGORY_LABELS, + FREQUENCY_LABELS, +} from '../types' +import { INCOME_CATEGORY_ICONS, CPF_DEFAULT_CATEGORIES } from './incomeExpensesConstants' +import { formatCollapsedLabel, formatCollapsedAmount } from './collapsedSummaryUtils' + +const INCOME_CATEGORY_OPTIONS = Object.entries(INCOME_CATEGORY_LABELS).map(([value, label]) => ({ + value, + label: `${INCOME_CATEGORY_ICONS[value] ?? ''} ${label}`, +})) + +const FREQUENCY_OPTIONS = Object.entries(FREQUENCY_LABELS).map(([value, label]) => ({ value, label })) + +const CPF_WAGE_OPTIONS = [ + { value: 'ow', label: 'Ordinary Wages (OW)' }, + { value: 'aw', label: 'Additional Wages (AW)' }, +] + +interface IncomeRowProps { + fieldIndex: number + isExpanded: boolean + onToggle: () => void + onRemove: () => void + cpfEligible: boolean + isMonet: boolean + defaults: { growthRate: number; frequency: string } +} + +export function IncomeRow({ + fieldIndex, + isExpanded, + onToggle, + onRemove, + cpfEligible, + isMonet, + defaults, +}: IncomeRowProps) { + const { watch, setValue, register, formState: { errors } } = useFormContext() + const income = watch(`incomes.${fieldIndex}`) + const fieldErrors = errors.incomes?.[fieldIndex] + + if (!income) return null + + const cpfApplies = income.cpfWageType !== null + const categoryIcon = INCOME_CATEGORY_ICONS[income.category] ?? '📋' + const categoryLabel = INCOME_CATEGORY_LABELS[income.category] ?? income.category + + const handleCpfToggle = (applies: boolean) => { + if (applies) { + const defaultType = CPF_DEFAULT_CATEGORIES.has(income.category) ? 'ow' : 'ow' + setValue(`incomes.${fieldIndex}.cpfWageType`, defaultType) + } else { + setValue(`incomes.${fieldIndex}.cpfWageType`, null) + } + } + + const handleCategoryChange = (newCategory: string) => { + setValue(`incomes.${fieldIndex}.category`, newCategory as any) + // Auto-toggle CPF based on new category + if (cpfEligible) { + if (CPF_DEFAULT_CATEGORIES.has(newCategory)) { + setValue(`incomes.${fieldIndex}.cpfWageType`, 'ow') + } else { + setValue(`incomes.${fieldIndex}.cpfWageType`, null) + } + } + } + + const getInputClass = (hasError?: boolean) => cn( + 'w-full py-2 px-3 rounded-lg text-sm transition-colors focus:outline-none', + hasError + ? 'border border-rose-500/40 bg-rose-500/5 text-white placeholder:text-slate-600 focus:border-rose-500/60' + : isMonet + ? 'bg-[var(--monet-lavender)]/5 border border-[var(--monet-lavender)]/15 text-[var(--monet-text-primary)] placeholder:text-[var(--monet-text-muted)] focus:border-[var(--monet-sage)]/40' + : 'bg-white/[0.03] border border-white/[0.06] text-white placeholder:text-slate-600 focus:border-blue-500/30 focus:bg-blue-500/[0.06]' + ) + + const matchesDefault = (field: 'growthRate' | 'frequency') => { + if (field === 'growthRate') return income.growthRate === defaults.growthRate + return income.frequency === defaults.frequency + } + + const hasAnyError = !!fieldErrors + + // ─── Collapsed row ──────────────────────────────────────────────────────── + if (!isExpanded) { + return ( + + {categoryIcon} + + {formatCollapsedLabel(income.name, categoryLabel)} + + + {formatCollapsedAmount(income.amount, income.frequency)} + + { e.stopPropagation(); onRemove() }} + className={cn( + 'flex-shrink-0 p-1 rounded-lg transition-all opacity-0 group-hover:opacity-100', + isMonet + ? 'text-[var(--monet-text-muted)] hover:text-rose-500 hover:bg-rose-50' + : 'text-slate-600 hover:text-rose-400 hover:bg-rose-500/10' + )} + > + + + + ) + } + + // ─── Expanded row ───────────────────────────────────────────────────────── + return ( + + + {/* Row 1: Name + Amount */} + + + + Name + + + + + + Amount + + setValue(`incomes.${fieldIndex}.amount`, val)} + size="sm" + /> + + + + {/* Row 2: Frequency + Category */} + + + + Frequency + {matchesDefault('frequency') && ( + (default) + )} + + setValue(`incomes.${fieldIndex}.frequency`, val as any)} + options={FREQUENCY_OPTIONS} + variant={isMonet ? 'monet' : 'dark'} + minWidth="100%" + /> + + + + Category + + + + + + {/* Row 3: CPF Section (only for eligible persons) */} + {cpfEligible && ( + + + CPF Contribution + + + {/* Toggle: CPF applies? */} + + handleCpfToggle(true)} + className={cn( + 'px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-150', + cpfApplies + ? isMonet + ? 'bg-[var(--monet-sage)]/15 text-[var(--monet-sage)] shadow-sm' + : 'bg-emerald-500/15 text-emerald-400 shadow-sm' + : isMonet + ? 'text-[var(--monet-text-muted)]' + : 'text-slate-500 hover:text-slate-300' + )} + > + Yes + + handleCpfToggle(false)} + className={cn( + 'px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-150', + !cpfApplies + ? isMonet + ? 'bg-[var(--monet-lavender)]/15 text-[var(--monet-text-primary)] shadow-sm' + : 'bg-white/[0.1] text-white shadow-sm' + : isMonet + ? 'text-[var(--monet-text-muted)]' + : 'text-slate-500 hover:text-slate-300' + )} + > + No + + + + {/* Wage Type dropdown (inline, right of toggle) */} + + {cpfApplies && ( + + + setValue(`incomes.${fieldIndex}.cpfWageType`, val as any)} + options={CPF_WAGE_OPTIONS} + variant={isMonet ? 'monet' : 'dark'} + minWidth="180px" + /> + + + + OW — Ordinary Wages + Regular monthly salary. Subject to the OW ceiling ($6,800/mo) for CPF contributions. + AW — Additional Wages + Bonuses, commissions, etc. Subject to a separate annual AW ceiling. + + + + + )} + + + + )} + + {/* Row 4: Growth Rate */} + + + Annual Growth + {matchesDefault('growthRate') && ( + (default) + )} + + setValue(`incomes.${fieldIndex}.growthRate`, val)} + isPercentage + size="sm" + /> + + + {/* Collapse / Delete actions */} + + + Done + + + + Remove + + + + + ) +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/components/LiabilityRow.tsx b/frontend/src/components/modals/OnboardingWizardModal/components/LiabilityRow.tsx new file mode 100644 index 000000000..2ef214b7d --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/components/LiabilityRow.tsx @@ -0,0 +1,205 @@ +import { useFormContext } from 'react-hook-form' +import { Trash2 } from 'lucide-react' +import { motion } from 'framer-motion' +import { cn } from '@/lib/utils' +import { formatCurrency } from '@/lib/format' +import { CurrencyInput } from '@/components/ui/CurrencyInput' +import { CustomDropdown } from '@/components/modals/ScenarioEventModal/components/CustomDropdown' +import type { OnboardingFormData } from '../types' +import { LIABILITY_CATEGORY_LABELS } from '../types' +import { LIABILITY_CATEGORY_ICONS } from './incomeExpensesConstants' + +const LIABILITY_CATEGORY_OPTIONS = Object.entries(LIABILITY_CATEGORY_LABELS).map(([value, label]) => ({ + value, + label: `${LIABILITY_CATEGORY_ICONS[value] ?? ''} ${label}`, +})) + +interface LiabilityRowProps { + fieldIndex: number + isExpanded: boolean + onToggle: () => void + onRemove: () => void + isMonet: boolean +} + +export function LiabilityRow({ + fieldIndex, + isExpanded, + onToggle, + onRemove, + isMonet, +}: LiabilityRowProps) { + const { watch, setValue, register, formState: { errors } } = useFormContext() + const liability = watch(`liabilities.${fieldIndex}`) + const fieldErrors = errors.liabilities?.[fieldIndex] + + if (!liability) return null + + const categoryIcon = LIABILITY_CATEGORY_ICONS[liability.category] ?? '📋' + const categoryLabel = LIABILITY_CATEGORY_LABELS[liability.category] ?? liability.category + + const getInputClass = (hasError?: boolean) => cn( + 'w-full py-2 px-3 rounded-lg text-sm transition-colors focus:outline-none', + hasError + ? 'border border-rose-500/40 bg-rose-500/5 text-white placeholder:text-slate-600 focus:border-rose-500/60' + : isMonet + ? 'bg-[var(--monet-lavender)]/5 border border-[var(--monet-lavender)]/15 text-[var(--monet-text-primary)] placeholder:text-[var(--monet-text-muted)] focus:border-[var(--monet-sage)]/40' + : 'bg-white/[0.03] border border-white/[0.06] text-white placeholder:text-slate-600 focus:border-blue-500/30 focus:bg-blue-500/[0.06]' + ) + + // ─── Collapsed row ──────────────────────────────────────────────────────── + const hasAnyError = !!fieldErrors + const displayName = liability.name || 'Untitled' + const displayAmount = liability.currentBalance > 0 ? `(${formatCurrency(liability.currentBalance)})` : '—' + + if (!isExpanded) { + return ( + + {categoryIcon} + + {displayName} · {categoryLabel} + + + {displayAmount} + + { e.stopPropagation(); onRemove() }} + className={cn( + 'flex-shrink-0 p-1 rounded-lg transition-all opacity-0 group-hover:opacity-100', + isMonet + ? 'text-[var(--monet-text-muted)] hover:text-rose-500 hover:bg-rose-50' + : 'text-slate-600 hover:text-rose-400 hover:bg-rose-500/10' + )} + > + + + + ) + } + + // ─── Expanded row ───────────────────────────────────────────────────────── + return ( + + + {/* Row 1: Name + Balance */} + + + + Name + + + + + + Outstanding Balance + + setValue(`liabilities.${fieldIndex}.currentBalance`, val)} + size="sm" + /> + + + + {/* Row 2: Category + APR */} + + + + Category + + setValue(`liabilities.${fieldIndex}.category`, val as any)} + options={LIABILITY_CATEGORY_OPTIONS} + variant={isMonet ? 'monet' : 'dark'} + minWidth="100%" + /> + + + + Interest Rate (APR) + + setValue(`liabilities.${fieldIndex}.interestRateApr`, val)} + isPercentage + size="sm" + /> + + + + {/* Row 3: Minimum Payment */} + + + Min. Monthly Payment + + setValue(`liabilities.${fieldIndex}.minimumPayment`, val)} + size="sm" + /> + + + {/* Collapse / Delete actions */} + + + Done + + + + Remove + + + + + ) +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/components/LoadSampleDataButton.tsx b/frontend/src/components/modals/OnboardingWizardModal/components/LoadSampleDataButton.tsx new file mode 100644 index 000000000..b6cd525e4 --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/components/LoadSampleDataButton.tsx @@ -0,0 +1,112 @@ +'use client' + +import { useState, useRef, useEffect } from 'react' +import { ClipboardList, ChevronUp } from 'lucide-react' +import { FINANCIAL_PROFILES } from '../../ProfileSelectionModal/profileConfigs' + +// Profile display metadata (emoji icons for the popover list) +const PROFILE_EMOJIS: Record = { + 'single-early-career': '🎓', + 'dink': '💑', + 'dink-kids-planned': '👶', + 'single-income-family': '🏠', + 'fire-focused': '🔥', +} + +interface LoadSampleDataButtonProps { + onLoadProfile: (profileId: string) => void + isMonet: boolean +} + +export function LoadSampleDataButton({ onLoadProfile, isMonet }: LoadSampleDataButtonProps) { + const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) + + // Close popover on outside click + useEffect(() => { + if (!isOpen) return + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [isOpen]) + + // Close on escape + useEffect(() => { + if (!isOpen) return + function handleEscape(event: KeyboardEvent) { + if (event.key === 'Escape') setIsOpen(false) + } + document.addEventListener('keydown', handleEscape) + return () => document.removeEventListener('keydown', handleEscape) + }, [isOpen]) + + const selectableProfiles = FINANCIAL_PROFILES.filter((p) => p.id !== 'blank-slate') + + return ( + + {/* Trigger button */} + setIsOpen((prev) => !prev)} + className={`flex items-center gap-1.5 text-xs font-medium transition-colors ${ + isMonet + ? 'text-[var(--monet-text-muted)] hover:text-[var(--monet-text-secondary)]' + : 'text-slate-600 hover:text-slate-400' + }`} + > + + Load Sample + + + + {/* Popover — opens upward */} + {isOpen && ( + + {selectableProfiles.map((profile) => ( + { + onLoadProfile(profile.id) + setIsOpen(false) + }} + className={`w-full flex items-start gap-2.5 px-2.5 py-2 rounded-lg text-left transition-all duration-150 ${ + isMonet + ? 'hover:bg-[var(--monet-lavender)]/10' + : 'hover:bg-white/[0.06]' + }`} + > + + {PROFILE_EMOJIS[profile.id] ?? '📋'} + + + + {profile.name} + + + {profile.tagline} + + + + ))} + + )} + + ) +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/components/PersonCard.tsx b/frontend/src/components/modals/OnboardingWizardModal/components/PersonCard.tsx new file mode 100644 index 000000000..4f236523b --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/components/PersonCard.tsx @@ -0,0 +1,239 @@ +import { useFormContext } from 'react-hook-form' +import { Trash2 } from 'lucide-react' +import { cn } from '@/lib/utils' +import { CustomDropdown } from '@/components/modals/ScenarioEventModal/components/CustomDropdown' +import { DatePicker } from '@/components/ui/DatePicker' +import { AnimatePresence, motion } from 'framer-motion' +import type { OnboardingFormData, OnboardingPerson } from '../types' +import { RELATIONSHIP_OPTIONS } from '../types' + +interface PersonCardProps { + index: number + person: OnboardingPerson + isSelf: boolean + onRemove: () => void + isMonet: boolean +} + +const GENDER_OPTIONS = [ + { value: 'male' as const, label: 'Male' }, + { value: 'female' as const, label: 'Female' }, +] + +const RESIDENCY_OPTIONS = [ + { value: 'citizen' as const, label: 'Citizen' }, + { value: 'pr' as const, label: 'PR' }, +] + +export function PersonCard({ index, person, isSelf, onRemove, isMonet }: PersonCardProps) { + const { register, setValue, watch, formState: { errors } } = useFormContext() + const residencyStatus = watch(`persons.${index}.residencyStatus`) + const relationship = watch(`persons.${index}.relationship`) + const dateOfBirth = watch(`persons.${index}.dateOfBirth`) + const prGrantDate = watch(`persons.${index}.prGrantDate`) + const todayString = new Date().toISOString().split('T')[0] + const isChild = relationship === 'child' + const isOtherRelationship = relationship === 'other' + + const fieldPrefix = `persons.${index}` as const + + const personErrors = errors.persons?.[index] + + const getInputClass = (fieldName?: keyof NonNullable) => { + const hasError = fieldName && personErrors?.[fieldName] + return cn( + 'w-full py-2 px-3 rounded-lg text-sm transition-colors focus:outline-none', + hasError + ? 'border border-rose-500/40 bg-rose-500/5 text-white placeholder:text-slate-600 focus:border-rose-500/60' + : isMonet + ? 'bg-[var(--monet-lavender)]/5 border border-[var(--monet-lavender)]/15 text-[var(--monet-text-primary)] placeholder:text-[var(--monet-text-muted)] focus:border-[var(--monet-sage)]/40' + : 'bg-white/[0.03] border border-white/[0.06] text-white placeholder:text-slate-600 focus:border-blue-500/30 focus:bg-blue-500/[0.06]' + ) + } + + // Fallback for fields that don't need error state + const inputClass = getInputClass() + + const labelClass = cn( + 'text-xs font-medium mb-1', + isMonet ? 'text-[var(--monet-text-secondary)]' : 'text-slate-400' + ) + + return ( + + {/* Card header - delete button only for non-self members */} + {!isSelf && ( + + + + + + )} + + {/* Row 1: Name, DOB, Gender */} + + + Name + + + + Date of Birth + setValue(`${fieldPrefix}.dateOfBirth`, val)} + placeholder="Select date" + maxDate={todayString} + variant={isMonet ? 'monet' : 'dark'} + hasError={!!personErrors?.dateOfBirth} + /> + + + Gender + setValue(`${fieldPrefix}.gender`, val)} + options={GENDER_OPTIONS} + variant={isMonet ? 'monet' : 'dark'} + minWidth="100%" + /> + + + + {/* Row 2: Residency, PR Grant Date, Retirement Age / Relationship */} + + {isSelf ? ( + <> + + Residency Status + setValue(`${fieldPrefix}.residencyStatus`, val as 'citizen' | 'pr')} + options={RESIDENCY_OPTIONS} + variant={isMonet ? 'monet' : 'dark'} + minWidth="100%" + /> + + + {residencyStatus === 'pr' && ( + + PR Grant Date + setValue(`${fieldPrefix}.prGrantDate`, val)} + placeholder="Select date" + maxDate={todayString} + variant={isMonet ? 'monet' : 'dark'} + /> + + )} + + {!isChild && ( + + Retirement Age + + + )} + > + ) : ( + <> + + Relationship + { + setValue(`${fieldPrefix}.relationship`, val) + if (val !== 'other') setValue(`${fieldPrefix}.customRelationship`, '') + }} + options={[...RELATIONSHIP_OPTIONS]} + variant={isMonet ? 'monet' : 'dark'} + minWidth="100%" + /> + + {isOtherRelationship && ( + + Specify Relationship + + + )} + + Residency Status + setValue(`${fieldPrefix}.residencyStatus`, val as 'citizen' | 'pr')} + options={RESIDENCY_OPTIONS} + variant={isMonet ? 'monet' : 'dark'} + minWidth="100%" + /> + + + {residencyStatus === 'pr' ? ( + + PR Grant Date + setValue(`${fieldPrefix}.prGrantDate`, val)} + placeholder="Select date" + maxDate={todayString} + variant={isMonet ? 'monet' : 'dark'} + /> + + ) : !isChild ? ( + + Retirement Age + + + ) : null} + + > + )} + + + ) +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/components/PersonalInfoStep.tsx b/frontend/src/components/modals/OnboardingWizardModal/components/PersonalInfoStep.tsx new file mode 100644 index 000000000..f3e2ab869 --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/components/PersonalInfoStep.tsx @@ -0,0 +1,138 @@ +import { useFormContext, useFieldArray } from 'react-hook-form' +import { Plus } from 'lucide-react' +import { cn } from '@/lib/utils' +import { generateUUID } from '@/lib/utils' +import { PERSON_COLORS } from '@/types/person' +import { PersonCard } from './PersonCard' +import type { OnboardingFormData, OnboardingPerson } from '../types' + +interface PersonalInfoStepProps { + isMonet: boolean +} + +export function PersonalInfoStep({ isMonet }: PersonalInfoStepProps) { + const { watch, register, control } = useFormContext() + const { fields, append, remove } = useFieldArray({ control, name: 'persons' }) + const persons = watch('persons') + + const handleAddPerson = () => { + const colorIndex = persons.length % PERSON_COLORS.length + const newPerson: OnboardingPerson = { + tempId: generateUUID(), + serverId: null, + name: '', + dateOfBirth: '', + gender: 'male', + residencyStatus: 'citizen', + prGrantDate: null, + relationship: persons.length === 1 ? 'spouse' : 'child', + customRelationship: '', + retirementAge: 65, + displayColor: PERSON_COLORS[colorIndex], + } + append(newPerson) + } + + const handleRemovePerson = (index: number) => { + if (index === 0) return // Can't remove self + + // TODO(human): Cascade-remove linked data when a person is deleted + // When a person is removed from Step 1, any incomes (Step 2) and CPF entries (Step 4) + // that reference this person's tempId should also be removed from the form. + // The person's tempId is: persons[index].tempId + // Incomes link via: income.personTempId + // CPF accounts link via: cpf.personTempId + // Implement the cascade logic below, then call remove(index) at the end. + + remove(index) + } + + const inputClass = cn( + 'w-full py-2 px-3 rounded-lg text-sm transition-colors focus:outline-none', + isMonet + ? 'bg-[var(--monet-lavender)]/5 border border-[var(--monet-lavender)]/15 text-[var(--monet-text-primary)] placeholder:text-[var(--monet-text-muted)] focus:border-[var(--monet-sage)]/40' + : 'bg-white/[0.03] border border-white/[0.06] text-white placeholder:text-slate-600 focus:border-blue-500/30 focus:bg-blue-500/[0.06]' + ) + + return ( + + {/* Section header */} + + + Household Members + + + Tell us about yourself and your household + + + + {/* Person cards */} + + {fields.map((field, index) => ( + handleRemovePerson(index)} + isMonet={isMonet} + /> + ))} + + + {/* Add button */} + + + Add Household Member + + + {/* Planning Horizon */} + + + Planning Horizon + + + + Years to project from now + + + + + + + + ) +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/components/StepIndicator.tsx b/frontend/src/components/modals/OnboardingWizardModal/components/StepIndicator.tsx new file mode 100644 index 000000000..6805ba65e --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/components/StepIndicator.tsx @@ -0,0 +1,87 @@ +import { Check, Minus } from 'lucide-react' +import { cn } from '@/lib/utils' +import { ONBOARDING_STEPS, type StepStatus } from '../types' + +interface StepIndicatorProps { + currentStepIndex: number + stepStatuses: StepStatus[] + onStepClick: (stepIndex: number) => void + isMonet: boolean +} + +export function StepIndicator({ currentStepIndex, stepStatuses, onStepClick, isMonet }: StepIndicatorProps) { + return ( + + {ONBOARDING_STEPS.map((step, index) => { + const status = stepStatuses[index] + const isActive = index === currentStepIndex + const isClickable = status === 'completed' || status === 'skipped' + + return ( + + {/* Step circle + label */} + isClickable && onStepClick(index)} + disabled={!isClickable} + className={cn( + 'flex flex-col items-center gap-1.5 group', + isClickable ? 'cursor-pointer' : 'cursor-default' + )} + > + {/* Circle */} + + {status === 'completed' ? ( + + ) : status === 'skipped' ? ( + + ) : ( + index + 1 + )} + + + {/* Label */} + + {step.label} + + + + {/* Connector line between steps */} + {index < ONBOARDING_STEPS.length - 1 && ( + + )} + + ) + })} + + ) +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/components/SummaryStep.tsx b/frontend/src/components/modals/OnboardingWizardModal/components/SummaryStep.tsx new file mode 100644 index 000000000..4efb91f03 --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/components/SummaryStep.tsx @@ -0,0 +1,184 @@ +import { useFormContext } from 'react-hook-form' +import { CheckCircle2, Users, Wallet, CreditCard, TrendingUp, TrendingDown, Landmark, Info } from 'lucide-react' +import { cn } from '@/lib/utils' +import { formatCurrency } from '@/lib/format' +import { ONBOARDING_STEPS, type StepStatus, type OnboardingFormData } from '../types' + +interface SummaryStepProps { + stepStatuses: StepStatus[] + onClose: () => void + onBack?: () => void + isMonet: boolean +} + +export function SummaryStep({ stepStatuses, onClose, onBack, isMonet }: SummaryStepProps) { + const { getValues } = useFormContext() + const { persons, incomes, expenses, assets, liabilities, cpfAccounts } = getValues() + + // Only count non-empty entries + const personCount = persons.filter(p => p.name.trim()).length + const incomeCount = incomes.filter(i => i.amount > 0).length + const expenseCount = expenses.filter(e => e.amount > 0).length + const assetCount = assets.filter(a => a.currentValue > 0).length + const liabilityCount = liabilities.filter(l => l.currentBalance > 0).length + const cpfPersonCount = cpfAccounts.filter(c => c.oaBalance > 0 || c.saBalance > 0 || c.maBalance > 0 || c.raBalance > 0).length + const cpfSubAccountCount = cpfAccounts.reduce((count, c) => { + return count + (c.oaBalance > 0 ? 1 : 0) + (c.saBalance > 0 ? 1 : 0) + (c.maBalance > 0 ? 1 : 0) + (c.raBalance > 0 ? 1 : 0) + }, 0) + + // Totals + const incomeMonthlyTotal = incomes.reduce((sum, inc) => { + if (inc.amount <= 0) return sum + const monthlyAmount = inc.frequency === 'annual' ? inc.amount / 12 + : inc.frequency === 'quarterly' ? inc.amount / 3 + : inc.frequency === 'weekly' ? inc.amount * 4.33 + : inc.frequency === 'biweekly' ? inc.amount * 2.17 + : inc.amount + return sum + monthlyAmount + }, 0) + + const expenseMonthlyTotal = expenses.reduce((sum, exp) => { + if (exp.amount <= 0) return sum + const monthlyAmount = exp.frequency === 'annual' ? exp.amount / 12 + : exp.frequency === 'quarterly' ? exp.amount / 3 + : exp.frequency === 'weekly' ? exp.amount * 4.33 + : exp.frequency === 'biweekly' ? exp.amount * 2.17 + : exp.amount + return sum + monthlyAmount + }, 0) + + const assetTotal = assets.reduce((sum, a) => sum + (a.currentValue ?? 0), 0) + const liabilityTotal = liabilities.reduce((sum, l) => sum + (l.currentBalance ?? 0), 0) + const cpfTotal = cpfAccounts.reduce((sum, c) => sum + c.oaBalance + c.saBalance + c.maBalance + c.raBalance, 0) + + const skippedSteps = ONBOARDING_STEPS + .filter((_, idx) => stepStatuses[idx] === 'skipped') + .map(s => s.label) + + const personNames = persons + .filter(p => p.name.trim()) + .map(p => p.name.trim()) + .join(', ') + + const summaryItems = [ + { icon: Users, label: `${personCount} Household Member${personCount !== 1 ? 's' : ''}`, detail: personNames, show: personCount > 0 }, + { icon: Wallet, label: `${incomeCount} Income Source${incomeCount !== 1 ? 's' : ''}`, detail: `${formatCurrency(incomeMonthlyTotal)}/mo total`, show: incomeCount > 0 }, + { icon: CreditCard, label: `${expenseCount} Recurring Expense${expenseCount !== 1 ? 's' : ''}`, detail: `${formatCurrency(expenseMonthlyTotal)}/mo total`, show: expenseCount > 0 }, + { icon: TrendingUp, label: `${assetCount} Asset${assetCount !== 1 ? 's' : ''}`, detail: `${formatCurrency(assetTotal)} total`, show: assetCount > 0 }, + { icon: TrendingDown, label: `${liabilityCount} Liabilit${liabilityCount !== 1 ? 'ies' : 'y'}`, detail: `(${formatCurrency(liabilityTotal)}) total`, show: liabilityCount > 0 }, + { icon: Landmark, label: `${cpfSubAccountCount} CPF Sub-account${cpfSubAccountCount !== 1 ? 's' : ''}`, detail: `${formatCurrency(cpfTotal)} combined`, show: cpfPersonCount > 0 }, + ] + + return ( + + {/* Success icon */} + + + + + + Your Plan is Ready! + + + {/* Summary card */} + + + What We Set Up + + + + {summaryItems.filter(item => item.show).map((item) => ( + + + + {item.label} + + + {item.detail} + + + ))} + + {summaryItems.every(item => !item.show) && ( + + No data was added during setup. + + )} + + + {skippedSteps.length > 0 && ( + + ⊘ + + Skipped: {skippedSteps.join(', ')} + + + )} + + + {/* Info banner */} + + + + You can fine-tune your financial data anytime from the dashboard. + Add scenario events to test “what-if” situations, explore CPF projections, or set up insurance coverage analysis. + + + + {/* Action buttons */} + + {onBack && ( + + ← Back to Edit + + )} + + Go to Dashboard → + + + + ) +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/components/collapsedSummaryUtils.ts b/frontend/src/components/modals/OnboardingWizardModal/components/collapsedSummaryUtils.ts new file mode 100644 index 000000000..a18588d8d --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/components/collapsedSummaryUtils.ts @@ -0,0 +1,26 @@ +import { formatCurrency } from '@/lib/format' + +/** Frequency → short display suffix mapping */ +const FREQUENCY_SUFFIXES: Record = { + weekly: '/wk', + biweekly: '/2wk', + monthly: '/mo', + quarterly: '/qtr', + annual: '/yr', +} + +/** + * Returns the left-side label: "Name · Category" + */ +export function formatCollapsedLabel(name: string, categoryLabel: string): string { + const displayName = name || 'Untitled' + return `${displayName} · ${categoryLabel}` +} + +/** + * Returns the right-side amount: "$5,000/mo" or "—" + */ +export function formatCollapsedAmount(amount: number, frequency: string): string { + const suffix = FREQUENCY_SUFFIXES[frequency] ?? '/mo' + return amount > 0 ? `${formatCurrency(amount)}${suffix}` : '—' +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/components/incomeExpensesConstants.ts b/frontend/src/components/modals/OnboardingWizardModal/components/incomeExpensesConstants.ts new file mode 100644 index 000000000..46e46f8d7 --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/components/incomeExpensesConstants.ts @@ -0,0 +1,41 @@ +// ─── Category icons for collapsed row summaries and dropdown options ────────── + +export const INCOME_CATEGORY_ICONS: Record = { + salary: '💼', + bonus: '🎁', + rental: '🏘', + freelance: '💻', + dividend: '📈', + other: '📋', +} + +export const EXPENSE_CATEGORY_ICONS: Record = { + housing: '🏠', + transport: '🚗', + food: '🍽', + utilities: '⚡', + insurance: '🛡', + living: '💰', + other: '📦', +} + +export const ASSET_CATEGORY_ICONS: Record = { + cash_savings: '💵', + stocks_etfs: '📊', + bonds: '🏦', + property: '🏘', + vehicle: '🚗', + other: '📦', +} + +export const LIABILITY_CATEGORY_ICONS: Record = { + mortgage: '🏠', + car_loan: '🚗', + student_loan: '🎓', + credit_card: '💳', + personal_loan: '🤝', + other: '📋', +} + +/** Income categories that default to CPF-eligible (Yes toggle) */ +export const CPF_DEFAULT_CATEGORIES = new Set(['salary', 'bonus']) diff --git a/frontend/src/components/modals/OnboardingWizardModal/hooks/useLoadWizardProfile.ts b/frontend/src/components/modals/OnboardingWizardModal/hooks/useLoadWizardProfile.ts new file mode 100644 index 000000000..d664a979b --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/hooks/useLoadWizardProfile.ts @@ -0,0 +1,172 @@ +import { useCallback, useState } from 'react' +import type { UseFormReturn } from 'react-hook-form' +import { generateUUID } from '@/lib/utils' +import { PERSON_COLORS } from '@/types/person' +import { FINANCIAL_PROFILES } from '../../ProfileSelectionModal/profileConfigs' +import type { ProfilePersonConfig } from '../../ProfileSelectionModal/types' +import type { + OnboardingFormData, + OnboardingPerson, + OnboardingIncome, + OnboardingExpense, + OnboardingAsset, + OnboardingCpf, +} from '../types' + +// ─── Age → Date of Birth ───────────────────────────────────────────────────── + +function ageToDob(age: number): string { + const today = new Date() + const birthYear = today.getFullYear() - age + // Use Jan 1 as a sensible default birthday + return `${birthYear}-01-01` +} + +// ─── Person mapping ────────────────────────────────────────────────────────── + +function mapPerson( + person: ProfilePersonConfig, + index: number, +): OnboardingPerson { + const isFirst = index === 0 + return { + tempId: generateUUID(), + serverId: null, + name: person.name, + dateOfBirth: ageToDob(person.age), + gender: 'male', // Default — user can change + residencyStatus: person.residencyStatus, + prGrantDate: null, + relationship: isFirst ? 'self' : 'spouse', + customRelationship: '', + retirementAge: 65, + displayColor: person.displayColor || PERSON_COLORS[index % PERSON_COLORS.length], + } +} + +// ─── Income mapping ────────────────────────────────────────────────────────── + +function mapIncome( + person: ProfilePersonConfig, + personTempId: string, +): OnboardingIncome | null { + if (!person.income) return null + return { + tempId: generateUUID(), + serverId: null, + personTempId, + name: person.income.name, + amount: person.income.amount, + frequency: 'monthly', + category: 'salary', + cpfWageType: person.income.cpfWageType ?? 'ow', + growthRate: person.income.growthRate, + } +} + +// ─── Expense generation ────────────────────────────────────────────────────── + +function generateDefaultExpenses(householdMonthlyIncome: number): OnboardingExpense[] { + if (householdMonthlyIncome <= 0) return [] + + const expenseTemplates: { name: string; category: OnboardingExpense['category']; ratio: number; growthRate: number }[] = [ + { name: 'Housing & Rent', category: 'housing', ratio: 0.25, growthRate: 3 }, + { name: 'Food & Dining', category: 'food', ratio: 0.12, growthRate: 3 }, + { name: 'Transport', category: 'transport', ratio: 0.08, growthRate: 2 }, + ] + + return expenseTemplates.map((template) => ({ + tempId: generateUUID(), + serverId: null, + name: template.name, + amount: Math.round(householdMonthlyIncome * template.ratio), + frequency: 'monthly' as const, + category: template.category, + growthRate: template.growthRate, + })) +} + +// ─── CPF mapping ───────────────────────────────────────────────────────────── + +function mapCpf( + person: ProfilePersonConfig, + personTempId: string, +): OnboardingCpf { + return { + tempId: generateUUID(), + serverId: null, + personTempId, + oaBalance: person.cpfBalances.oa, + saBalance: person.cpfBalances.sa, + maBalance: person.cpfBalances.ma, + raBalance: 0, + } +} + +// ─── Full conversion ───────────────────────────────────────────────────────── + +function profileToFormData(profileId: string): OnboardingFormData | null { + const profile = FINANCIAL_PROFILES.find((p) => p.id === profileId) + if (!profile || profile.persons.length === 0) return null + + const persons: OnboardingPerson[] = profile.persons.map(mapPerson) + const incomes: OnboardingIncome[] = [] + const cpfAccounts: OnboardingCpf[] = [] + + for (let i = 0; i < profile.persons.length; i++) { + const personConfig = profile.persons[i] + const personTempId = persons[i].tempId + + const income = mapIncome(personConfig, personTempId) + if (income) incomes.push(income) + + cpfAccounts.push(mapCpf(personConfig, personTempId)) + } + + // Total household monthly income for expense/asset scaling + const householdMonthlyIncome = incomes.reduce((sum, inc) => sum + inc.amount, 0) + + const expenses = generateDefaultExpenses(householdMonthlyIncome) + + // Emergency fund: 3 months of income as cash savings + const assets: OnboardingAsset[] = householdMonthlyIncome > 0 + ? [{ + tempId: generateUUID(), + serverId: null, + name: 'Emergency Fund', + category: 'cash_savings', + currentValue: Math.round(householdMonthlyIncome * 3), + growthRate: 1.5, + }] + : [] + + return { + persons, + projectionYears: 30, + incomes, + expenses, + assets, + liabilities: [], + cpfAccounts, + } +} + +// ─── Hook ──────────────────────────────────────────────────────────────────── + +export function useLoadWizardProfile() { + const [isProfileLoaded, setIsProfileLoaded] = useState(false) + + const loadProfile = useCallback( + (profileId: string, form: UseFormReturn): boolean => { + const formData = profileToFormData(profileId) + if (!formData) return false + + form.reset(formData) + setIsProfileLoaded(true) + return true + }, + [], + ) + + return { loadProfile, isProfileLoaded } +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/hooks/useOnboardingForm.ts b/frontend/src/components/modals/OnboardingWizardModal/hooks/useOnboardingForm.ts new file mode 100644 index 000000000..f4764c599 --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/hooks/useOnboardingForm.ts @@ -0,0 +1,102 @@ +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { generateUUID } from '@/lib/utils' +import { PERSON_COLORS } from '@/types/person' +import { + onboardingFormSchema, + type OnboardingFormData, + type OnboardingPerson, + type OnboardingIncome, + type OnboardingExpense, +} from '../types' + +// ─── Default person (self) ──────────────────────────────────────────────────── + +function createDefaultSelfPerson(): OnboardingPerson { + return { + tempId: generateUUID(), + serverId: null, + name: '', + dateOfBirth: '', + gender: 'male', + residencyStatus: 'citizen', + prGrantDate: null, + relationship: 'self', + customRelationship: '', + retirementAge: 65, + displayColor: PERSON_COLORS[0], + } +} + +// ─── Default income row ─────────────────────────────────────────────────────── + +interface IncomeOverrides { + growthRate?: number + frequency?: 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'annual' +} + +export function createDefaultIncome( + personTempId: string, + overrides?: IncomeOverrides, +): OnboardingIncome { + return { + tempId: generateUUID(), + serverId: null, + personTempId, + name: '', + amount: 0, + frequency: overrides?.frequency ?? 'monthly', + category: 'salary', + cpfWageType: 'ow', + growthRate: overrides?.growthRate ?? 3, + } +} + +// ─── Default expense row ────────────────────────────────────────────────────── + +interface ExpenseOverrides { + growthRate?: number + frequency?: 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'annual' +} + +export function createDefaultExpense( + overrides?: ExpenseOverrides, +): OnboardingExpense { + return { + tempId: generateUUID(), + serverId: null, + name: '', + amount: 0, + frequency: overrides?.frequency ?? 'monthly', + category: 'living', + growthRate: overrides?.growthRate ?? 2, + } +} + +// ─── Form defaults ──────────────────────────────────────────────────────────── + +function createDefaultFormValues(): OnboardingFormData { + const selfPerson = createDefaultSelfPerson() + + return { + persons: [selfPerson], + projectionYears: 30, + incomes: [], + expenses: [], + assets: [], + liabilities: [], + cpfAccounts: [], + } +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +export function useOnboardingForm() { + const form = useForm({ + resolver: zodResolver(onboardingFormSchema), + defaultValues: createDefaultFormValues(), + mode: 'onSubmit', // Only validate on step submit, not on every change + }) + + return { form } +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/hooks/useOnboardingSubmit.ts b/frontend/src/components/modals/OnboardingWizardModal/hooks/useOnboardingSubmit.ts new file mode 100644 index 000000000..4aa5c1f3e --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/hooks/useOnboardingSubmit.ts @@ -0,0 +1,269 @@ +import { useState, useRef, useCallback } from 'react' +import type { UseFormReturn } from 'react-hook-form' +import { useQueryClient } from '@tanstack/react-query' +import { personsApi } from '@/api/financial/persons' +import { createIncome } from '@/api/financial/incomes' +import { createExpense } from '@/api/financial/expenses' +import { createAsset } from '@/api/financial/assets' +import { createLiability } from '@/api/financial/liabilities' +import { createCPFAccount } from '@/api/financial/cpf' +import { settingsApi } from '@/api/financial/settings' +import { QUERY_KEYS } from '@/lib/queryKeys' +import type { OnboardingFormData } from '../types' +import type { FieldErrors } from 'react-hook-form' + +/** Recursively extract the first human-readable error message from nested FieldErrors. */ +function extractFirstErrorMessage(errors: FieldErrors): string | null { + for (const value of Object.values(errors)) { + if (!value) continue + if (typeof value.message === 'string' && value.message) return value.message + // Nested (e.g. array fields like persons.0.dateOfBirth) + if (typeof value === 'object') { + const nested = extractFirstErrorMessage(value as FieldErrors) + if (nested) return nested + } + } + return null +} + +/** + * Maps tempId → serverId for persons created in Step 1. + * Used by Steps 2 and 4 to resolve person foreign keys. + */ +type PersonIdMap = Map + +export function useOnboardingSubmit(form: UseFormReturn) { + const [isSubmitting, setIsSubmitting] = useState(false) + const [submissionError, setSubmissionError] = useState(null) + const queryClient = useQueryClient() + + // Persistent map across steps: tempId → serverId + const personIdMapRef = useRef(new Map()) + + // ─── Step 1: Create persons + update planning horizon ───────────────────── + + const submitStep1 = useCallback(async (): Promise => { + const { persons, projectionYears } = form.getValues() + + // Validate at least one person with a name + const validPersons = persons.filter(p => p.name.trim()) + if (validPersons.length === 0) { + setSubmissionError('Please add at least one person with a name.') + return false + } + + for (const person of validPersons) { + // Skip if already saved + if (person.serverId) { + personIdMapRef.current.set(person.tempId, person.serverId) + continue + } + + const created = await personsApi.createPerson({ + name: person.name.trim(), + dateOfBirth: person.dateOfBirth, + gender: person.gender, + residencyStatus: person.residencyStatus, + prGrantDate: person.residencyStatus === 'pr' ? (person.prGrantDate ?? undefined) : undefined, + displayColor: person.displayColor, + relationship: (person.relationship?.toLowerCase() ?? 'self') as 'self' | 'spouse' | 'child' | 'parent' | 'sibling' | 'other', + }) + + // Store mapping and update form + personIdMapRef.current.set(person.tempId, created.id) + const personIndex = persons.findIndex(p => p.tempId === person.tempId) + if (personIndex >= 0) { + form.setValue(`persons.${personIndex}.serverId`, created.id) + } + } + + // Compute terminal age from oldest person's current age + projection years + const oldestPersonAge = validPersons.reduce((maxAge, person) => { + const birthDate = new Date(person.dateOfBirth) + const today = new Date() + const age = today.getFullYear() - birthDate.getFullYear() + return Math.max(maxAge, age) + }, 0) + const terminalAge = oldestPersonAge + projectionYears + + // Update terminal age setting + try { + const currentSettings = await settingsApi.getUserSettings() + await settingsApi.updateUserSettings({ + ...currentSettings, + terminalAge, + }) + } catch { + // Non-critical — don't block step progression + } + + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.financial.persons }) + return true + }, [form, queryClient]) + + // ─── Step 2: Create incomes + expenses ──────────────────────────────────── + + const submitStep2 = useCallback(async (): Promise => { + const { incomes, expenses } = form.getValues() + + // Create incomes + for (let i = 0; i < incomes.length; i++) { + const income = incomes[i] + if (income.serverId || income.amount <= 0) continue + + const personServerId = personIdMapRef.current.get(income.personTempId) + + const created = await createIncome({ + name: income.name.trim() || 'Salary', + personId: personServerId ?? null, + amount: income.amount, + frequency: income.frequency, + startDate: new Date().toISOString(), + category: income.category, + growthRate: income.growthRate, + cpfWageType: income.cpfWageType, + }) + + form.setValue(`incomes.${i}.serverId`, created.id) + } + + // Create expenses + for (let i = 0; i < expenses.length; i++) { + const expense = expenses[i] + if (expense.serverId || expense.amount <= 0) continue + + const created = await createExpense({ + name: expense.name.trim() || 'Expense', + amount: expense.amount, + frequency: expense.frequency, + category: expense.category, + growthRate: expense.growthRate, + }) + + form.setValue(`expenses.${i}.serverId`, created.id) + } + + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.financial.incomes }) + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.financial.expenses }) + return true + }, [form, queryClient]) + + // ─── Step 3: Create assets + liabilities ────────────────────────────────── + + const submitStep3 = useCallback(async (): Promise => { + const { assets, liabilities } = form.getValues() + + for (let i = 0; i < assets.length; i++) { + const asset = assets[i] + if (asset.serverId || asset.currentValue <= 0) continue + + const created = await createAsset({ + name: asset.name.trim() || 'Asset', + category: asset.category, + currentValue: asset.currentValue, + annualGrowthRate: asset.growthRate, + }) + + form.setValue(`assets.${i}.serverId`, created.id) + } + + for (let i = 0; i < liabilities.length; i++) { + const liability = liabilities[i] + if (liability.serverId || liability.currentBalance <= 0) continue + + const created = await createLiability({ + name: liability.name.trim() || 'Liability', + category: liability.category, + currentBalance: liability.currentBalance, + interestRateApr: liability.interestRateApr, + minimumPayment: liability.minimumPayment, + }) + + form.setValue(`liabilities.${i}.serverId`, created.id) + } + + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.financial.assets }) + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.financial.liabilities }) + // cash_savings assets are routed to cash accounts table on the backend + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.financial.cashAccounts }) + return true + }, [form, queryClient]) + + // ─── Step 4: Create CPF accounts ────────────────────────────────────────── + // TODO(human): Implement the CPF account creation logic + const submitStep4 = useCallback(async (): Promise => { + const { cpfAccounts } = form.getValues() + + for (let i = 0; i < cpfAccounts.length; i++) { + const cpf = cpfAccounts[i] + if (cpf.serverId) continue + + const personServerId = personIdMapRef.current.get(cpf.personTempId) + if (!personServerId) continue // Person wasn't created (Step 1 was skipped) + + // Only create if at least one balance is > 0 + const hasBalance = cpf.oaBalance > 0 || cpf.saBalance > 0 || cpf.maBalance > 0 || cpf.raBalance > 0 + if (!hasBalance) continue + + const created = await createCPFAccount({ + personId: personServerId, + oaBalance: cpf.oaBalance, + saBalance: cpf.saBalance, + maBalance: cpf.maBalance, + raBalance: cpf.raBalance, + }) + + form.setValue(`cpfAccounts.${i}.serverId`, created.id) + } + + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.financial.cpf }) + return true + }, [form, queryClient]) + + // ─── Step dispatcher ────────────────────────────────────────────────────── + + const stepSubmitters = [submitStep1, submitStep2, submitStep3, submitStep4] + + // Fields to validate per step (triggers formState.errors for red borders) + const stepFieldNames: Record = { + 0: ['persons', 'projectionYears'], + 1: ['incomes', 'expenses'], + 2: ['assets', 'liabilities'], + 3: ['cpfAccounts'], + } + + const submitStep = useCallback(async (stepIndex: number): Promise => { + setIsSubmitting(true) + setSubmissionError(null) + + // Trigger field-level validation so formState.errors populates (red borders) + const fieldsToValidate = stepFieldNames[stepIndex] + if (fieldsToValidate) { + const isValid = await form.trigger(fieldsToValidate as any) + if (!isValid) { + // Surface the first validation error so the user knows what's missing + const errors = form.formState.errors + const firstErrorMessage = extractFirstErrorMessage(errors) + if (firstErrorMessage) { + setSubmissionError(firstErrorMessage) + } + setIsSubmitting(false) + return false + } + } + + try { + const submitter = stepSubmitters[stepIndex] + if (!submitter) return true + return await submitter() + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to save. Please try again.' + setSubmissionError(message) + return false + } finally { + setIsSubmitting(false) + } + }, [stepSubmitters, form]) + + return { submitStep, isSubmitting, submissionError } +} diff --git a/frontend/src/components/modals/OnboardingWizardModal/types.ts b/frontend/src/components/modals/OnboardingWizardModal/types.ts new file mode 100644 index 000000000..c673df989 --- /dev/null +++ b/frontend/src/components/modals/OnboardingWizardModal/types.ts @@ -0,0 +1,191 @@ +import { z } from 'zod' +import type { Gender } from '@/types/person' +import type { ResidencyStatus } from '@/types/cpf' + +// ─── Step status tracking ───────────────────────────────────────────────────── + +export type StepStatus = 'pending' | 'active' | 'completed' | 'skipped' + +export const ONBOARDING_STEPS = [ + { key: 'personal', label: 'Personal Info' }, + { key: 'income', label: 'Income & Expenses' }, + { key: 'assets', label: 'Assets & Liabilities' }, + { key: 'cpf', label: 'CPF Accounts' }, +] as const + +export type StepKey = (typeof ONBOARDING_STEPS)[number]['key'] + +// ─── Person form entry ──────────────────────────────────────────────────────── + +export const onboardingPersonSchema = z.object({ + tempId: z.string(), // Client-side ID for tracking + serverId: z.string().nullable(), // API-assigned ID after creation + name: z.string().min(1, 'Name is required'), + dateOfBirth: z.string().min(1, 'Date of birth is required'), + gender: z.enum(['male', 'female']) as z.ZodType, + residencyStatus: z.enum(['citizen', 'pr']) as z.ZodType, + prGrantDate: z.string().nullable(), + relationship: z.string().min(1, 'Relationship is required'), + customRelationship: z.string(), + retirementAge: z.number().min(50).max(100), + displayColor: z.string(), +}) + +export type OnboardingPerson = z.infer + +// ─── Income form entry ──────────────────────────────────────────────────────── + +export const onboardingIncomeSchema = z.object({ + tempId: z.string(), + serverId: z.string().nullable(), + personTempId: z.string(), // Links to person.tempId + name: z.string().min(1, 'Name is required'), + amount: z.number().min(0, 'Amount must be positive'), + frequency: z.enum(['weekly', 'biweekly', 'monthly', 'quarterly', 'annual']), + category: z.enum(['salary', 'bonus', 'rental', 'freelance', 'dividend', 'other']), + cpfWageType: z.enum(['ow', 'aw']).nullable(), + growthRate: z.number(), +}) + +export type OnboardingIncome = z.infer + +// ─── Expense form entry ─────────────────────────────────────────────────────── + +export const onboardingExpenseSchema = z.object({ + tempId: z.string(), + serverId: z.string().nullable(), + name: z.string().min(1, 'Name is required'), + amount: z.number().min(0, 'Amount must be positive'), + frequency: z.enum(['weekly', 'biweekly', 'monthly', 'quarterly', 'annual']), + category: z.enum(['housing', 'transport', 'food', 'utilities', 'insurance', 'living', 'other']), + growthRate: z.number(), +}) + +export type OnboardingExpense = z.infer + +// ─── Asset form entry ───────────────────────────────────────────────────────── + +export const onboardingAssetSchema = z.object({ + tempId: z.string(), + serverId: z.string().nullable(), + name: z.string().min(1, 'Name is required'), + category: z.enum(['cash_savings', 'stocks_etfs', 'bonds', 'property', 'vehicle', 'other']), + currentValue: z.number().min(0), + growthRate: z.number(), +}) + +export type OnboardingAsset = z.infer + +// ─── Liability form entry ───────────────────────────────────────────────────── + +export const onboardingLiabilitySchema = z.object({ + tempId: z.string(), + serverId: z.string().nullable(), + name: z.string().min(1, 'Name is required'), + category: z.enum(['mortgage', 'car_loan', 'student_loan', 'credit_card', 'personal_loan', 'other']), + currentBalance: z.number().min(0), + interestRateApr: z.number().min(0), + minimumPayment: z.number().min(0), +}) + +export type OnboardingLiability = z.infer + +// ─── CPF form entry ─────────────────────────────────────────────────────────── + +export const onboardingCpfSchema = z.object({ + tempId: z.string(), + serverId: z.string().nullable(), + personTempId: z.string(), // Links to person.tempId + oaBalance: z.number().min(0), + saBalance: z.number().min(0), + maBalance: z.number().min(0), + raBalance: z.number().min(0), +}) + +export type OnboardingCpf = z.infer + +// ─── Full onboarding form data ──────────────────────────────────────────────── + +export const onboardingFormSchema = z.object({ + // Step 1 + persons: z.array(onboardingPersonSchema).min(1, 'At least one person is required'), + projectionYears: z.number().min(1).max(80), + + // Step 2 + incomes: z.array(onboardingIncomeSchema), + expenses: z.array(onboardingExpenseSchema), + + // Step 3 + assets: z.array(onboardingAssetSchema), + liabilities: z.array(onboardingLiabilitySchema), + + // Step 4 + cpfAccounts: z.array(onboardingCpfSchema), +}) + +export type OnboardingFormData = z.infer + +// ─── Category label maps ────────────────────────────────────────────────────── + +export const INCOME_CATEGORY_LABELS: Record = { + salary: 'Salary', + bonus: 'Bonus', + rental: 'Rental Income', + freelance: 'Freelance', + dividend: 'Dividend', + other: 'Other', +} + +export const EXPENSE_CATEGORY_LABELS: Record = { + housing: 'Housing', + transport: 'Transport', + food: 'Food & Dining', + utilities: 'Utilities', + insurance: 'Insurance', + living: 'Living Expenses', + other: 'Other', +} + +export const ASSET_CATEGORY_LABELS: Record = { + cash_savings: 'Cash & Savings', + stocks_etfs: 'Stocks & ETFs', + bonds: 'Bonds', + property: 'Property', + vehicle: 'Vehicle', + other: 'Other', +} + +export const LIABILITY_CATEGORY_LABELS: Record = { + mortgage: 'Mortgage', + car_loan: 'Car Loan', + student_loan: 'Student Loan', + credit_card: 'Credit Card', + personal_loan: 'Personal Loan', + other: 'Other', +} + +export const RELATIONSHIP_LABELS: Record = { + self: 'Self', + spouse: 'Spouse', + child: 'Child', + parent: 'Parent', + sibling: 'Sibling', + other: 'Other', +} + +/** Dropdown options for non-self persons */ +export const RELATIONSHIP_OPTIONS = [ + { value: 'spouse', label: 'Spouse' }, + { value: 'child', label: 'Child' }, + { value: 'parent', label: 'Parent' }, + { value: 'sibling', label: 'Sibling' }, + { value: 'other', label: 'Other' }, +] as const + +export const FREQUENCY_LABELS: Record = { + weekly: 'Weekly', + biweekly: 'Fortnightly', + monthly: 'Monthly', + quarterly: 'Quarterly', + annual: 'Annually', +} diff --git a/frontend/src/components/modals/PersonsModal/PersonsModal.tsx b/frontend/src/components/modals/PersonsModal/PersonsModal.tsx index 9c1701bdc..c1b15f735 100644 --- a/frontend/src/components/modals/PersonsModal/PersonsModal.tsx +++ b/frontend/src/components/modals/PersonsModal/PersonsModal.tsx @@ -2,7 +2,9 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { Users, Plus, Trash2, Pencil, X, Check, Briefcase, Save, Calendar, Flag } from 'lucide-react' +import { clsx } from 'clsx' import { Modal } from '@/components/ui/Modal' +import { useColorScheme } from '@/stores' import { usePersonFilter } from '@/contexts/PersonFilterContext' import { useCreatePersonMutation, @@ -41,6 +43,8 @@ interface PendingChanges { } export function PersonsModal({ isOpen, onClose }: PersonsModalProps) { + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' const { persons, isLoading } = usePersonFilter() const [editingId, setEditingId] = useState(null) const [editName, setEditName] = useState('') @@ -308,16 +312,27 @@ export function PersonsModal({ isOpen, onClose }: PersonsModalProps) { {/* Header */} - +
Sign in to continue to Assetra
Sign in to continue to WealthProject
+
Analyze scenarios and explore coverage options
{session.user.name}
{session.user.email}
{`${absoluteYear} (Age ${displayAge})`}
{formatCurrency(netWorth)}
{formatCurrency(displaySavings)}
Create and compare property purchase scenarios.
Simulate balances, investments, and retirement.
Coming soon
Singapore tax calculations and scenario planning.
Analyze coverage gaps and plan your protection.
{reliefInfo.description}
Relief cap of {formatCurrency(PERSONAL_RELIEF_CAP)} reached
+ A control point already exists at this age +
+ {controlPoints.length === 0 + ? 'Add points to customize your coverage plan' + : `${controlPoints.length} point${controlPoints.length !== 1 ? 's' : ''} defined`} +
+ “{point.reason}” +
+ No control points yet +
+ Add control points to define custom coveragetargets at specific ages +
+ {getStatusText()} +
+ {gapCount} {gapCount === 1 ? 'gap' : 'gaps'} to address +
+ Add policies to see your score +
+ {isHospitalization ? ( + <>Target: {category.label}> + ) : ( + <> + Target: {formatCurrency(category.target)} + {category.current > 0 && ( + <> · Current: {formatCurrency(category.current)}> + )} + > + )} +
+ {isEmpty + ? 'No policies yet' + : coverageStatus.gapCount > 0 + ? `${coverageStatus.gapCount} ${coverageStatus.gapCount > 1 ? 'gaps' : 'gap'} to address` + : 'Fully covered'} +
+ Test your coverage against different scenarios +
Select a person to view their coverage journey
+ How your insurance needs change over time +
+ Source: Health Insured SG +
+ {option.title} +
+ {option.description} +
+ {currentInfo.description} +
+ No upcoming milestones detected +
+ {milestone.description} +
Add an insurance policy to your portfolio
+ Add an insurance policy to your portfolio +
+ Step 1 of 3 +
+ Select a person to calculate their coverage needs +
+ No income found for {selectedPerson?.name}. Add income in Financial Data first. +
+ Coverage targets are calculated as multiples of income (e.g., 10× for life insurance). +
+ {education.title} +
+ {config.description} +
+ Your situation +
+ Coverage target +
+ {referenceMode === 'expenses' + ? formatExpenseMultiplier(targetAmount as number, annualExpenses) + : `≈ ${multiplier}× your annual income`} +
+ {reasoning} +
+ {title} +
+ {description} +
+ {helpText} +
+ Private vs Public +
+ MOH Rules from April 2026 +
+ New IP riders can no longer fully cover deductibles ($1,500–$3,500 minimum out-of-pocket). + Existing policies bought before Nov 2025 are unaffected. +
+ {subStep === 0 + ? 'Where would you prefer to be treated?' + : hospitalPref === 'private' + ? 'Which room type do you prefer?' + : 'Which ward class suits you best?'} +
+ From Apr 2026: Minimum $3,500 deductible + 5% co-pay (capped at $6K/year) +
+ MediShield Life covers Class B2/C. Class A/B1 needs an Integrated Shield Plan (ISP). +
+ {subStep === 0 + ? 'Who depends on your income?' + : 'What financial obligations need to be covered?'} +
+ {person.name} +
+ Age {person.age} + {person.relationship && person.relationship !== 'self' + ? ` · ${person.relationship.charAt(0).toUpperCase() + person.relationship.slice(1)}` + : person.age < 18 ? ' · Child' : person.age >= 65 ? ' · Elderly' : ''} +
+ No other persons added. Add family members in the main app to select them here. +
+ {answers.lifeTpd.dependentCount} dependent{answers.lifeTpd.dependentCount !== 1 ? 's' : ''} selected + {answers.lifeTpd.youngestDependentAge !== null && ( + <> · Youngest age {answers.lifeTpd.youngestDependentAge} · {answers.lifeTpd.yearsUntilIndependent} years until independent> + )} +
+ {answers.lifeTpd.spousePersonId + ? spouseIncome > 0 + ? `Annual income: ${formatCurrency(spouseIncome)} — reduces coverage needed` + : 'No income found for this person' + : 'Select if spouse/partner has their own income — this reduces coverage needed'} +
+ No dependents? You may only need minimal coverage for final expenses + (funeral costs, outstanding debts). Consider if this changes in the future. +
+ From your financial data +
+ No financial data found. Add liabilities and assets in the main app for automatic calculation. +
+ = {formatCurrency(incomeReplacement)} (income replacement) + + {formatCurrency(autoPopulate.computed.totalMortgage + autoPopulate.computed.totalOtherDebts)} (debts) + + {formatCurrency(sanitizedObligations)} (obligations) - + {formatCurrency(autoPopulate.computed.totalAssets)} (assets) +
+ Can you survive financially during a long recovery period? +
+ = ({formatCurrency(monthlyExpenses)} × {answers.criticalIllness.expectedRecoveryMonths} months) - + ({answers.criticalIllness.emergencyFundMonths} months emergency fund) - + ({formatCurrency(answers.criticalIllness.existingCiCoverage)} existing) +
+ Key insight: Critical illness coverage + replaces income during recovery. The payout is a lump sum, not monthly payments, + so you have flexibility in how you use it. +
+ How risky is your lifestyle and occupation? +
+ Can your assets cover some risks, reducing the need for insurance? +
Willing to use assets to cover insurance gaps
This can reduce your recommended coverage amounts
Based on your answers, we recommend:
+ Remember: These are personalized recommendations based on your + specific situation. You can adjust them in the next step. +
+ Here's a summary of your coverage targets. You can always adjust these later. +
+ % of income you're willing to spend on insurance +
+ ≈ {formatCurrency(Math.round(targets.maxAnnualPremium / 12))}/month +
+ Coverage Settings +
+ Reset all guidelines to default values? +
+ Your Targets +
+ {formatExpenseMultiplier( + targets[type as keyof typeof targets] as number, + annualExpenses + )} +
+ Premium Budget +
+ {formatCurrency(targets.maxAnnualPremium)} +
+ per year max +
+ Updated{' '} + {new Date(guidelines.updatedAt).toLocaleDateString('en-SG', { + day: 'numeric', + month: 'short', + year: 'numeric', + })} +
+ Select a person to view their coverage journey +
Manage all your insurance policies in one place
- Add your insurance policies to track coverage and analyze gaps +
+ Add your insurance policies to track coverage and analyze gaps in your protection
- © {new Date().getFullYear()} Assetra. All rights reserved. -
- Built for Singaporeans, by Singaporeans + © {new Date().getFullYear()} WealthProject. All rights reserved.
- What takes hours in spreadsheets, + Tired of complicated spreadsheet formulas?
- Assetra does in seconds. + Achieve clarity without the complexity with WealthProject.
- Start free, upgrade when you need more. No hidden fees. + Full access to everything during our trial period. No credit card required.
- {plan.description} -
+ {plan.description} +
{hint}
{error}
Side-by-side layouts require a minimum screen width of 1280px
+ Your progress won't be saved. You can always set up your financial plan later from the dashboard. +
{submissionError}
+ What you own — savings, investments, property, etc. +
No assets added yet. Add your savings or investments to track your net worth.
+ What you owe — mortgages, loans, credit cards, etc. +
No liabilities added yet. Track your debts for a complete financial picture.
+ CPF accounts are only applicable for Singapore Citizens and Permanent Residents. + None of your household members have Citizen or PR residency status. +
+ Enter your current CPF balances (check cpf.gov.sg) +
+ + RA is created at age 55. Leave as $0 if you're under 55. +
+ Salaries, bonuses, rental income, etc. +
No income sources yet. Add your salary or other income to get started.
+ Housing, transport, food, utilities, etc. +
No expenses added yet. Tracking spending helps build an accurate plan.
OW — Ordinary Wages
Regular monthly salary. Subject to the OW ceiling ($6,800/mo) for CPF contributions.
AW — Additional Wages
Bonuses, commissions, etc. Subject to a separate annual AW ceiling.
+ Tell us about yourself and your household +
+ No data was added during setup. +
+ You can fine-tune your financial data anytime from the dashboard. + Add scenario events to test “what-if” situations, explore CPF projections, or set up insurance coverage analysis. +