diff --git a/app/components/CapitalCommitmentsTable/CapitalCommitmentsTable.test.tsx b/app/components/CapitalCommitmentsTable/CapitalCommitmentsTable.test.tsx new file mode 100644 index 0000000..01546cc --- /dev/null +++ b/app/components/CapitalCommitmentsTable/CapitalCommitmentsTable.test.tsx @@ -0,0 +1,72 @@ +import { + CapitalCommitment, + CapitalCommitmentType, + createCapitalCommitment, + createCapitalCommitmentType, +} from "~/gen"; +import { CapitalCommitmentsTable } from "./CapitalCommitmentsTable"; +import { render, screen } from "@testing-library/react"; + +describe("CapitalCommitmentsTable", () => { + let capitalCommitments: Array = []; + let capitalCommitmentTypes: Array = []; + beforeAll(() => { + capitalCommitments = Array.from(Array(1), () => + createCapitalCommitment({ + type: "CONS", + plannedDate: `${new Date("April 2024")}`, + totalValue: 1e6, + }), + ); + capitalCommitmentTypes = Array.from(Array(1), () => + createCapitalCommitmentType({ + code: "CONS", + description: "CONSTRUCTION", + }), + ); + }); + + it("should render the table header", () => { + render( + , + ); + + expect(screen.getByText(/Date/)).toBeVisible(); + expect(screen.getByText(/Description/)).toBeVisible(); + expect(screen.getByText(/Commitment/)).toBeVisible(); + }); + + it("should render the month and year of the commitment", () => { + render( + , + ); + expect(screen.getByText(/Apr 2024/)).toBeVisible(); + }); + + it("should render the description of the commitment type", () => { + render( + , + ); + expect(screen.getByText(/CONSTRUCTION/)).toBeVisible(); + }); + + it("should render the value of the commitment", () => { + render( + , + ); + + expect(screen.getByText(/\$1.00M/)).toBeVisible(); + }); +}); diff --git a/app/components/CapitalCommitmentsTable/CapitalCommitmentsTable.tsx b/app/components/CapitalCommitmentsTable/CapitalCommitmentsTable.tsx new file mode 100644 index 0000000..724383a --- /dev/null +++ b/app/components/CapitalCommitmentsTable/CapitalCommitmentsTable.tsx @@ -0,0 +1,75 @@ +import numbro from "numbro"; +import { format } from "date-fns"; +import { + TableContainer, + Table, + Thead, + Tr, + Th, + Tbody, + Td, +} from "@nycplanning/streetscape"; +import { CapitalCommitment, CapitalCommitmentType } from "~/gen"; + +export interface CapitalCommitmentsTableProps { + capitalCommitments: Array; + capitalCommitmentTypes: Array; +} +export function CapitalCommitmentsTable({ + capitalCommitments, + capitalCommitmentTypes, +}: CapitalCommitmentsTableProps) { + return ( + + + + + + + + + + + {capitalCommitments.map((commitment) => ( + + + + + + ))} + +
+ Date + + Description + + Commitment +
+ {format(commitment.plannedDate, "MMM yyyy")} + + {capitalCommitmentTypes.find( + (type) => type.code === commitment.type, + )?.description ?? commitment.type} + + {numbro(commitment.totalValue) + .format({ + average: true, + output: "currency", + mantissa: 2, + }) + .toUpperCase()} +
+
+ ); +} diff --git a/app/components/CapitalProjectDetailPanel/CapitalCommitmentsTimeline.test.tsx b/app/components/CapitalProjectDetailPanel/CapitalCommitmentsTimeline.test.tsx new file mode 100644 index 0000000..3000c89 --- /dev/null +++ b/app/components/CapitalProjectDetailPanel/CapitalCommitmentsTimeline.test.tsx @@ -0,0 +1,121 @@ +import { render, screen } from "@testing-library/react"; +import { CapitalCommitmentsTimeline } from "./CapitalCommitmentsTimeline"; +import { createCapitalCommitment } from "~/gen"; + +describe("CapitalCommitmentsTimeline", () => { + const currentYear = new Date().getFullYear(); + + it("should label past, current, and future commitments", () => { + const capitalCommitments = Array.from(Array(1), () => + createCapitalCommitment(), + ); + render( + , + ); + + expect(screen.getByText(/Past/)).toBeVisible(); + expect(screen.getByText(/Current/)).toBeVisible(); + expect(screen.getByText(/Future/)).toBeVisible(); + }); + + it("should sum commitments two in the past, one current, and none in the future", () => { + const capitalCommitments = Array.from(Array(3), (_, i) => + createCapitalCommitment({ + plannedDate: new Date(`Jan ${currentYear - i}`).toString(), + totalValue: 1e6, + }), + ); + render( + , + ); + + expect(screen.getByText(/\$2.00M/)).toBeVisible(); + expect(screen.getByText(/\$1.00M/)).toBeVisible(); + expect(screen.getByText(/\$0.00/)).toBeVisible(); + }); + + it("should sum commitments none in the past, one current, and three in the future", () => { + const capitalCommitments = Array.from(Array(4), (_, i) => + createCapitalCommitment({ + plannedDate: new Date(`Jan ${currentYear + i}`).toString(), + totalValue: 1e6, + }), + ); + render( + , + ); + + expect(screen.getByText(/\$0.00/)).toBeVisible(); + expect(screen.getByText(/\$1.00M/)).toBeVisible(); + expect(screen.getByText(/\$3.00M/)).toBeVisible(); + }); + + it("should show a range of past years", () => { + const capitalCommitments = Array.from(Array(4), (_, i) => + createCapitalCommitment({ + plannedDate: new Date(`Jan ${currentYear - i}`).toString(), + }), + ); + render( + , + ); + + expect( + screen.getByText(`${currentYear - 3} - ${currentYear - 1}`), + ).toBeVisible(); + }); + + it("should show a single past year", () => { + const capitalCommitments = Array.from(Array(2), (_, i) => + createCapitalCommitment({ + plannedDate: new Date(`Jan ${currentYear - i}`).toString(), + }), + ); + render( + , + ); + + expect(screen.getByText(`${currentYear - 1}`)).toBeVisible(); + }); + + it("should show the current year", () => { + const capitalCommitments = Array.from(Array(1), () => + createCapitalCommitment({ + plannedDate: new Date(`Jan ${currentYear}`).toString(), + }), + ); + render( + , + ); + + expect(screen.getByText(currentYear)).toBeVisible(); + }); + + it("should show a range of future years", () => { + const capitalCommitments = Array.from(Array(4), (_, i) => + createCapitalCommitment({ + plannedDate: new Date(`Jan ${currentYear + i}`).toString(), + }), + ); + render( + , + ); + + expect( + screen.getByText(`${currentYear + 1} - ${currentYear + 3}`), + ).toBeVisible(); + }); + + it("should show a single future year", () => { + const capitalCommitments = Array.from(Array(2), (_, i) => + createCapitalCommitment({ + plannedDate: new Date(`Jan ${currentYear + i}`).toString(), + }), + ); + render( + , + ); + + expect(screen.getByText(`${currentYear + 1}`)).toBeVisible(); + }); +}); diff --git a/app/components/CapitalProjectDetailPanel/CapitalCommitmentsTimeline.tsx b/app/components/CapitalProjectDetailPanel/CapitalCommitmentsTimeline.tsx new file mode 100644 index 0000000..6979336 --- /dev/null +++ b/app/components/CapitalProjectDetailPanel/CapitalCommitmentsTimeline.tsx @@ -0,0 +1,194 @@ +import { Flex } from "@nycplanning/streetscape"; +import numbro from "numbro"; +import { CapitalCommitment } from "~/gen"; + +export interface CapitalCommitmentsTimelineProps { + capitalCommitments: Array; +} + +export const getDisplayYear = ( + minYear: number | null, + maxYear: number | null, +) => { + // If there were no commitments in a year, then the min and max year will both be null + if (minYear !== maxYear) return `${minYear} - ${maxYear}`; + // Only one of the variables need to be checked to know there were no commitments + if (maxYear === null) return ""; + // Through deduction, we know there was at least one commitment but they were all from the same year. + return `${maxYear}`; +}; + +export function CapitalCommitmentsTimeline({ + capitalCommitments, +}: CapitalCommitmentsTimelineProps) { + const currentYear = new Date().getFullYear(); + const commitmentsCount = capitalCommitments.length; + let maxPastYear = null; + let minPastYear = null; + let maxFutureYear = null; + let minFutureYear = null; + let pastCommitmentsTotal = 0; + let futureCommitmentsTotal = 0; + let currentCommitmentsTotal = 0; + for (let i = commitmentsCount; i--; ) { + const commitment = capitalCommitments[i]; + const commitmentYear = new Date(commitment.plannedDate).getFullYear(); + + if (commitmentYear < currentYear) { + pastCommitmentsTotal += commitment.totalValue; + maxPastYear = + maxPastYear === null + ? commitmentYear + : commitmentYear > maxPastYear + ? commitmentYear + : maxPastYear; + + minPastYear = + minPastYear === null + ? commitmentYear + : commitmentYear < minPastYear + ? commitmentYear + : minPastYear; + } else if (commitmentYear > currentYear) { + futureCommitmentsTotal += commitment.totalValue; + maxFutureYear = + maxFutureYear === null + ? commitmentYear + : commitmentYear > maxFutureYear + ? commitmentYear + : maxFutureYear; + + minFutureYear = + minFutureYear === null + ? commitmentYear + : commitmentYear < minFutureYear + ? commitmentYear + : minFutureYear; + } else if (commitmentYear === currentYear) { + currentCommitmentsTotal += commitment.totalValue; + } + } + + return ( + + + + + + + + + + + {numbro(pastCommitmentsTotal) + .format({ + average: true, + output: "currency", + mantissa: 2, + }) + .toUpperCase()} + + + Past + + + {getDisplayYear(minPastYear, maxPastYear)} + + + {numbro(currentCommitmentsTotal) + .format({ + average: true, + output: "currency", + mantissa: 2, + }) + .toUpperCase()} + + + Current + + + {currentYear} + + + {numbro(futureCommitmentsTotal) + .format({ + average: true, + output: "currency", + mantissa: 2, + }) + .toUpperCase()} + + + Future + + + {getDisplayYear(minFutureYear, maxFutureYear)} + + + + + ); +} diff --git a/app/components/CapitalProjectDetailPanel/CapitalProjectDetailPanel.test.tsx b/app/components/CapitalProjectDetailPanel/CapitalProjectDetailPanel.test.tsx index 2a88c2e..4a913e9 100644 --- a/app/components/CapitalProjectDetailPanel/CapitalProjectDetailPanel.test.tsx +++ b/app/components/CapitalProjectDetailPanel/CapitalProjectDetailPanel.test.tsx @@ -6,6 +6,7 @@ import { } from "~/gen"; import { render, screen } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; +import { Flex } from "@nycplanning/streetscape"; describe("CapitalProjectDetailPanel", () => { let capitalProject: CapitalProjectBudgeted; @@ -29,6 +30,7 @@ describe("CapitalProjectDetailPanel", () => { capitalProject={capitalProject} agencies={agencies} onClose={onClose} + capitalCommitmentsTimeline={} />, ); expect(screen.getByText(capitalProject.description)).toBeVisible(); @@ -40,28 +42,19 @@ describe("CapitalProjectDetailPanel", () => { capitalProject={capitalProject} agencies={agencies} onClose={onClose} + capitalCommitmentsTimeline={} />, ); expect(screen.getByText(agencies[0].name)).toBeVisible(); }); - it("should render the name of the sponsoring agency", () => { - render( - , - ); - expect(screen.getByText(agencies[1].name)).toBeVisible(); - }); - it("should call onClose when the back chevron is clicked", async () => { render( } />, ); @@ -77,6 +70,7 @@ describe("CapitalProjectDetailPanel", () => { capitalProject={capitalProject} agencies={agencies} onClose={onClose} + capitalCommitmentsTimeline={} />, ); expect(screen.getByText("FY2019")).toBeVisible(); diff --git a/app/components/CapitalProjectDetailPanel/CapitalProjectDetailPanel.tsx b/app/components/CapitalProjectDetailPanel/CapitalProjectDetailPanel.tsx index 5bd5626..3f66b17 100644 --- a/app/components/CapitalProjectDetailPanel/CapitalProjectDetailPanel.tsx +++ b/app/components/CapitalProjectDetailPanel/CapitalProjectDetailPanel.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import { getYear, getMonth, compareAsc } from "date-fns"; import numbro from "numbro"; import { ChevronLeftIcon } from "@chakra-ui/icons"; @@ -11,12 +10,12 @@ import { Wrap, WrapItem, IconButton, - Hide, } from "@nycplanning/streetscape"; import { CapitalProjectBudgeted, Agency } from "../../gen"; export interface CapitalProjectDetailPanelProps { capitalProject: CapitalProjectBudgeted; + capitalCommitmentsTimeline: React.ReactNode; agencies: Agency[]; onClose: () => void; } @@ -35,46 +34,24 @@ const formatFiscalYearRange = (minDate: Date, maxDate: Date) => { export const CapitalProjectDetailPanel = ({ capitalProject, + capitalCommitmentsTimeline, agencies, onClose, }: CapitalProjectDetailPanelProps) => { - const [isExpanded, setIsExpanded] = useState(false); - return ( - - - { - setIsExpanded(!isExpanded); - }} - /> - - + + } - color={"black"} - backgroundColor={"white"} + color={"gray.600"} + backgroundColor={"inherit"} _hover={{ border: "none", backgroundColor: "blackAlpha.100", @@ -85,74 +62,27 @@ export const CapitalProjectDetailPanel = ({ {capitalProject.description} - - - - Capital Commitments - - - {formatFiscalYearRange( - new Date(capitalProject.minDate), - new Date(capitalProject.maxDate), - )} - - - Total Future Commitments - - - {numbro(capitalProject.commitmentsTotal) - .format({ - average: true, - mantissa: 2, - output: "currency", - spaceSeparated: true, - }) - .toUpperCase()} - - - - Project ID: {capitalProject.managingCode} - {capitalProject.id} - - - - Managing Agency + + + + Project ID:  - { - agencies.find( - (agency) => agency.initials === capitalProject.managingAgency, - )?.name - } + {capitalProject.managingCode} + {capitalProject.id} - - - Sponsoring Agency - - - {capitalProject.sponsoringAgencies - .map( - (initials) => - agencies.find((agency) => agency.initials === initials)?.name, - ) - .join(" ")} - - - - - Project Type + + + Project Type:  {capitalProject.budgetTypes.map((budgetType) => ( @@ -161,12 +91,9 @@ export const CapitalProjectDetailPanel = ({ as={"span"} display={"inline-block"} width="auto" - paddingX={2} - paddingY={1} - borderRadius={"0.5rem"} - borderColor="gray.400" - borderStyle={"solid"} - borderWidth={"1.5px"} + paddingX={0.5} + paddingY={0.25} + borderRadius={"0.25rem"} backgroundColor={"gray.100"} > {budgetType} @@ -175,6 +102,63 @@ export const CapitalProjectDetailPanel = ({ ))} + + + + + Managing Agency + + { + agencies.find( + (agency) => agency.initials === capitalProject.managingAgency, + )?.name + } + + + + + Commitments + + {formatFiscalYearRange( + new Date(capitalProject.minDate), + new Date(capitalProject.maxDate), + )} + + + + Total + + {numbro(capitalProject.commitmentsTotal) + .format({ + average: true, + mantissa: 2, + output: "currency", + spaceSeparated: true, + }) + .toUpperCase()} + + + + {capitalCommitmentsTimeline} + ); diff --git a/app/components/MobilePanelSizeControl.test.tsx b/app/components/MobilePanelSizeControl.test.tsx new file mode 100644 index 0000000..95c9e2b --- /dev/null +++ b/app/components/MobilePanelSizeControl.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MobilePanelSizeControl } from "./MobilePanelSizeControl"; + +describe("MobilePanelSizeControl", () => { + it("should have be clickable to expand", async () => { + const isExpanded = false; + const isExpandedToggle = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByLabelText(/Expand/)); + expect(isExpandedToggle).toHaveBeenCalled(); + }); + + it("should have be clickable to collapse", async () => { + const isExpanded = true; + const isExpandedToggle = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByLabelText(/Collapse/)); + expect(isExpandedToggle).toHaveBeenCalled(); + }); +}); diff --git a/app/components/MobilePanelSizeControl.tsx b/app/components/MobilePanelSizeControl.tsx new file mode 100644 index 0000000..5d4f618 --- /dev/null +++ b/app/components/MobilePanelSizeControl.tsx @@ -0,0 +1,31 @@ +import { Box, BoxProps, Hide } from "@nycplanning/streetscape"; + +export interface MobilePanelSizeControlProps extends BoxProps { + isExpanded: boolean; + isExpandedToggle: () => void; +} +export function MobilePanelSizeControl({ + isExpanded, + isExpandedToggle, + ...props +}: MobilePanelSizeControlProps) { + return ( + + + + ); +} diff --git a/app/components/Overlay.tsx b/app/components/Overlay.tsx index c17cd15..1fbeb42 100644 --- a/app/components/Overlay.tsx +++ b/app/components/Overlay.tsx @@ -9,7 +9,7 @@ export const Overlay = ({ children }: OverlayProps) => { return (