diff --git a/epictrack-web/src/apiManager/http-request-handler/index.ts b/epictrack-web/src/apiManager/http-request-handler/index.ts index 58db5b250..a566c0392 100644 --- a/epictrack-web/src/apiManager/http-request-handler/index.ts +++ b/epictrack-web/src/apiManager/http-request-handler/index.ts @@ -54,12 +54,11 @@ const PatchRequest = (url: string, data = {}) => { }, }) .then((response) => { - // console.warn("PatchRequest - Response:", response); // Log the response return response; }) .catch((error) => { - console.error("PatchRequest - Error:", error); // Log the error - throw error; // Ensure the error propagates + console.error("PatchRequest - Error:", error); + throw error; }); }; diff --git a/epictrack-web/src/components/icons/index.tsx b/epictrack-web/src/components/icons/index.tsx index 0592599d4..b48c37df2 100644 --- a/epictrack-web/src/components/icons/index.tsx +++ b/epictrack-web/src/components/icons/index.tsx @@ -330,19 +330,12 @@ const GoToIcon = (props: IconProps) => { ); }; -const ExclamationSmallIcon = (props: IconProps) => { +const ExclamationSmallIcon = ({ fill, ...rest }: IconProps) => { return ( - + ); @@ -353,7 +346,6 @@ const ExclamationMediumIcon = (props: IconProps) => { ); diff --git a/epictrack-web/src/components/workPlan/WorkPlanContainer.tsx b/epictrack-web/src/components/workPlan/WorkPlanContainer.tsx index 93611b870..4a3824088 100644 --- a/epictrack-web/src/components/workPlan/WorkPlanContainer.tsx +++ b/epictrack-web/src/components/workPlan/WorkPlanContainer.tsx @@ -14,15 +14,18 @@ import Status from "./status"; import Icons from "../icons"; import { IconProps } from "../icons/type"; import Issues from "./issues"; +import { WorkIssue } from "../../models/Issue"; import WorkState from "./WorkState"; import { isStatusOutOfDate } from "./status/shared"; import About from "./about"; import { useLocation } from "react-router-dom"; import { WORKPLAN_TAB } from "./constants"; +import { StalenessEnum } from "constants/application-constant"; import useRouterLocationStateForHelpPage from "hooks/useRouterLocationStateForHelpPage"; +import { calculateStaleness } from "./utils"; const IndicatorIcon: React.FC = Icons["IndicatorIcon"]; - +const ExclamationSmallIcon: React.FC = Icons["ExclamationSmallIcon"]; const tabPanel: SxProps = { paddingTop: "2rem", }; @@ -35,6 +38,10 @@ const WorkPlanContainer = () => { const ctx = useContext(WorkplanContext); + const { issues } = useContext(WorkplanContext) as { + issues: WorkIssue[]; + }; + const activeStaff = ctx.team.filter( (staffWorkRole) => staffWorkRole.is_active ); @@ -43,118 +50,177 @@ const WorkPlanContainer = () => { setSelectedTabIndex(index); }; - if (ctx.loading) { - return ; - } - const statusOutOfDate = ctx.statuses.length === 0 || isStatusOutOfDate(ctx.statuses.find((status) => status.is_approved)); + const mapIssues = (issues: WorkIssue[]): StalenessEnum | null => { + const stalenessPriority = [ + StalenessEnum.GOOD, + StalenessEnum.WARN, + StalenessEnum.CRITICAL, + ]; + + // Helper function to get the "highest" staleness + const getHigherStaleness = ( + a: StalenessEnum, + b: StalenessEnum + ): StalenessEnum => { + return stalenessPriority.indexOf(a) > stalenessPriority.indexOf(b) + ? a + : b; + }; + + if (issues.length === 0) return StalenessEnum.GOOD; // No issues to check + + const topStaleness = issues.reduce((currentHighest, issue) => { + const staleness = calculateStaleness(issue); + console.info("stalenessTab:", staleness); + return getHigherStaleness(currentHighest, staleness); + }, StalenessEnum.GOOD); // Start with GOOD as the "lowest" level + + return topStaleness; + }; + + const highestStaleness = mapIssues(issues) ?? StalenessEnum.GOOD; // Fallback to GOOD + + console.info("highestStaleness:", highestStaleness); + + const iconStyles = React.useMemo(() => { + if (highestStaleness === "CRITICAL") { + return { + fill: Palette.error.light, + }; + } + if (highestStaleness === "WARN") { + return { + fill: Palette.secondary.light, + }; + } + return { + fill: Palette.white, + }; + }, [highestStaleness]); + return ( - - - - {ctx.work?.title} - - - - - - - + {ctx.loading ? ( + + ) : ( + - + + {ctx.work?.title} + + + + + + + + + } + /> + + } + /> + + + + + + + + + + + + + + + + + + + + + - } - /> - - - - - - - - - - - - - - - - - - - - - - - - - + > + + + + )} + ); }; diff --git a/epictrack-web/src/components/workPlan/issues/IssueAccordion/index.tsx b/epictrack-web/src/components/workPlan/issues/IssueAccordion/index.tsx index 897cdabe9..7a3d09068 100644 --- a/epictrack-web/src/components/workPlan/issues/IssueAccordion/index.tsx +++ b/epictrack-web/src/components/workPlan/issues/IssueAccordion/index.tsx @@ -14,24 +14,44 @@ const ExpandIcon: React.FC = Icons["ExpandIcon"]; const IssueAccordion = ({ issue, - defaultOpen = true, + staleness, + defaultOpen = false, onInteraction = () => { return; }, }: { issue: WorkIssue; + staleness?: string; defaultOpen?: boolean; onInteraction?: () => void; }) => { const [expanded, setExpanded] = React.useState(defaultOpen); + const iconStyles = React.useMemo(() => { + if (staleness === "CRITICAL") { + return { + fill: Palette.error.dark, + background: Palette.error.bg.light, + }; + } + if (staleness === "WARN") { + return { + fill: Palette.secondary.dark, + background: Palette.secondary.bg.light, + }; + } + return { + fill: Palette.success.dark, + background: Palette.success.bg.light, + }; + }, [staleness]); + return ( { setExpanded(!expanded); - if (!expanded) { onInteraction(); } @@ -42,9 +62,11 @@ const IssueAccordion = ({ expandIcon={ } diff --git a/epictrack-web/src/components/workPlan/issues/IssuesView.tsx b/epictrack-web/src/components/workPlan/issues/IssuesView.tsx index 2ba69455e..4d8be8a24 100644 --- a/epictrack-web/src/components/workPlan/issues/IssuesView.tsx +++ b/epictrack-web/src/components/workPlan/issues/IssuesView.tsx @@ -3,15 +3,17 @@ import AddIcon from "@mui/icons-material/Add"; import NoDataEver from "../../shared/NoDataEver"; import { IssuesContext } from "./IssuesContext"; import IssuesViewSkeleton from "./IssuesViewSkeleton"; -import { Else, If, Then } from "react-if"; +import { When } from "react-if"; import IssueAccordion from "./IssueAccordion"; -import { Button, Grid } from "@mui/material"; +import { Box, Button, Grid } from "@mui/material"; import { WorkplanContext } from "../WorkPlanContext"; import { WorkIssue } from "../../../models/Issue"; import IssueDialogs from "./Dialogs"; import { Restricted, hasPermission } from "components/shared/restricted"; import { useAppSelector } from "hooks"; -import { ROLES } from "constants/application-constant"; +import { ROLES, StalenessEnum } from "constants/application-constant"; +import { calculateStaleness } from "../utils"; +import WarningBox from "../../shared/warningBox"; const IssuesView = () => { const { issues, team } = React.useContext(WorkplanContext) as { @@ -26,46 +28,30 @@ const IssuesView = () => { const canCreate = hasPermission({ roles, allowed: [ROLES.CREATE] }); const isTeamMember = team?.some((member) => member.staff.email === email); - const lastInteractedIssue = React.useRef(null); - - // Sorting function const sortIssues = (issues: WorkIssue[]): WorkIssue[] => { return [...issues].sort((a, b) => { - // First, sort by resolved status if (a.is_resolved !== b.is_resolved) { return a.is_resolved ? 1 : -1; // Unresolved items come first } - // Then, sort by date if (a.start_date > b.start_date) { return -1; } if (a.start_date < b.start_date) { return 1; } - return 0; //Equal + return 0; }); }; - // Mapping function - const mapIssues = ( - issues: WorkIssue[], - lastInteractedIssue: React.MutableRefObject - ) => { - return issues.map((issue, index) => ( - - { - lastInteractedIssue.current = issue.id; - }} - /> - - )); + const mapIssues = (issues: WorkIssue[]) => { + return issues.map((currentIssue, index) => { + const staleness = calculateStaleness(currentIssue); + return ( + + + + ); + }); }; const sortedIssues = sortIssues(issues); @@ -76,39 +62,51 @@ const IssuesView = () => { return ( <> - - - setCreateIssueFormIsOpen(true)} - addButtonProps={{ - disabled: !canCreate && !isTeamMember, - }} + + setCreateIssueFormIsOpen(true)} + addButtonProps={{ + disabled: !canCreate && !isTeamMember, + }} + /> + + + + - - - - - + + 0}> + + + + - - - {mapIssues(sortedIssues, lastInteractedIssue)} + Issue + + - - + {mapIssues(sortedIssues)} + + ); diff --git a/epictrack-web/src/components/workPlan/issues/__tests__/IssueAccordion.cy.tsx b/epictrack-web/src/components/workPlan/issues/__tests__/IssueAccordion.cy.tsx index 692af95fc..af5c8dc67 100644 --- a/epictrack-web/src/components/workPlan/issues/__tests__/IssueAccordion.cy.tsx +++ b/epictrack-web/src/components/workPlan/issues/__tests__/IssueAccordion.cy.tsx @@ -11,6 +11,7 @@ const mockIssue: WorkIssue = { expected_resolution_date: "2021-10-01", is_active: true, is_high_priority: true, + is_resolved: false, work_id: 1, is_deleted: false, created_by: "John Doe", @@ -72,31 +73,31 @@ describe("", () => { ); - // Assert that the issue details are visible + // Assert that the issue details are hidden to start cy.get('[data-cy="issue-title"]') .should("be.visible") .contains(mockIssueOne.title); cy.get('[data-cy="issue-description"]') - .should("be.visible") + .should("not.be.visible") .contains(mockUpdates[0].description); cy.get('[data-cy="issue-title"]'); - cy.get('[data-cy="approved-chip"]'); - cy.get('[data-cy="new-issue-update-button"]'); - cy.get('[data-cy="edit-issue-update-button"]'); - cy.get('[data-cy="empty-issue-history"]'); - - // Click to collapse accordion + // Click to expand accordion cy.get(`[data-cy="${mockIssue.id}-expand-icon"]`).click(); - // Assert that the issue details are not visible + // Assert that the issue details are visible cy.get('[data-cy="issue-title"]') .should("be.visible") .contains(mockIssue.title); cy.get('[data-cy="issue-description"]') - .should("not.be.visible") + .should("be.visible") .contains(mockUpdates[0].description); + + cy.get('[data-cy="approved-chip"]'); + cy.get('[data-cy="new-issue-update-button"]'); + cy.get('[data-cy="edit-issue-update-button"]'); + cy.get('[data-cy="empty-issue-history"]'); }); it("unapproved update", () => { @@ -115,6 +116,9 @@ describe("", () => { ); + // Click to expand accordion + cy.get(`[data-cy="${mockIssue.id}-expand-icon"]`).click(); + // Assert that the issue details are visible cy.get('[data-cy="issue-title"]') .should("be.visible") @@ -150,6 +154,9 @@ describe("", () => { ); + // Click to expand accordion + cy.get(`[data-cy="${mockIssue.id}-expand-icon"]`).click(); + // Assert that the issue details are visible cy.get('[data-cy="issue-title"]') .should("be.visible") @@ -197,6 +204,9 @@ describe("", () => { ); + // Click to expand accordion + cy.get(`[data-cy="${mockIssue.id}-expand-icon"]`).click(); + // Assert that the issue details are visible cy.get('[data-cy="issue-title"]') .should("be.visible") diff --git a/epictrack-web/src/components/workPlan/utils.ts b/epictrack-web/src/components/workPlan/utils.ts index 89c240077..7ccea2d06 100644 --- a/epictrack-web/src/components/workPlan/utils.ts +++ b/epictrack-web/src/components/workPlan/utils.ts @@ -1,4 +1,10 @@ -import { ROLES } from "../../constants/application-constant"; +import { + ROLES, + ISSUES_STALENESS_THRESHOLD, + StalenessEnum, +} from "../../constants/application-constant"; +import moment from "moment"; +import dateUtils from "../../utils/dateUtils"; import { useContext } from "react"; import { useAppSelector } from "hooks"; import { WorkplanContext } from "./WorkPlanContext"; @@ -30,3 +36,31 @@ export const useUserHasRole = () => { member.staff.email === email && rolesArray.includes(member.role.name) ); }; + +// Helper function to calculate staleness +export const calculateStaleness = (issue: { + updates: { posted_date: string }[]; + type?: string; +}) => { + const now = moment(); + // Check if there are no updates + if (!issue.updates || issue.updates.length === 0) { + return StalenessEnum.GOOD; // No update, consider it "GOOD" + } + + // Calculate the difference in days from the latest update + const diffDays = dateUtils.diff( + now.toLocaleString(), + issue.updates[0]?.posted_date, + "days" + ); + + // Determine the staleness level + if (diffDays > ISSUES_STALENESS_THRESHOLD.StalenessEnum.CRITICAL) { + return StalenessEnum.CRITICAL; + } else if (diffDays > ISSUES_STALENESS_THRESHOLD.StalenessEnum.WARN) { + return StalenessEnum.WARN; + } else { + return StalenessEnum.GOOD; + } +}; diff --git a/epictrack-web/src/constants/application-constant.ts b/epictrack-web/src/constants/application-constant.ts index 5034ee49b..d3c6a8674 100644 --- a/epictrack-web/src/constants/application-constant.ts +++ b/epictrack-web/src/constants/application-constant.ts @@ -140,6 +140,17 @@ export enum StalenessEnum { GOOD = "GOOD", } +export const ISSUES_STALENESS_THRESHOLD = { + StalenessEnum: { + CRITICAL: 36, + WARN: 29, + }, +}; + +export const STATUS_STALENESS_THRESHOLD = { + [StalenessEnum.CRITICAL]: 7, +}; + export const REPORT_STALENESS_THRESHOLD = { [REPORT_TYPE.EA_REFERRAL]: { [StalenessEnum.CRITICAL]: 10, diff --git a/epictrack-web/src/services/issueService/index.ts b/epictrack-web/src/services/issueService/index.ts index 73218de60..4f83a57b1 100644 --- a/epictrack-web/src/services/issueService/index.ts +++ b/epictrack-web/src/services/issueService/index.ts @@ -29,13 +29,11 @@ class IssueService { try { const response = await http.PatchRequest(query, JSON.stringify(data)); - console.warn("response data:", response); // Log the returned data - return response; // Return the response after logging it + return response; } catch (error) { - console.error("Error in editIssue:", error); // Log any errors - throw error; // Rethrow the error after logging + console.error("Error in editIssue:", error); + throw error; } - // return await http.PatchRequest(query, JSON.stringify(data)); } async editIssueUpdate(