diff --git a/app/components/Dashboard/Cards/ResaleValue.tsx b/app/components/Dashboard/Cards/ResaleValue.tsx new file mode 100644 index 0000000..085dc99 --- /dev/null +++ b/app/components/Dashboard/Cards/ResaleValue.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import GraphCard from "../../ui/GraphCard"; +import ResaleValueWrapper from "../../graphs/ResaleValueWrapper"; +import { Drawer } from "../../ui/Drawer"; +import { Household } from "@/app/models/Household"; +import TenureSelector from "../../ui/TenureSelector"; + +const TENURES = ['landPurchase', 'landRent'] as const; +type Tenure = (typeof TENURES)[number]; + +interface DashboardProps { + data: Household; +} + +export const ResaleValue: React.FC = ({ data }) => { + const [selectedTenure, setSelectedTenure] = useState('landPurchase'); + + return ( + +
+
+ {TENURES.map(tenure => ( + setSelectedTenure(tenure)} + > + {`Fairhold ${tenure === 'landPurchase' ? 'Land Purchase' : 'Land Rent'}`} + + ))} +
+ + + + +
+
+ ); +}; diff --git a/app/components/graphs/ResaleValueLineChart.tsx b/app/components/graphs/ResaleValueLineChart.tsx new file mode 100644 index 0000000..8b33b38 --- /dev/null +++ b/app/components/graphs/ResaleValueLineChart.tsx @@ -0,0 +1,140 @@ +import React from "react"; +import { LineChart, Line, CartesianGrid, XAxis, YAxis, Label, TooltipProps } from "recharts"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, +} from "@/components/ui/chart"; +import { formatValue } from "@/app/lib/format"; + +type CustomTooltipProps = TooltipProps & { + payload?: Array<{ + name: keyof Omit; + value: number; + stroke: string; + }>; +}; + +const chartConfig = { + noMaintenance: { + label: "No maintenance", + color: "rgb(var(--fairhold-land-color-rgb))", + }, + lowMaintenance: { + label: "Low maintenance", + color: "rgb(var(--fairhold-land-color-rgb))", + }, + mediumMaintenance: { + label: "Medium maintenance", + color: "rgb(var(--fairhold-land-color-rgb))", + }, + highMaintenance: { + label: "High maintenance", + color: "rgb(var(--fairhold-land-color-rgb))", + }, +} satisfies ChartConfig; + +export interface DataPoint { + year: number; + noMaintenance: number; + lowMaintenance: number; + mediumMaintenance: number; + highMaintenance: number; +} + +interface ResaleValueLineChartProps { + data: DataPoint[]; + selectedMaintenance: "noMaintenance" | "lowMaintenance" | "mediumMaintenance" | "highMaintenance"; + maxY: number; +} + +const ResaleValueLineChart: React.FC = ({ + data, + selectedMaintenance, + maxY +}) => { + const renderLine = (dataKey: keyof Omit) => ( + + ); + const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { + if (!active || !payload || !payload.length) return null; + + return ( +
+

Year {label}

+ {payload.map((entry) => ( +
+ + + + {chartConfig[entry.name as keyof typeof chartConfig].label}: + {formatValue(entry.value ?? 0)} +
+ ))} +
+ ); + }; + + return ( + + + + + + + + + + + } /> + {renderLine("highMaintenance")} + {renderLine("mediumMaintenance")} + {renderLine("lowMaintenance")} + {renderLine("noMaintenance")} + + + + + ); +}; + +export default ResaleValueLineChart; \ No newline at end of file diff --git a/app/components/graphs/ResaleValueWrapper.tsx b/app/components/graphs/ResaleValueWrapper.tsx new file mode 100644 index 0000000..9842517 --- /dev/null +++ b/app/components/graphs/ResaleValueWrapper.tsx @@ -0,0 +1,78 @@ +"use client"; +import React from "react"; +import ErrorBoundary from "../ErrorBoundary"; +import { Household } from "@/app/models/Household"; +import ResaleValueLineChart from "./ResaleValueLineChart"; +import type { DataPoint } from "./ResaleValueLineChart" + +interface ResaleValueWrapperProps { + tenure: 'landPurchase' | 'landRent'; + household: Household; +} + +const ResaleValueWrapper: React.FC = ({ + tenure, + household +}) => { + // Since we want one line (user selected) to be solid, need to map the maintenance percentage to the line type + const getSelectedMaintenance = (maintenancePercentage: number): 'noMaintenance' | 'lowMaintenance' | 'mediumMaintenance' | 'highMaintenance' => { + switch (maintenancePercentage) { + case 0: + return 'noMaintenance'; + case 0.015: + return 'lowMaintenance'; + case 0.019: + return 'mediumMaintenance'; + case 0.025: + return 'highMaintenance'; + default: + return 'lowMaintenance'; + } + }; + + /** Needs either `landRent` or `landPurchase` to denote Fairhold tenure type; based on this arg, it will determine if land resale value is 0 or FHLP over time */ + const formatData = (household: Household) => { + const lifetime = household.lifetime.lifetimeData + const chartData: DataPoint[] = []; + + for (let i = 0; i < lifetime.length; i++ ) { + // Fairhold land rent cannot be sold for anything, assign as 0 + const landValue = tenure === 'landRent' ? 0 : lifetime[i].fairholdLandPurchaseResaleValue; + + chartData.push({ + year: i + 1, + noMaintenance: landValue + lifetime[i].depreciatedHouseResaleValueNoMaintenance, + lowMaintenance: landValue + lifetime[i].depreciatedHouseResaleValueLowMaintenance, + mediumMaintenance: landValue + lifetime[i].depreciatedHouseResaleValueMediumMaintenance, + highMaintenance: landValue + lifetime[i].depreciatedHouseResaleValueHighMaintenance + }) + + } + return chartData + } + + const formattedData = formatData(household); + const selectedMaintenance = getSelectedMaintenance(household.property.maintenancePercentage) + + // We want a constant y value across the graphs so we can compare resale values between them + const finalYear = household.lifetime.lifetimeData[household.lifetime.lifetimeData.length - 1] + const maxY = Math.ceil((1.1 * (finalYear.fairholdLandPurchaseResaleValue + finalYear.depreciatedHouseResaleValueHighMaintenance)) / 100000) * 100000 // Scale y axis by 1.1 (for a bit of visual headroom) and round to nearest hundred thousand to make things tidy + + if (!household) { + return
No household data available
; + } + + return ( + +
+ +
+
+ ); +}; + +export default ResaleValueWrapper \ No newline at end of file diff --git a/app/components/ui/Dashboard.tsx b/app/components/ui/Dashboard.tsx index 0c9784a..7678952 100644 --- a/app/components/ui/Dashboard.tsx +++ b/app/components/ui/Dashboard.tsx @@ -6,6 +6,7 @@ import { WhatWouldYouChoose } from "../Dashboard/Cards/WhatWouldYouChoose"; import { WhatDifference } from "../Dashboard/Cards/WhatDifference"; import { HowMuchFHCost } from "../Dashboard/Cards/HowMuchFHCost"; import { Carousel } from "./Carousel"; +import { ResaleValue } from "../Dashboard/Cards/ResaleValue"; interface DashboardProps { processedData: Household; @@ -40,7 +41,7 @@ const Dashboard: React.FC = ({ inputData, processedData }) => { in theory less than Freehold - + diff --git a/app/components/ui/TenureSelector.tsx b/app/components/ui/TenureSelector.tsx new file mode 100644 index 0000000..eaab5e9 --- /dev/null +++ b/app/components/ui/TenureSelector.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { cn } from "@/lib/utils"; + +interface TenureSelectorProps { + isSelected?: boolean; + onClick?: () => void; + className?: string; + children: React.ReactNode; +} + +const TenureSelector: React.FC = ({ + isSelected = false, + onClick, + className, + children, +}) => { + return ( + + ); +}; + +export default TenureSelector; \ No newline at end of file