Merged in feature/IO-2650-Lifecycle-V2 (pull request #1344)

Feature/IO-2650 Lifecycle V2
This commit is contained in:
Dave Richer
2024-03-14 16:33:14 +00:00
8 changed files with 602 additions and 357 deletions

View File

@@ -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 <Tag color={record.color}>{record.status}</Tag>
}
},
{
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 <DashboardRefreshRequired {...cardProps} />;
const extra = `${t('job_lifecycle.content.calculated_based_on')} ${lifecycleData.jobs} ${t('job_lifecycle.content.jobs_in_since')} ${fortyFiveDaysAgo()}`
return (
<Card title={t("job_lifecycle.titles.dashboard")} {...cardProps}>
<LoadingSkeleton loading={loading}>
<div style={{overflow: 'scroll', height: "100%"}}>
<div id="bar-container" style={{
display: 'flex',
width: '100%',
height: '100px',
textAlign: 'center',
borderRadius: '5px',
borderWidth: '5px',
borderStyle: 'solid',
borderColor: '#f0f2f5',
margin: 0,
padding: 0
}}>
{lifecycleData.summations.map((key, index, array) => {
const isFirst = index === 0;
const isLast = index === array.length - 1;
return (
<div key={key.status} style={{
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
margin: 0,
padding: 0,
borderTop: '1px solid #f0f2f5',
borderBottom: '1px solid #f0f2f5',
borderLeft: isFirst ? '1px solid #f0f2f5' : undefined,
borderRight: isLast ? '1px solid #f0f2f5' : undefined,
backgroundColor: key.color,
width: `${key.percentage}%`
}}
aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
>
{key.percentage > 15 ?
<>
<div>{key.roundedPercentage}</div>
<div style={{
backgroundColor: '#f0f2f5',
borderRadius: '5px',
paddingRight: '2px',
paddingLeft: '2px',
fontSize: '0.8rem',
}}>
{key.status}
</div>
</>
: null}
</div>
);
})}
</div>
<Card extra={extra} type='inner' title={t('job_lifecycle.content.legend_title')}
style={{marginTop: '10px'}}>
<div>
{lifecycleData.summations.map((key) => (
<Tag color={key.color} style={{width: '13vh', padding: '4px', margin: '4px'}}>
<div
aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
style={{
backgroundColor: '#f0f2f5',
color: '#000',
padding: '4px',
textAlign: 'center'
}}>
{key.status} [{lifecycleData.statusCounts[key.status]}] ({key.roundedPercentage})
</div>
</Tag>
))}
</div>
</Card>
<Card style={{marginTop: "5px"}} type='inner' title={t("job_lifecycle.titles.top_durations")}>
<Table size="small" pagination={false} columns={columns} dataSource={ lifecycleData.summations.sort((a, b) => b.value - a.value).slice(0, 3)}/>
</Card>
</div>
</LoadingSkeleton>
</Card>
);
}
export const JobLifecycleDashboardGQL = `
job_lifecycle: jobs(where: {
actual_in: {
_gte: "${moment().subtract(45, 'days').toISOString()}"
}
}) {
id
actual_in
} `;

View File

@@ -1,6 +1,6 @@
import Icon, {SyncOutlined} from "@ant-design/icons"; import Icon, {SyncOutlined} from "@ant-design/icons";
import {gql, useMutation, useQuery} from "@apollo/client"; import {gql, useMutation, useQuery} from "@apollo/client";
import { Button, Dropdown, Menu, PageHeader, Space, notification } from "antd"; import {Button, Dropdown, Menu, notification, PageHeader, Space} from "antd";
import i18next from "i18next"; import i18next from "i18next";
import _ from "lodash"; import _ from "lodash";
import moment from "moment"; import moment from "moment";
@@ -12,10 +12,7 @@ import { connect } from "react-redux";
import {createStructuredSelector} from "reselect"; import {createStructuredSelector} from "reselect";
import {logImEXEvent} from "../../firebase/firebase.utils"; import {logImEXEvent} from "../../firebase/firebase.utils";
import {UPDATE_DASHBOARD_LAYOUT} from "../../graphql/user.queries"; import {UPDATE_DASHBOARD_LAYOUT} from "../../graphql/user.queries";
import { import {selectBodyshop, selectCurrentUser,} from "../../redux/user/user.selectors";
selectBodyshop,
selectCurrentUser,
} from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import DashboardMonthlyEmployeeEfficiency, { import DashboardMonthlyEmployeeEfficiency, {
DashboardMonthlyEmployeeEfficiencyGql, DashboardMonthlyEmployeeEfficiencyGql,
@@ -29,7 +26,8 @@ import DashboardMonthlyRevenueGraph, {
import DashboardProjectedMonthlySales, { import DashboardProjectedMonthlySales, {
DashboardProjectedMonthlySalesGql, DashboardProjectedMonthlySalesGql,
} from "../dashboard-components/pojected-monthly-sales/projected-monthly-sales.component"; } 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, { import DashboardTotalProductionHours, {
DashboardTotalProductionHoursGql, DashboardTotalProductionHoursGql,
} from "../dashboard-components/total-production-hours/total-production-hours.component"; } from "../dashboard-components/total-production-hours/total-production-hours.component";
@@ -43,6 +41,9 @@ import DashboardScheduledInToday, {
import DashboardScheduledOutToday, { import DashboardScheduledOutToday, {
DashboardScheduledOutTodayGql, DashboardScheduledOutTodayGql,
} from "../dashboard-components/scheduled-out-today/scheduled-out-today.component"; } 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 "./dashboard-grid.styles.scss";
import {GenerateDashboardData} from "./dashboard-grid.utils"; import {GenerateDashboardData} from "./dashboard-grid.utils";
@@ -186,7 +187,7 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
}} }}
onClick={() => handleRemoveComponent(item.i)} onClick={() => handleRemoveComponent(item.i)}
/> />
<TheComponent className="dashboard-card" data={dashboarddata} /> <TheComponent className="dashboard-card" bodyshop={bodyshop} data={dashboarddata}/>
</LoadingSkeleton> </LoadingSkeleton>
</div> </div>
); );
@@ -265,6 +266,7 @@ const componentList = {
w: 2, w: 2,
h: 2, h: 2,
}, },
// Typo in Efficency should be Efficiency, but changing it would reset users dashboard settings
MonthlyEmployeeEfficency: { MonthlyEmployeeEfficency: {
label: i18next.t("dashboard.titles.monthlyemployeeefficiency"), label: i18next.t("dashboard.titles.monthlyemployeeefficiency"),
component: DashboardMonthlyEmployeeEfficiency, component: DashboardMonthlyEmployeeEfficiency,
@@ -292,6 +294,15 @@ const componentList = {
w: 10, w: 10,
h: 3, 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 createDashboardQuery = (state) => {

View File

@@ -1236,7 +1236,15 @@
"relative_end": "Relative End", "relative_end": "Relative End",
"relative_start": "Relative Start", "relative_start": "Relative Start",
"start": "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": { "content": {
"current_status_accumulated_time": "Current Status Accumulated Time", "current_status_accumulated_time": "Current Status Accumulated Time",
@@ -1248,7 +1256,9 @@
"title": "Job Lifecycle Component", "title": "Job Lifecycle Component",
"title_durations": "Historical Status Durations", "title_durations": "Historical Status Durations",
"title_loading": "Loading", "title_loading": "Loading",
"title_transitions": "Transitions" "title_transitions": "Transitions",
"calculated_based_on": "Calculated based on",
"jobs_in_since": "Jobs in since"
}, },
"errors": { "errors": {
"fetch": "Error getting Job Lifecycle Data" "fetch": "Error getting Job Lifecycle Data"

View File

@@ -1236,7 +1236,15 @@
"relative_end": "", "relative_end": "",
"relative_start": "", "relative_start": "",
"start": "", "start": "",
"value": "" "value": "",
"status": "",
"percentage": "",
"human_readable": "",
"status_count": ""
},
"titles": {
"dashboard": "",
"top_durations": ""
}, },
"content": { "content": {
"current_status_accumulated_time": "", "current_status_accumulated_time": "",
@@ -1248,7 +1256,9 @@
"title": "", "title": "",
"title_durations": "", "title_durations": "",
"title_loading": "", "title_loading": "",
"title_transitions": "" "title_transitions": "",
"calculated_based_on": "",
"jobs_in_since": ""
}, },
"errors": { "errors": {
"fetch": "Error al obtener los datos del ciclo de vida del trabajo" "fetch": "Error al obtener los datos del ciclo de vida del trabajo"

View File

@@ -1236,7 +1236,15 @@
"relative_end": "", "relative_end": "",
"relative_start": "", "relative_start": "",
"start": "", "start": "",
"value": "" "value": "",
"status": "",
"percentage": "",
"human_readable": "",
"status_count": ""
},
"titles": {
"dashboard": "",
"top_durations": ""
}, },
"content": { "content": {
"current_status_accumulated_time": "", "current_status_accumulated_time": "",
@@ -1248,7 +1256,9 @@
"title": "", "title": "",
"title_durations": "", "title_durations": "",
"title_loading": "", "title_loading": "",
"title_transitions": "" "title_transitions": "",
"calculated_based_on": "",
"jobs_in_since": ""
}, },
"errors": { "errors": {
"fetch": "Erreur lors de l'obtention des données du cycle de vie des tâches" "fetch": "Erreur lors de l'obtention des données du cycle de vie des tâches"

View File

@@ -3,6 +3,7 @@ const queries = require("../graphql-client/queries");
const moment = require("moment"); const moment = require("moment");
const durationToHumanReadable = require("../utils/durationToHumanReadable"); const durationToHumanReadable = require("../utils/durationToHumanReadable");
const calculateStatusDuration = require("../utils/calculateStatusDuration"); const calculateStatusDuration = require("../utils/calculateStatusDuration");
const getLifecycleStatusColor = require("../utils/getLifecycleStatusColor");
const jobLifecycle = async (req, res) => { const jobLifecycle = async (req, res) => {
// Grab the jobids and statuses from the request body // Grab the jobids and statuses from the request body
@@ -28,12 +29,12 @@ const jobLifecycle = async (req, res) => {
jobIDs, jobIDs,
transitions: [] transitions: []
}); });
} }
const transitionsByJobId = _.groupBy(resp.transitions, 'jobid'); const transitionsByJobId = _.groupBy(resp.transitions, 'jobid');
const groupedTransitions = {}; const groupedTransitions = {};
const allDurations = [];
for (let jobId in transitionsByJobId) { for (let jobId in transitionsByJobId) {
let lifecycle = transitionsByJobId[jobId].map(transition => { let lifecycle = transitionsByJobId[jobId].map(transition => {
@@ -53,15 +54,57 @@ const jobLifecycle = async (req, res) => {
return transition; return transition;
}); });
const durations = calculateStatusDuration(lifecycle, statuses);
groupedTransitions[jobId] = { groupedTransitions[jobId] = {
lifecycle: lifecycle, lifecycle,
durations: calculateStatusDuration(lifecycle, statuses), 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({ return res.status(200).json({
jobIDs, jobIDs,
transition: groupedTransitions, transition: groupedTransitions,
durations: {
jobs: jobIDs.length,
summations: finalSummations,
totalStatuses: finalSummations.length,
total: finalTotal,
statusCounts: finalStatusCounts,
humanReadable: durationToHumanReadable(moment.duration(finalTotal))
}
}); });
} }

View File

@@ -1,15 +1,7 @@
const durationToHumanReadable = require("./durationToHumanReadable"); const durationToHumanReadable = require("./durationToHumanReadable");
const moment = require("moment"); const moment = require("moment");
const getLifecycleStatusColor = require("./getLifecycleStatusColor");
const _ = require("lodash"); 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) => { const calculateStatusDuration = (transitions, statuses) => {
let statusDuration = {}; let statusDuration = {};
@@ -33,26 +25,16 @@ const calculateStatusDuration = (transitions, statuses) => {
if (!transition.prev_value) { if (!transition.prev_value) {
statusDuration[transition.value] = { statusDuration[transition.value] = {
value: duration, value: duration,
humanReadable: transition.duration_readable humanReadable: durationToHumanReadable(moment.duration(duration))
}; };
} else if (!transition.next_value) {
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
};
}
} else { } else {
if (statusDuration[transition.value]) { if (statusDuration[transition.value]) {
statusDuration[transition.value].value += duration; statusDuration[transition.value].value += duration;
statusDuration[transition.value].humanReadable = transition.duration_readable; statusDuration[transition.value].humanReadable = durationToHumanReadable(moment.duration(statusDuration[transition.value].value));
} else { } else {
statusDuration[transition.value] = { statusDuration[transition.value] = {
value: duration, value: duration,
humanReadable: transition.duration_readable humanReadable: durationToHumanReadable(moment.duration(duration))
}; };
} }
} }
@@ -79,7 +61,7 @@ const calculateStatusDuration = (transitions, statuses) => {
value, value,
humanReadable, humanReadable,
percentage: statusDuration[status].percentage, percentage: statusDuration[status].percentage,
color: getColor(status), color: getLifecycleStatusColor(status),
roundedPercentage: `${Math.round(statusDuration[status].percentage)}%` roundedPercentage: `${Math.round(statusDuration[status].percentage)}%`
}); });
} }

View File

@@ -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;