diff --git a/api/models/projection.ts b/api/models/projection.ts index e9b4195..9e642cf 100644 --- a/api/models/projection.ts +++ b/api/models/projection.ts @@ -10,4 +10,5 @@ export default interface Projection { interestApplied: number; totalPaid: number; totalInterestApplied: number; + threshold: number; } diff --git a/components/layout.tsx b/components/layout.tsx index 3987544..61dab8b 100644 --- a/components/layout.tsx +++ b/components/layout.tsx @@ -1,6 +1,6 @@ import { ReactNode } from "react"; -import Footer from "./ui/organisms/footer"; -import Header from "./ui/organisms/header"; +import Footer from "./ui/organisms/Footer"; +import Header from "./ui/organisms/Header"; interface LayoutProps { children?: ReactNode | undefined; @@ -11,11 +11,7 @@ function Layout({ children, className }: LayoutProps) { return (
-
-
- {children} -
-
+
{children}
); diff --git a/components/ui/atoms/button.tsx b/components/ui/atoms/Button.tsx similarity index 100% rename from components/ui/atoms/button.tsx rename to components/ui/atoms/Button.tsx diff --git a/components/ui/atoms/checkbox.tsx b/components/ui/atoms/Checkbox.tsx similarity index 100% rename from components/ui/atoms/checkbox.tsx rename to components/ui/atoms/Checkbox.tsx diff --git a/components/ui/atoms/Container.tsx b/components/ui/atoms/Container.tsx new file mode 100644 index 0000000..fb07390 --- /dev/null +++ b/components/ui/atoms/Container.tsx @@ -0,0 +1,19 @@ +import classNames from "classnames"; + +export type ContainerProps = { + children?: React.ReactNode; + className?: string; +}; + +export const Container = ({ className, children }: ContainerProps) => { + return ( +
+ {children} +
+ ); +}; diff --git a/components/ui/atoms/date-picker.tsx b/components/ui/atoms/DatePicker.tsx similarity index 97% rename from components/ui/atoms/date-picker.tsx rename to components/ui/atoms/DatePicker.tsx index b24efc2..9fe9034 100644 --- a/components/ui/atoms/date-picker.tsx +++ b/components/ui/atoms/DatePicker.tsx @@ -1,5 +1,5 @@ import { forwardRef } from "react"; -import Tooltip from "./tooltip"; +import Tooltip from "./Tooltip"; import ReactDatePicker from "react-datepicker"; import "react-datepicker/dist/react-datepicker.css"; import { ReactDatePickerProps } from "react-datepicker"; @@ -44,7 +44,7 @@ const DatePicker = forwardRef( )} ); - }, + } ); export default DatePicker; diff --git a/components/ui/atoms/highlight.tsx b/components/ui/atoms/Highlight.tsx similarity index 65% rename from components/ui/atoms/highlight.tsx rename to components/ui/atoms/Highlight.tsx index ece786f..0f219d4 100644 --- a/components/ui/atoms/highlight.tsx +++ b/components/ui/atoms/Highlight.tsx @@ -6,7 +6,7 @@ const Highlight = (props: HighlightProps) => { const { children } = props; return ( - + {children} ); diff --git a/components/ui/atoms/input.tsx b/components/ui/atoms/Input.tsx similarity index 96% rename from components/ui/atoms/input.tsx rename to components/ui/atoms/Input.tsx index 78fee9a..fdacb21 100644 --- a/components/ui/atoms/input.tsx +++ b/components/ui/atoms/Input.tsx @@ -1,5 +1,5 @@ import { forwardRef } from "react"; -import Tooltip from "./tooltip"; +import Tooltip from "./Tooltip"; interface InputProps extends React.InputHTMLAttributes { id: string; @@ -14,7 +14,7 @@ interface InputProps extends React.InputHTMLAttributes { const Input = forwardRef(function InputGroup( props: InputProps, - ref, + ref ) { const { id, diff --git a/components/ui/atoms/input-group.tsx b/components/ui/atoms/InputGroup.tsx similarity index 97% rename from components/ui/atoms/input-group.tsx rename to components/ui/atoms/InputGroup.tsx index e757190..7860b0e 100644 --- a/components/ui/atoms/input-group.tsx +++ b/components/ui/atoms/InputGroup.tsx @@ -1,5 +1,5 @@ import { forwardRef } from "react"; -import Tooltip from "./tooltip"; +import Tooltip from "./Tooltip"; interface InputProps extends React.InputHTMLAttributes { id: string; @@ -15,7 +15,7 @@ interface InputProps extends React.InputHTMLAttributes { const InputGroup = forwardRef(function InputGroup( props: InputProps, - ref, + ref ) { const { id, diff --git a/components/ui/atoms/select.tsx b/components/ui/atoms/Select.tsx similarity index 100% rename from components/ui/atoms/select.tsx rename to components/ui/atoms/Select.tsx diff --git a/components/ui/atoms/tooltip.tsx b/components/ui/atoms/Tooltip.tsx similarity index 100% rename from components/ui/atoms/tooltip.tsx rename to components/ui/atoms/Tooltip.tsx diff --git a/components/ui/molecules/balanceGraph.tsx b/components/ui/molecules/BalanceGraph.tsx similarity index 92% rename from components/ui/molecules/balanceGraph.tsx rename to components/ui/molecules/BalanceGraph.tsx index f154b49..5c11d2f 100644 --- a/components/ui/molecules/balanceGraph.tsx +++ b/components/ui/molecules/BalanceGraph.tsx @@ -20,7 +20,7 @@ import { getLabelsForGroupedDataCallback, groupDataEveryNthPeriod, } from "./graphUtils"; -import currencyFormatter from "../../../utils/currencyFormatter"; +import { currencyFormatter } from "../../../utils/currencyFormatter"; import { DateTime } from "luxon"; ChartJS.register( @@ -30,7 +30,7 @@ ChartJS.register( LineElement, Title, Tooltip, - Legend, + Legend ); interface BalanceGraphProps { @@ -49,11 +49,11 @@ const BalanceGraph = (props: BalanceGraphProps) => { this: Scale, tickValue: string | number, index: number, - ticks: Tick[], + ticks: Tick[] ): string { return getLabelsForGroupedDataCallback( props.results.results, - this.getLabelForValue(index), + this.getLabelForValue(index) ); }, autoSkip: false, @@ -77,7 +77,7 @@ const BalanceGraph = (props: BalanceGraphProps) => { } if (context.parsed.y !== null) { - label += currencyFormatter().format(context.parsed.y); + label += currencyFormatter.format(context.parsed.y); } return label; @@ -109,7 +109,7 @@ const BalanceGraph = (props: BalanceGraphProps) => { const dataSetsPerLoanType = props.loanTypes.map((loanType, index) => ({ label: LoanTypeToDescription(loanType), data: groupedData.data.map( - (x) => x.projections.find((p) => p.loanType == loanType)?.debtRemaining, + (x) => x.projections.find((p) => p.loanType == loanType)?.debtRemaining ), borderColor: colors[index], backgroundColor: backgroundColors[index], diff --git a/components/ui/molecules/graphHeader.tsx b/components/ui/molecules/GraphHeader.tsx similarity index 100% rename from components/ui/molecules/graphHeader.tsx rename to components/ui/molecules/GraphHeader.tsx diff --git a/components/ui/molecules/loan-repayment-narrative.tsx b/components/ui/molecules/LoanRepaymentNarrative.tsx similarity index 89% rename from components/ui/molecules/loan-repayment-narrative.tsx rename to components/ui/molecules/LoanRepaymentNarrative.tsx index c8f28bf..84a6641 100644 --- a/components/ui/molecules/loan-repayment-narrative.tsx +++ b/components/ui/molecules/LoanRepaymentNarrative.tsx @@ -3,8 +3,8 @@ import React from "react"; import { DateTime } from "luxon"; import LoanType from "../../../models/loanType"; import { Results } from "../../../api/models/results"; -import Highlight from "../atoms/highlight"; -import currencyFormatter from "../../../utils/currencyFormatter"; +import Highlight from "../atoms/Highlight"; +import { currencyFormatter } from "../../../utils/currencyFormatter"; import { RepaymentStatusToDescription } from "../../../models/repaymentStatus"; interface LoanRepaymentNarrativeProps { @@ -48,7 +48,7 @@ const LoanRepaymentNarrative: NextPage = ({ const explainer = ( <> - Will be{" "} + The loan will be{" "} {RepaymentStatusToDescription( periodCompleteProjection.repaymentStatus ).toLowerCase()}{" "} @@ -58,7 +58,7 @@ const LoanRepaymentNarrative: NextPage = ({ return ( <> -

+

{paidOffDiff().years > 0 && paidOffDiff().months > 0 && ( <> {explainer} @@ -83,14 +83,14 @@ const LoanRepaymentNarrative: NextPage = ({ {periodComplete.periodDate.toFormat("LLLL yyyy")} .

-

+

There is{" "} - {currencyFormatter().format(periodCompleteProjection.totalPaid)} + {currencyFormatter.format(periodCompleteProjection.totalPaid)} {" "} remaining to pay, with{" "} - {currencyFormatter().format( + {currencyFormatter.format( periodCompleteProjection.totalInterestApplied )} {" "} diff --git a/components/ui/molecules/MonthLoanTable.tsx b/components/ui/molecules/MonthLoanTable.tsx new file mode 100644 index 0000000..e66e6e5 --- /dev/null +++ b/components/ui/molecules/MonthLoanTable.tsx @@ -0,0 +1,352 @@ +import type { NextPage } from "next"; +import React, { useCallback } from "react"; +import LoanType from "../../../models/loanType"; +import { Results } from "../../../api/models/results"; +import { + currencyFormatter, + currencyFormatterNoFraction, +} from "../../../utils/currencyFormatter"; +import Result from "../../../api/models/result"; +import classNames from "classnames"; +import { + ChevronDoubleLeft, + ChevronDoubleRight, + ChevronLeft, + ChevronRight, +} from "react-bootstrap-icons"; + +interface MonthLoanTableProps { + results: Results; + loanType: LoanType; +} + +export const MonthLoanTable: NextPage = ({ + results, + loanType, +}) => { + const periodCompleteIndex = results.results.findIndex((r) => + r.projections.find( + (p) => + p.loanType == loanType && + (p.repaymentStatus == "WrittenOff" || p.repaymentStatus == "PaidOff") + ) + )!; + + const completedResults = results.results.slice(0, periodCompleteIndex); + const paging = usePager(completedResults); + + return ( +

+
+ + + + + + + + + + + + + + {paging.pageResults.map((result, index) => { + const projection = result.projections.find( + (x) => x.loanType == loanType + )!; + const prevResult = results.results.find( + (x) => x.period == result.period - 1 + ); + const prevPrevResult = results.results.find( + (x) => x.period == result.period - 2 + ); + const prevProjection = prevResult?.projections.find( + (x) => x.loanType == loanType + )!; + const prevPrevProjection = prevPrevResult?.projections.find( + (x) => x.loanType == loanType + )!; + + const salaryChange = + !prevResult || prevResult.salary == result.salary + ? null + : prevResult.salary < result.salary + ? "up" + : "down"; + const thresholdChange = + !prevProjection || + prevProjection.threshold == projection.threshold + ? null + : prevProjection.threshold < projection.threshold + ? "up" + : "down"; + + const debtDecreasedFirstTime = + prevPrevProjection?.debtRemaining < + prevProjection?.debtRemaining && + prevProjection?.debtRemaining > projection.debtRemaining; + + const increasedFirstTime = + prevPrevProjection?.debtRemaining > + prevProjection?.debtRemaining && + prevProjection?.debtRemaining < projection.debtRemaining; + + return ( + + + + + + + + + + ); + })} + +
+ Month + + Paid + + Interest added + + Debt remaining + + Interest rate + + Repayment threshold + + Income +
+ {result.periodDate.toFormat("LLLL yyyy")} + + {currencyFormatter.format(projection.paid)} + + {currencyFormatter.format(projection.interestApplied)} + + {currencyFormatter.format(projection.debtRemaining)} + {debtDecreasedFirstTime && ( + + ↓ + + )} + {increasedFirstTime && ( + + ↑ + + )} + + {projection.interestRate * 100}% + + {currencyFormatterNoFraction.format(projection.threshold)} + {thresholdChange && ( + + {thresholdChange == "up" ? "↑" : "↓"} + + )} + + {currencyFormatter.format(result.salary)} + {salaryChange && ( + + {salaryChange == "up" ? "↑" : "↓"} + + )} +
+
+
+ +
+
+ ); +}; + +const usePager = (results: Result[]) => { + const [currentPage, setCurrentPage] = React.useState(1); + const [itemsPerPage, setItemsPerPage] = React.useState(10); + + const updateItemsPerPage = useCallback( + (itemsPerPage: number) => { + setItemsPerPage(itemsPerPage); + const newLastPage = Math.ceil(results.length / itemsPerPage); + + if (currentPage > newLastPage) { + setCurrentPage(newLastPage); + } + }, + [currentPage, results.length] + ); + + const pageResults = results.slice( + (currentPage - 1) * itemsPerPage, + (currentPage - 1) * itemsPerPage + itemsPerPage + ); + + return { + pageResults, + currentPage, + itemsPerPage, + setItemsPerPage: updateItemsPerPage, + setCurrentPage, + }; +}; + +type PaginationProps = { + total: number; + currentPage: number; + setCurrentPage: (page: number) => void; + setItemsPerPage: (itemsPerPage: number) => void; + itemsPerPage: number; + itemsPerPageOptions?: number[]; +}; + +const Pagination = ({ + total, + currentPage, + setCurrentPage, + itemsPerPage, + setItemsPerPage, + itemsPerPageOptions = [10, 20, 30, 40, 50], +}: PaginationProps) => { + const pageCount = Math.ceil(total / itemsPerPage); + const canGoBack = currentPage > 1; + const canGoForward = currentPage < pageCount; + + let pagesToDistribute = 4; + const pages = [currentPage]; + + let i = 1; + while (pagesToDistribute > 0) { + let currentPagesToDistribute = pagesToDistribute; + if (currentPage - i > 0) { + pages.push(currentPage - i); + pagesToDistribute--; + } + + if (currentPage + i <= pageCount) { + pages.push(currentPage + i); + pagesToDistribute--; + } + + if (pagesToDistribute == currentPagesToDistribute) { + break; + } + + i++; + } + + pages.sort((a, b) => a - b); + + return ( +
+
+
+ + +
+
+ + {currentPage * itemsPerPage - itemsPerPage + 1}- + {currentPage * itemsPerPage} of {total} + +
+
+
+ + + + + {pages.map((number) => ( + + ))} + + + + +
+
+ ); +}; diff --git a/components/ui/molecules/totalsGraph.tsx b/components/ui/molecules/TotalsGraph.tsx similarity index 92% rename from components/ui/molecules/totalsGraph.tsx rename to components/ui/molecules/TotalsGraph.tsx index 308bd61..892241c 100644 --- a/components/ui/molecules/totalsGraph.tsx +++ b/components/ui/molecules/TotalsGraph.tsx @@ -20,7 +20,7 @@ import { getLabelsForGroupedDataCallback, groupDataEveryNthPeriod, } from "./graphUtils"; -import currencyFormatter from "../../../utils/currencyFormatter"; +import { currencyFormatter } from "../../../utils/currencyFormatter"; import { DateTime } from "luxon"; ChartJS.register( @@ -30,7 +30,7 @@ ChartJS.register( LineElement, Title, Tooltip, - Legend, + Legend ); interface TotalsGraphProps { @@ -49,11 +49,11 @@ const TotalsGraph = (props: TotalsGraphProps) => { this: Scale, tickValue: string | number, index: number, - ticks: Tick[], + ticks: Tick[] ): string { return getLabelsForGroupedDataCallback( props.results.results, - this.getLabelForValue(index), + this.getLabelForValue(index) ); }, autoSkip: false, @@ -77,7 +77,7 @@ const TotalsGraph = (props: TotalsGraphProps) => { } if (context.parsed.y !== null) { - label += currencyFormatter().format(context.parsed.y); + label += currencyFormatter.format(context.parsed.y); } return label; diff --git a/components/ui/molecules/graphUtils.ts b/components/ui/molecules/graphUtils.ts index f389bea..d1341d4 100644 --- a/components/ui/molecules/graphUtils.ts +++ b/components/ui/molecules/graphUtils.ts @@ -35,7 +35,7 @@ export function groupDataEveryNthPeriod(results: Result[]) { export function getLabelsForGroupedDataCallback( results: Result[], - label: string, + label: string ) { var date = DateTime.fromISO(label); diff --git a/components/ui/organisms/assumptions-input.tsx b/components/ui/organisms/AssumptionsInput.tsx similarity index 97% rename from components/ui/organisms/assumptions-input.tsx rename to components/ui/organisms/AssumptionsInput.tsx index 68599c3..778e3bd 100644 --- a/components/ui/organisms/assumptions-input.tsx +++ b/components/ui/organisms/AssumptionsInput.tsx @@ -1,6 +1,6 @@ import { NextPage } from "next"; import { ChangeEvent } from "react"; -import InputGroup from "../atoms/input-group"; +import InputGroup from "../atoms/InputGroup"; interface AssumptionsInputProps { salaryGrowth: number; diff --git a/components/ui/organisms/details-input.tsx b/components/ui/organisms/DetailsInput.tsx similarity index 98% rename from components/ui/organisms/details-input.tsx rename to components/ui/organisms/DetailsInput.tsx index 510d332..9d483eb 100644 --- a/components/ui/organisms/details-input.tsx +++ b/components/ui/organisms/DetailsInput.tsx @@ -1,17 +1,17 @@ import { NextPage } from "next"; import { RefObject, useEffect, useState } from "react"; -import InputGroup from "../atoms/input-group"; import { Loan } from "../../../models/loan"; +import InputGroup from "../atoms/InputGroup"; import LoanType from "../../../models/loanType"; import { DateTime } from "luxon"; import { Details } from "../../../models/details"; import { Controller, useFieldArray, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import Input from "../atoms/input"; -import Button from "../atoms/button"; +import Input from "../atoms/Input"; +import Button from "../atoms/Button"; import { Trash } from "react-bootstrap-icons"; -import DatePicker from "../atoms/date-picker"; +import DatePicker from "../atoms/DatePicker"; import classNames from "classnames"; let nextMonth = () => { diff --git a/components/ui/organisms/footer.tsx b/components/ui/organisms/Footer.tsx similarity index 100% rename from components/ui/organisms/footer.tsx rename to components/ui/organisms/Footer.tsx diff --git a/components/ui/organisms/header.tsx b/components/ui/organisms/Header.tsx similarity index 98% rename from components/ui/organisms/header.tsx rename to components/ui/organisms/Header.tsx index ca65b36..13c6687 100644 --- a/components/ui/organisms/header.tsx +++ b/components/ui/organisms/Header.tsx @@ -43,7 +43,7 @@ const Header = () => { {isNavOpen && ( -
+
= ({ results, loanType }) => { + const [resultType, setResultType] = React.useState( + ResultType.Chart + ); + + return ( +
+

+ Your{" "} + + {LoanTypeToDescription(loanType)} + {" "} + results +

+
+ +
+ +
+ +