diff --git a/client/src/components/dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx b/client/src/components/dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx new file mode 100644 index 000000000..05d855be0 --- /dev/null +++ b/client/src/components/dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx @@ -0,0 +1,168 @@ +import {Badge, Card, Space, Table, Tag} from "antd"; +import LoadingSkeleton from "../../loading-skeleton/loading-skeleton.component"; +import {useTranslation} from "react-i18next"; +import React, {useEffect, useState} from "react"; +import moment from "moment"; +import DashboardRefreshRequired from "../refresh-required.component"; +import axios from "axios"; + +const fortyFiveDaysAgo = () =>moment().subtract(45, 'days').toLocaleString(); + +export default function JobLifecycleDashboardComponent({data, bodyshop, ...cardProps}) { + const {t} = useTranslation(); + const [loading, setLoading] = useState(false); + const [lifecycleData, setLifecycleData] = useState(null); + + useEffect(() => { + async function getLifecycleData() { + if (data && data.job_lifecycle) { + setLoading(true); + const response = await axios.post("/job/lifecycle", { + jobids: data.job_lifecycle.map(x => x.id), + statuses: bodyshop.md_order_statuses + }); + setLifecycleData(response.data.durations); + setLoading(false); + } + } + + getLifecycleData().catch(e => { + console.error(`Error in getLifecycleData: ${e}`); + }) + }, [data]); + + const columns = [ + { + title: t('job_lifecycle.columns.status'), + dataIndex: 'status', + bgColor: 'red', + key: 'status', + render: (text, record) => { + return {record.status} + } + }, + { + title: t('job_lifecycle.columns.human_readable'), + dataIndex: 'humanReadable', + key: 'humanReadable', + }, + { + title: t('job_lifecycle.columns.status_count'), + key: 'statusCount', + render: (text, record) => { + return lifecycleData.statusCounts[record.status]; + } + }, + { + title: t('job_lifecycle.columns.percentage'), + dataIndex: 'percentage', + key: 'percentage', + render: (text, record) => { + return record.percentage.toFixed(2)+'%'; + } + }, + ]; + + if (!data) return null; + + if (!data.job_lifecycle || !lifecycleData) return ; + + const extra = `${t('job_lifecycle.content.calculated_based_on')} ${lifecycleData.jobs} ${t('job_lifecycle.content.jobs_in_since')} ${fortyFiveDaysAgo()}` + + return ( + + +
+
+ {lifecycleData.summations.map((key, index, array) => { + const isFirst = index === 0; + const isLast = index === array.length - 1; + return ( +
+ + {key.percentage > 15 ? + <> +
{key.roundedPercentage}
+
+ {key.status} +
+ + : null} +
+ ); + })} +
+ +
+ {lifecycleData.summations.map((key) => ( + +
+ {key.status} [{lifecycleData.statusCounts[key.status]}] ({key.roundedPercentage}) +
+
+ ))} +
+
+ + b.value - a.value).slice(0, 3)}/> + + + + + ); +} + +export const JobLifecycleDashboardGQL = ` +job_lifecycle: jobs(where: { + actual_in: { + _gte: "${moment().subtract(45, 'days').toISOString()}" + } +}) { + id + actual_in +} `; \ No newline at end of file diff --git a/client/src/components/dashboard-grid/dashboard-grid.component.jsx b/client/src/components/dashboard-grid/dashboard-grid.component.jsx index 7012d943b..2843593b4 100644 --- a/client/src/components/dashboard-grid/dashboard-grid.component.jsx +++ b/client/src/components/dashboard-grid/dashboard-grid.component.jsx @@ -1,380 +1,391 @@ -import Icon, { SyncOutlined } from "@ant-design/icons"; -import { gql, useMutation, useQuery } from "@apollo/client"; -import { Button, Dropdown, Menu, PageHeader, Space, notification } from "antd"; +import Icon, {SyncOutlined} from "@ant-design/icons"; +import {gql, useMutation, useQuery} from "@apollo/client"; +import {Button, Dropdown, Menu, notification, PageHeader, Space} from "antd"; import i18next from "i18next"; import _ from "lodash"; import moment from "moment"; -import React, { useState } from "react"; -import { Responsive, WidthProvider } from "react-grid-layout"; -import { useTranslation } from "react-i18next"; -import { MdClose } from "react-icons/md"; -import { connect } from "react-redux"; -import { createStructuredSelector } from "reselect"; -import { logImEXEvent } from "../../firebase/firebase.utils"; -import { UPDATE_DASHBOARD_LAYOUT } from "../../graphql/user.queries"; -import { - selectBodyshop, - selectCurrentUser, -} from "../../redux/user/user.selectors"; +import React, {useState} from "react"; +import {Responsive, WidthProvider} from "react-grid-layout"; +import {useTranslation} from "react-i18next"; +import {MdClose} from "react-icons/md"; +import {connect} from "react-redux"; +import {createStructuredSelector} from "reselect"; +import {logImEXEvent} from "../../firebase/firebase.utils"; +import {UPDATE_DASHBOARD_LAYOUT} from "../../graphql/user.queries"; +import {selectBodyshop, selectCurrentUser,} from "../../redux/user/user.selectors"; import AlertComponent from "../alert/alert.component"; import DashboardMonthlyEmployeeEfficiency, { - DashboardMonthlyEmployeeEfficiencyGql, + DashboardMonthlyEmployeeEfficiencyGql, } from "../dashboard-components/monthly-employee-efficiency/monthly-employee-efficiency.component"; import DashboardMonthlyJobCosting from "../dashboard-components/monthly-job-costing/monthly-job-costing.component"; import DashboardMonthlyLaborSales from "../dashboard-components/monthly-labor-sales/monthly-labor-sales.component"; import DashboardMonthlyPartsSales from "../dashboard-components/monthly-parts-sales/monthly-parts-sales.component"; import DashboardMonthlyRevenueGraph, { - DashboardMonthlyRevenueGraphGql, + DashboardMonthlyRevenueGraphGql, } from "../dashboard-components/monthly-revenue-graph/monthly-revenue-graph.component"; import DashboardProjectedMonthlySales, { - DashboardProjectedMonthlySalesGql, + DashboardProjectedMonthlySalesGql, } from "../dashboard-components/pojected-monthly-sales/projected-monthly-sales.component"; -import DashboardTotalProductionDollars from "../dashboard-components/total-production-dollars/total-production-dollars.component"; +import DashboardTotalProductionDollars + from "../dashboard-components/total-production-dollars/total-production-dollars.component"; import DashboardTotalProductionHours, { - DashboardTotalProductionHoursGql, + DashboardTotalProductionHoursGql, } from "../dashboard-components/total-production-hours/total-production-hours.component"; import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component"; //Combination of the following: // /node_modules/react-grid-layout/css/styles.css // /node_modules/react-resizable/css/styles.css import DashboardScheduledInToday, { - DashboardScheduledInTodayGql, + DashboardScheduledInTodayGql, } from "../dashboard-components/scheduled-in-today/scheduled-in-today.component"; import DashboardScheduledOutToday, { - DashboardScheduledOutTodayGql, + DashboardScheduledOutTodayGql, } from "../dashboard-components/scheduled-out-today/scheduled-out-today.component"; +import JobLifecycleDashboardComponent, { + JobLifecycleDashboardGQL +} from "../dashboard-components/job-lifecycle/job-lifecycle-dashboard.component"; import "./dashboard-grid.styles.scss"; -import { GenerateDashboardData } from "./dashboard-grid.utils"; +import {GenerateDashboardData} from "./dashboard-grid.utils"; const ResponsiveReactGridLayout = WidthProvider(Responsive); const mapStateToProps = createStructuredSelector({ - currentUser: selectCurrentUser, - bodyshop: selectBodyshop, + currentUser: selectCurrentUser, + bodyshop: selectBodyshop, }); const mapDispatchToProps = (dispatch) => ({ - //setUserLanguage: language => dispatch(setUserLanguage(language)) + //setUserLanguage: language => dispatch(setUserLanguage(language)) }); -export function DashboardGridComponent({ currentUser, bodyshop }) { - const { t } = useTranslation(); - const [state, setState] = useState({ - ...(bodyshop.associations[0].user.dashboardlayout - ? bodyshop.associations[0].user.dashboardlayout - : { items: [], layout: {}, layouts: [] }), - }); - - const { loading, error, data, refetch } = useQuery( - createDashboardQuery(state), - { fetchPolicy: "network-only", nextFetchPolicy: "network-only" } - ); - - const [updateLayout] = useMutation(UPDATE_DASHBOARD_LAYOUT); - - const handleLayoutChange = async (layout, layouts) => { - logImEXEvent("dashboard_change_layout"); - - setState({ ...state, layout, layouts }); - - const result = await updateLayout({ - variables: { - email: currentUser.email, - layout: { ...state, layout, layouts }, - }, +export function DashboardGridComponent({currentUser, bodyshop}) { + const {t} = useTranslation(); + const [state, setState] = useState({ + ...(bodyshop.associations[0].user.dashboardlayout + ? bodyshop.associations[0].user.dashboardlayout + : {items: [], layout: {}, layouts: []}), }); - if (!!result.errors) { - notification["error"]({ - message: t("dashboard.errors.updatinglayout", { - message: JSON.stringify(result.errors), - }), - }); - } - }; - const handleRemoveComponent = (key) => { - logImEXEvent("dashboard_remove_component", { name: key }); - const idxToRemove = state.items.findIndex((i) => i.i === key); - const items = _.cloneDeep(state.items); + const {loading, error, data, refetch} = useQuery( + createDashboardQuery(state), + {fetchPolicy: "network-only", nextFetchPolicy: "network-only"} + ); - items.splice(idxToRemove, 1); - setState({ ...state, items }); - }; + const [updateLayout] = useMutation(UPDATE_DASHBOARD_LAYOUT); - const handleAddComponent = (e) => { - logImEXEvent("dashboard_add_component", { name: e }); - setState({ - ...state, - items: [ - ...state.items, - { - i: e.key, - x: (state.items.length * 2) % (state.cols || 12), - y: 99, // puts it at the bottom - w: componentList[e.key].w || 2, - h: componentList[e.key].h || 2, - }, - ], - }); - }; + const handleLayoutChange = async (layout, layouts) => { + logImEXEvent("dashboard_change_layout"); - const dashboarddata = React.useMemo( - () => GenerateDashboardData(data), - [data] - ); - const existingLayoutKeys = state.items.map((i) => i.i); - const addComponentOverlay = ( - - {Object.keys(componentList).map((key) => ( - - {componentList[key].label} - - ))} - - ); + setState({...state, layout, layouts}); - if (error) return ; - - return ( -
- - - - - - + const result = await updateLayout({ + variables: { + email: currentUser.email, + layout: {...state, layout, layouts}, + }, + }); + if (!!result.errors) { + notification["error"]({ + message: t("dashboard.errors.updatinglayout", { + message: JSON.stringify(result.errors), + }), + }); } - /> + }; + const handleRemoveComponent = (key) => { + logImEXEvent("dashboard_remove_component", {name: key}); + const idxToRemove = state.items.findIndex((i) => i.i === key); - - {state.items.map((item, index) => { - const TheComponent = componentList[item.i].component; - return ( -
{ + logImEXEvent("dashboard_add_component", {name: e}); + setState({ + ...state, + items: [ + ...state.items, + { + i: e.key, + x: (state.items.length * 2) % (state.cols || 12), + y: 99, // puts it at the bottom + w: componentList[e.key].w || 2, + h: componentList[e.key].h || 2, + }, + ], + }); + }; + + const dashboarddata = React.useMemo( + () => GenerateDashboardData(data), + [data] + ); + const existingLayoutKeys = state.items.map((i) => i.i); + const addComponentOverlay = ( + + {Object.keys(componentList).map((key) => ( + + {componentList[key].label} + + ))} + + ); + + if (error) return ; + + return ( +
+ + + + + + + } + /> + + - - handleRemoveComponent(item.i)} - /> - - -
- ); - })} - -
- ); + {state.items.map((item, index) => { + const TheComponent = componentList[item.i].component; + return ( +
+ + handleRemoveComponent(item.i)} + /> + + +
+ ); + })} +
+
+ ); } export default connect( - mapStateToProps, - mapDispatchToProps + mapStateToProps, + mapDispatchToProps )(DashboardGridComponent); const componentList = { - ProductionDollars: { - label: i18next.t("dashboard.titles.productiondollars"), - component: DashboardTotalProductionDollars, - gqlFragment: null, - w: 1, - h: 1, - minW: 2, - minH: 1, - }, - ProductionHours: { - label: i18next.t("dashboard.titles.productionhours"), - component: DashboardTotalProductionHours, - gqlFragment: DashboardTotalProductionHoursGql, - w: 3, - h: 1, - minW: 3, - minH: 1, - }, - ProjectedMonthlySales: { - label: i18next.t("dashboard.titles.projectedmonthlysales"), - component: DashboardProjectedMonthlySales, - gqlFragment: DashboardProjectedMonthlySalesGql, - w: 2, - h: 1, - minW: 2, - minH: 1, - }, - MonthlyRevenueGraph: { - label: i18next.t("dashboard.titles.monthlyrevenuegraph"), - component: DashboardMonthlyRevenueGraph, - gqlFragment: DashboardMonthlyRevenueGraphGql, - w: 4, - h: 2, - minW: 4, - minH: 2, - }, - MonthlyJobCosting: { - label: i18next.t("dashboard.titles.monthlyjobcosting"), - component: DashboardMonthlyJobCosting, - gqlFragment: null, - minW: 6, - minH: 3, - w: 6, - h: 3, - }, - MonthlyPartsSales: { - label: i18next.t("dashboard.titles.monthlypartssales"), - component: DashboardMonthlyPartsSales, - gqlFragment: null, - minW: 2, - minH: 2, - w: 2, - h: 2, - }, - MonthlyLaborSales: { - label: i18next.t("dashboard.titles.monthlylaborsales"), - component: DashboardMonthlyLaborSales, - gqlFragment: null, - minW: 2, - minH: 2, - w: 2, - h: 2, - }, - MonthlyEmployeeEfficency: { - label: i18next.t("dashboard.titles.monthlyemployeeefficiency"), - component: DashboardMonthlyEmployeeEfficiency, - gqlFragment: DashboardMonthlyEmployeeEfficiencyGql, - minW: 2, - minH: 2, - w: 2, - h: 2, - }, - ScheduleInToday: { - label: i18next.t("dashboard.titles.scheduledintoday"), - component: DashboardScheduledInToday, - gqlFragment: DashboardScheduledInTodayGql, - minW: 6, - minH: 2, - w: 10, - h: 3, - }, - ScheduleOutToday: { - label: i18next.t("dashboard.titles.scheduledouttoday"), - component: DashboardScheduledOutToday, - gqlFragment: DashboardScheduledOutTodayGql, - minW: 6, - minH: 2, - w: 10, - h: 3, - }, + ProductionDollars: { + label: i18next.t("dashboard.titles.productiondollars"), + component: DashboardTotalProductionDollars, + gqlFragment: null, + w: 1, + h: 1, + minW: 2, + minH: 1, + }, + ProductionHours: { + label: i18next.t("dashboard.titles.productionhours"), + component: DashboardTotalProductionHours, + gqlFragment: DashboardTotalProductionHoursGql, + w: 3, + h: 1, + minW: 3, + minH: 1, + }, + ProjectedMonthlySales: { + label: i18next.t("dashboard.titles.projectedmonthlysales"), + component: DashboardProjectedMonthlySales, + gqlFragment: DashboardProjectedMonthlySalesGql, + w: 2, + h: 1, + minW: 2, + minH: 1, + }, + MonthlyRevenueGraph: { + label: i18next.t("dashboard.titles.monthlyrevenuegraph"), + component: DashboardMonthlyRevenueGraph, + gqlFragment: DashboardMonthlyRevenueGraphGql, + w: 4, + h: 2, + minW: 4, + minH: 2, + }, + MonthlyJobCosting: { + label: i18next.t("dashboard.titles.monthlyjobcosting"), + component: DashboardMonthlyJobCosting, + gqlFragment: null, + minW: 6, + minH: 3, + w: 6, + h: 3, + }, + MonthlyPartsSales: { + label: i18next.t("dashboard.titles.monthlypartssales"), + component: DashboardMonthlyPartsSales, + gqlFragment: null, + minW: 2, + minH: 2, + w: 2, + h: 2, + }, + MonthlyLaborSales: { + label: i18next.t("dashboard.titles.monthlylaborsales"), + component: DashboardMonthlyLaborSales, + gqlFragment: null, + minW: 2, + minH: 2, + w: 2, + h: 2, + }, + // Typo in Efficency should be Efficiency, but changing it would reset users dashboard settings + MonthlyEmployeeEfficency: { + label: i18next.t("dashboard.titles.monthlyemployeeefficiency"), + component: DashboardMonthlyEmployeeEfficiency, + gqlFragment: DashboardMonthlyEmployeeEfficiencyGql, + minW: 2, + minH: 2, + w: 2, + h: 2, + }, + ScheduleInToday: { + label: i18next.t("dashboard.titles.scheduledintoday"), + component: DashboardScheduledInToday, + gqlFragment: DashboardScheduledInTodayGql, + minW: 6, + minH: 2, + w: 10, + h: 3, + }, + ScheduleOutToday: { + label: i18next.t("dashboard.titles.scheduledouttoday"), + component: DashboardScheduledOutToday, + gqlFragment: DashboardScheduledOutTodayGql, + minW: 6, + minH: 2, + w: 10, + h: 3, + }, + JobLifecycle: { + label: i18next.t("dashboard.titles.joblifecycle"), + component: JobLifecycleDashboardComponent, + gqlFragment: JobLifecycleDashboardGQL, + minW: 6, + minH: 3, + w: 6, + h: 3, + }, }; const createDashboardQuery = (state) => { - const componentBasedAdditions = - state && - Array.isArray(state.layout) && - state.layout - .map((item, index) => componentList[item.i].gqlFragment || "") - .join(""); - return gql` - query QUERY_DASHBOARD_DETAILS { ${componentBasedAdditions || ""} - monthly_sales: jobs(where: {_and: [ - { voided: {_eq: false}}, - {date_invoiced: {_gte: "${moment() + const componentBasedAdditions = + state && + Array.isArray(state.layout) && + state.layout + .map((item, index) => componentList[item.i].gqlFragment || "") + .join(""); + return gql` + query QUERY_DASHBOARD_DETAILS { ${componentBasedAdditions || ""} + monthly_sales: jobs(where: {_and: [ + { voided: {_eq: false}}, + {date_invoiced: {_gte: "${moment() .startOf("month") - .startOf("day") - .toISOString()}"}}, {date_invoiced: {_lte: "${moment() + .startOf("day") + .toISOString()}"}}, {date_invoiced: {_lte: "${moment() .endOf("month") .endOf("day") .toISOString()}"}}]}) { id - ro_number - date_invoiced - job_totals - rate_la1 - rate_la2 - rate_la3 - rate_la4 - rate_laa - rate_lab - rate_lad - rate_lae - rate_laf - rate_lag - rate_lam - rate_lar - rate_las - rate_lau - rate_ma2s - rate_ma2t - rate_ma3s - rate_mabl - rate_macs - rate_mahw - rate_mapa - rate_mash - rate_matd - joblines(where: { removed: { _eq: false } }) { - id - mod_lbr_ty - mod_lb_hrs - act_price - part_qty - part_type - } - } - production_jobs: jobs(where: { inproduction: { _eq: true } }) { - id - ro_number - ins_co_nm - job_totals - joblines(where: { removed: { _eq: false } }) { - id - mod_lbr_ty - mod_lb_hrs - act_price - part_qty - part_type - } - labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } }) { - aggregate { - sum { - mod_lb_hrs - } - } - } - larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" }, removed: { _eq: false } }) { - aggregate { - sum { - mod_lb_hrs - } - } - } - } - }`; + ro_number + date_invoiced + job_totals + rate_la1 + rate_la2 + rate_la3 + rate_la4 + rate_laa + rate_lab + rate_lad + rate_lae + rate_laf + rate_lag + rate_lam + rate_lar + rate_las + rate_lau + rate_ma2s + rate_ma2t + rate_ma3s + rate_mabl + rate_macs + rate_mahw + rate_mapa + rate_mash + rate_matd + joblines(where: { removed: { _eq: false } }) { + id + mod_lbr_ty + mod_lb_hrs + act_price + part_qty + part_type + } + } + production_jobs: jobs(where: { inproduction: { _eq: true } }) { + id + ro_number + ins_co_nm + job_totals + joblines(where: { removed: { _eq: false } }) { + id + mod_lbr_ty + mod_lb_hrs + act_price + part_qty + part_type + } + labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } }) { + aggregate { + sum { + mod_lb_hrs + } + } + } + larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" }, removed: { _eq: false } }) { + aggregate { + sum { + mod_lb_hrs + } + } + } + } + }`; }; diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 40323fecb..11ed00362 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -1236,7 +1236,15 @@ "relative_end": "Relative End", "relative_start": "Relative Start", "start": "Start", - "value": "Value" + "value": "Value", + "status": "Status", + "percentage": "Percentage", + "human_readable": "Human Readable", + "status_count": "In Status" + }, + "titles": { + "dashboard": "Job Lifecycle", + "top_durations": "Top Durations" }, "content": { "current_status_accumulated_time": "Current Status Accumulated Time", @@ -1248,7 +1256,9 @@ "title": "Job Lifecycle Component", "title_durations": "Historical Status Durations", "title_loading": "Loading", - "title_transitions": "Transitions" + "title_transitions": "Transitions", + "calculated_based_on": "Calculated based on", + "jobs_in_since": "Jobs in since" }, "errors": { "fetch": "Error getting Job Lifecycle Data" diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 6e3e0ec9b..996ba84bc 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -1236,7 +1236,15 @@ "relative_end": "", "relative_start": "", "start": "", - "value": "" + "value": "", + "status": "", + "percentage": "", + "human_readable": "", + "status_count": "" + }, + "titles": { + "dashboard": "", + "top_durations": "" }, "content": { "current_status_accumulated_time": "", @@ -1248,7 +1256,9 @@ "title": "", "title_durations": "", "title_loading": "", - "title_transitions": "" + "title_transitions": "", + "calculated_based_on": "", + "jobs_in_since": "" }, "errors": { "fetch": "Error al obtener los datos del ciclo de vida del trabajo" diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 4b4818371..b6130f4d5 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -1236,7 +1236,15 @@ "relative_end": "", "relative_start": "", "start": "", - "value": "" + "value": "", + "status": "", + "percentage": "", + "human_readable": "", + "status_count": "" + }, + "titles": { + "dashboard": "", + "top_durations": "" }, "content": { "current_status_accumulated_time": "", @@ -1248,7 +1256,9 @@ "title": "", "title_durations": "", "title_loading": "", - "title_transitions": "" + "title_transitions": "", + "calculated_based_on": "", + "jobs_in_since": "" }, "errors": { "fetch": "Erreur lors de l'obtention des données du cycle de vie des tâches" diff --git a/server/job/job-lifecycle.js b/server/job/job-lifecycle.js index c2ea72737..b94f9ab68 100644 --- a/server/job/job-lifecycle.js +++ b/server/job/job-lifecycle.js @@ -3,6 +3,7 @@ const queries = require("../graphql-client/queries"); const moment = require("moment"); const durationToHumanReadable = require("../utils/durationToHumanReadable"); const calculateStatusDuration = require("../utils/calculateStatusDuration"); +const getLifecycleStatusColor = require("../utils/getLifecycleStatusColor"); const jobLifecycle = async (req, res) => { // Grab the jobids and statuses from the request body @@ -28,12 +29,12 @@ const jobLifecycle = async (req, res) => { jobIDs, transitions: [] }); - } const transitionsByJobId = _.groupBy(resp.transitions, 'jobid'); const groupedTransitions = {}; + const allDurations = []; for (let jobId in transitionsByJobId) { let lifecycle = transitionsByJobId[jobId].map(transition => { @@ -53,15 +54,57 @@ const jobLifecycle = async (req, res) => { return transition; }); + const durations = calculateStatusDuration(lifecycle, statuses); + groupedTransitions[jobId] = { - lifecycle: lifecycle, - durations: calculateStatusDuration(lifecycle, statuses), + lifecycle, + durations }; + + if (durations?.summations) { + allDurations.push(durations.summations); + } } + const finalSummations = []; + const flatGroupedAllDurations = _.groupBy(allDurations.flat(),'status'); + + const finalStatusCounts = Object.keys(flatGroupedAllDurations).reduce((acc, status) => { + acc[status] = flatGroupedAllDurations[status].length; + return acc; + }, {}); + // Calculate total value of all statuses + const finalTotal = Object.values(flatGroupedAllDurations).reduce((total, statusArr) => { + return total + statusArr.reduce((acc, curr) => acc + curr.value, 0); + }, 0); + + Object.keys(flatGroupedAllDurations).forEach(status => { + const value = flatGroupedAllDurations[status].reduce((acc, curr) => acc + curr.value, 0); + const humanReadable = durationToHumanReadable(moment.duration(value)); + const percentage = (value / finalTotal) * 100; + const color = getLifecycleStatusColor(status); + const roundedPercentage = `${Math.round(percentage)}%`; + finalSummations.push({ + status, + value, + humanReadable, + percentage, + color, + roundedPercentage + }); + }); + return res.status(200).json({ jobIDs, transition: groupedTransitions, + durations: { + jobs: jobIDs.length, + summations: finalSummations, + totalStatuses: finalSummations.length, + total: finalTotal, + statusCounts: finalStatusCounts, + humanReadable: durationToHumanReadable(moment.duration(finalTotal)) + } }); } diff --git a/server/utils/calculateStatusDuration.js b/server/utils/calculateStatusDuration.js index 16165d001..1fcdcc487 100644 --- a/server/utils/calculateStatusDuration.js +++ b/server/utils/calculateStatusDuration.js @@ -1,15 +1,7 @@ const durationToHumanReadable = require("./durationToHumanReadable"); const moment = require("moment"); +const getLifecycleStatusColor = require("./getLifecycleStatusColor"); const _ = require("lodash"); -const crypto = require('crypto'); - -const getColor = (key) => { - const hash = crypto.createHash('sha256'); - hash.update(key); - const hashedKey = hash.digest('hex'); - const num = parseInt(hashedKey, 16); - return '#' + (num % 16777215).toString(16).padStart(6, '0'); -}; const calculateStatusDuration = (transitions, statuses) => { let statusDuration = {}; @@ -33,26 +25,16 @@ const calculateStatusDuration = (transitions, statuses) => { if (!transition.prev_value) { statusDuration[transition.value] = { value: duration, - humanReadable: transition.duration_readable + humanReadable: durationToHumanReadable(moment.duration(duration)) }; - } else if (!transition.next_value) { + } else { if (statusDuration[transition.value]) { statusDuration[transition.value].value += duration; - statusDuration[transition.value].humanReadable = transition.duration_readable; + statusDuration[transition.value].humanReadable = durationToHumanReadable(moment.duration(statusDuration[transition.value].value)); } else { statusDuration[transition.value] = { value: duration, - humanReadable: transition.duration_readable - }; - } - } else { - if (statusDuration[transition.value]) { - statusDuration[transition.value].value += duration; - statusDuration[transition.value].humanReadable = transition.duration_readable; - } else { - statusDuration[transition.value] = { - value: duration, - humanReadable: transition.duration_readable + humanReadable: durationToHumanReadable(moment.duration(duration)) }; } } @@ -79,7 +61,7 @@ const calculateStatusDuration = (transitions, statuses) => { value, humanReadable, percentage: statusDuration[status].percentage, - color: getColor(status), + color: getLifecycleStatusColor(status), roundedPercentage: `${Math.round(statusDuration[status].percentage)}%` }); } diff --git a/server/utils/getLifecycleStatusColor.js b/server/utils/getLifecycleStatusColor.js new file mode 100644 index 000000000..d726e1cb3 --- /dev/null +++ b/server/utils/getLifecycleStatusColor.js @@ -0,0 +1,11 @@ +const crypto = require('crypto'); + +const getLifecycleStatusColor = (key) => { + const hash = crypto.createHash('sha256'); + hash.update(key); + const hashedKey = hash.digest('hex'); + const num = parseInt(hashedKey, 16); + return '#' + (num % 16777215).toString(16).padStart(6, '0'); +}; + +module.exports = getLifecycleStatusColor; \ No newline at end of file