Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 255 additions & 0 deletions frontend/src/app/vehicle-planner/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
'use client'

import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { X } from 'lucide-react'
import clsx from 'clsx'

import { VehiclePlannerTabs } from '@/components/vehicle-planner/VehiclePlannerTabs'
import { VehicleFinancingTab } from '@/components/vehicle-planner/tabs/VehicleFinancingTab'
import { CostBreakdownTab } from '@/components/vehicle-planner/tabs/CostBreakdownTab'
import { DepreciationTab } from '@/components/vehicle-planner/tabs/DepreciationTab'
import { TotalCostTab } from '@/components/vehicle-planner/tabs/TotalCostTab'
import { ScenariosTab } from '@/components/vehicle-planner/tabs/ScenariosTab'
import { useColorScheme } from '@/stores'
import {
useVehiclePlannerStore,
useVehiclePlannerActions,
useVehicleActiveTab,
useActiveVehicleScenario,
} from '@/stores/vehiclePlannerStore'

// ============================================================================
// THEME-AWARE DESIGN (reuses insurance planner theme palette)
// ============================================================================

const monetColors = {
bgCream: '#FAF8F5',
bgPaleBlue: '#F0F4F8',
bgWarmWhite: '#FFFEF9',
textPrimary: '#3D3D3D',
textSecondary: '#6B6B6B',
amber: '#D4A574',
amberLight: '#E8D4BC',
sunlightGoldLight: '#EDE6D8',
}

const darkColors = {
textPrimary: '#f1f5f9',
textSecondary: '#94a3b8',
primary: '#fbbf24',
accent: '#f59e0b',
}

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 (Dashboard takeover) ──────────────

export function VehiclePlannerView({ onClose }: { onClose?: () => void }) {
const activeTab = useVehicleActiveTab()
const activeScenario = useActiveVehicleScenario()
const scenarios = useVehiclePlannerStore((s) => s.scenarios)
const { setActiveTab, addScenario } = useVehiclePlannerActions()
const colorScheme = useColorScheme()
const isMonet = colorScheme === 'monet'
const colors = isMonet ? monetColors : darkColors

// Auto-create first scenario if none exist
useEffect(() => {
if (scenarios.length === 0) {
addScenario('My Vehicle')
}
}, [scenarios.length, addScenario])

return (
<div
className={clsx(
'flex h-full flex-col overflow-hidden relative transition-colors duration-300',
!isMonet && 'bg-[#0a0a0a]'
)}
style={isMonet ? {
background: `linear-gradient(135deg, ${monetColors.bgCream} 0%, ${monetColors.bgPaleBlue} 50%, ${monetColors.bgWarmWhite} 100%)`,
} : undefined}
>
{/* Canvas texture overlay */}
<div
className={clsx(
'absolute inset-0 pointer-events-none',
isMonet ? 'opacity-[0.03] mix-blend-multiply' : 'opacity-[0.02]'
)}
style={{ backgroundImage: canvasTexture }}
/>

{/* Ambient light effects */}
<div
className="absolute -top-32 -left-32 w-96 h-96 rounded-full pointer-events-none"
style={{
background: isMonet
? `radial-gradient(circle, ${monetColors.amberLight} 0%, transparent 70%)`
: `radial-gradient(circle, ${darkColors.primary}20 0%, transparent 70%)`,
filter: 'blur(60px)',
opacity: isMonet ? 0.6 : 0.4,
}}
/>

{/* Title + Tabs */}
<div className="shrink-0 relative z-10 px-8 pt-6">
<div className="flex items-center justify-between mb-5">
<h1
className="text-2xl font-semibold tracking-tight"
style={{
color: colors.textPrimary,
fontFamily: isMonet ? "'Cormorant Garamond', Georgia, serif" : 'inherit',
}}
>
Vehicle Planner
</h1>
<div className="flex items-center gap-3">
{onClose && (
<button
type="button"
onClick={onClose}
className="flex h-9 w-9 items-center justify-center rounded-lg transition-all duration-200 hover:scale-105"
style={{
background: isMonet ? 'rgba(255,255,255,0.6)' : 'rgba(255,255,255,0.05)',
color: colors.textSecondary,
}}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>

<VehiclePlannerTabs activeTab={activeTab} onTabChange={setActiveTab} />
</div>

{/* Main Content */}
<main className="flex-1 overflow-y-auto relative z-10">
{activeTab === 'vehicle' && <VehicleFinancingTab />}
{activeTab === 'costs' && <CostBreakdownTab />}
{activeTab === 'depreciation' && <DepreciationTab />}
{activeTab === 'tco' && <TotalCostTab />}
{activeTab === 'scenarios' && <ScenariosTab />}
{!activeScenario && scenarios.length === 0 && (
<div className="flex items-center justify-center h-full">
<p style={{ color: colors.textSecondary }}>Loading...</p>
</div>
)}
</main>
</div>
)
}

// ─── Standalone page ─────────────────────────────────

export default function VehiclePlannerPage() {
const router = useRouter()
const activeTab = useVehicleActiveTab()
const activeScenario = useActiveVehicleScenario()
const scenarios = useVehiclePlannerStore((s) => s.scenarios)
const { setActiveTab, addScenario } = useVehiclePlannerActions()
const colorScheme = useColorScheme()
const isMonet = colorScheme === 'monet'
const colors = isMonet ? monetColors : darkColors

const handleClose = () => {
router.push('/dashboard')
}

useEffect(() => {
if (scenarios.length === 0) {
addScenario('My Vehicle')
}
}, [scenarios.length, addScenario])

return (
<div
className={clsx(
'flex h-screen flex-col overflow-hidden relative transition-colors duration-300',
!isMonet && 'bg-[#050505]'
)}
style={isMonet ? {
background: `linear-gradient(135deg, ${monetColors.bgCream} 0%, ${monetColors.bgPaleBlue} 50%, ${monetColors.bgWarmWhite} 100%)`,
} : undefined}
>
{/* Canvas texture overlay */}
<div
className={clsx(
'absolute inset-0 pointer-events-none',
isMonet ? 'opacity-[0.03] mix-blend-multiply' : 'opacity-[0.02]'
)}
style={{ backgroundImage: canvasTexture }}
/>

{/* Ambient light effects */}
<div
className="absolute -top-40 -left-40 w-[500px] h-[500px] rounded-full pointer-events-none"
style={{
background: isMonet
? `radial-gradient(circle, ${monetColors.sunlightGoldLight} 0%, transparent 70%)`
: `radial-gradient(circle, ${darkColors.primary}15 0%, transparent 70%)`,
filter: 'blur(80px)',
opacity: isMonet ? 0.5 : 0.4,
}}
/>
<div
className="absolute top-1/3 -right-32 w-80 h-80 rounded-full pointer-events-none"
style={{
background: isMonet
? `radial-gradient(circle, ${monetColors.amberLight} 0%, transparent 70%)`
: `radial-gradient(circle, ${darkColors.accent}10 0%, transparent 70%)`,
filter: 'blur(60px)',
opacity: isMonet ? 0.3 : 0.3,
}}
/>

{/* Title + Tabs */}
<div className="shrink-0 relative z-10 mx-auto max-w-7xl px-8 pt-6">
<div className="flex items-center justify-between mb-5">
<h1
className="text-2xl font-semibold tracking-tight"
style={{
color: colors.textPrimary,
fontFamily: isMonet ? "'Cormorant Garamond', Georgia, serif" : 'inherit',
}}
>
Vehicle Planner
</h1>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleClose}
className="flex h-9 w-9 items-center justify-center rounded-lg transition-all duration-200 hover:scale-105"
style={{
background: isMonet ? 'rgba(255,255,255,0.6)' : 'rgba(255,255,255,0.05)',
color: colors.textSecondary,
}}
title="Return to Dashboard"
>
<X className="h-4 w-4" />
</button>
</div>
</div>

<VehiclePlannerTabs activeTab={activeTab} onTabChange={setActiveTab} />
</div>

{/* Main Content */}
<main className="flex-1 overflow-y-auto relative z-10">
<div className="mx-auto max-w-7xl">
{activeTab === 'vehicle' && <VehicleFinancingTab />}
{activeTab === 'costs' && <CostBreakdownTab />}
{activeTab === 'depreciation' && <DepreciationTab />}
{activeTab === 'tco' && <TotalCostTab />}
{activeTab === 'scenarios' && <ScenariosTab />}
{!activeScenario && scenarios.length === 0 && (
<div className="flex items-center justify-center h-64">
<p style={{ color: colors.textSecondary }}>Loading...</p>
</div>
)}
</div>
</main>
</div>
)
}
28 changes: 27 additions & 1 deletion frontend/src/components/dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ const InsurancePlannerView = dynamic(
{ ssr: false, loading: FeatureModuleLoading }
)

const VehiclePlannerView = dynamic(
() => import('@/app/vehicle-planner/page').then(mod => ({ default: mod.VehiclePlannerView })),
{ ssr: false, loading: FeatureModuleLoading }
)

import { usePersonsQuery } from '@/hooks/queries/usePersonsQuery'
import { useTimeline } from '@/hooks/useTimeline'
import { usePictureInPicture } from '@/hooks/usePictureInPicture'
Expand All @@ -61,6 +66,8 @@ export function Dashboard() {
closeCPFView,
showInsurancePlanner,
closeInsurancePlanner,
showVehiclePlanner,
closeVehiclePlanner,
showPropertyPlanner,
propertyScenarioToEdit,
openPropertyPlanner,
Expand All @@ -87,6 +94,8 @@ export function Dashboard() {
closeCPFView: s.closeCPFView,
showInsurancePlanner: s.showInsurancePlanner,
closeInsurancePlanner: s.closeInsurancePlanner,
showVehiclePlanner: s.showVehiclePlanner,
closeVehiclePlanner: s.closeVehiclePlanner,
showPropertyPlanner: s.showPropertyPlanner,
propertyScenarioToEdit: s.propertyScenarioToEdit,
openPropertyPlanner: s.openPropertyPlanner,
Expand Down Expand Up @@ -367,6 +376,23 @@ gap-6 p-6`}>
<InsurancePlannerView onClose={closeInsurancePlanner} />
</div>
</>
) : showVehiclePlanner ? (
/* Vehicle Planner View - shows header + vehicle planner */
<>
<div className="shrink-0">
<FinancialWorkspace headerOnly />
</div>
<div
className={clsx(
'flex flex-1 flex-col overflow-hidden min-h-0 rounded-2xl border transition-colors duration-300',
isMonet
? 'border-[var(--monet-lavender)]/20 bg-white/60 backdrop-blur-xl'
: 'border-white/[0.06] bg-[#0a0a0a]/80'
)}
>
<VehiclePlannerView onClose={closeVehiclePlanner} />
</div>
</>
) : isSideBySide ? (
/* Side-by-side layout: chart-left or chart-right */
<>
Expand Down Expand Up @@ -423,7 +449,7 @@ gap-6 p-6`}>
</div>

{/* Picture-in-Picture mini chart - disabled in side-by-side layouts */}
{showPiP && !showCPFView && !showInsurancePlanner && !isSideBySide && (
{showPiP && !showCPFView && !showInsurancePlanner && !showVehiclePlanner && !isSideBySide && (
<MiniChart
timelineYears={timeline.chartYears}
timelineMonths={timeline.chartMonths}
Expand Down
29 changes: 18 additions & 11 deletions frontend/src/components/dashboard/FinancialWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,14 @@ export function FinancialWorkspace({
openCPFView,
openPropertyPlanner,
openInsurancePlanner,
openVehiclePlanner,
openLayoutModal,
} = useFeatureModulesStore(
useShallow((s) => ({
openCPFView: s.openCPFView,
openPropertyPlanner: s.openPropertyPlanner,
openInsurancePlanner: s.openInsurancePlanner,
openVehiclePlanner: s.openVehiclePlanner,
openLayoutModal: s.openLayoutModal,
}))
)
Expand Down Expand Up @@ -301,18 +303,23 @@ export function FinancialWorkspace({
<p className={classes.menuText.secondary}>Analyze coverage gaps and plan your protection.</p>
</div>
</button>
{/* Coming Soon Modules */}
<div className={classes.menuItem.disabled}>
<div className="flex items-start w-full gap-3 px-4 py-3 text-left text-sm">
<span className={clsx(classes.iconBadge.base, classes.iconBadge.disabled)}>
<Car className="h-4 w-4" />
</span>
<div className="space-y-0.5">
<div className={clsx("font-medium", classes.menuText.disabled)}>Vehicle Purchase</div>
<p className={classes.menuText.disabledSecondary}>Coming soon</p>
</div>
{/* Vehicle Planner */}
<button
onClick={() => {
setIsModuleMenuOpen(false)
openVehiclePlanner()
}}
className={clsx(classes.menuItem.base, classes.menuItem.withBorder, classes.menuItem.hover)}
type="button"
>
<span className={clsx(classes.iconBadge.base, classes.iconBadge.amber)}>
<Car className="h-4 w-4" />
</span>
<div className="space-y-0.5">
<div className="font-medium">Vehicle Planner</div>
<p className={classes.menuText.secondary}>Calculate total cost of vehicle ownership in SG.</p>
</div>
</div>
</button>
<div className={classes.menuItem.disabled}>
<div className="flex items-start w-full gap-3 px-4 py-3 text-left text-sm">
<span className={clsx(classes.iconBadge.base, classes.iconBadge.disabled)}>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/insurance/tabs/JourneyTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -956,7 +956,7 @@ function DarkCoverageBreakdownCard({
className={cn(T.bodyText, 'w-24 text-right')}
style={{ color: perPersonGap > 0 ? DARK_PALETTE.red : DARK_PALETTE.green }}
>
{perPersonGap > 0 ? `(\u2212${formatCoverageAmount(perPersonGap)})` : 'OK'}
{perPersonGap > 0 ? `(${formatCoverageAmount(perPersonGap)})` : 'OK'}
</span>
</div>
)
Expand Down
Loading