Compare commits

..

3 Commits

Author SHA1 Message Date
Patrick Fic
c27e206687 Add index to audit trail. 2024-03-15 10:24:00 -07:00
Patrick Fic
01fd253f1d Manual modification to hasura migration. 2024-03-15 10:23:13 -07:00
Patrick Fic
3eab3e2fb6 Add ioevent logging for events. 2024-03-15 09:55:14 -07:00
27 changed files with 441 additions and 626 deletions

View File

@@ -1,169 +0,0 @@
import {Card, 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, bodyshop]);
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,391 +1,380 @@
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, notification, PageHeader, Space} from "antd"; import { Button, Dropdown, Menu, PageHeader, Space, notification } from "antd";
import i18next from "i18next"; import i18next from "i18next";
import _ from "lodash"; import _ from "lodash";
import moment from "moment"; import moment from "moment";
import React, {useState} from "react"; import React, { useState } from "react";
import {Responsive, WidthProvider} from "react-grid-layout"; import { Responsive, WidthProvider } from "react-grid-layout";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import {MdClose} from "react-icons/md"; import { MdClose } from "react-icons/md";
import {connect} from "react-redux"; 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 {selectBodyshop, selectCurrentUser,} from "../../redux/user/user.selectors"; import {
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,
} from "../dashboard-components/monthly-employee-efficiency/monthly-employee-efficiency.component"; } from "../dashboard-components/monthly-employee-efficiency/monthly-employee-efficiency.component";
import DashboardMonthlyJobCosting from "../dashboard-components/monthly-job-costing/monthly-job-costing.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 DashboardMonthlyLaborSales from "../dashboard-components/monthly-labor-sales/monthly-labor-sales.component";
import DashboardMonthlyPartsSales from "../dashboard-components/monthly-parts-sales/monthly-parts-sales.component"; import DashboardMonthlyPartsSales from "../dashboard-components/monthly-parts-sales/monthly-parts-sales.component";
import DashboardMonthlyRevenueGraph, { import DashboardMonthlyRevenueGraph, {
DashboardMonthlyRevenueGraphGql, DashboardMonthlyRevenueGraphGql,
} from "../dashboard-components/monthly-revenue-graph/monthly-revenue-graph.component"; } from "../dashboard-components/monthly-revenue-graph/monthly-revenue-graph.component";
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 import DashboardTotalProductionDollars from "../dashboard-components/total-production-dollars/total-production-dollars.component";
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";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component"; import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
//Combination of the following: //Combination of the following:
// /node_modules/react-grid-layout/css/styles.css // /node_modules/react-grid-layout/css/styles.css
// /node_modules/react-resizable/css/styles.css // /node_modules/react-resizable/css/styles.css
import DashboardScheduledInToday, { import DashboardScheduledInToday, {
DashboardScheduledInTodayGql, DashboardScheduledInTodayGql,
} from "../dashboard-components/scheduled-in-today/scheduled-in-today.component"; } from "../dashboard-components/scheduled-in-today/scheduled-in-today.component";
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";
const ResponsiveReactGridLayout = WidthProvider(Responsive); const ResponsiveReactGridLayout = WidthProvider(Responsive);
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export function DashboardGridComponent({currentUser, bodyshop}) { export function DashboardGridComponent({ currentUser, bodyshop }) {
const {t} = useTranslation(); const { t } = useTranslation();
const [state, setState] = useState({ const [state, setState] = useState({
...(bodyshop.associations[0].user.dashboardlayout ...(bodyshop.associations[0].user.dashboardlayout
? bodyshop.associations[0].user.dashboardlayout ? bodyshop.associations[0].user.dashboardlayout
: {items: [], layout: {}, layouts: []}), : { 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 },
},
}); });
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 {loading, error, data, refetch} = useQuery( const items = _.cloneDeep(state.items);
createDashboardQuery(state),
{fetchPolicy: "network-only", nextFetchPolicy: "network-only"}
);
const [updateLayout] = useMutation(UPDATE_DASHBOARD_LAYOUT); items.splice(idxToRemove, 1);
setState({ ...state, items });
};
const handleLayoutChange = async (layout, layouts) => { const handleAddComponent = (e) => {
logImEXEvent("dashboard_change_layout"); 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,
},
],
});
};
setState({...state, layout, layouts}); const dashboarddata = React.useMemo(
() => GenerateDashboardData(data),
[data]
);
const existingLayoutKeys = state.items.map((i) => i.i);
const addComponentOverlay = (
<Menu onClick={handleAddComponent}>
{Object.keys(componentList).map((key) => (
<Menu.Item
key={key}
value={key}
disabled={existingLayoutKeys.includes(key)}
>
{componentList[key].label}
</Menu.Item>
))}
</Menu>
);
const result = await updateLayout({ if (error) return <AlertComponent message={error.message} type="error" />;
variables: {
email: currentUser.email, return (
layout: {...state, layout, layouts}, <div>
}, <PageHeader
}); extra={
if (!!result.errors) { <Space>
notification["error"]({ <Button onClick={() => refetch()}>
message: t("dashboard.errors.updatinglayout", { <SyncOutlined />
message: JSON.stringify(result.errors), </Button>
}), <Dropdown overlay={addComponentOverlay} trigger={["click"]}>
}); <Button>{t("dashboard.actions.addcomponent")}</Button>
</Dropdown>
</Space>
} }
}; />
const handleRemoveComponent = (key) => {
logImEXEvent("dashboard_remove_component", {name: key});
const idxToRemove = state.items.findIndex((i) => i.i === key);
const items = _.cloneDeep(state.items); <ResponsiveReactGridLayout
className="layout"
items.splice(idxToRemove, 1); breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
setState({...state, items}); cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
}; width="100%"
layouts={state.layouts}
const handleAddComponent = (e) => { onLayoutChange={handleLayoutChange}
logImEXEvent("dashboard_add_component", {name: e}); // onBreakpointChange={onBreakpointChange}
setState({ >
...state, {state.items.map((item, index) => {
items: [ const TheComponent = componentList[item.i].component;
...state.items, return (
{ <div
i: e.key, key={item.i}
x: (state.items.length * 2) % (state.cols || 12), data-grid={{
y: 99, // puts it at the bottom ...item,
w: componentList[e.key].w || 2, minH: componentList[item.i].minH || 1,
h: componentList[e.key].h || 2, minW: componentList[item.i].minW || 1,
}, }}
],
});
};
const dashboarddata = React.useMemo(
() => GenerateDashboardData(data),
[data]
);
const existingLayoutKeys = state.items.map((i) => i.i);
const addComponentOverlay = (
<Menu onClick={handleAddComponent}>
{Object.keys(componentList).map((key) => (
<Menu.Item
key={key}
value={key}
disabled={existingLayoutKeys.includes(key)}
>
{componentList[key].label}
</Menu.Item>
))}
</Menu>
);
if (error) return <AlertComponent message={error.message} type="error"/>;
return (
<div>
<PageHeader
extra={
<Space>
<Button onClick={() => refetch()}>
<SyncOutlined/>
</Button>
<Dropdown overlay={addComponentOverlay} trigger={["click"]}>
<Button>{t("dashboard.actions.addcomponent")}</Button>
</Dropdown>
</Space>
}
/>
<ResponsiveReactGridLayout
className="layout"
breakpoints={{lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0}}
cols={{lg: 12, md: 10, sm: 6, xs: 4, xxs: 2}}
width="100%"
layouts={state.layouts}
onLayoutChange={handleLayoutChange}
// onBreakpointChange={onBreakpointChange}
> >
{state.items.map((item, index) => { <LoadingSkeleton loading={loading}>
const TheComponent = componentList[item.i].component; <Icon
return ( component={MdClose}
<div key={item.i}
key={item.i} style={{
data-grid={{ position: "absolute",
...item, zIndex: "2",
minH: componentList[item.i].minH || 1, right: ".25rem",
minW: componentList[item.i].minW || 1, top: ".25rem",
}} cursor: "pointer",
> }}
<LoadingSkeleton loading={loading}> onClick={() => handleRemoveComponent(item.i)}
<Icon />
component={MdClose} <TheComponent className="dashboard-card" data={dashboarddata} />
key={item.i} </LoadingSkeleton>
style={{ </div>
position: "absolute", );
zIndex: "2", })}
right: ".25rem", </ResponsiveReactGridLayout>
top: ".25rem", </div>
cursor: "pointer", );
}}
onClick={() => handleRemoveComponent(item.i)}
/>
<TheComponent className="dashboard-card" bodyshop={bodyshop} data={dashboarddata}/>
</LoadingSkeleton>
</div>
);
})}
</ResponsiveReactGridLayout>
</div>
);
} }
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(DashboardGridComponent); )(DashboardGridComponent);
const componentList = { const componentList = {
ProductionDollars: { ProductionDollars: {
label: i18next.t("dashboard.titles.productiondollars"), label: i18next.t("dashboard.titles.productiondollars"),
component: DashboardTotalProductionDollars, component: DashboardTotalProductionDollars,
gqlFragment: null, gqlFragment: null,
w: 1, w: 1,
h: 1, h: 1,
minW: 2, minW: 2,
minH: 1, minH: 1,
}, },
ProductionHours: { ProductionHours: {
label: i18next.t("dashboard.titles.productionhours"), label: i18next.t("dashboard.titles.productionhours"),
component: DashboardTotalProductionHours, component: DashboardTotalProductionHours,
gqlFragment: DashboardTotalProductionHoursGql, gqlFragment: DashboardTotalProductionHoursGql,
w: 3, w: 3,
h: 1, h: 1,
minW: 3, minW: 3,
minH: 1, minH: 1,
}, },
ProjectedMonthlySales: { ProjectedMonthlySales: {
label: i18next.t("dashboard.titles.projectedmonthlysales"), label: i18next.t("dashboard.titles.projectedmonthlysales"),
component: DashboardProjectedMonthlySales, component: DashboardProjectedMonthlySales,
gqlFragment: DashboardProjectedMonthlySalesGql, gqlFragment: DashboardProjectedMonthlySalesGql,
w: 2, w: 2,
h: 1, h: 1,
minW: 2, minW: 2,
minH: 1, minH: 1,
}, },
MonthlyRevenueGraph: { MonthlyRevenueGraph: {
label: i18next.t("dashboard.titles.monthlyrevenuegraph"), label: i18next.t("dashboard.titles.monthlyrevenuegraph"),
component: DashboardMonthlyRevenueGraph, component: DashboardMonthlyRevenueGraph,
gqlFragment: DashboardMonthlyRevenueGraphGql, gqlFragment: DashboardMonthlyRevenueGraphGql,
w: 4, w: 4,
h: 2, h: 2,
minW: 4, minW: 4,
minH: 2, minH: 2,
}, },
MonthlyJobCosting: { MonthlyJobCosting: {
label: i18next.t("dashboard.titles.monthlyjobcosting"), label: i18next.t("dashboard.titles.monthlyjobcosting"),
component: DashboardMonthlyJobCosting, component: DashboardMonthlyJobCosting,
gqlFragment: null, gqlFragment: null,
minW: 6, minW: 6,
minH: 3, minH: 3,
w: 6, w: 6,
h: 3, h: 3,
}, },
MonthlyPartsSales: { MonthlyPartsSales: {
label: i18next.t("dashboard.titles.monthlypartssales"), label: i18next.t("dashboard.titles.monthlypartssales"),
component: DashboardMonthlyPartsSales, component: DashboardMonthlyPartsSales,
gqlFragment: null, gqlFragment: null,
minW: 2, minW: 2,
minH: 2, minH: 2,
w: 2, w: 2,
h: 2, h: 2,
}, },
MonthlyLaborSales: { MonthlyLaborSales: {
label: i18next.t("dashboard.titles.monthlylaborsales"), label: i18next.t("dashboard.titles.monthlylaborsales"),
component: DashboardMonthlyLaborSales, component: DashboardMonthlyLaborSales,
gqlFragment: null, gqlFragment: null,
minW: 2, minW: 2,
minH: 2, minH: 2,
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, gqlFragment: DashboardMonthlyEmployeeEfficiencyGql,
gqlFragment: DashboardMonthlyEmployeeEfficiencyGql, minW: 2,
minW: 2, minH: 2,
minH: 2, w: 2,
w: 2, h: 2,
h: 2, },
}, ScheduleInToday: {
ScheduleInToday: { label: i18next.t("dashboard.titles.scheduledintoday"),
label: i18next.t("dashboard.titles.scheduledintoday"), component: DashboardScheduledInToday,
component: DashboardScheduledInToday, gqlFragment: DashboardScheduledInTodayGql,
gqlFragment: DashboardScheduledInTodayGql, minW: 6,
minW: 6, minH: 2,
minH: 2, w: 10,
w: 10, h: 3,
h: 3, },
}, ScheduleOutToday: {
ScheduleOutToday: { label: i18next.t("dashboard.titles.scheduledouttoday"),
label: i18next.t("dashboard.titles.scheduledouttoday"), component: DashboardScheduledOutToday,
component: DashboardScheduledOutToday, gqlFragment: DashboardScheduledOutTodayGql,
gqlFragment: DashboardScheduledOutTodayGql, minW: 6,
minW: 6, minH: 2,
minH: 2, 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) => {
const componentBasedAdditions = const componentBasedAdditions =
state && state &&
Array.isArray(state.layout) && Array.isArray(state.layout) &&
state.layout state.layout
.map((item, index) => componentList[item.i].gqlFragment || "") .map((item, index) => componentList[item.i].gqlFragment || "")
.join(""); .join("");
return gql` return gql`
query QUERY_DASHBOARD_DETAILS { ${componentBasedAdditions || ""} query QUERY_DASHBOARD_DETAILS { ${componentBasedAdditions || ""}
monthly_sales: jobs(where: {_and: [ monthly_sales: jobs(where: {_and: [
{ voided: {_eq: false}}, { voided: {_eq: false}},
{date_invoiced: {_gte: "${moment() {date_invoiced: {_gte: "${moment()
.startOf("month") .startOf("month")
.startOf("day") .startOf("day")
.toISOString()}"}}, {date_invoiced: {_lte: "${moment() .toISOString()}"}}, {date_invoiced: {_lte: "${moment()
.endOf("month") .endOf("month")
.endOf("day") .endOf("day")
.toISOString()}"}}]}) { .toISOString()}"}}]}) {
id id
ro_number ro_number
date_invoiced date_invoiced
job_totals job_totals
rate_la1 rate_la1
rate_la2 rate_la2
rate_la3 rate_la3
rate_la4 rate_la4
rate_laa rate_laa
rate_lab rate_lab
rate_lad rate_lad
rate_lae rate_lae
rate_laf rate_laf
rate_lag rate_lag
rate_lam rate_lam
rate_lar rate_lar
rate_las rate_las
rate_lau rate_lau
rate_ma2s rate_ma2s
rate_ma2t rate_ma2t
rate_ma3s rate_ma3s
rate_mabl rate_mabl
rate_macs rate_macs
rate_mahw rate_mahw
rate_mapa rate_mapa
rate_mash rate_mash
rate_matd rate_matd
joblines(where: { removed: { _eq: false } }) { joblines(where: { removed: { _eq: false } }) {
id id
mod_lbr_ty mod_lbr_ty
mod_lb_hrs mod_lb_hrs
act_price act_price
part_qty part_qty
part_type part_type
} }
} }
production_jobs: jobs(where: { inproduction: { _eq: true } }) { production_jobs: jobs(where: { inproduction: { _eq: true } }) {
id id
ro_number ro_number
ins_co_nm ins_co_nm
job_totals job_totals
joblines(where: { removed: { _eq: false } }) { joblines(where: { removed: { _eq: false } }) {
id id
mod_lbr_ty mod_lbr_ty
mod_lb_hrs mod_lb_hrs
act_price act_price
part_qty part_qty
part_type part_type
} }
labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } }) { labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } }) {
aggregate { aggregate {
sum { sum {
mod_lb_hrs mod_lb_hrs
} }
} }
} }
larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" }, removed: { _eq: false } }) { larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" }, removed: { _eq: false } }) {
aggregate { aggregate {
sum { sum {
mod_lb_hrs mod_lb_hrs
} }
} }
} }
} }
}`; }`;
}; };

View File

@@ -4,6 +4,8 @@ import { getAuth, updatePassword, updateProfile } from "firebase/auth";
import { getFirestore } from "firebase/firestore"; import { getFirestore } from "firebase/firestore";
import { getMessaging, getToken, onMessage } from "firebase/messaging"; import { getMessaging, getToken, onMessage } from "firebase/messaging";
import { store } from "../redux/store"; import { store } from "../redux/store";
import axios from "axios";
import { checkBeta } from "../utils/handleBeta";
const config = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG); const config = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
initializeApp(config); initializeApp(config);
@@ -86,6 +88,18 @@ export const logImEXEvent = (eventName, additionalParams, stateProp = null) => {
null, null,
...additionalParams, ...additionalParams,
}; };
axios.post("/ioevent", {
useremail:
(state.user && state.user.currentUser && state.user.currentUser.email) ||
null,
bodyshopid:
(state.user && state.user.bodyshop && state.user.bodyshop.id) || null,
operationName: eventName,
variables: additionalParams,
dbevent: false,
env: checkBeta() ? "beta" : "master",
});
// console.log( // console.log(
// "%c[Analytics]", // "%c[Analytics]",
// "background-color: green ;font-weight:bold;", // "background-color: green ;font-weight:bold;",

View File

@@ -898,8 +898,7 @@
"scheduledindate": "Sheduled In Today: {{date}}", "scheduledindate": "Sheduled In Today: {{date}}",
"scheduledintoday": "Sheduled In Today", "scheduledintoday": "Sheduled In Today",
"scheduledoutdate": "Sheduled Out Today: {{date}}", "scheduledoutdate": "Sheduled Out Today: {{date}}",
"scheduledouttoday": "Sheduled Out Today", "scheduledouttoday": "Sheduled Out Today"
"joblifecycle": "Job Lifecycle"
} }
}, },
"dms": { "dms": {
@@ -1237,15 +1236,7 @@
"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",
@@ -1257,9 +1248,7 @@
"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

@@ -898,8 +898,7 @@
"scheduledindate": "", "scheduledindate": "",
"scheduledintoday": "", "scheduledintoday": "",
"scheduledoutdate": "", "scheduledoutdate": "",
"scheduledouttoday": "", "scheduledouttoday": ""
"joblifecycle": ""
} }
}, },
"dms": { "dms": {
@@ -1237,15 +1236,7 @@
"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": "",
@@ -1257,9 +1248,7 @@
"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,15 +1236,7 @@
"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": "",
@@ -1256,10 +1248,7 @@
"title": "", "title": "",
"title_durations": "", "title_durations": "",
"title_loading": "", "title_loading": "",
"title_transitions": "", "title_transitions": ""
"calculated_based_on": "",
"jobs_in_since": "",
"joblifecycle": ""
}, },
"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

@@ -4200,7 +4200,7 @@
interval_sec: 10 interval_sec: 10
num_retries: 0 num_retries: 0
timeout_sec: 60 timeout_sec: 60
webhook_from_env: HASURA_API_URL webhook: https://worktest.home.irony.online
headers: headers:
- name: event-secret - name: event-secret
value_from_env: EVENT_SECRET value_from_env: EVENT_SECRET

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."ioevents" add column "useremail" text
-- not null;

View File

@@ -0,0 +1 @@
alter table "public"."ioevents" add column "useremail" text;

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."ioevents" add column "bodyshopid" uuid
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."ioevents" add column "bodyshopid" uuid
null;

View File

@@ -0,0 +1 @@
alter table "public"."ioevents" alter column "useremail" set not null;

View File

@@ -0,0 +1 @@
alter table "public"."ioevents" alter column "useremail" drop not null;

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."ioevents" add column "env" text
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."ioevents" add column "env" text
null;

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "public"."ioevents_useremail";

View File

@@ -0,0 +1,2 @@
CREATE INDEX "ioevents_useremail" on
"public"."ioevents" using btree ("useremail");

View File

@@ -0,0 +1 @@
alter table "public"."ioevents" drop constraint "ioevents_useremail_fkey";

View File

@@ -0,0 +1,5 @@
alter table "public"."ioevents"
add constraint "ioevents_useremail_fkey"
foreign key ("useremail")
references "public"."users"
("email") on update set null on delete set null;

View File

@@ -0,0 +1 @@
alter table "public"."ioevents" drop constraint "ioevents_bodyshopid_fkey";

View File

@@ -0,0 +1,5 @@
alter table "public"."ioevents"
add constraint "ioevents_bodyshopid_fkey"
foreign key ("bodyshopid")
references "public"."bodyshops"
("id") on update set null on delete set null;

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "public"."idx_audit_trail_type";

View File

@@ -0,0 +1,2 @@
CREATE INDEX "idx_audit_trail_type" on
"public"."audit_trail" using btree ("type");

View File

@@ -11,27 +11,40 @@ require("dotenv").config({
}); });
exports.default = async (req, res) => { exports.default = async (req, res) => {
const { operationName, time, dbevent, user, imexshopid } = req.body; const {
useremail,
bodyshopid,
operationName,
variables,
env,
time,
dbevent,
user,
} = req.body;
try { try {
// await client.request(queries.INSERT_IOEVENT, { await client.request(queries.INSERT_IOEVENT, {
// event: { event: {
// operationname: operationName, operationname: operationName,
// time, time,
// dbevent, dbevent,
// }, env,
// }); variables,
console.log("IOEVENT", operationName, time, dbevent, user, imexshopid); bodyshopid,
logger.log("ioevent", "trace", user, null, { useremail,
imexshopid, },
operationName,
time,
dbevent,
}); });
res.sendStatus(200); res.sendStatus(200);
} catch (error) { } catch (error) {
console.log("error", error); logger.log("ioevent-error", "trace", user, null, {
res.status(400).send(error); operationname: operationName,
time,
dbevent,
env,
variables,
bodyshopid,
useremail,
});
res.sendStatus(200);
} }
}; };

View File

@@ -3,7 +3,6 @@ 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
@@ -29,12 +28,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 => {
@@ -54,57 +53,15 @@ const jobLifecycle = async (req, res) => {
return transition; return transition;
}); });
const durations = calculateStatusDuration(lifecycle, statuses);
groupedTransitions[jobId] = { groupedTransitions[jobId] = {
lifecycle, lifecycle: lifecycle,
durations durations: calculateStatusDuration(lifecycle, statuses),
}; };
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,7 +1,15 @@
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 = {};
@@ -25,16 +33,26 @@ const calculateStatusDuration = (transitions, statuses) => {
if (!transition.prev_value) { if (!transition.prev_value) {
statusDuration[transition.value] = { statusDuration[transition.value] = {
value: duration, value: duration,
humanReadable: durationToHumanReadable(moment.duration(duration)) humanReadable: transition.duration_readable
}; };
} else { } else if (!transition.next_value) {
if (statusDuration[transition.value]) { if (statusDuration[transition.value]) {
statusDuration[transition.value].value += duration; statusDuration[transition.value].value += duration;
statusDuration[transition.value].humanReadable = durationToHumanReadable(moment.duration(statusDuration[transition.value].value)); statusDuration[transition.value].humanReadable = transition.duration_readable;
} else { } else {
statusDuration[transition.value] = { statusDuration[transition.value] = {
value: duration, value: duration,
humanReadable: durationToHumanReadable(moment.duration(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
}; };
} }
} }
@@ -61,7 +79,7 @@ const calculateStatusDuration = (transitions, statuses) => {
value, value,
humanReadable, humanReadable,
percentage: statusDuration[status].percentage, percentage: statusDuration[status].percentage,
color: getLifecycleStatusColor(status), color: getColor(status),
roundedPercentage: `${Math.round(statusDuration[status].percentage)}%` roundedPercentage: `${Math.round(statusDuration[status].percentage)}%`
}); });
} }

View File

@@ -1,11 +0,0 @@
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;