From 30c81d0b3acd887c0fdcd11c628617e5e41a4f5d Mon Sep 17 00:00:00 2001 From: "Frank Pigeon Jr." Date: Fri, 8 Nov 2024 15:49:27 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8adds=20CAN=20Spending=20page?= =?UTF-8?q?=20BLI=20Table=20(#3029)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: simplify CAN Detail prefer parent to do the hard work * style: update breadcrumb for CANs * refactor: add BLIs for table * feat: adds CanBudgetLines Table and Row * feat: filter CAN BLIs by fiscal year * feat: adds Expandable Row * feat: adds messages to in_review BLI * feat: adds notes --- .pre-commit-config.yaml | 17 +- frontend/cypress/e2e/canDetail.cy.js | 17 +- .../CABBudgetLineTable.constants.js | 1 + .../CANBudgetLineTable/CANBudgetLineTable.jsx | 50 ++++ .../CANBudgetLineTable.test.jsx | 33 +++ .../CANBudgetLineTableRow.jsx | 225 ++++++++++++++++++ .../CANBudgetLineTableRow.test.jsx | 84 +++++++ .../CANs/CANBudgetLineTable/index.js | 1 + frontend/src/index.jsx | 2 +- frontend/src/pages/cans/detail/Can.jsx | 32 ++- frontend/src/pages/cans/detail/CanDetail.jsx | 30 ++- .../src/pages/cans/detail/CanSpending.jsx | 30 ++- 12 files changed, 483 insertions(+), 39 deletions(-) create mode 100644 frontend/src/components/CANs/CANBudgetLineTable/CABBudgetLineTable.constants.js create mode 100644 frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTable.jsx create mode 100644 frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTable.test.jsx create mode 100644 frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTableRow.jsx create mode 100644 frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTableRow.test.jsx create mode 100644 frontend/src/components/CANs/CANBudgetLineTable/index.js diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 930bc2da23..05ff838eba 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,16 +12,10 @@ repos: - id: check-yaml - id: check-added-large-files - id: check-merge-conflict - - repo: https://github.com/hadolint/hadolint rev: v2.10.0 hooks: - id: hadolint - # We're running black, but doing it via nox session instead - see below - # - repo: https://github.com/psf/black - # rev: 22.6.0 - # hooks: - # - id: black - repo: https://github.com/pre-commit/mirrors-isort rev: v5.10.1 hooks: @@ -46,21 +40,16 @@ repos: - css - html pass_filenames: false - - repo: local - hooks: - id: trufflehog name: TruffleHog description: Detect secrets in your data. - # For running trufflehog locally, use the following: - # entry: bash -c 'trufflehog git file://. --since-commit HEAD --only-verified --fail' - # For running trufflehog in docker, use the following entry instead: - entry: bash -c 'docker run --rm -v "$(pwd):/workdir" -i --rm trufflesecurity/trufflehog:latest git file:///workdir --since-commit HEAD --only-verified --fail' + entry: bash -c 'if command -v podman >/dev/null 2>&1; then podman run --rm -v "$(pwd):/workdir" -i trufflesecurity/trufflehog:latest git file:///workdir --since-commit HEAD --only-verified --fail; elif command -v docker >/dev/null 2>&1; then docker run --rm -v "$(pwd):/workdir" -i trufflesecurity/trufflehog:latest git file:///workdir --since-commit HEAD --only-verified --fail; else echo "Neither docker nor podman found. Please install one of them." && exit 1; fi' language: system stages: ["pre-commit", "pre-push"] - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook rev: v9.18.0 hooks: - id: commitlint - stages: [ commit-msg ] - additional_dependencies: [ "@commitlint/config-conventional" ] + stages: [commit-msg] + additional_dependencies: ["@commitlint/config-conventional"] language_version: 22.8.0 diff --git a/frontend/cypress/e2e/canDetail.cy.js b/frontend/cypress/e2e/canDetail.cy.js index 5542caa246..31081bb55a 100644 --- a/frontend/cypress/e2e/canDetail.cy.js +++ b/frontend/cypress/e2e/canDetail.cy.js @@ -11,7 +11,7 @@ afterEach(() => { }); describe("CAN detail page", () => { - it("loads", () => { + it("shows relevant CAN data", () => { cy.visit("/cans/502/"); cy.get("h1").should("contain", "G99PHS9"); // heading cy.get("p").should("contain", "SSRD - 5 Years"); // sub-heading @@ -19,4 +19,19 @@ describe("CAN detail page", () => { cy.get("span").should("contain", "Director Derrek"); // division director cy.get("span").should("contain", "Program Support"); // portfolio }); + it("shows the CAN Spending page", () => { + cy.visit("/cans/504/spending"); + cy.get("#fiscal-year-select").select("2021"); + cy.get("h1").should("contain", "G994426"); // heading + cy.get("p").should("contain", "HS - 5 Years"); // sub-heading + // should contain the budget line table + cy.get("table").should("exist"); + // table should have more than 1 row + cy.get("tbody").children().should("have.length.greaterThan", 1); + // switch to a different fiscal year + cy.get("#fiscal-year-select").select("2022"); + // table should not exist + cy.get("tbody").should("not.exist"); + cy.get("p").should("contain", "No budget lines have been added to this CAN."); + }); }); diff --git a/frontend/src/components/CANs/CANBudgetLineTable/CABBudgetLineTable.constants.js b/frontend/src/components/CANs/CANBudgetLineTable/CABBudgetLineTable.constants.js new file mode 100644 index 0000000000..c141ef13b7 --- /dev/null +++ b/frontend/src/components/CANs/CANBudgetLineTable/CABBudgetLineTable.constants.js @@ -0,0 +1 @@ +export const TABLE_HEADERS = ["BL ID #", "Agreement", "Obligate By", "FY", "Total", "% of CAN", "Status"]; diff --git a/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTable.jsx b/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTable.jsx new file mode 100644 index 0000000000..a52d7cf4eb --- /dev/null +++ b/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTable.jsx @@ -0,0 +1,50 @@ +import { formatDateNeeded } from "../../../helpers/utils"; +import Table from "../../UI/Table"; +import { TABLE_HEADERS } from "./CABBudgetLineTable.constants"; +import CANBudgetLineTableRow from "./CANBudgetLineTableRow"; +/** + * @typedef {import("../../../components/BudgetLineItems/BudgetLineTypes").BudgetLine} BudgetLine + */ + +/** + * @typedef {Object} CANBudgetLineTableProps + * @property {BudgetLine[]} budgetLines + */ + +/** + * @component - The CAN Budget Line Table. + * @param {CANBudgetLineTableProps} props + * @returns {JSX.Element} - The component JSX. + */ +const CANBudgetLineTable = ({ budgetLines }) => { + if (budgetLines.length === 0) { + return

No budget lines have been added to this CAN.

; + } + + return ( + + {budgetLines.map((budgetLine) => ( + + ))} +
+ ); +}; + +export default CANBudgetLineTable; diff --git a/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTable.test.jsx b/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTable.test.jsx new file mode 100644 index 0000000000..8e4402f829 --- /dev/null +++ b/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTable.test.jsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import CANBudgetLineTable from "./CANBudgetLineTable"; +import store from "../../../store"; +import { budgetLine } from "../../../tests/data"; + +describe("CANBudgetLineTable", () => { + const mockBudgetLines = [ + { ...budgetLine, status: "Approved", amount: 1000 }, + { ...budgetLine, status: "Pending", amount: 2000 } + ]; + + it("renders 'No budget lines have been added to this CAN.' when there are no budget lines", () => { + render( + + + + ); + expect(screen.getByText("No budget lines have been added to this CAN.")).toBeInTheDocument(); + }); + + it("renders table with budget lines", () => { + render( + + + + ); + expect(screen.getByText("Approved")).toBeInTheDocument(); + expect(screen.getByText("Pending")).toBeInTheDocument(); + expect(screen.getByText("$1,000.00")).toBeInTheDocument(); + expect(screen.getByText("$2,000.00")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTableRow.jsx b/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTableRow.jsx new file mode 100644 index 0000000000..68cc051516 --- /dev/null +++ b/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTableRow.jsx @@ -0,0 +1,225 @@ +import { faClock } from "@fortawesome/free-regular-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import CurrencyFormat from "react-currency-format"; +import { + formatDateToMonthDayYear, + totalBudgetLineAmountPlusFees, + totalBudgetLineFeeAmount +} from "../../../helpers/utils"; +import useGetUserFullNameFromId from "../../../hooks/user.hooks"; +import TableRowExpandable from "../../UI/TableRowExpandable"; +import { + changeBgColorIfExpanded, + expandedRowBGColor, + removeBorderBottomIfExpanded +} from "../../UI/TableRowExpandable/TableRowExpandable.helpers"; +import { useTableRow } from "../../UI/TableRowExpandable/TableRowExpandable.hooks"; +import TableTag from "../../UI/TableTag"; +import { useChangeRequestsForTooltip } from "../../../hooks/useChangeRequests.hooks"; + +/** + * @typedef {import("../../../components/BudgetLineItems/BudgetLineTypes").BudgetLine} BudgetLine + */ + +/** + * @typedef {Object} CANBudgetLineTableRowProps + * @property {BudgetLine} budgetLine + * @property {number} blId + * @property {string} agreementName - TODO + * @property {string} obligateDate + * @property {number | string } fiscalYear + * @property {number} amount + * @property {number} fee + * @property {number} percentOfCAN - TODO + * @property {string} status + * @property {boolean} inReview + * @property {number} creatorId + * @property {string} creationDate + * @property {string} procShopCode - TODO + * @property {number} procShopFeePercentage + * @property {string} notes + */ + +/** + * @component - The CAN Budget Line Table. + * @param {CANBudgetLineTableRowProps} props + * @returns {JSX.Element} - The component JSX. + */ +const CANBudgetLineTableRow = ({ + budgetLine, + blId, + agreementName, + obligateDate, + fiscalYear, + amount, + fee, + percentOfCAN, + status, + inReview, + creatorId, + creationDate, + procShopCode, + procShopFeePercentage, + notes +}) => { + const lockedMessage = useChangeRequestsForTooltip(budgetLine); + const { isExpanded, setIsRowActive, setIsExpanded } = useTableRow(); + const borderExpandedStyles = removeBorderBottomIfExpanded(isExpanded); + const bgExpandedStyles = changeBgColorIfExpanded(isExpanded); + const budgetLineCreatorName = useGetUserFullNameFromId(creatorId); + const feeTotal = totalBudgetLineFeeAmount(amount, fee); + const budgetLineTotalPlusFees = totalBudgetLineAmountPlusFees(amount, feeTotal); + const displayCreatedDate = formatDateToMonthDayYear(creationDate); + + const TableRowData = ( + <> + + {blId} + + + {agreementName} + + + {obligateDate} + + + {fiscalYear} + + + + + + {percentOfCAN}% + + + + + + ); + + const ExpandedData = ( + +
+
+
Created By
+
+ {budgetLineCreatorName} +
+
+ + {displayCreatedDate} +
+
+
+
Notes
+
+ {notes} +
+
+
+
+
Procurement Shop
+
+ {`${procShopCode}-Fee Rate: ${procShopFeePercentage * 100}%`} +
+
+
+
+
SubTotal
+
+ +
+
+
+
Fees
+
+ +
+
+
+
+
+ + ); + + return ( + + ); +}; + +export default CANBudgetLineTableRow; diff --git a/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTableRow.test.jsx b/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTableRow.test.jsx new file mode 100644 index 0000000000..bdc2d9e8af --- /dev/null +++ b/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTableRow.test.jsx @@ -0,0 +1,84 @@ +import { render, screen } from "@testing-library/react"; +import CANBudgetLineTableRow from "./CANBudgetLineTableRow"; +import { formatDateNeeded } from "../../../helpers/utils"; +import { Provider } from "react-redux"; +import store from "../../../store"; +import { budgetLine } from "../../../tests/data"; +import userEvent from "@testing-library/user-event"; + +const mockBudgetLine = { + ...budgetLine, + id: 1, + date_needed: "2023-10-01", + fiscal_year: 2023, + amount: 1000, + proc_shop_fee_percentage: 0.05, + status: "Pending", + in_review: true, + created_by: 1, + created_on: "2023-09-01" +}; + +describe("CANBudgetLineTableRow", () => { + test("renders table row data correctly", () => { + render( + + + + ); + + expect(screen.getByText("TBD")).toBeInTheDocument(); + expect(screen.getByText(formatDateNeeded(mockBudgetLine.date_needed))).toBeInTheDocument(); + expect(screen.getByText(mockBudgetLine.fiscal_year)).toBeInTheDocument(); + expect(screen.getByText("$1,050.00")).toBeInTheDocument(); // amount + fee + expect(screen.getByText("3%")).toBeInTheDocument(); + }); + + test("renders expanded data correctly", async () => { + render( + + + + ); + + // Simulate expanding the row + await userEvent.click(screen.getByTestId("expand-row")); + + expect(screen.getByText("Created By")).toBeInTheDocument(); + expect(screen.getByText("comment one")).toBeInTheDocument(); + expect(screen.getByText("Procurement Shop")).toBeInTheDocument(); + expect(screen.getByText("$1,000.00")).toBeInTheDocument(); // amount + expect(screen.getByText("$50.00")).toBeInTheDocument(); // fee + }); +}); diff --git a/frontend/src/components/CANs/CANBudgetLineTable/index.js b/frontend/src/components/CANs/CANBudgetLineTable/index.js new file mode 100644 index 0000000000..41a7b91331 --- /dev/null +++ b/frontend/src/components/CANs/CANBudgetLineTable/index.js @@ -0,0 +1 @@ +export { default } from "./CANBudgetLineTable"; diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index c1b93859fd..d82fbd06e9 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -247,7 +247,7 @@ const router = createBrowserRouter( to="/cans" className="text-primary" > - Cans + CANs ) }} diff --git a/frontend/src/pages/cans/detail/Can.jsx b/frontend/src/pages/cans/detail/Can.jsx index af5d31525c..fc717e5a16 100644 --- a/frontend/src/pages/cans/detail/Can.jsx +++ b/frontend/src/pages/cans/detail/Can.jsx @@ -9,9 +9,11 @@ import CANFiscalYearSelect from "../list/CANFiscalYearSelect"; import CanDetail from "./CanDetail"; import CanFunding from "./CanFunding"; import CanSpending from "./CanSpending"; +import React from "react"; /** - @typedef {import("../../../components/CANs/CANTypes").CAN} CAN -*/ + * @typedef {import("../../../components/CANs/CANTypes").CAN} CAN + * @typedef {import("../../../components/BudgetLineItems/BudgetLineTypes").BudgetLine} BudgetLine + */ const Can = () => { const urlPathParams = useParams(); @@ -21,6 +23,11 @@ const Can = () => { const selectedFiscalYear = useSelector((state) => state.canDetail.selectedFiscalYear); const fiscalYear = Number(selectedFiscalYear.value); + const filteredCANByFiscalYear = React.useMemo(() => { + if (!fiscalYear || !can) return {}; + return can.funding_details?.fiscal_year === fiscalYear ? can : {}; + }, [can, fiscalYear]); + if (isLoading) { return
Loading Can...
; } @@ -28,12 +35,18 @@ const Can = () => { return
Can not found
; } + const { number, description, nick_name: nickname, portfolio } = can; + + /** @type {{budget_line_items?: BudgetLine[]}} */ + const { budget_line_items: budgetLines } = filteredCANByFiscalYear; + const { division_id: divisionId, team_leaders: teamLeaders, name: portfolioName } = portfolio; + const noData = "TBD"; const subTitle = `${can.nick_name} - ${can.active_period} ${can.active_period > 1 ? "Years" : "Year"}`; return ( @@ -47,11 +60,20 @@ const Can = () => { } + element={ + + } /> } + element={} /> { - const canDivisionId = can.portfolio.division_id; - const { data: division, isSuccess } = useGetDivisionQuery(canDivisionId); +const CanDetail = ({ description, number, nickname, portfolioName, teamLeaders, divisionId }) => { + const { data: division, isSuccess } = useGetDivisionQuery(divisionId); const divisionDirectorFullName = useGetUserFullNameFromId(isSuccess ? division.division_director_id : null); return ( @@ -35,7 +39,7 @@ const CanDetail = ({ can }) => {
@@ -51,22 +55,22 @@ const CanDetail = ({ can }) => {
-
Team Leaders
- {can.portfolio?.team_leaders && - can.portfolio?.team_leaders.length > 0 && - can.portfolio.team_leaders.map((teamLeader) => ( +
Team Leader
+ {teamLeaders && + teamLeaders.length > 0 && + teamLeaders.map((teamLeader) => (
{ +import CANBudgetLineTable from "../../../components/CANs/CANBudgetLineTable"; +/** + @typedef {import("../../../components/CANs/CANTypes").CAN} CAN + @typedef {import("../../../components/BudgetLineItems/BudgetLineTypes").BudgetLine} BudgetLine +*/ + +/** + * @typedef {Object} CanSpendingProps + * @property {BudgetLine[]} budgetLines + */ + +/** + * @component - The CAN detail page. + * @param {CanSpendingProps} props + * @returns {JSX.Element} - The component JSX. + */ +const CanSpending = ({ budgetLines }) => { return ( -
-

Can Spending

-

coming soon...

-
+
+

CAN Spending Summary

+

The summary below shows the CANs total budget and spending across all budget lines

+ {/* Note: Cards go here */} +

CAN Budget Lines

+

This is a list of all budget lines allocating funding from this CAN for the selected fiscal year.

+ +
); };