Merge branch 'feature/payroll' into feature/america

This commit is contained in:
Patrick Fic
2023-07-28 09:39:05 -07:00
58 changed files with 2975 additions and 701 deletions

View File

@@ -1474,6 +1474,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>admin_jobuninvoice</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>admin_jobunvoid</name>
<definition_loaded>false</definition_loaded>
@@ -5776,6 +5797,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>nextstatus</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>percent</name>
<definition_loaded>false</definition_loaded>
@@ -19482,6 +19524,27 @@
<folder_node>
<name>actions</name>
<children>
<concept_node>
<name>assign_team</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>converttolabor</name>
<definition_loaded>false</definition_loaded>
@@ -19503,6 +19566,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>dispatchparts</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>new</name>
<definition_loaded>false</definition_loaded>
@@ -19618,6 +19702,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>assigned_team</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>db_price</name>
<definition_loaded>false</definition_loaded>
@@ -23731,6 +23836,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>date_void</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>ded_amt</name>
<definition_loaded>false</definition_loaded>
@@ -33912,6 +34038,27 @@
<folder_node>
<name>tech</name>
<children>
<concept_node>
<name>claimtask</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>home</name>
<definition_loaded>false</definition_loaded>
@@ -36000,6 +36147,141 @@
</folder_node>
</children>
</folder_node>
<folder_node>
<name>parts_dispatch</name>
<children>
<folder_node>
<name>errors</name>
<children>
<concept_node>
<name>creating</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
<folder_node>
<name>fields</name>
<children>
<concept_node>
<name>number</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>percent_accepted</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
<folder_node>
<name>labels</name>
<children>
<concept_node>
<name>parts_dispatch</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
</children>
</folder_node>
<folder_node>
<name>parts_dispatch_lines</name>
<children>
<folder_node>
<name>fields</name>
<children>
<concept_node>
<name>accepted_at</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
</children>
</folder_node>
<folder_node>
<name>parts_orders</name>
<children>
@@ -40727,6 +41009,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>parts_return_slip</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>sublet_order</name>
<definition_loaded>false</definition_loaded>
@@ -45750,6 +46053,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>payall</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>printemployee</name>
<definition_loaded>false</definition_loaded>
@@ -46264,6 +46588,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>task_name</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
<folder_node>
@@ -46332,6 +46677,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>claimtaskpreview</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>clockhours</name>
<definition_loaded>false</definition_loaded>
@@ -46521,6 +46887,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>payrollclaimedtasks</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>pmbreak</name>
<definition_loaded>false</definition_loaded>
@@ -46626,6 +47013,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>task</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>timetickets</name>
<definition_loaded>false</definition_loaded>
@@ -46647,6 +47055,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>unassigned</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>zeroactualnegativeprod</name>
<definition_loaded>false</definition_loaded>
@@ -46846,6 +47275,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>unassignedlines</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
</children>

View File

@@ -1,9 +1,26 @@
import Dinero from "dinero.js";
import React, { forwardRef } from "react";
const ReadOnlyFormItem = ({ value, type = "text", onChange }, ref) => {
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
const ReadOnlyFormItem = (
{ bodyshop, value, type = "text", onChange },
ref
) => {
if (!value) return null;
switch (type) {
case "employee":
const emp = bodyshop.employees.find((e) => e.id === value);
return `${emp?.first_name} ${emp?.last_name}`;
case "text":
return <div>{value}</div>;
case "currency":
@@ -14,4 +31,8 @@ const ReadOnlyFormItem = ({ value, type = "text", onChange }, ref) => {
return <div>{value}</div>;
}
};
export default forwardRef(ReadOnlyFormItem);
export default connect(
mapStateToProps,
mapDispatchToProps
)(forwardRef(ReadOnlyFormItem));

View File

@@ -45,6 +45,10 @@ import JobSendPartPriceChangeComponent from "../job-send-parts-price-change/job-
import PartsOrderModalContainer from "../parts-order-modal/parts-order-modal.container";
import JobLinesExpander from "./job-lines-expander.component";
import JobLinesPartPriceChange from "./job-lines-part-price-change.component";
import JoblineTeamAssignment from "../job-line-team-assignment/job-line-team-assignmnent.component";
import JobLineDispatchButton from "../job-line-dispatch-button/job-line-dispatch-button.component";
import JobLineBulkAssignComponent from "../job-line-bulk-assign/job-line-bulk-assign.component";
import { useTreatments } from "@splitsoftware/splitio-react";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -76,7 +80,11 @@ export function JobLinesComponent({
setBillEnterContext,
}) {
const [deleteJobLine] = useMutation(DELETE_JOB_LINE_BY_PK);
const { Enhanced_Payroll } = useTreatments(
["Enhanced_Payroll"],
{},
bodyshop.imexshopid
);
const [selectedLines, setSelectedLines] = useState([]);
const [state, setState] = useState({
sortedInfo: {},
@@ -106,7 +114,9 @@ export function JobLinesComponent({
onCell: (record) => ({
className: record.manual_line && "job-line-manual",
style: {
...(record.critical ? { boxShadow: " -.5em 0 0 #FFC107" } : {}),
...(record.critical || true
? { boxShadow: " -.5em 0 0 #FFC107" }
: {}),
},
}),
sortOrder:
@@ -121,10 +131,21 @@ export function JobLinesComponent({
sortOrder:
state.sortedInfo.columnKey === "oem_partno" && state.sortedInfo.order,
ellipsis: true,
render: (text, record) =>
`${record.oem_partno || ""} ${
record.alt_partno ? `(${record.alt_partno})` : ""
}`.trim(),
onCell: (record) => ({
className: record.manual_line && "job-line-manual",
style: {
...(record.parts_dispatch_lines[0]?.accepted_at || true
? { boxShadow: " -.5em 0 0 #FFC107" }
: {}),
},
}),
render: (text, record) => (
<span class="ant-table-cell-content">
{`${record.oem_partno || ""} ${
record.alt_partno ? `(${record.alt_partno})` : ""
}`.trim()}
</span>
),
},
{
title: t("joblines.fields.op_code_desc"),
@@ -273,6 +294,19 @@ export function JobLinesComponent({
state.sortedInfo.columnKey === "line_ind" && state.sortedInfo.order,
responsive: ["md"],
},
...(Enhanced_Payroll.treatment === "on"
? [
{
title: t("joblines.fields.assigned_team"),
dataIndex: "assigned_team",
key: "assigned_team",
render: (text, record) => (
<JoblineTeamAssignment disabled={jobRO} jobline={record} />
),
},
]
: []),
{
title: t("joblines.fields.notes"),
dataIndex: "notes",
@@ -391,7 +425,11 @@ export function JobLinesComponent({
setSelectedLines(
_.uniq([
...selectedLines,
...jobLines.filter((item) => markedTypes.includes(item.part_type)),
...jobLines.filter(
(item) =>
markedTypes.includes(item.part_type) ||
markedTypes.includes(item.mod_lbr_ty)
),
])
);
}
@@ -404,6 +442,10 @@ export function JobLinesComponent({
<Menu.Item key="PAL">{t("joblines.fields.part_types.PAL")}</Menu.Item>
<Menu.Item key="PAS">{t("joblines.fields.part_types.PAS")}</Menu.Item>
<Menu.Divider />
<Menu.Item key="LAB">{t("joblines.fields.lbr_types.LAB")}</Menu.Item>
<Menu.Item key="LAR">{t("joblines.fields.lbr_types.LAR")}</Menu.Item>
<Menu.Item key="LAM">{t("joblines.fields.lbr_types.LAM")}</Menu.Item>
<Menu.Divider />
<Menu.Item key="clear">{t("general.labels.clear")}</Menu.Item>
</Menu>
);
@@ -427,6 +469,18 @@ export function JobLinesComponent({
</Space>
</Tag>
)}
<JobLineDispatchButton
selectedLines={selectedLines}
setSelectedLines={setSelectedLines}
job={job}
/>
{Enhanced_Payroll.treatment === "on" && (
<JobLineBulkAssignComponent
selectedLines={selectedLines}
setSelectedLines={setSelectedLines}
job={job}
/>
)}
<Button
disabled={
(job && !job.converted) ||

View File

@@ -0,0 +1,126 @@
import React, { useState } from "react";
import { useMutation } from "@apollo/client";
import { Button, Form, Popover, Select, Space, notification } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { UPDATE_LINE_BULK_ASSIGN } from "../../graphql/jobs-lines.queries";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import {
selectBodyshop,
selectCurrentUser,
} from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
jobRO: selectJobReadOnly,
currentUser: selectCurrentUser,
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(JoblineBulkAssign);
export function JoblineBulkAssign({
setSelectedLines,
selectedLines,
bodyshop,
jobRO,
job,
currentUser,
}) {
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const { t } = useTranslation();
const [assignLines] = useMutation(UPDATE_LINE_BULK_ASSIGN);
const handleConvert = async (values) => {
try {
setLoading(true);
const result = await assignLines({
variables: {
jobline: {
assigned_team: values.assigned_team,
},
ids: selectedLines.map((l) => l.id),
},
});
if (result.errors) {
notification.open({
type: "error",
message: t("parts_dispatch.errors.creating", {
error: JSON.stringify(result.errors),
}),
});
}
setVisible(false);
} catch (error) {
notification.open({
type: "error",
message: t("parts_dispatch.errors.creating", {
error: error,
}),
});
} finally {
setLoading(false);
}
};
const popMenu = (
<div>
<Form layout="vertical" form={form} onFinish={handleConvert}>
<Form.Item
name={"assigned_team"}
label={t("joblines.fields.assigned_team")}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<Select
showSearch
style={{ width: 200 }}
optionFilterProp="children"
filterOption={(input, option) =>
option.props.children
.toLowerCase()
.indexOf(input.toLowerCase()) >= 0
}
>
{bodyshop.employee_teams.map((team) => (
<Select.Option value={team.id} key={team.id} name={team.name}>
{team.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Space wrap>
<Button type="danger" onClick={() => form.submit()} loading={loading}>
{t("general.actions.save")}
</Button>
<Button onClick={() => setVisible(false)}>
{t("general.actions.cancel")}
</Button>
</Space>
</Form>
</div>
);
return (
<Popover open={visible} content={popMenu}>
<Button
disabled={selectedLines.length === 0 || jobRO}
loading={loading}
onClick={() => setVisible(true)}
>
{t("joblines.actions.assign_team", { count: selectedLines.length })}
</Button>
</Popover>
);
}

View File

@@ -0,0 +1,162 @@
import React, { useState } from "react";
import { useMutation } from "@apollo/client";
import { Button, Form, Popover, Select, Space, notification } from "antd";
import moment from "moment";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { INSERT_PARTS_DISPATCH } from "../../graphql/parts-dispatch.queries";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import {
selectBodyshop,
selectCurrentUser,
} from "../../redux/user/user.selectors";
import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
jobRO: selectJobReadOnly,
currentUser: selectCurrentUser,
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(JobLineDispatchButton);
export function JobLineDispatchButton({
setSelectedLines,
selectedLines,
bodyshop,
jobRO,
job,
currentUser,
}) {
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const Templates = TemplateList("job_special", {
ro_number: job.ro_number,
});
const { t } = useTranslation();
const [dispatchLines] = useMutation(INSERT_PARTS_DISPATCH);
const handleConvert = async (values) => {
try {
setLoading(true);
//THIS HAS NOT YET BEEN TESTED. START BY FINISHING THIS FUNCTION.
const result = await dispatchLines({
variables: {
partsDispatch: {
dispatched_at: moment(),
employeeid: values.employeeid,
jobid: job.id,
dispatched_by: currentUser.email,
parts_dispatch_lines: {
data: selectedLines.map((l) => ({
joblineid: l.id,
quantity: l.part_qty,
})),
},
},
//joblineids: selectedLines.map((l) => l.id),
},
});
if (result.errors) {
notification.open({
type: "error",
message: t("parts_dispatch.errors.creating", {
error: JSON.stringify(result.errors),
}),
});
} else {
setSelectedLines([]);
await GenerateDocument(
{
name: Templates.parts_dispatch.key,
variables: {
id: result.data.insert_part_dispatch_one.id,
},
},
{},
"p"
);
}
setVisible(false);
} catch (error) {
notification.open({
type: "error",
message: t("parts_dispatch.errors.creating", {
error: JSON.stringify(error),
}),
});
} finally {
setLoading(false);
}
};
const popMenu = (
<div>
<Form layout="vertical" form={form} onFinish={handleConvert}>
<Form.Item
name={"employeeid"}
label={t("timetickets.fields.employee")}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<Select
showSearch
style={{ width: 200 }}
optionFilterProp="children"
filterOption={(input, option) =>
option.props.children
.toLowerCase()
.indexOf(input.toLowerCase()) >= 0
}
>
{bodyshop.employees
.filter((emp) => emp.active)
.map((emp) => (
<Select.Option
value={emp.id}
key={emp.id}
name={`${emp.first_name} ${emp.last_name}`}
>
{`${emp.first_name} ${emp.last_name}`}
</Select.Option>
))}
</Select>
</Form.Item>
<Space wrap>
<Button type="danger" onClick={() => form.submit()} loading={loading}>
{t("general.actions.save")}
</Button>
<Button onClick={() => setVisible(false)}>
{t("general.actions.cancel")}
</Button>
</Space>
</Form>
</div>
);
return (
<Popover open={visible} content={popMenu}>
<Button
disabled={selectedLines.length === 0 || jobRO}
loading={loading}
onClick={() => setVisible(true)}
>
{t("joblines.actions.dispatchparts", { count: selectedLines.length })}
</Button>
</Popover>
);
}

View File

@@ -0,0 +1,103 @@
import { notification, Select } from "antd";
import React, { useEffect, useState } from "react";
import { useMutation } from "@apollo/client";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { UPDATE_JOB_LINE } from "../../graphql/jobs-lines.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export function JoblineTeamAssignment({ bodyshop, jobline, disabled }) {
const [editing, setEditing] = useState(false);
const [loading, setLoading] = useState(false);
const [assignedTeam, setAssignedTeam] = useState(jobline.assigned_team);
const [updateJob] = useMutation(UPDATE_JOB_LINE);
const { t } = useTranslation();
useEffect(() => {
if (editing) setAssignedTeam(jobline.assigned_team);
}, [editing, jobline.assigned_team]);
const handleChange = (e) => {
setAssignedTeam(e);
};
const handleSave = async (e) => {
setLoading(true);
const result = await updateJob({
variables: {
lineId: jobline.id,
line: { assigned_team: assignedTeam },
},
});
if (
assignedTeam === null ||
assignedTeam === undefined ||
assignedTeam === ""
) {
alert("TODO - implement calculation to reduce assigned hours if needed.");
}
if (!!!result.errors) {
notification["success"]({ message: t("joblines.successes.saved") });
} else {
notification["error"]({
message: t("joblines.errors.saving", {
error: JSON.stringify(result.errors),
}),
});
}
setLoading(false);
setEditing(false);
};
if (editing)
return (
<div>
<LoadingSpinner loading={loading}>
<Select
autoFocus
allowClear
dropdownMatchSelectWidth={100}
value={assignedTeam}
onSelect={handleChange}
onBlur={handleSave}
onClear={() => handleChange(null)}
>
{Object.values(bodyshop.employee_teams).map((s, idx) => (
<Select.Option key={idx} value={s.id}>
{s.name}
</Select.Option>
))}
</Select>
</LoadingSpinner>
</div>
);
const team = bodyshop.employee_teams.find(
(tm) => tm.id === jobline.assigned_team
);
return (
<div
style={{ width: "100%", minHeight: "1rem", cursor: "pointer" }}
onClick={() => !disabled && setEditing(true)}
>
{team?.name}
</div>
);
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(JoblineTeamAssignment);

View File

@@ -1,14 +1,14 @@
import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import { useMutation } from "@apollo/client";
import { Button, Form, notification } from "antd";
import moment from "moment";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { UPDATE_JOB } from "../../graphql/jobs.queries";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import moment from "moment";
import FormDatePicker from "../form-date-picker/form-date-picker.component";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import FormDatePicker from "../form-date-picker/form-date-picker.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component";
import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -38,8 +38,8 @@ export function JobsAdminDatesChange({ insertAuditTrail, job }) {
setLoading(true);
const result = await updateJob({
variables: { jobId: job.id, job: values },
refetchQueries: ['GET_JOB_BY_PK'],
awaitRefetchQueries:true
refetchQueries: ["GET_JOB_BY_PK"],
awaitRefetchQueries: true,
});
const changedAuditFields = form.getFieldsValue(
@@ -126,7 +126,10 @@ export function JobsAdminDatesChange({ insertAuditTrail, job }) {
<Form.Item label={t("jobs.fields.actual_in")} name="actual_in">
<DateTimePicker />
</Form.Item>
<Form.Item label={t("jobs.fields.date_repairstarted")} name="date_repairstarted">
<Form.Item
label={t("jobs.fields.date_repairstarted")}
name="date_repairstarted"
>
<DateTimePicker />
</Form.Item>
<Form.Item
@@ -173,6 +176,9 @@ export function JobsAdminDatesChange({ insertAuditTrail, job }) {
>
<DateTimePicker />
</Form.Item>
<Form.Item label={t("jobs.fields.date_void")} name="date_void">
<DateTimePicker />
</Form.Item>
</LayoutFormRow>
</Form>

View File

@@ -1,19 +1,18 @@
import { useMutation } from "@apollo/client";
import { gql, useMutation } from "@apollo/client";
import { Button, notification } from "antd";
import { gql } from "@apollo/client";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import moment from "moment";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { INSERT_EXPORT_LOG } from "../../graphql/accounting.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import {
selectBodyshop,
selectCurrentUser,
} from "../../redux/user/user.selectors";
import moment from "moment";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { INSERT_EXPORT_LOG } from "../../graphql/accounting.queries";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser,
@@ -150,6 +149,10 @@ export function JobAdminMarkReexport({
if (!result.errors) {
notification["success"]({ message: t("jobs.successes.save") });
insertAuditTrail({
jobid: job.id,
operation: AuditTrailMapping.admin_jobuninvoice(),
});
} else {
notification["error"]({
message: t("jobs.errors.saving", {

View File

@@ -33,8 +33,9 @@ export function JobsAdminUnvoid({
mutation UNVOID_JOB($jobId: uuid!) {
update_jobs_by_pk(pk_columns: {id: $jobId}, _set: {voided: false, status: "${
bodyshop.md_ro_statuses.default_imported
}"}) {
}", date_void: null}) {
id
date_void
voided
status
}

View File

@@ -141,6 +141,10 @@ export function JobsDetailDatesComponent({ jobRO, job, bodyshop }) {
<Form.Item label={t("jobs.fields.date_exported")} name="date_exported">
<DateTimePicker disabled={true || jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.date_void")} name="date_void">
<DateTimePicker disabled={true || jobRO} />
</Form.Item>
</FormRow>
</div>
);

View File

@@ -5,10 +5,10 @@ import {
Dropdown,
Form,
Menu,
notification,
Popconfirm,
Popover,
Select,
notification,
} from "antd";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
@@ -24,12 +24,12 @@ import {
selectBodyshop,
selectCurrentUser,
} from "../../redux/user/user.selectors";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import JobsDetailHeaderActionsAddevent from "./jobs-detail-header-actions.addevent";
import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util";
import JobsDetaiLheaderCsi from "./jobs-detail-header-actions.csi.component";
import DuplicateJob from "./jobs-detail-header-actions.duplicate.util";
import JobsDetailHeaderActionsExportcustdataComponent from "./jobs-detail-header-actions.exportcustdata.component";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -516,6 +516,7 @@ export function JobsDetailHeaderActions({
scheduled_in: null,
scheduled_completion: null,
inproduction: false,
date_void: new Date(),
},
note: [
{

View File

@@ -5,9 +5,13 @@ import { createStructuredSelector } from "reselect";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import LaborAllocationsTableComponent from "../labor-allocations-table/labor-allocations-table.component";
import TimeTicketList from "../time-ticket-list/time-ticket-list.component";
import PayrollLaborAllocationsTable from "../labor-allocations-table/labor-allocations-table.payroll.component";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatments } from "@splitsoftware/splitio-react";
const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly,
bodyshop: selectBodyshop,
});
export default connect(mapStateToProps, null)(JobsDetailLaborContainer);
@@ -48,6 +52,7 @@ const adjSpan = {
};
export function JobsDetailLaborContainer({
bodyshop,
jobRO,
job,
jobId,
@@ -58,6 +63,12 @@ export function JobsDetailLaborContainer({
techConsole,
adjustments,
}) {
const { Enhanced_Payroll } = useTreatments(
["Enhanced_Payroll"],
{},
bodyshop.imexshopid
);
return (
<Row gutter={[16, 16]}>
<Col {...ticketSpan}>
@@ -70,14 +81,28 @@ export function JobsDetailLaborContainer({
jobId={jobId}
/>
</Col>
<Col {...adjSpan}>
<LaborAllocationsTableComponent
jobId={jobId}
joblines={joblines}
timetickets={timetickets}
adjustments={adjustments}
/>
</Col>
{Enhanced_Payroll.treatment === "on" ? (
<Col {...adjSpan}>
<PayrollLaborAllocationsTable
jobId={jobId}
joblines={joblines}
timetickets={timetickets}
refetch={refetch}
adjustments={adjustments}
/>
</Col>
) : (
<Col {...adjSpan}>
<LaborAllocationsTableComponent
jobId={jobId}
joblines={joblines}
timetickets={timetickets}
refetch={refetch}
adjustments={adjustments}
/>
</Col>
)}
</Row>
);
}

View File

@@ -6,12 +6,14 @@ import BillsListTable from "../bills-list-table/bills-list-table.component";
import JobBillsTotal from "../job-bills-total/job-bills-total.component";
import PartsOrderListTableComponent from "../parts-order-list-table/parts-order-list-table.component";
import PartsOrderModal from "../parts-order-modal/parts-order-modal.container";
import PartsDispatchTable from "../parts-dispatch-table/parts-dispatch-table.component";
export default function JobsDetailPliComponent({
job,
billsQuery,
handleBillOnRowClick,
handlePartsOrderOnRowClick,
handlePartsDispatchOnRowClick,
}) {
return (
<div>
@@ -43,6 +45,13 @@ export default function JobsDetailPliComponent({
billsQuery={billsQuery}
/>
</Col>
<Col span={24}>
<PartsDispatchTable
job={job}
handleOnRowClick={handlePartsDispatchOnRowClick}
billsQuery={billsQuery}
/>
</Col>
</Row>
</div>
);

View File

@@ -39,12 +39,24 @@ export default function JobsDetailPliContainer({ job }) {
}
};
const handlePartsDispatchOnRowClick = (record) => {
if (record) {
if (record.id) {
search.partsdispatchid = record.id;
history.push({ search: queryString.stringify(search) });
}
} else {
delete search.partsdispatchid;
history.push({ search: queryString.stringify(search) });
}
};
return (
<JobsDetailPliComponent
job={job}
billsQuery={billsQuery}
handleBillOnRowClick={handleBillOnRowClick}
handlePartsOrderOnRowClick={handlePartsOrderOnRowClick}
handlePartsDispatchOnRowClick={handlePartsDispatchOnRowClick}
/>
);
}

View File

@@ -0,0 +1,300 @@
import { Button, Card, Col, Row, Space, Table, Typography } from "antd";
import { SyncOutlined } from '@ant-design/icons'
import axios from "axios";
import _ from "lodash";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectTechnician } from "../../redux/tech/tech.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import "./labor-allocations-table.styles.scss";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
technician: selectTechnician,
});
export function PayrollLaborAllocationsTable({
jobId,
joblines,
timetickets,
bodyshop,
adjustments,
technician,
refetch,
}) {
const { t } = useTranslation();
const [totals, setTotals] = useState([]);
const [state, setState] = useState({
sortedInfo: {
columnKey: "cost_center",
field: "cost_center",
order: "ascend",
},
filteredInfo: {},
});
useEffect(() => {
async function CalculateTotals() {
const { data } = await axios.post("/payroll/calculatelabor", {
jobid: jobId,
});
setTotals(data);
}
if (!!joblines && !!timetickets && !!bodyshop) {
CalculateTotals();
}
if (!jobId) setTotals([]);
}, [joblines, timetickets, bodyshop, adjustments, jobId]);
const convertedLines = useMemo(
() => joblines && joblines.filter((j) => j.convertedtolbr),
[joblines]
);
const columns = [
{
title: t("timetickets.fields.employee"),
dataIndex: "employeeid",
key: "employeeid",
render: (text, record) => {
if (record.employeeid === undefined) {
return (
<span style={{ color: "tomato", fontWeight: "bolder" }}>
{t("timetickets.labels.unassigned")}
</span>
);
}
const emp = bodyshop.employees.find((e) => e.id === record.employeeid);
return `${emp?.first_name} ${emp?.last_name}`;
},
},
{
title: t("joblines.fields.mod_lbr_ty"),
dataIndex: "mod_lbr_ty",
key: "mod_lbr_ty",
render: (text, record) =>
record.employeeid === undefined ? (
<span style={{ color: "tomato", fontWeight: "bolder" }}>
{t("timetickets.labels.unassigned")}
</span>
) : (
t(`joblines.fields.lbr_types.${record.mod_lbr_ty?.toUpperCase()}`)
),
},
// {
// title: t("timetickets.fields.rate"),
// dataIndex: "rate",
// key: "rate",
// },
{
title: t("jobs.labels.hrs_total"),
dataIndex: "expectedHours",
key: "expectedHours",
sorter: (a, b) => a.expectedHours - b.expectedHours,
sortOrder:
state.sortedInfo.columnKey === "expectedHours" &&
state.sortedInfo.order,
render: (text, record) => record.expectedHours.toFixed(5),
},
{
title: t("jobs.labels.hrs_claimed"),
dataIndex: "claimedHours",
key: "claimedHours",
sorter: (a, b) => a.claimedHours - b.claimedHours,
sortOrder:
state.sortedInfo.columnKey === "claimedHours" && state.sortedInfo.order,
render: (text, record) =>
record.claimedHours && record.claimedHours.toFixed(5),
},
{
title: t("jobs.labels.difference"),
dataIndex: "difference",
key: "difference",
sorter: (a, b) => a.difference - b.difference,
sortOrder:
state.sortedInfo.columnKey === "difference" && state.sortedInfo.order,
render: (text, record) => {
const difference = _.round(
record.expectedHours - record.claimedHours,
5
);
return (
<strong
style={{
color: difference >= 0 ? "green" : "red",
}}
>
{difference}
</strong>
);
},
},
];
const convertedTableCols = [
{
title: t("joblines.fields.line_desc"),
dataIndex: "line_desc",
key: "line_desc",
ellipsis: true,
},
{
title: t("joblines.fields.op_code_desc"),
dataIndex: "op_code_desc",
key: "op_code_desc",
ellipsis: true,
render: (text, record) =>
`${record.op_code_desc || ""}${
record.alt_partm ? ` ${record.alt_partm}` : ""
}`,
},
{
title: t("joblines.fields.act_price"),
dataIndex: "act_price",
key: "act_price",
ellipsis: true,
render: (text, record) => (
<>
<CurrencyFormatter>
{record.db_ref === "900510" || record.db_ref === "900511"
? record.prt_dsmk_m
: record.act_price}
</CurrencyFormatter>
{record.prt_dsmk_p && record.prt_dsmk_p !== 0 ? (
<span
style={{ marginLeft: ".2rem" }}
>{`(${record.prt_dsmk_p}%)`}</span>
) : (
<></>
)}
</>
),
},
{
title: t("joblines.fields.part_qty"),
dataIndex: "part_qty",
key: "part_qty",
},
{
title: t("joblines.fields.mod_lbr_ty"),
dataIndex: "conv_mod_lbr_ty",
key: "conv_mod_lbr_ty",
render: (text, record) =>
record.convertedtolbr_data && record.convertedtolbr_data.mod_lbr_ty,
},
{
title: t("joblines.fields.mod_lb_hrs"),
dataIndex: "conv_mod_lb_hrs",
key: "conv_mod_lb_hrs",
render: (text, record) =>
record.convertedtolbr_data &&
record.convertedtolbr_data.mod_lb_hrs &&
record.convertedtolbr_data.mod_lb_hrs.toFixed(5),
},
];
const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
};
const summary =
totals &&
totals.reduce(
(acc, val) => {
acc.hrs_total += val.expectedHours;
acc.hrs_claimed += val.claimedHours;
// acc.adjustments += val.adjustments;
acc.difference += val.expectedHours - val.claimedHours;
return acc;
},
{ hrs_total: 0, hrs_claimed: 0, adjustments: 0, difference: 0 }
);
return (
<Row gutter={[16, 16]}>
<Col span={24}>
<Card
title={t("jobs.labels.laborallocations")}
extra={
<Space>
<Button
onClick={async () => {
await axios.post("/payroll/payall", {
jobid: jobId,
});
if (refetch) refetch();
}}
>
{t("timetickets.actions.payall")}
</Button>
<Button
onClick={async () => {
const { data } = await axios.post("/payroll/calculatelabor", {
jobid: jobId,
});
setTotals(data);
refetch();
}}
>
<SyncOutlined/>
</Button>
</Space>
}
>
<Table
columns={columns}
rowKey={(record) => `${record.employeeid} ${record.mod_lbr_ty}`}
pagination={false}
onChange={handleTableChange}
dataSource={totals}
scroll={{
x: true,
}}
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell>
<Typography.Title level={4}>
{t("general.labels.totals")}
</Typography.Title>
</Table.Summary.Cell>
<Table.Summary.Cell></Table.Summary.Cell>
<Table.Summary.Cell>
{summary.hrs_total.toFixed(5)}
</Table.Summary.Cell>
<Table.Summary.Cell>
{summary.hrs_claimed.toFixed(5)}
</Table.Summary.Cell>
<Table.Summary.Cell>
{summary.difference.toFixed(5)}
</Table.Summary.Cell>
</Table.Summary.Row>
)}
/>
</Card>
</Col>
{convertedLines && convertedLines.length > 0 && (
<Col span={24}>
<Card title={t("jobs.labels.convertedtolabor")}>
<Table
columns={convertedTableCols}
rowKey="id"
pagination={false}
dataSource={convertedLines}
scroll={{
x: true,
}}
/>
</Card>
</Col>
)}
</Row>
);
}
export default connect(mapStateToProps, null)(PayrollLaborAllocationsTable);

View File

@@ -0,0 +1,49 @@
import { Card, Col, Row, Table } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { DateTimeFormatter } from "../../utils/DateFormatter";
export default function PartsDispatchExpander({ dispatch, job }) {
const { t } = useTranslation();
const columns = [
{
title: t("joblines.fields.part_qty"),
dataIndex: "quantity",
key: "quantity",
width: "10%",
//sorter: (a, b) => alphaSort(a.number, b.number),
},
{
title: t("joblines.fields.line_desc"),
dataIndex: "joblineid",
key: "joblineid",
//sorter: (a, b) => alphaSort(a.number, b.number),
render: (text, record) => record.jobline.line_desc,
},
{
title: t("parts_dispatch_lines.fields.accepted_at"),
dataIndex: "accepted_at",
key: "accepted_at",
width: "20%",
//sorter: (a, b) => alphaSort(a.number, b.number),
render: (text, record) => (
<DateTimeFormatter>{record.accepted_at}</DateTimeFormatter>
),
},
];
return (
<Card>
<Row gutter={[16, 16]}>
<Col span={24}>
<Table
pagination={false}
dataSource={dispatch.parts_dispatch_lines}
columns={columns}
/>
</Col>
</Row>
</Card>
);
}

View File

@@ -0,0 +1,155 @@
import {
MinusCircleTwoTone,
PlusCircleTwoTone,
SyncOutlined,
} from "@ant-design/icons";
import { Button, Card, Input, Space, Table } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { TemplateList } from "../../utils/TemplateConstants";
import { alphaSort } from "../../utils/sorters";
import PartsDispatchExpander from "../parts-dispatch-expander/parts-dispatch-expander.component";
import PrintWrapperComponent from "../print-wrapper/print-wrapper.component";
const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly,
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({});
export function PartDispatchTableComponent({
bodyshop,
jobRO,
job,
billsQuery,
handleOnRowClick,
}) {
const { t } = useTranslation();
const [state, setState] = useState({
sortedInfo: {},
});
// const search = queryString.parse(useLocation().search);
// const selectedBill = search.billid;
const [searchText, setSearchText] = useState("");
const Templates = TemplateList("job_special");
const { refetch } = billsQuery;
const recordActions = (record) => (
<Space wrap>
<PrintWrapperComponent
templateObject={{
name: Templates.parts_dispatch.key,
variables: { id: record.id },
}}
/>
</Space>
);
const columns = [
{
title: t("parts_dispatch.fields.number"),
dataIndex: "number",
key: "number",
sorter: (a, b) => alphaSort(a.number, b.number),
width: "10%",
sortOrder:
state.sortedInfo.columnKey === "number" && state.sortedInfo.order,
},
{
title: t("timetickets.fields.employee"),
dataIndex: "employeeid",
key: "employeeid",
sorter: (a, b) => alphaSort(a.employeeid, b.employeeid),
sortOrder:
state.sortedInfo.columnKey === "employeeid" && state.sortedInfo.order,
render: (text, record) => {
const e = bodyshop.employees.find((e) => e.id === record.employeeid);
return `${e?.first_name || ""} ${e?.last_name || ""}`.trim();
},
},
{
title: t("parts_dispatch.fields.percent_accepted"),
dataIndex: "percent_accepted",
key: "percent_accepted",
render: (text, record) =>
record.parts_dispatch_lines.length > 0
? `
${(
(record.parts_dispatch_lines.filter((l) => l.accepted_at)
.length /
record.parts_dispatch_lines.length) *
100
).toFixed(0)}%`
: "0%",
},
{
title: t("general.labels.actions"),
dataIndex: "actions",
key: "actions",
width: "10%",
render: (text, record) => recordActions(record, true),
},
];
const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
};
return (
<Card
title={t("parts_dispatch.labels.parts_dispatch")}
extra={
<Space wrap>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Input.Search
placeholder={t("general.labels.search")}
value={searchText}
onChange={(e) => {
e.preventDefault();
setSearchText(e.target.value);
}}
/>
</Space>
}
>
<Table
loading={billsQuery.loading}
scroll={{
x: true, // y: "50rem"
}}
expandable={{
expandedRowRender: (record) => (
<PartsDispatchExpander dispatch={record} job={job} />
),
rowExpandable: (record) => true,
expandIcon: ({ expanded, onExpand, record }) =>
expanded ? (
<MinusCircleTwoTone onClick={(e) => onExpand(record, e)} />
) : (
<PlusCircleTwoTone onClick={(e) => onExpand(record, e)} />
),
}}
columns={columns}
rowKey="id"
dataSource={billsQuery.data ? billsQuery.data.parts_dispatch : []}
onChange={handleTableChange}
/>
</Card>
);
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(PartDispatchTableComponent);

View File

@@ -9,9 +9,11 @@ export default function PrintWrapperComponent({
children,
id,
emailOnly = false,
disabled,
}) {
const [loading, setLoading] = useState(false);
const handlePrint = async (type) => {
if (disabled) return;
setLoading(true);
await GenerateDocument(templateObject, messageObject, type, id);
setLoading(false);
@@ -20,8 +22,18 @@ export default function PrintWrapperComponent({
return (
<Space>
{children || null}
{!emailOnly && <PrinterFilled onClick={() => handlePrint("p")} />}
<MailFilled onClick={() => handlePrint("e")} />
{!emailOnly && (
<PrinterFilled
disabled={disabled}
onClick={() => handlePrint("p")}
style={{ cursor: disabled ? "not-allowed" : null }}
/>
)}
<MailFilled
disabled={disabled}
onClick={() => handlePrint("e")}
style={{ cursor: disabled ? "not-allowed" : null }}
/>
{loading && <Spin />}
</Space>
);

View File

@@ -24,6 +24,8 @@ import ProductionListColumnNote from "./production-list-columns.productionnote.c
import ProductionListColumnCategory from "./production-list-columns.status.category";
import ProductionListColumnStatus from "./production-list-columns.status.component";
import ProductionlistColumnTouchTime from "./prodution-list-columns.touchtime.component";
import { store } from "../../redux/store";
import { setModalContext } from "../../redux/modals/modals.actions";
const r = ({ technician, state, activeStatuses, bodyshop }) => {
return [
@@ -38,6 +40,29 @@ const r = ({ technician, state, activeStatuses, bodyshop }) => {
</Link>
),
},
{
title: i18n.t("timetickets.actions.claimtasks"),
dataIndex: "claimtasks",
key: "claimtasks",
ellipsis: true,
render: (text, record) => (
<div
onClick={() => {
store.dispatch(
setModalContext({
context: {
actions: {},
context: { jobid: record.id },
},
modal: "timeTicketTask",
})
);
}}
>
{i18n.t("timetickets.actions.claimtasks")}
</div>
),
},
{
title: i18n.t("jobs.fields.ro_number"),
dataIndex: "ro_number",

View File

@@ -602,6 +602,18 @@ export default function ShopInfoGeneral({ form }) {
>
<Select mode="tags" />
</Form.Item>
<Form.Item
name={["md_email_cc", "parts_return_slip"]}
label={t("bodyshop.fields.md_email_cc", { template: "parts_return_slip" })}
rules={[
{
//message: t("general.validation.required"),
type: "array",
},
]}
>
<Select mode="tags" />
</Form.Item>
<Form.Item
name={["tt_allow_post_to_invoiced"]}
label={t("bodyshop.fields.tt_allow_post_to_invoiced")}

View File

@@ -7,6 +7,7 @@ import {
Input,
InputNumber,
Row,
Select,
Space,
Switch,
} from "antd";
@@ -15,7 +16,21 @@ import { useTranslation } from "react-i18next";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
export default function ShopInfoTaskPresets({ form }) {
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(ShopInfoTaskPresets);
export function ShopInfoTaskPresets({ bodyshop, form }) {
const { t } = useTranslation();
return (
@@ -59,6 +74,7 @@ export default function ShopInfoTaskPresets({ form }) {
<Input />
</Form.Item>
<Form.Item
span={12}
label={t("bodyshop.fields.md_tasks_presets.hourstype")}
key={`${index}hourstype`}
name={[field.name, "hourstype"]}
@@ -71,7 +87,15 @@ export default function ShopInfoTaskPresets({ form }) {
>
<Checkbox.Group>
<Row>
<Col span={8}>
<Col span={4}>
<Checkbox
value="LAA"
style={{ lineHeight: "32px" }}
>
{t("joblines.fields.lbr_types.LAA")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox
value="LAB"
style={{ lineHeight: "32px" }}
@@ -79,23 +103,23 @@ export default function ShopInfoTaskPresets({ form }) {
{t("joblines.fields.lbr_types.LAB")}
</Checkbox>
</Col>
<Col span={8}>
<Col span={4}>
<Checkbox
value="LAR"
value="LAD"
style={{ lineHeight: "32px" }}
>
{t("joblines.fields.lbr_types.LAR")}
{t("joblines.fields.lbr_types.LAD")}
</Checkbox>
</Col>
<Col span={8}>
<Col span={4}>
<Checkbox
value="LAM"
value="LAE"
style={{ lineHeight: "32px" }}
>
{t("joblines.fields.lbr_types.LAM")}
{t("joblines.fields.lbr_types.LAE")}
</Checkbox>
</Col>
<Col span={8}>
<Col span={4}>
<Checkbox
value="LAF"
style={{ lineHeight: "32px" }}
@@ -103,7 +127,7 @@ export default function ShopInfoTaskPresets({ form }) {
{t("joblines.fields.lbr_types.LAF")}
</Checkbox>
</Col>
<Col span={8}>
<Col span={4}>
<Checkbox
value="LAG"
style={{ lineHeight: "32px" }}
@@ -111,12 +135,90 @@ export default function ShopInfoTaskPresets({ form }) {
{t("joblines.fields.lbr_types.LAG")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox
value="LAM"
style={{ lineHeight: "32px" }}
>
{t("joblines.fields.lbr_types.LAM")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox
value="LAM"
style={{ lineHeight: "32px" }}
>
{t("joblines.fields.lbr_types.LAM")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox
value="LAR"
style={{ lineHeight: "32px" }}
>
{t("joblines.fields.lbr_types.LAR")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox
value="LAS"
style={{ lineHeight: "32px" }}
>
{t("joblines.fields.lbr_types.LAS")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox
value="LAU"
style={{ lineHeight: "32px" }}
>
{t("joblines.fields.lbr_types.LAU")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox
value="LA1"
style={{ lineHeight: "32px" }}
>
{t("joblines.fields.lbr_types.LA1")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox
value="LA2"
style={{ lineHeight: "32px" }}
>
{t("joblines.fields.lbr_types.LA2")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox
value="LA3"
style={{ lineHeight: "32px" }}
>
{t("joblines.fields.lbr_types.LA3")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox
value="LA4"
style={{ lineHeight: "32px" }}
>
{t("joblines.fields.lbr_types.LA4")}
</Checkbox>
</Col>
</Row>
</Checkbox.Group>
</Form.Item>
<Form.Item
label={t("bodyshop.fields.md_tasks_presets.percent")}
key={`${index}percent`}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
name={[field.name, "percent"]}
>
<InputNumber min={0} max={100} />
@@ -128,6 +230,17 @@ export default function ShopInfoTaskPresets({ form }) {
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.md_tasks_presets.nextstatus")}
key={`${index}nextstatus`}
name={[field.name, "nextstatus"]}
>
<Select
options={bodyshop.md_ro_statuses.production_statuses.map(
(o) => ({ value: o, label: o })
)}
/>
</Form.Item>
<Space wrap>
<DeleteFilled
onClick={() => {

View File

@@ -199,7 +199,7 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
},
]}
>
<InputNumber min={0} max={100} />
<InputNumber min={0} max={100} precision={2}/>
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAA")}

View File

@@ -11,22 +11,38 @@ import { createStructuredSelector } from "reselect";
import { techLogout } from "../../redux/tech/tech.actions";
import { selectTechnician } from "../../redux/tech/tech.selectors";
import { BsKanban } from "react-icons/bs";
import { useTreatments } from "@splitsoftware/splitio-react";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { setModalContext } from "../../redux/modals/modals.actions";
const { Sider } = Layout;
const mapStateToProps = createStructuredSelector({
technician: selectTechnician,
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({
techLogout: () => dispatch(techLogout()),
setTimeTicketTaskContext: (context) =>
dispatch(setModalContext({ context: context, modal: "timeTicketTask" })),
});
export function TechSider({ technician, techLogout }) {
export function TechSider({
technician,
techLogout,
bodyshop,
setTimeTicketTaskContext,
}) {
const [collapsed, setCollapsed] = useState(true);
const { t } = useTranslation();
const onCollapse = (collapsed) => {
setCollapsed(collapsed);
};
const { Enhanced_Payroll } = useTreatments(
["Enhanced_Payroll"],
{},
bodyshop.imexshopid
);
return (
<Sider
@@ -51,13 +67,29 @@ export function TechSider({ technician, techLogout }) {
<Menu.Item key="2" disabled={!!!technician} icon={<SearchOutlined />}>
<Link to={`/tech/joblookup`}>{t("menus.tech.joblookup")}</Link>
</Menu.Item>
<Menu.Item
key="3"
disabled={!!!technician}
icon={<Icon component={FaBusinessTime} />}
>
<Link to={`/tech/jobclock`}>{t("menus.tech.jobclockin")}</Link>
</Menu.Item>
{Enhanced_Payroll.treatment === "on" ? (
<Menu.Item
key="3"
disabled={!!!technician}
icon={<Icon component={FaBusinessTime} />}
onClick={() => {
setTimeTicketTaskContext({
actions: {},
context: { jobid: null },
});
}}
>
{t("menus.tech.claimtask")}
</Menu.Item>
) : (
<Menu.Item
key="3"
disabled={!!!technician}
icon={<Icon component={FaBusinessTime} />}
>
<Link to={`/tech/jobclock`}>{t("menus.tech.jobclockin")}</Link>
</Menu.Item>
)}
<Menu.Item
key="4"
disabled={!!!technician}

View File

@@ -1,4 +1,4 @@
import { EditFilled } from "@ant-design/icons";
import { EditFilled, SyncOutlined } from "@ant-design/icons";
import { Button, Card, Checkbox, Space, Table } from "antd";
import moment from "moment";
import React, { useMemo, useState } from "react";
@@ -18,6 +18,8 @@ import RbacWrapper, {
HasRbacAccess,
} from "../rbac-wrapper/rbac-wrapper.component";
import TimeTicketEnterButton from "../time-ticket-enter-button/time-ticket-enter-button.component";
import { useTreatments } from "@splitsoftware/splitio-react";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
authLevel: selectAuthLevel,
@@ -46,7 +48,11 @@ export function TimeTicketList({
});
const { t } = useTranslation();
const { Enhanced_Payroll } = useTreatments(
["Enhanced_Payroll"],
{},
bodyshop.imexshopid
);
const totals = useMemo(() => {
if (timetickets)
return timetickets.reduce(
@@ -126,21 +132,26 @@ export function TimeTicketList({
}) || [],
onFilter: (value, record) => value.includes(record.cost_center),
},
{
title: t("jobs.fields.ro_number"),
dataIndex: "ro_number",
key: "ro_number",
sorter: (a, b) =>
alphaSort(a.job && a.job.ro_number, b.job && b.job.ro_number),
sortOrder:
state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order,
render: (text, record) =>
record.job && (
<Link to={"/manage/jobs/" + record.job.id}>
{record.job.ro_number || "N/A"}
</Link>
),
},
...(jobId
? []
: [
{
title: t("jobs.fields.ro_number"),
dataIndex: "ro_number",
key: "ro_number",
sorter: (a, b) =>
alphaSort(a.job && a.job.ro_number, b.job && b.job.ro_number),
sortOrder:
state.sortedInfo.columnKey === "ro_number" &&
state.sortedInfo.order,
render: (text, record) =>
record.job && (
<Link to={"/manage/jobs/" + record.job.id}>
{record.job.ro_number || "N/A"}
</Link>
),
},
]),
{
title: t("timetickets.fields.productivehrs"),
dataIndex: "productivehrs",
@@ -150,14 +161,20 @@ export function TimeTicketList({
state.sortedInfo.columnKey === "productivehrs" &&
state.sortedInfo.order,
},
{
title: t("timetickets.fields.actualhrs"),
dataIndex: "actualhrs",
key: "actualhrs",
sorter: (a, b) => a.actualhrs - b.actualhrs,
sortOrder:
state.sortedInfo.columnKey === "actualhrs" && state.sortedInfo.order,
},
...(Enhanced_Payroll.treatment === "on"
? []
: [
{
title: t("timetickets.fields.actualhrs"),
dataIndex: "actualhrs",
key: "actualhrs",
sorter: (a, b) => a.actualhrs - b.actualhrs,
sortOrder:
state.sortedInfo.columnKey === "actualhrs" &&
state.sortedInfo.order,
},
]),
{
title: t("timetickets.fields.memo"),
dataIndex: "memo",
@@ -168,42 +185,60 @@ export function TimeTicketList({
render: (text, record) =>
record.clockon || record.clockoff ? t(record.memo) : record.memo,
},
{
title: t("timetickets.fields.clockon"),
dataIndex: "clockon",
key: "clockon",
...(Enhanced_Payroll.treatment === "on"
? [
{
title: t("timetickets.fields.task_name"),
dataIndex: "task_name",
key: "task_name",
sorter: (a, b) => alphaSort(a.task_name, b.task_name),
sortOrder:
state.sortedInfo.columnKey === "task_name" &&
state.sortedInfo.order,
},
]
: []),
...(Enhanced_Payroll.treatment === "on"
? []
: [
{
title: t("timetickets.fields.clockon"),
dataIndex: "clockon",
key: "clockon",
render: (text, record) => (
<DateTimeFormatter>{record.clockon}</DateTimeFormatter>
),
},
{
title: t("timetickets.fields.clockoff"),
dataIndex: "clockoff",
key: "clockoff",
render: (text, record) => (
<DateTimeFormatter>{record.clockon}</DateTimeFormatter>
),
},
{
title: t("timetickets.fields.clockoff"),
dataIndex: "clockoff",
key: "clockoff",
render: (text, record) => (
<DateTimeFormatter>{record.clockoff}</DateTimeFormatter>
),
},
{
title: t("timetickets.fields.clockhours"),
dataIndex: "clockhours",
key: "clockhours",
render: (text, record) => {
if (record.clockoff && record.clockon)
return (
<div>
{moment(record.clockoff)
.diff(moment(record.clockon), "hours", true)
.toFixed(2)}
</div>
);
else {
return null;
}
},
},
]),
render: (text, record) => (
<DateTimeFormatter>{record.clockoff}</DateTimeFormatter>
),
},
{
title: t("timetickets.fields.clockhours"),
dataIndex: "clockhours",
key: "clockhours",
render: (text, record) => {
if (record.clockoff && record.clockon)
return (
<div>
{moment(record.clockoff)
.diff(moment(record.clockon), "hours", true)
.toFixed(2)}
</div>
);
else {
return null;
}
},
},
// {
// title: "Pay",
// dataIndex: "pay",
@@ -274,17 +309,12 @@ export function TimeTicketList({
title={t("timetickets.labels.timetickets")}
extra={
<Space wrap>
{
// <TimeTicketListTeamPay
// actions={{ refetch }}
// context={{ jobId: jobId }}
// />
}
{bodyshop.md_tasks_presets.enable_tasks && (
{jobId && bodyshop.md_tasks_presets.enable_tasks && (
<Button
disabled={disabled}
onClick={() => {
setTimeTicketTaskContext({
actions: {},
actions: { refetch: refetch },
context: { jobid: jobId },
});
}}
@@ -303,6 +333,13 @@ export function TimeTicketList({
</TimeTicketEnterButton>
))}
{extra}
<Button
onClick={async () => {
refetch();
}}
>
<SyncOutlined />
</Button>
</Space>
}
>

View File

@@ -1,28 +1,21 @@
import {
Alert,
Button,
Checkbox,
Col,
Form,
Input,
InputNumber,
Radio,
Row,
Skeleton,
Space,
Table,
Spin,
Typography,
} from "antd";
import _ from "lodash";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component";
import EmployeeTeamSearchSelectComponent from "../employee-team-search-select/employee-team-search-select.component";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
import ReadOnlyFormItemComponent from "../form-items-formatted/read-only-form-item.component";
import JobSearchSelectComponent from "../job-search-select/job-search-select.component";
import { CalculateAllocationsTotals } from "../labor-allocations-table/labor-allocations-table.utility";
import { LaborAllocationContainer } from "../time-ticket-modal/time-ticket-modal.component";
import TimeTicketsTasksPresets from "../time-ticket-tasks-presets/time-ticket-tasks-presets.component";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
@@ -39,20 +32,14 @@ export default connect(
export function TimeTicketTaskModalComponent({
bodyshop,
form,
lineTicketCalled,
calculateTimeTickets,
lineTicketLoading,
lineTicketData,
queryJobInfo,
loading,
completedTasks,
unassignedHours,
}) {
const { t } = useTranslation();
return (
<div>
<TimeTicketsTasksPresets
form={form}
calculateTimeTickets={calculateTimeTickets}
/>
<Row gutter={[16, 16]}>
<Col xl={12} lg={24}>
<Form.Item
@@ -65,308 +52,138 @@ export function TimeTicketTaskModalComponent({
},
]}
>
<JobSearchSelectComponent
convertedOnly={!bodyshop.tt_allow_post_to_invoiced}
notExported={!bodyshop.tt_allow_post_to_invoiced}
/>
<JobSearchSelectComponent convertedOnly={true} notExported={true} />
</Form.Item>
<Form.Item
name="employeeteamid"
label={t("timetickets.fields.employee_team")}
>
<EmployeeTeamSearchSelectComponent />
</Form.Item>
<Form.Item
name="hourstype"
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<Checkbox.Group>
<Space wrap>
<Checkbox value="LAB" style={{ display: "flex" }}>
{t("jobs.fields.lab")}
</Checkbox>
<Checkbox value="LAR" style={{ display: "flex" }}>
{t("jobs.fields.lar")}
</Checkbox>
<Checkbox value="LAM" style={{ display: "flex" }}>
{t("jobs.fields.lam")}
</Checkbox>
<Checkbox value="LAF" style={{ display: "flex" }}>
{t("jobs.fields.laf")}
</Checkbox>
<Checkbox value="LAG" style={{ display: "flex" }}>
{t("jobs.fields.lag")}
</Checkbox>
</Space>
</Checkbox.Group>
</Form.Item>
<Space wrap align="start">
<Form.Item
name="percent"
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<InputNumber min={0} max={100} precision={1} addonAfter="%" />
<Space wrap>
<Form.Item name="task" label={t("timetickets.labels.task")}>
{loading ? (
<Spin />
) : (
<Radio.Group
optionType="button"
options={bodyshop.md_tasks_presets.presets.map((preset) => ({
value: preset.name,
label: preset.name,
disabled: completedTasks.includes(preset.name),
}))}
/>
)}
</Form.Item>
<Form.Item dependencies={["task"]}>
{() => {
const { task } = form.getFieldsValue();
const theTaskPreset = bodyshop.md_tasks_presets.presets.find(
(tp) => tp.name === task
);
<Button onClick={calculateTimeTickets}>
{t("tt_approvals.labels.calculate")}
</Button>
if (!task) return null;
return (
<table className="task-tickets-table">
<tbody>
<tr>
<td>{t("bodyshop.fields.md_tasks_presets.percent")}</td>
<td>{`${theTaskPreset.percent || 0}%`}</td>
</tr>
<tr>
<td>
{t("bodyshop.fields.md_tasks_presets.hourstype")}
</td>
<td>{theTaskPreset.hourstype.join(", ")}</td>
</tr>
<tr>
<td>
{t("bodyshop.fields.md_tasks_presets.nextstatus")}
</td>
<td>{theTaskPreset.nextstatus}</td>
</tr>
</tbody>
</table>
);
}}
</Form.Item>
</Space>
</Col>
<Col xl={12} lg={24}>
<Form.Item shouldUpdate>
{() => {
const data = form.getFieldValue("timetickets");
return (
<Table
dataSource={data}
rowKey={"employeeid"}
columns={[
{
title: t("timetickets.fields.employee"),
dataIndex: "employee",
key: "employee",
render: (text, record) => {
const emp = bodyshop.employees.find(
(e) => e.id === record.employeeid
);
return `${emp?.first_name} ${emp?.last_name}`;
},
},
{
title: t("timetickets.fields.cost_center"),
dataIndex: "cost_center",
key: "cost_center",
render: (text, record) =>
record.cost_center === "timetickets.labels.shift"
? t(record.cost_center)
: record.cost_center,
},
{
title: t("timetickets.fields.productivehrs"),
dataIndex: "productivehrs",
key: "productivehrs",
},
{
title: "Percentage",
dataIndex: "percentage",
key: "percentage",
},
{
title: "Rate",
dataIndex: "rate",
key: "rate",
},
// {
// title: "Pay",
// dataIndex: "pay",
// key: "pay",
// },
]}
/>
);
}}
</Form.Item>
<Form.List
name={["timetickets"]}
rules={[
{
validator: (rule, value) => {
//Check the cost center,
const totals = CalculateAllocationsTotals(
bodyshop,
lineTicketData.joblines,
lineTicketData.timetickets,
lineTicketData.jobs_by_pk.lbr_adjustments
);
const grouped = _.groupBy(value, "cost_center");
let error = false;
Object.keys(grouped).forEach((key) => {
const totalProdTicketHours = grouped[key].reduce(
(acc, val) => acc + val.productivehrs,
0
);
const fieldTypeToCheck = "cost_center";
// bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber
// ? "mod_lbr_ty"
// : "cost_center";
const costCenterDiff =
Math.round(
totals.find((total) => total[fieldTypeToCheck] === key)
?.difference * 10
) / 10;
if (totalProdTicketHours > costCenterDiff) error = true;
else {
// return Promise.resolve();
}
});
if (!error) return Promise.resolve();
return Promise.reject(
"Too many hours are being claimed as a part of this task"
);
},
},
]}
>
{(fields, { add, remove, move }, { errors }) => {
return (
<div>
{errors.map((e, idx) => (
<Alert key={idx} message={e} />
))}
<div
style={{
display: "none",
}}
>
{fields.map((field, index) => (
<Form.Item
key={field.key}
style={{ padding: 0, margin: 2 }}
>
<Space wrap>
<Form.Item
label={t("timetickets.fields.employeeid")}
key={`${index}employeeid`}
name={[field.name, "employeeid"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<EmployeeSearchSelectComponent
options={bodyshop.employees}
/>
</Form.Item>
<Form.Item
label={t("timetickets.fields.date")}
key={`${index}date`}
name={[field.name, "date"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<FormDateTimePickerComponent />
</Form.Item>
<Form.Item
label={t("timetickets.fields.productivehrs")}
key={`${index}productivehrs`}
name={[field.name, "productivehrs"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t("timetickets.fields.actualhrs")}
key={`${index}actualhrs`}
name={[field.name, "actualhrs"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t("timetickets.fields.rate")}
key={`${index}rate`}
name={[field.name, "rate"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<InputNumber />
</Form.Item>
<Form.Item
label={t("timetickets.fields.cost_center")}
key={`${index}cost_center`}
name={[field.name, "cost_center"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("timetickets.fields.memo")}
key={`${index}memo`}
name={[field.name, "memo"]}
>
<Input />
</Form.Item>
</Space>
</Form.Item>
))}
</div>
</div>
);
}}
</Form.List>
{loading ? (
<Skeleton />
) : (
<Form.List name="timetickets">
{(fields, { add, remove, move }) => {
return (
<>
<Typography.Title level={4}>
{t("timetickets.labels.claimtaskpreview")}
</Typography.Title>
<table className="task-tickets-table">
<thead>
<tr>
<th>{t("timetickets.fields.employee")}</th>
<th>{t("timetickets.fields.cost_center")}</th>
<th>{t("timetickets.fields.ciecacode")}</th>
<th>{t("timetickets.fields.productivehrs")}</th>
</tr>
</thead>
<tbody>
{fields.map((field, index) => (
<tr key={field.key}>
<td>
<Form.Item
key={`${index}employeeid`}
name={[field.name, "employeeid"]}
>
<ReadOnlyFormItemComponent type="employee" />
</Form.Item>
</td>
<td>
<Form.Item
key={`${index}cost_center`}
name={[field.name, "cost_center"]}
>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<Form.Item
key={`${index}ciecacode`}
name={[field.name, "ciecacode"]}
>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<Form.Item
key={`${index}productivehrs`}
name={[field.name, "productivehrs"]}
>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
</tr>
))}
</tbody>
</table>
<Alert
type="success"
message={t("timetickets.labels.payrollclaimedtasks")}
/>
</>
);
}}
</Form.List>
)}
{unassignedHours > 0 && (
<Alert
type="error"
message={t("timetickets.validation.unassignedlines", {
unassignedHours: unassignedHours,
})}
/>
)}
</Col>
</Row>
<Form.Item dependencies={["jobid"]}>
{() => {
const jobid = form.getFieldValue("jobid");
if (
(!lineTicketCalled && jobid) ||
(jobid &&
lineTicketData?.jobs_by_pk?.id !== jobid &&
!lineTicketLoading)
) {
queryJobInfo({ variables: { id: jobid } }).then(() =>
calculateTimeTickets("")
);
}
return (
<LaborAllocationContainer
jobid={jobid || null}
loading={lineTicketLoading}
lineTicketData={lineTicketData}
hideTimeTickets
/>
);
}}
</Form.Item>
{bodyshop?.md_tasks_presets?.use_approvals && (
<Col span={24}>
<Col xl={12} lg={24}>
<Alert
message={t("tt_approvals.labels.approval_queue_in_use")}
type="warning"

View File

@@ -1,21 +1,17 @@
import React, { useEffect } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { useLazyQuery, useMutation, useQuery } from "@apollo/client";
import { Form, Modal, notification } from "antd";
import Dinero from "dinero.js";
import _ from "lodash";
import moment from "moment";
import axios from "axios";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { QUERY_ACTIVE_EMPLOYEES } from "../../graphql/employees.queries";
import { GET_JOB_INFO_DRAW_CALCULATIONS } from "../../graphql/jobs-lines.queries";
import { INSERT_NEW_TIME_TICKET } from "../../graphql/timetickets.queries";
import { toggleModalVisible } from "../../redux/modals/modals.actions";
import { selectTimeTicketTasks } from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import TimeTicketTaskModalComponent from "./time-ticket-task-modal.component";
import { INSERT_NEW_TT_APPROVALS } from "../../graphql/tt-approvals.queries";
import { useApolloClient } from "@apollo/client";
import { QUERY_COMPLETED_TASKS } from "../../graphql/jobs.queries";
import "./time-ticket-task-modal.styles.scss";
const mapStateToProps = createStructuredSelector({
timeTicketTasksModal: selectTimeTicketTasks,
@@ -35,148 +31,78 @@ export function TimeTickeTaskModalContainer({
toggleModalVisible,
}) {
const [form] = Form.useForm();
const { context, visible } = timeTicketTasksModal;
const { data: EmployeeAutoCompleteData } = useQuery(QUERY_ACTIVE_EMPLOYEES, {
skip: !visible,
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
});
const { context, visible, actions } = timeTicketTasksModal;
const [completedTasks, setCompletedTasks] = useState([]);
const [unassignedHours, setUnassignedHours] = useState(0);
const { t } = useTranslation();
const [insertTimeTickets] = useMutation(INSERT_NEW_TIME_TICKET);
const [insertTimeTicketApproval] = useMutation(INSERT_NEW_TT_APPROVALS);
const [queryJobInfo, { called, loading, data: lineTicketData }] =
useLazyQuery(GET_JOB_INFO_DRAW_CALCULATIONS, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
});
const [loading, setLoading] = useState(false);
const client = useApolloClient();
async function handleFinish(values) {
try {
if (bodyshop.md_tasks_presets.use_approvals) {
const result = await insertTimeTicketApproval({
variables: {
timeTicketInput: values.timetickets.map((ticket) => ({
..._.omit(ticket, "pay"),
bodyshopid: bodyshop.id,
})),
},
});
if (result.errors) {
notification.open({
type: "error",
message: t("timetickets.errors.creating", {
message: JSON.stringify(result.errors),
}),
});
} else {
notification.open({
type: "success",
message: t("timetickets.successes.created"),
});
form.resetFields();
toggleModalVisible();
}
} else {
const result = await insertTimeTickets({
variables: {
timeTicketInput: values.timetickets.map((ticket) =>
_.omit(ticket, "pay")
),
},
refetchQueries: ["GET_LINE_TICKET_BY_PK"]
});
if (result.errors) {
notification.open({
type: "error",
message: t("timetickets.errors.creating", {
message: JSON.stringify(result.errors),
}),
});
} else {
notification.open({
type: "success",
message: t("timetickets.successes.created"),
});
toggleModalVisible();
}
}
} catch (error) {
console.log("🚀 ~ file: time-ticket-task-modal.container.jsx:104 ~ handleFinish ~ error:", error)
notification.open({
type: "error",
message: t("timetickets.errors.creating", {
message: JSON.stringify(error),
}),
calculateTickets({ values, handleFinish: true });
}
const getCompletedTasks = useCallback(
async (jobid) => {
setLoading(true);
const { data } = await client.query({
query: QUERY_COMPLETED_TASKS,
variables: { jobid },
});
} finally {
setCompletedTasks(data.jobs_by_pk.completed_tasks || []);
setLoading(false);
},
[client]
);
useEffect(() => {
if (visible) {
form.setFieldsValue({ ...context, task: null, timetickets: null });
if (context.jobid) {
getCompletedTasks(context.jobid);
}
}
}, [context.jobid, visible, getCompletedTasks, form, context]);
async function handleValueChange(changedValues, allValues) {
if (changedValues.jobid) {
getCompletedTasks(changedValues.jobid);
}
if (allValues.jobid && allValues.task) {
calculateTickets({ values: allValues, handleFinish: false });
}
}
useEffect(() => {
if (visible && context.jobid) {
queryJobInfo({ variables: { id: context.jobid } });
}
}, [context.jobid, queryJobInfo, visible]);
const calculateTimeTickets = (presetMemo) => {
const formData = form.getFieldsValue();
if (
!formData.jobid ||
!formData.employeeteamid ||
!formData.hourstype ||
formData.hourstype.length === 0 ||
!formData.percent ||
!lineTicketData
) {
return;
}
let data = [];
let eligibleHours = 0;
const theTeam = JSON.parse(formData.employeeteamid);
if (theTeam) {
formData.hourstype.forEach((hourstype) => {
eligibleHours =
lineTicketData.joblines.reduce(
(acc, val) =>
acc + (hourstype === val.mod_lbr_ty ? val.mod_lb_hrs : 0),
0
) * (formData.percent / 100 || 0);
theTeam.employee_team_members.forEach((e) => {
const newTicket = {
employeeid: e.employeeid,
bodyshopid: bodyshop.id,
date: moment().format("YYYY-MM-DD"),
jobid: formData.jobid,
rate: e.labor_rates[hourstype],
actualhrs: 0,
memo: typeof presetMemo === "string" ? presetMemo : "",
flat_rate: true,
ciecacode: hourstype,
cost_center:
bodyshop.md_responsibility_centers.defaults.costs[hourstype],
productivehrs:
Math.round(eligibleHours * 100 * (e.percentage / 100)) / 100,
pay: Dinero({
amount: Math.round((e.labor_rates[hourstype] || 0) * 100),
})
.multiply(
Math.round(eligibleHours * 100 * (e.percentage / 100)) / 100
)
.toFormat("$0.00"),
};
data.push(newTicket);
const calculateTickets = async ({ values, handleFinish }) => {
setLoading(true);
try {
const { data, ...response } = await axios.post("/payroll/claimtask", {
jobid: values.jobid,
task: values.task,
calculateOnly: !handleFinish,
});
if (response.status === 200 && handleFinish) {
//Close the modal
if (actions?.refetch) actions.refetch();
toggleModalVisible();
} else if (handleFinish === false) {
form.setFieldsValue({ timetickets: data.ticketsToInsert });
setUnassignedHours(data.unassignedHours);
} else {
notification.open({
type: "error",
message: t("timetickets.errors.creating", {
message: JSON.stringify(data),
}),
});
}
} catch (error) {
notification.open({
type: "error",
message: t("timetickets.errors.creating", { message: error.message }),
});
form.setFieldsValue({
timetickets: data.filter((d) => d.productivehrs > 0),
});
form.validateFields();
} finally {
setLoading(false);
}
};
@@ -197,17 +123,13 @@ export function TimeTickeTaskModalContainer({
layout="vertical"
onFinish={handleFinish}
initialValues={context}
onValuesChange={handleValueChange}
>
<TimeTicketTaskModalComponent
form={form}
employeeAutoCompleteOptions={
EmployeeAutoCompleteData && EmployeeAutoCompleteData.employees
}
lineTicketData={lineTicketData}
lineTicketLoading={loading}
lineTicketCalled={called}
calculateTimeTickets={calculateTimeTickets}
queryJobInfo={queryJobInfo}
loading={loading}
completedTasks={completedTasks}
unassignedHours={unassignedHours}
/>
</Form>
</Modal>

View File

@@ -0,0 +1,19 @@
.task-tickets-table {
table-layout: fixed;
width: 100%;
th,
td {
padding: 8px;
text-align: left;
border-bottom: 1px solid #ddd;
.ant-form-item {
margin-bottom: 0px !important;
}
}
tr:hover {
background-color: #f5f5f5;
}
}

View File

@@ -1,30 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTranslation } from "react-i18next";
import { Button, Dropdown } from "antd";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(TimeTicketTaskCollector);
export function TimeTicketTaskCollector({ form, bodyshop }) {
const { t } = useTranslation();
const items = [];
return (
<Dropdown menu={{ items }}>
<Button>{t("timetickets.actions.tasks")}</Button>
</Dropdown>
);
}

View File

@@ -1,72 +0,0 @@
import { Button, Dropdown } from "antd";
import React from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(TimeTicketsTasksPresets);
export function TimeTicketsTasksPresets({
bodyshop,
form,
calculateTimeTickets,
}) {
const handleClick = (props) => {
const preset = bodyshop.md_tasks_presets?.presets?.find((p) => {
return p.name === props.key;
});
if (preset) {
form.setFieldsValue({
percent: preset.percent,
hourstype: preset.hourstype,
});
calculateTimeTickets(preset.memo);
}
};
return (
<Dropdown
trigger="click"
menu={{
items: bodyshop.md_tasks_presets?.presets
? bodyshop.md_tasks_presets?.presets?.map((p) => ({
label: p.name,
key: p.name,
}))
: [],
onClick: handleClick,
}}
>
<Button>Presets</Button>
</Dropdown>
);
}
// const samplePresets = [
// {
// name: "Teardown",
// hourstype: ["LAB", "LAM"],
// percent: 10,
// memo: "Teardown Preset Task",
// },
// {
// name: "Disassembly",
// hourstype: ["LAB", "LAD"],
// percent: 20,
// memo: "Disassy Preset Claim",
// },
// { name: "Body", hourstype: ["LAB", "LAD"], percent: 20 },
// { name: "Prep", hourstype: ["LAR"], percent: 20 },
// ];

View File

@@ -24,11 +24,7 @@ export const QUERY_ALL_BILLS_PAGINATED = gql`
$limit: Int
$order: [bills_order_by!]!
) {
bills(
offset: $offset
limit: $limit
order_by: $order
) {
bills(offset: $offset, limit: $limit, order_by: $order) {
id
vendorid
vendor {
@@ -97,6 +93,23 @@ export const QUERY_BILLS_BY_JOBID = gql`
comments
user_email
}
parts_dispatch(where: { jobid: { _eq: $jobid } }) {
id
dispatched_at
dispatched_by
employeeid
number
parts_dispatch_lines {
joblineid
id
quantity
accepted_at
jobline {
id
line_desc
}
}
}
bills(where: { jobid: { _eq: $jobid } }, order_by: { date: desc }) {
id
vendorid

View File

@@ -119,6 +119,19 @@ export const QUERY_BODYSHOP = gql`
tt_enforce_hours_for_tech_console
md_tasks_presets
use_paint_scale_data
employee_teams(
order_by: { name: asc }
where: { active: { _eq: true } }
) {
id
name
employee_team_members {
id
employeeid
labor_rates
percentage
}
}
employees {
user_email
id
@@ -235,6 +248,19 @@ export const UPDATE_SHOP = gql`
enforce_conversion_category
tt_enforce_hours_for_tech_console
md_tasks_presets
employee_teams(
order_by: { name: asc }
where: { active: { _eq: true } }
) {
id
name
employee_team_members {
id
employeeid
labor_rates
percentage
}
}
employees {
id
first_name

View File

@@ -52,7 +52,6 @@ export const GET_LINE_TICKET_BY_PK = gql`
op_code_desc
convertedtolbr
convertedtolbr_data
}
timetickets(where: { jobid: { _eq: $id } }) {
actualhrs
@@ -69,6 +68,7 @@ export const GET_LINE_TICKET_BY_PK = gql`
rate
committed_at
commited_by
task_name
employee {
id
first_name
@@ -245,6 +245,7 @@ export const UPDATE_JOB_LINE = gql`
removed
convertedtolbr
convertedtolbr_data
assigned_team
}
}
}
@@ -349,3 +350,19 @@ export const UPDATE_LINE_PPC = gql`
}
}
`;
export const UPDATE_LINE_BULK_ASSIGN = gql`
mutation UPDATE_LINE_BULK_ASSIGN(
$ids: [uuid!]!
$jobline: joblines_set_input
) {
update_joblines_many(
updates: { _set: $jobline, where: { id: { _in: $ids } } }
) {
returning {
id
assigned_team
}
}
}
`;

View File

@@ -682,6 +682,7 @@ export const GET_JOB_BY_PK = gql`
date_rentalresp
date_exported
date_repairstarted
date_void
status
owner_owing
tax_registration_number
@@ -725,6 +726,15 @@ export const GET_JOB_BY_PK = gql`
ah_detail_line
act_price_before_ppc
critical
parts_dispatch_lines(limit: 1, order_by: { accepted_at: desc }) {
id
accepted_at
parts_dispatch {
id
employeeid
}
}
assigned_team
billlines(limit: 1, order_by: { bill: { date: desc } }) {
id
quantity
@@ -1109,6 +1119,7 @@ export const UPDATE_JOB = gql`
scheduled_completion
actual_in
date_repairstarted
date_void
}
}
}
@@ -1156,6 +1167,7 @@ export const VOID_JOB = gql`
update_jobs_by_pk(_set: $job, pk_columns: { id: $jobId }) {
id
date_exported
date_void
status
alt_transport
ro_number
@@ -2187,3 +2199,12 @@ export const GET_JOB_LINE_ORDERS = gql`
}
}
`;
export const QUERY_COMPLETED_TASKS = gql`
query QUERY_COMPLETED_TASKS($jobid: uuid!) {
jobs_by_pk(id: $jobid) {
id
completed_tasks
}
}
`;

View File

@@ -0,0 +1,17 @@
import { gql } from "@apollo/client";
export const INSERT_PARTS_DISPATCH = gql`
mutation INSERT_PARTS_DISPATCH($partsDispatch: parts_dispatch_insert_input!) {
insert_parts_dispatch_one(object: $partsDispatch) {
id
jobid
number
employeeid
parts_dispatch_lines {
id
joblineid
quantity
}
}
}
`;

View File

@@ -48,6 +48,7 @@ export const QUERY_TIME_TICKETS_IN_RANGE = gql`
flat_rate
commited_by
committed_at
task_name
job {
id
ro_number

View File

@@ -35,7 +35,11 @@ const TechJobClock = lazy(() =>
const TechShiftClock = lazy(() =>
import("../tech-shift-clock/tech-shift-clock.component")
);
const TimeTicketModalTask = lazy(() =>
import(
"../../components/time-ticket-task-modal/time-ticket-task-modal.container"
)
);
const { Content } = Layout;
const mapStateToProps = createStructuredSelector({
@@ -70,6 +74,7 @@ export function TechPage({ technician, match }) {
<FeatureWrapper featureName="tech-console">
<TimeTicketModalContainer />
<PrintCenterModalContainer />
<TimeTicketModalTask />
<Switch>
<Route
exact

View File

@@ -101,6 +101,7 @@
"messages": {
"admin_jobmarkexported": "ADMIN: Job marked as exported.",
"admin_jobmarkforreexport": "ADMIN: Job marked for re-export.",
"admin_jobuninvoice": "ADMIN: Job has been uninvoiced.",
"admin_jobunvoid": "ADMIN: Job has been unvoided.",
"billposted": "Bill with invoice number {{invoice_number}} posted.",
"billupdated": "Bill with invoice number {{invoice_number}} updated.",
@@ -349,6 +350,7 @@
"hourstype": "Hour Types",
"memo": "Time Ticket Memo",
"name": "Preset Name",
"nextstatus": "Next Status",
"percent": "Percent",
"use_approvals": "Use Time Ticket Approval Queue"
},
@@ -1214,7 +1216,9 @@
},
"joblines": {
"actions": {
"assign_team": "Assign Team",
"converttolabor": "Convert amount to Labor.",
"dispatchparts": "Dispatch Parts ({{count}})",
"new": "New Line"
},
"errors": {
@@ -1224,6 +1228,7 @@
"fields": {
"act_price": "Retail Price",
"ah_detail_line": "Mark as Detail Labor Line (Autohouse Only)",
"assigned_team": "Team",
"db_price": "List Price",
"lbr_types": {
"LA1": "LA1",
@@ -1441,6 +1446,7 @@
"date_repairstarted": "Repairs Started",
"date_scheduled": "Scheduled",
"date_towin": "Towed In",
"date_void": "Void",
"ded_amt": "Deductible",
"ded_note": "Deductible Note",
"ded_status": "Deductible Status",
@@ -1991,6 +1997,7 @@
"shops": "My Shops"
},
"tech": {
"claimtask": "Claim Task",
"home": "Home",
"jobclockin": "Job Clock In",
"jobclockout": "Job Clock Out",
@@ -2131,6 +2138,23 @@
"orderinhouse": "Order as In House"
}
},
"parts_dispatch": {
"errors": {
"creating": "Error dispatching parts. {{error}}"
},
"fields": {
"number": "Number",
"percent_accepted": "% Accepted"
},
"labels": {
"parts_dispatch": "Parts Dispatch"
}
},
"parts_dispatch_lines": {
"fields": {
"accepted_at": "Accepted At"
}
},
"parts_orders": {
"actions": {
"backordered": "Mark Backordered",
@@ -2408,6 +2432,7 @@
"jobs": {
"individual_job_note": "Job Note RO: {{ro_number}}",
"parts_order": "Parts Order PO: {{ro_number}} - {{name}}",
"parts_return_slip": "Parts Return PO: {{ro_number}} - {{name}}",
"sublet_order": "Sublet Order PO: {{ro_number}} - {{name}}"
}
},
@@ -2708,6 +2733,7 @@
"commit": "Commit Tickets ({{count}})",
"commitone": "Commit",
"enter": "Enter New Time Ticket",
"payall": "Pay All",
"printemployee": "Print Time Tickets",
"uncommit": "Uncommit"
},
@@ -2735,12 +2761,14 @@
"flat_rate": "Flat Rate?",
"memo": "Memo",
"productivehrs": "Productive Hours",
"ro_number": "Job to Post Against"
"ro_number": "Job to Post Against",
"task_name": "Task"
},
"labels": {
"alreadyclockedon": "You are already clocked in to the following job(s):",
"ambreak": "AM Break",
"amshift": "AM Shift",
"claimtaskpreview": "Claimed Tasks Preview",
"clockhours": "Shift Clock Hours Summary",
"clockintojob": "Clock In to Job",
"deleteconfirm": "Are you sure you want to delete this time ticket? This cannot be undone.",
@@ -2750,12 +2778,15 @@
"jobhours": "Job Related Time Tickets Summary",
"lunch": "Lunch",
"new": "New Time Ticket",
"payrollclaimedtasks": "These time tickets will be automatically entered to the system as a part of claiming this task. These numbers are calculated using the jobs assigned lines. If lines are unassigned, they will be excluded from created tickets.",
"pmbreak": "PM Break",
"pmshift": "PM Shift",
"shift": "Shift",
"shiftalreadyclockedon": "Active Shift Time Tickets",
"straight_time": "Straight Time",
"task": "Task",
"timetickets": "Time Tickets",
"unassigned": "Unassigned",
"zeroactualnegativeprod": "Actual hours must be 0 if entering negative productive hours."
},
"successes": {
@@ -2768,7 +2799,8 @@
"validation": {
"clockoffmustbeafterclockon": "Clock off time must be the same or after clock in time.",
"clockoffwithoutclockon": "Clock off time cannot be set without a clock in time.",
"hoursenteredmorethanavailable": "The number of hours entered is more than what is available for this cost center."
"hoursenteredmorethanavailable": "The number of hours entered is more than what is available for this cost center.",
"unassignedlines": "There are currently {{unassignedHours}} hours of repair lines that are unassigned. These hours are not including in the above calculations and must be paid manually."
}
},
"titles": {

View File

@@ -101,6 +101,7 @@
"messages": {
"admin_jobmarkexported": "",
"admin_jobmarkforreexport": "",
"admin_jobuninvoice": "",
"admin_jobunvoid": "",
"billposted": "",
"billupdated": "",
@@ -349,6 +350,7 @@
"hourstype": "",
"memo": "",
"name": "",
"nextstatus": "",
"percent": "",
"use_approvals": ""
},
@@ -1214,7 +1216,9 @@
},
"joblines": {
"actions": {
"assign_team": "",
"converttolabor": "",
"dispatchparts": "",
"new": ""
},
"errors": {
@@ -1224,6 +1228,7 @@
"fields": {
"act_price": "Precio actual",
"ah_detail_line": "",
"assigned_team": "",
"db_price": "Precio de base de datos",
"lbr_types": {
"LA1": "",
@@ -1441,6 +1446,7 @@
"date_repairstarted": "",
"date_scheduled": "Programado",
"date_towin": "",
"date_void": "",
"ded_amt": "Deducible",
"ded_note": "",
"ded_status": "Estado deducible",
@@ -1991,6 +1997,7 @@
"shops": "Mis tiendas"
},
"tech": {
"claimtask": "",
"home": "",
"jobclockin": "",
"jobclockout": "",
@@ -2131,6 +2138,23 @@
"orderinhouse": ""
}
},
"parts_dispatch": {
"errors": {
"creating": ""
},
"fields": {
"number": "",
"percent_accepted": ""
},
"labels": {
"parts_dispatch": ""
}
},
"parts_dispatch_lines": {
"fields": {
"accepted_at": ""
}
},
"parts_orders": {
"actions": {
"backordered": "",
@@ -2408,6 +2432,7 @@
"jobs": {
"individual_job_note": "",
"parts_order": "",
"parts_return_slip": "",
"sublet_order": ""
}
},
@@ -2708,6 +2733,7 @@
"commit": "",
"commitone": "",
"enter": "",
"payall": "",
"printemployee": "",
"uncommit": ""
},
@@ -2735,12 +2761,14 @@
"flat_rate": "",
"memo": "",
"productivehrs": "",
"ro_number": ""
"ro_number": "",
"task_name": ""
},
"labels": {
"alreadyclockedon": "",
"ambreak": "",
"amshift": "",
"claimtaskpreview": "",
"clockhours": "",
"clockintojob": "",
"deleteconfirm": "",
@@ -2750,12 +2778,15 @@
"jobhours": "",
"lunch": "",
"new": "",
"payrollclaimedtasks": "",
"pmbreak": "",
"pmshift": "",
"shift": "",
"shiftalreadyclockedon": "",
"straight_time": "",
"task": "",
"timetickets": "",
"unassigned": "",
"zeroactualnegativeprod": ""
},
"successes": {
@@ -2768,7 +2799,8 @@
"validation": {
"clockoffmustbeafterclockon": "",
"clockoffwithoutclockon": "",
"hoursenteredmorethanavailable": ""
"hoursenteredmorethanavailable": "",
"unassignedlines": ""
}
},
"titles": {

View File

@@ -101,6 +101,7 @@
"messages": {
"admin_jobmarkexported": "",
"admin_jobmarkforreexport": "",
"admin_jobuninvoice": "",
"admin_jobunvoid": "",
"billposted": "",
"billupdated": "",
@@ -349,6 +350,7 @@
"hourstype": "",
"memo": "",
"name": "",
"nextstatus": "",
"percent": "",
"use_approvals": ""
},
@@ -1214,7 +1216,9 @@
},
"joblines": {
"actions": {
"assign_team": "",
"converttolabor": "",
"dispatchparts": "",
"new": ""
},
"errors": {
@@ -1224,6 +1228,7 @@
"fields": {
"act_price": "Prix actuel",
"ah_detail_line": "",
"assigned_team": "",
"db_price": "Prix de la base de données",
"lbr_types": {
"LA1": "",
@@ -1441,6 +1446,7 @@
"date_repairstarted": "",
"date_scheduled": "Prévu",
"date_towin": "",
"date_void": "",
"ded_amt": "Déductible",
"ded_note": "",
"ded_status": "Statut de franchise",
@@ -1991,6 +1997,7 @@
"shops": "Mes boutiques"
},
"tech": {
"claimtask": "",
"home": "",
"jobclockin": "",
"jobclockout": "",
@@ -2131,6 +2138,23 @@
"orderinhouse": ""
}
},
"parts_dispatch": {
"errors": {
"creating": ""
},
"fields": {
"number": "",
"percent_accepted": ""
},
"labels": {
"parts_dispatch": ""
}
},
"parts_dispatch_lines": {
"fields": {
"accepted_at": ""
}
},
"parts_orders": {
"actions": {
"backordered": "",
@@ -2408,6 +2432,7 @@
"jobs": {
"individual_job_note": "",
"parts_order": "",
"parts_return_slip": "",
"sublet_order": ""
}
},
@@ -2708,6 +2733,7 @@
"commit": "",
"commitone": "",
"enter": "",
"payall": "",
"printemployee": "",
"uncommit": ""
},
@@ -2735,12 +2761,14 @@
"flat_rate": "",
"memo": "",
"productivehrs": "",
"ro_number": ""
"ro_number": "",
"task_name": ""
},
"labels": {
"alreadyclockedon": "",
"ambreak": "",
"amshift": "",
"claimtaskpreview": "",
"clockhours": "",
"clockintojob": "",
"deleteconfirm": "",
@@ -2750,12 +2778,15 @@
"jobhours": "",
"lunch": "",
"new": "",
"payrollclaimedtasks": "",
"pmbreak": "",
"pmshift": "",
"shift": "",
"shiftalreadyclockedon": "",
"straight_time": "",
"task": "",
"timetickets": "",
"unassigned": "",
"zeroactualnegativeprod": ""
},
"successes": {
@@ -2768,7 +2799,8 @@
"validation": {
"clockoffmustbeafterclockon": "",
"clockoffwithoutclockon": "",
"hoursenteredmorethanavailable": ""
"hoursenteredmorethanavailable": "",
"unassignedlines": ""
}
},
"titles": {

View File

@@ -36,6 +36,7 @@ const AuditTrailMapping = {
jobnoteupdated: () => i18n.t("audit_trail.messages.jobnoteupdated"),
jobnotedeleted: () => i18n.t("audit_trail.messages.jobnotedeleted"),
admin_jobunvoid: () => i18n.t("audit_trail.messages.admin_jobunvoid"),
admin_jobuninvoice: () => i18n.t("audit_trail.messages.admin_jobuninvoice"),
admin_jobmarkforreexport: () =>
i18n.t("audit_trail.messages.admin_jobmarkforreexport"),
admin_jobmarkexported: () =>

View File

@@ -559,6 +559,15 @@ export const TemplateList = (type, context) => {
}),
disabled: false,
},
parts_dispatch: {
title: i18n.t("printcenter.jobs.parts_dispatch"),
description: "",
key: "parts_dispatch",
subject: i18n.t("printcenter.subjects.jobs.parts_dispatch", {
ro_number: (context && context.ro_number) || "",
}),
disabled: false,
},
}
: {}),
...(!type || type === "appointment"
@@ -606,7 +615,14 @@ export const TemplateList = (type, context) => {
},
parts_return_slip: {
title: i18n.t("printcenter.jobs.parts_return_slip"),
subject: i18n.t("printcenter.jobs.parts_return_slip"),
subject: i18n.t("printcenter.subjects.jobs.parts_return_slip", {
ro_number: context && context.job && context.job.ro_number,
name: (
(context && context.job && context.job.ownr_ln) ||
(context && context.job && context.job.ownr_co_nm) ||
""
).trim(),
}),
description: "",
key: "parts_return_slip",
disabled: false,
@@ -1237,7 +1253,7 @@ export const TemplateList = (type, context) => {
disabled: false,
rangeFilter: {
object: i18n.t("reportcenter.labels.objects.jobs"),
field: i18n.t("jobs.fields.date_open"),
field: i18n.t("jobs.fields.date_void"),
},
group: "sales",
},

View File

@@ -3495,6 +3495,7 @@
- v_model_yr
- v_vin
- vehicleid
- date_void
- voided
select_permissions:
- role: user
@@ -3761,6 +3762,7 @@
- v_model_yr
- v_vin
- vehicleid
- date_void
- voided
filter:
bodyshop:
@@ -4037,6 +4039,7 @@
- v_model_yr
- v_vin
- vehicleid
- date_void
- voided
filter:
bodyshop:
@@ -5559,6 +5562,7 @@
- memo
- productivehrs
- rate
- task_name
- ttapprovalqueueid
- updated_at
select_permissions:
@@ -5582,6 +5586,7 @@
- memo
- productivehrs
- rate
- task_name
- ttapprovalqueueid
- updated_at
filter:
@@ -5614,6 +5619,7 @@
- memo
- productivehrs
- rate
- task_name
- ttapprovalqueueid
- updated_at
filter:

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"."jobs" add column "void_date" Timestamp
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "void_date" Timestamp
null;

View File

@@ -0,0 +1 @@
ALTER TABLE "public"."jobs" ALTER COLUMN "void_date" TYPE timestamp without time zone;

View File

@@ -0,0 +1 @@
ALTER TABLE "public"."jobs" ALTER COLUMN "void_date" TYPE timestamptz;

View File

@@ -0,0 +1 @@
alter table "public"."jobs" rename column "date_void" to "void_date";

View File

@@ -0,0 +1 @@
alter table "public"."jobs" rename column "void_date" to "date_void";

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"."timetickets" add column "task_name" text
-- null;

View File

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

View File

@@ -49,6 +49,7 @@
"nodemailer": "^6.9.1",
"phone": "^3.1.35",
"query-string": "^7.1.1",
"recursive-diff": "^1.0.9",
"soap": "^1.0.0",
"socket.io": "^4.6.1",
"ssh2-sftp-client": "^9.0.4",

View File

@@ -261,6 +261,15 @@ app.post(
intellipay.postback
);
const payroll = require("./server/payroll/payroll");
app.post(
"/payroll/calculatelabor",
fb.validateFirebaseIdToken,
payroll.calculatelabor
);
app.post("/payroll/payall", fb.validateFirebaseIdToken, payroll.payall);
app.post("/payroll/claimtask", fb.validateFirebaseIdToken, payroll.claimtask);
var ioevent = require("./server/ioevent/ioevent");
app.post("/ioevent", ioevent.default);
// app.post("/newlog", (req, res) => {

View File

@@ -1823,3 +1823,104 @@ exports.ACTIVE_SHOP_BY_USER = `query ACTIVE_SHOP_BY_USER($user: String) {
}
}
`;
exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
jobs_by_pk(id: $id) {
bodyshop{
id
md_responsibility_centers
md_tasks_presets
employee_teams{
id
name
employee_team_members{
id
employee{
id
first_name
last_name
}
percentage
labor_rates
}
}
}
timetickets{
id
employeeid
rate
productivehrs
actualhrs
ciecacode
}
lbr_adjustments
ro_number
id
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
status
materials
completed_tasks
joblines(where: { removed: { _eq: false } }){
id
line_no
unq_seq
line_ind
line_desc
part_type
line_ref
oem_partno
db_price
act_price
part_qty
mod_lbr_ty
db_hrs
mod_lb_hrs
lbr_op
lbr_amt
op_code_desc
status
notes
location
tax_part
db_ref
manual_line
prt_dsmk_p
prt_dsmk_m
misc_amt
misc_tax
assigned_team
convertedtolbr
convertedtolbr_data
}
}
}`;
exports.INSERT_TIME_TICKETS = `mutation INSERT_TIMETICKETS($timetickets: [timetickets_insert_input!]!) {
insert_timetickets(objects: $timetickets) {
affected_rows
}
}
`;

View File

@@ -0,0 +1,130 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const GraphQLClient = require("graphql-request").GraphQLClient;
const logger = require("../utils/logger");
const {
CalculateExpectedHoursForJob,
CalculateTicketsHoursForJob,
} = require("./pay-all");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
exports.calculatelabor = async function (req, res) {
const BearerToken = req.headers.authorization;
const { jobid, calculateOnly } = req.body;
logger.log("job-payroll-calculate-labor", "DEBUG", req.user.email, jobid, null);
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {
headers: {
Authorization: BearerToken,
},
});
try {
const { jobs_by_pk: job } = await client
.setHeaders({ Authorization: BearerToken })
.request(queries.QUERY_JOB_PAYROLL_DATA, {
id: jobid,
});
//iterate over each ticket, building a hash of team -> employee to calculate total assigned hours.
const { employeeHash, assignmentHash } = CalculateExpectedHoursForJob(job);
const ticketHash = CalculateTicketsHoursForJob(job);
const totals = [];
//Iteratively go through all 4 levels of the object and create an array that can be presented.
// use the employee hash as the golden record (i.e. what they should have), and add what they've claimed.
//While going through, delete items from ticket hash.
//Anything left in ticket hash is an extra entered item.
Object.keys(employeeHash).forEach((employeeIdKey) => {
//At the employee level.
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
//At the labor level
Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach(
(rateKey) => {
//At the rate level.
const expectedHours =
employeeHash[employeeIdKey][laborTypeKey][rateKey];
//Will the following line fail? Probably if it doesn't exist.
const claimedHours = get(
ticketHash,
`${employeeIdKey}.${laborTypeKey}.${rateKey}`
);
if (claimedHours) {
delete ticketHash[employeeIdKey][laborTypeKey][rateKey];
}
totals.push({
employeeid: employeeIdKey,
rate: rateKey,
mod_lbr_ty: laborTypeKey,
expectedHours,
claimedHours: claimedHours || 0,
});
}
);
});
});
Object.keys(ticketHash).forEach((employeeIdKey) => {
//At the employee level.
Object.keys(ticketHash[employeeIdKey]).forEach((laborTypeKey) => {
//At the labor level
Object.keys(ticketHash[employeeIdKey][laborTypeKey]).forEach(
(rateKey) => {
//At the rate level.
const expectedHours = 0;
//Will the following line fail? Probably if it doesn't exist.
const claimedHours = get(
ticketHash,
`${employeeIdKey}.${laborTypeKey}.${rateKey}`
);
if (claimedHours) {
delete ticketHash[employeeIdKey][laborTypeKey][rateKey];
}
totals.push({
employeeid: employeeIdKey,
rate: rateKey,
mod_lbr_ty: laborTypeKey,
expectedHours,
claimedHours: claimedHours || 0,
});
}
);
});
});
if (assignmentHash.unassigned > 0) {
totals.push({
employeeid: undefined,
//rate: rateKey,
//mod_lbr_ty: laborTypeKey,
expectedHours: assignmentHash.unassigned,
claimedHours: 0,
});
}
res.json(totals);
//res.json(assignmentHash);
} catch (error) {
logger.log(
"job-payroll-calculate-labor-error",
"ERROR",
req.user.email,
jobid,
{
jobid: jobid,
error,
}
);
res.status(503).send();
}
};
get = function (obj, key) {
return key.split(".").reduce(function (o, x) {
return typeof o == "undefined" || o === null ? o : o[x];
}, obj);
};

View File

@@ -0,0 +1,101 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const GraphQLClient = require("graphql-request").GraphQLClient;
const logger = require("../utils/logger");
const {
CalculateExpectedHoursForJob,
CalculateTicketsHoursForJob,
} = require("./pay-all");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
exports.claimtask = async function (req, res) {
const BearerToken = req.headers.authorization;
const { jobid, task, calculateOnly } = req.body;
logger.log("job-payroll-pay-all", "DEBUG", req.user.email, jobid, null);
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {
headers: {
Authorization: BearerToken,
},
});
try {
const { jobs_by_pk: job } = await client
.setHeaders({ Authorization: BearerToken })
.request(queries.QUERY_JOB_PAYROLL_DATA, {
id: jobid,
});
const theTaskPreset = job.bodyshop.md_tasks_presets.presets.find(
(tp) => tp.name === task
);
if (!theTaskPreset) {
res
.status(400)
.json({ success: false, error: "Provided task preset not found." });
return;
}
//Get all of the assignments that are filtered.
const { assignmentHash, employeeHash } = CalculateExpectedHoursForJob(
job,
theTaskPreset.hourstype
);
const ticketsToInsert = [];
//Then add them in based on a percentage to each employee.
Object.keys(employeeHash).forEach((employeeIdKey) => {
//At the employee level.
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
//At the labor level
Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach(
(rateKey) => {
//At the rate level.
const expectedHours =
employeeHash[employeeIdKey][laborTypeKey][rateKey] *
(theTaskPreset.percent / 100);
ticketsToInsert.push({
task_name: task,
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: employeeIdKey,
productivehrs: expectedHours,
rate: rateKey,
ciecacode: laborTypeKey,
flat_rate: true,
cost_center:
job.bodyshop.md_responsibility_centers.defaults.costs[
laborTypeKey
],
memo: `*Claimed Task* ${theTaskPreset.memo}`,
});
}
);
});
});
if (!calculateOnly) {
//Insert the time ticekts if we're not just calculating them.
const insertResult = await client.request(queries.INSERT_TIME_TICKETS, {
timetickets: ticketsToInsert.filter(
(ticket) => ticket.productivehrs !== 0
),
});
const updateResult = await client.request(queries.UPDATE_JOB, {
jobId: job.id,
job: {
completed_tasks: [...job.completed_tasks, task],
},
});
}
res.json({ unassignedHours: assignmentHash.unassigned, ticketsToInsert });
} catch (error) {
logger.log("job-payroll-claim-task-error", "ERROR", req.user.email, jobid, {
jobid: jobid,
error,
});
res.status(503).send();
}
};

321
server/payroll/pay-all.js Normal file
View File

@@ -0,0 +1,321 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const GraphQLClient = require("graphql-request").GraphQLClient;
const _ = require("lodash");
const rdiff = require("recursive-diff");
const logger = require("../utils/logger");
const { json } = require("body-parser");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
exports.payall = async function (req, res) {
const BearerToken = req.headers.authorization;
const { jobid, calculateOnly } = req.body;
logger.log("job-payroll-pay-all", "DEBUG", req.user.email, jobid, null);
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {
headers: {
Authorization: BearerToken,
},
});
try {
const { jobs_by_pk: job } = await client
.setHeaders({ Authorization: BearerToken })
.request(queries.QUERY_JOB_PAYROLL_DATA, {
id: jobid,
});
//iterate over each ticket, building a hash of team -> employee to calculate total assigned hours.
const { employeeHash, assignmentHash } = CalculateExpectedHoursForJob(job);
const ticketHash = CalculateTicketsHoursForJob(job);
if (assignmentHash.unassigned > 0) {
res.json({ success: false, error: "Unassigned hours." });
return;
}
//Calculate how much time each tech should have by labor type.
//Doing this order creates a diff of changes on the ticket hash to make it the same as the employee hash.
const recursiveDiff = rdiff.getDiff(ticketHash, employeeHash, true);
const ticketsToInsert = [];
recursiveDiff.forEach((diff) => {
//Every iteration is what we would need to insert into the time ticket hash
//so that it would match the employee hash exactly.
const path = diffParser(diff);
if (diff.op === "add") {
if (typeof diff.val === "object" && Object.keys(diff.val).length > 1) {
//Multiple values to add.
Object.keys(diff.val).forEach((key) => {
console.log("Hours", diff.val[key][Object.keys(diff.val[key])[0]]);
console.log("Rate", Object.keys(diff.val[key])[0]);
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: diff.val[key][Object.keys(diff.val[key])[0]],
rate: Object.keys(diff.val[key])[0],
ciecacode: key,
cost_center:
job.bodyshop.md_responsibility_centers.defaults.costs[key],
flat_rate: true,
memo: `*SYS-PAY* Add unclaimed hours. (${req.user.email})`,
});
});
} else {
//Only the 1 value to add.
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: path.hours,
rate: path.rate,
ciecacode: path.mod_lbr_ty,
flat_rate: true,
cost_center:
job.bodyshop.md_responsibility_centers.defaults.costs[
path.mod_lbr_ty
],
memo: `*SYS-PAY* Add unclaimed hours. (${req.user.email})`,
});
}
} else if (diff.op === "update") {
//An old ticket amount isn't sufficient
//We can't modify the existing ticket, it might already be committed. So let's add a new one instead.
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: diff.val - diff.oldVal,
rate: path.rate,
ciecacode: path.mod_lbr_ty,
flat_rate: true,
cost_center:
job.bodyshop.md_responsibility_centers.defaults.costs[
path.mod_lbr_ty
],
memo: `*SYS-PAY* Adjust claimed hours per assignment. (${req.user.email})`,
});
} else {
//Has to be a delete
if (
typeof diff.oldVal === "object" &&
Object.keys(diff.oldVal).length > 1
) {
//Multiple oldValues to add.
Object.keys(diff.oldVal).forEach((key) => {
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs:
diff.oldVal[key][Object.keys(diff.oldVal[key])[0]] * -1,
rate: Object.keys(diff.oldVal[key])[0],
ciecacode: key,
cost_center:
job.bodyshop.md_responsibility_centers.defaults.costs[key],
flat_rate: true,
memo: `*SYS-PAY* Remove claimed hours per assignment. (${req.user.email})`,
});
});
} else {
//Only the 1 value to add.
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: path.hours * -1,
rate: path.rate,
ciecacode: path.mod_lbr_ty,
cost_center:
job.bodyshop.md_responsibility_centers.defaults.costs[
path.mod_lbr_ty
],
flat_rate: true,
memo: `*SYS-PAY* Remove claimed hours per assignment. (${req.user.email})`,
});
}
}
});
const insertResult = await client.request(queries.INSERT_TIME_TICKETS, {
timetickets: ticketsToInsert.filter(
(ticket) => ticket.productivehrs !== 0
),
});
res.json(ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0));
} catch (error) {
logger.log(
"job-payroll-labor-totals-error",
"ERROR",
req.user.email,
jobid,
{
jobid: jobid,
error,
}
);
res.status(503).send();
}
};
function diffParser(diff) {
const type = typeof diff.oldVal;
let mod_lbr_ty, rate, hours;
if (diff.path.length === 1) {
if (diff.op === "add") {
mod_lbr_ty = Object.keys(diff.val)[0];
rate = Object.keys(diff.val[mod_lbr_ty])[0];
// hours = diff.oldVal[mod_lbr_ty][rate];
} else {
mod_lbr_ty = Object.keys(diff.oldVal)[0];
rate = Object.keys(diff.oldVal[mod_lbr_ty])[0];
// hours = diff.oldVal[mod_lbr_ty][rate];
}
} else if (diff.path.length === 2) {
mod_lbr_ty = diff.path[1];
if (diff.op === "add") {
rate = Object.keys(diff.val)[0];
} else {
rate = Object.keys(diff.oldVal)[0];
}
} else if (diff.path.length === 3) {
mod_lbr_ty = diff.path[1];
rate = diff.path[2];
//hours = 0;
}
//Set the hours
if (
typeof diff.val === "number" &&
diff.val !== null &&
diff.val !== undefined
) {
hours = diff.val;
} else if (diff.val !== null && diff.val !== undefined) {
hours = diff.val[Object.keys(diff.val)[0]];
} else if (
typeof diff.oldVal === "number" &&
diff.oldVal !== null &&
diff.oldVal !== undefined
) {
hours = diff.oldVal;
} else {
hours = diff.oldVal[Object.keys(diff.oldVal)[0]];
}
const ret = {
multiVal: false,
employeeid: diff.path[0], // Always True
mod_lbr_ty,
rate,
hours,
};
return ret;
}
function CalculateExpectedHoursForJob(job, filterToLbrTypes) {
const assignmentHash = { unassigned: 0 };
const employeeHash = {}; // employeeid => Cieca labor type => rate => hours. Contains how many hours each person should be paid.
job.joblines
.filter((jobline) => {
if (!filterToLbrTypes) return true;
else {
return (
filterToLbrTypes.includes(jobline.mod_lbr_ty) ||
(jobline.convertedtolbr &&
filterToLbrTypes.includes(jobline.convertedtolbr_data.mod_lbr_ty))
);
}
})
.forEach((jobline) => {
if (jobline.convertedtolbr) {
// Line has been converte to labor. Temporarily re-assign the hours.
jobline.mod_lbr_ty =
jobline.mod_lbr_ty || jobline.convertedtolbr_data.mod_lbr_ty;
jobline.mod_lb_hrs += jobline.convertedtolbr_data.mod_lb_hrs;
}
if (jobline.mod_lb_hrs != 0) {
//Check if the line is assigned. If not, keep track of it as an unassigned line by type.
if (jobline.assigned_team === null) {
assignmentHash.unassigned =
assignmentHash.unassigned + jobline.mod_lb_hrs;
} else {
//Line is assigned.
if (!assignmentHash[jobline.assigned_team]) {
assignmentHash[jobline.assigned_team] = 0;
}
assignmentHash[jobline.assigned_team] =
assignmentHash[jobline.assigned_team] + jobline.mod_lb_hrs;
//Create the assignment breakdown.
const theTeam = job.bodyshop.employee_teams.find(
(team) => team.id === jobline.assigned_team
);
theTeam.employee_team_members.forEach((tm) => {
//Figure out how many hours they are owed at this line, and at what rate.
if (!employeeHash[tm.employee.id]) {
employeeHash[tm.employee.id] = {};
}
if (!employeeHash[tm.employee.id][jobline.mod_lbr_ty]) {
employeeHash[tm.employee.id][jobline.mod_lbr_ty] = {};
}
if (
!employeeHash[tm.employee.id][jobline.mod_lbr_ty][
tm.labor_rates[jobline.mod_lbr_ty]
]
) {
employeeHash[tm.employee.id][jobline.mod_lbr_ty][
tm.labor_rates[jobline.mod_lbr_ty]
] = 0;
}
const hoursOwed = (tm.percentage * jobline.mod_lb_hrs) / 100;
employeeHash[tm.employee.id][jobline.mod_lbr_ty][
tm.labor_rates[jobline.mod_lbr_ty]
] =
employeeHash[tm.employee.id][jobline.mod_lbr_ty][
tm.labor_rates[jobline.mod_lbr_ty]
] + hoursOwed;
});
}
}
});
return { assignmentHash, employeeHash };
}
function CalculateTicketsHoursForJob(job) {
const ticketHash = {}; // employeeid => Cieca labor type => rate => hours.
//Calculate how much each employee has been paid so far.
job.timetickets.forEach((ticket) => {
if (!ticketHash[ticket.employeeid]) {
ticketHash[ticket.employeeid] = {};
}
if (!ticketHash[ticket.employeeid][ticket.ciecacode]) {
ticketHash[ticket.employeeid][ticket.ciecacode] = {};
}
if (!ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate]) {
ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] = 0;
}
ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] =
ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] +
ticket.productivehrs;
});
return ticketHash;
}
exports.CalculateExpectedHoursForJob = CalculateExpectedHoursForJob;
exports.CalculateTicketsHoursForJob = CalculateTicketsHoursForJob;

View File

@@ -0,0 +1,3 @@
exports.calculatelabor = require("./calculate-totals").calculatelabor;
exports.payall = require("./pay-all").payall;
exports.claimtask = require("./claim-task").claimtask;

View File

@@ -3724,6 +3724,11 @@ readable-stream@^3.0.2, readable-stream@^3.1.1, readable-stream@^3.4.0, readable
string_decoder "^1.1.1"
util-deprecate "^1.0.1"
recursive-diff@^1.0.9:
version "1.0.9"
resolved "https://registry.yarnpkg.com/recursive-diff/-/recursive-diff-1.0.9.tgz#e617cbfcf125d4d73954c06997289c2d3321d5f7"
integrity sha512-5mqpskzvXDo5Vy29Vj8tH30a0+XBmY11aqWGoN/uB94UHRwndX2EuPvH+WtbqOYkrwAF718/lDo6U4CB1qSSqQ==
remote-content@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/remote-content/-/remote-content-3.0.1.tgz#4025d0126e873fd05b1076a6bfdaf73f5db100e3"