WIP Payroll. Added line assignment & begin server side calc.

This commit is contained in:
Patrick Fic
2023-07-07 15:01:46 -07:00
parent 384153d914
commit bd7fbfff37
17 changed files with 1127 additions and 8 deletions

View File

@@ -14067,6 +14067,48 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>scheduledintoday</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>scheduledouttoday</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>
@@ -19398,6 +19440,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>
@@ -19555,6 +19618,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>
@@ -23928,6 +24012,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>dms_unsold</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>dms_wip_acctnumber</name>
<definition_loaded>false</definition_loaded>
@@ -43600,6 +43705,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>jobs_scheduled_completion</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>lag_time</name>
<definition_loaded>false</definition_loaded>

View File

@@ -45,7 +45,9 @@ 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";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -287,6 +289,14 @@ export function JobLinesComponent({
state.sortedInfo.columnKey === "line_ind" && state.sortedInfo.order,
responsive: ["md"],
},
{
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",
@@ -405,7 +415,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)
),
])
);
}
@@ -418,6 +432,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>
);
@@ -446,6 +464,11 @@ export function JobLinesComponent({
setSelectedLines={setSelectedLines}
job={job}
/>
<JobLineBulkAssignComponent
selectedLines={selectedLines}
setSelectedLines={setSelectedLines}
job={job}
/>
<Button
disabled={
(job && !job.converted) ||

View File

@@ -0,0 +1,130 @@
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,
}) {
console.log(
"🚀 ~ file: job-line-bulk-assign.component.jsx:36 ~ selectedLines:",
selectedLines
);
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,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

@@ -5,6 +5,7 @@ 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";
const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly,
@@ -78,6 +79,14 @@ export function JobsDetailLaborContainer({
adjustments={adjustments}
/>
</Col>
<Col {...adjSpan}>
<PayrollLaborAllocationsTable
jobId={jobId}
joblines={joblines}
timetickets={timetickets}
adjustments={adjustments}
/>
</Col>
</Row>
);
}

View File

@@ -0,0 +1,272 @@
import { EditFilled } from "@ant-design/icons";
import { Button, Card, Col, Row, Space, Table, Typography } from "antd";
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 { alphaSort } from "../../utils/sorters";
import LaborAllocationsAdjustmentEdit from "../labor-allocations-adjustment-edit/labor-allocations-adjustment-edit.component";
import "./labor-allocations-table.styles.scss";
import { CalculateAllocationsTotals } from "./labor-allocations-table.utility";
import axios from "axios";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
technician: selectTechnician,
});
export function PayrollLaborAllocationsTable({
jobId,
joblines,
timetickets,
bodyshop,
adjustments,
technician,
}) {
const { t } = useTranslation();
const [totals, setTotals] = useState([]);
const [state, setState] = useState({
sortedInfo: {
columnKey: "cost_center",
field: "cost_center",
order: "ascend",
},
filteredInfo: {},
});
useEffect(() => {
if (!!joblines && !!timetickets && !!bodyshop);
setTotals(
CalculateAllocationsTotals(bodyshop, joblines, timetickets, adjustments)
);
if (!jobId) setTotals([]);
}, [joblines, timetickets, bodyshop, adjustments, jobId]);
const convertedLines = useMemo(
() => joblines && joblines.filter((j) => j.convertedtolbr),
[joblines]
);
const columns = [
{
title: t("timetickets.fields.cost_center"),
dataIndex: "cost_center",
key: "cost_center",
defaultSortOrder: "cost_center",
sorter: (a, b) => alphaSort(a.cost_center, b.cost_center),
sortOrder:
state.sortedInfo.columnKey === "cost_center" && state.sortedInfo.order,
render: (text, record) => `${record.cost_center} (${record.mod_lbr_ty})`,
},
{
title: t("jobs.labels.hrs_total"),
dataIndex: "total",
key: "total",
sorter: (a, b) => a.total - b.total,
sortOrder:
state.sortedInfo.columnKey === "total" && state.sortedInfo.order,
render: (text, record) => record.total.toFixed(1),
},
{
title: t("jobs.labels.hrs_claimed"),
dataIndex: "hrs_claimed",
key: "hrs_claimed",
sorter: (a, b) => a.claimed - b.claimed,
sortOrder:
state.sortedInfo.columnKey === "claimed" && state.sortedInfo.order,
render: (text, record) => record.claimed && record.claimed.toFixed(1),
},
{
title: t("jobs.labels.adjustments"),
dataIndex: "adjustments",
key: "adjustments",
sorter: (a, b) => a.adjustments - b.adjustments,
sortOrder:
state.sortedInfo.columnKey === "adjustments" && state.sortedInfo.order,
render: (text, record) => (
<Space wrap>
{record.adjustments.toFixed(1)}
{!technician && (
<LaborAllocationsAdjustmentEdit
jobId={jobId}
adjustments={adjustments}
mod_lbr_ty={record.opcode}
refetchQueryNames={["GET_LINE_TICKET_BY_PK"]}
>
<EditFilled />
</LaborAllocationsAdjustmentEdit>
)}
</Space>
),
},
{
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) => (
<strong
style={{
color: record.difference >= 0 ? "green" : "red",
}}
>
{_.round(record.difference, 1)}
</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(1),
},
];
const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
};
const summary =
totals &&
totals.reduce(
(acc, val) => {
acc.hrs_total += val.total;
acc.hrs_claimed += val.claimed;
acc.adjustments += val.adjustments;
acc.difference += val.difference;
return acc;
},
{ hrs_total: 0, hrs_claimed: 0, adjustments: 0, difference: 0 }
);
return (
<Row gutter={[16, 16]}>
<Col span={24}>
<Button
onClick={async () => {
console.log(
await axios.post("/payroll/payall", {
jobid: jobId,
})
);
}}
>
Calc
</Button>
<Card title={t("jobs.labels.laborallocations")}>
<Table
columns={columns}
rowKey={(record) => `${record.cost_center} ${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>
{summary.hrs_total.toFixed(1)}
</Table.Summary.Cell>
<Table.Summary.Cell>
{summary.hrs_claimed.toFixed(1)}
</Table.Summary.Cell>
<Table.Summary.Cell>
{summary.adjustments.toFixed(1)}
</Table.Summary.Cell>
<Table.Summary.Cell>
{summary.difference.toFixed(1)}
</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

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

@@ -51,7 +51,6 @@ export const GET_LINE_TICKET_BY_PK = gql`
op_code_desc
convertedtolbr
convertedtolbr_data
}
timetickets(where: { jobid: { _eq: $id } }) {
actualhrs
@@ -244,6 +243,7 @@ export const UPDATE_JOB_LINE = gql`
removed
convertedtolbr
convertedtolbr_data
assigned_team
}
}
}
@@ -348,3 +348,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

@@ -733,6 +733,7 @@ export const GET_JOB_BY_PK = gql`
employeeid
}
}
assigned_team
billlines(limit: 1, order_by: { bill: { date: desc } }) {
id
quantity

View File

@@ -1212,6 +1212,7 @@
},
"joblines": {
"actions": {
"assign_team": "Assign Team",
"converttolabor": "Convert amount to Labor.",
"dispatchparts": "Dispatch Parts ({{count}})",
"new": "New Line"
@@ -1223,6 +1224,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",
@@ -1455,8 +1457,8 @@
"cost_dms_acctnumber": "Cost DMS Acct #",
"dms_make": "DMS Make",
"dms_model": "DMS Model",
"dms_wip_acctnumber": "Cost WIP DMS Acct #",
"dms_unsold": "New, Unsold Vehicle",
"dms_wip_acctnumber": "Cost WIP DMS Acct #",
"id": "DMS ID",
"inservicedate": "In Service Date",
"journal": "Journal #",
@@ -2590,8 +2592,8 @@
"job_costing_ro_ins_co": "Job Costing by RO Source",
"jobs_completed_not_invoiced": "Jobs Completed not Invoiced",
"jobs_invoiced_not_exported": "Jobs Invoiced not Exported",
"jobs_scheduled_completion": "Jobs Scheduled Completion",
"jobs_reconcile": "Parts/Sublet/Labor Reconciliation",
"jobs_scheduled_completion": "Jobs Scheduled Completion",
"lag_time": "Lag Time",
"open_orders": "Open Orders by Date",
"open_orders_csr": "Open Orders by CSR",

View File

@@ -1212,6 +1212,7 @@
},
"joblines": {
"actions": {
"assign_team": "",
"converttolabor": "",
"dispatchparts": "",
"new": ""
@@ -1223,6 +1224,7 @@
"fields": {
"act_price": "Precio actual",
"ah_detail_line": "",
"assigned_team": "",
"db_price": "Precio de base de datos",
"lbr_types": {
"LA1": "",
@@ -1455,8 +1457,8 @@
"cost_dms_acctnumber": "",
"dms_make": "",
"dms_model": "",
"dms_wip_acctnumber": "",
"dms_unsold": "",
"dms_wip_acctnumber": "",
"id": "",
"inservicedate": "",
"journal": "",
@@ -2590,8 +2592,8 @@
"job_costing_ro_ins_co": "",
"jobs_completed_not_invoiced": "",
"jobs_invoiced_not_exported": "",
"jobs_scheduled_completion": "",
"jobs_reconcile": "",
"jobs_scheduled_completion": "",
"lag_time": "",
"open_orders": "",
"open_orders_csr": "",

View File

@@ -1212,6 +1212,7 @@
},
"joblines": {
"actions": {
"assign_team": "",
"converttolabor": "",
"dispatchparts": "",
"new": ""
@@ -1223,6 +1224,7 @@
"fields": {
"act_price": "Prix actuel",
"ah_detail_line": "",
"assigned_team": "",
"db_price": "Prix de la base de données",
"lbr_types": {
"LA1": "",
@@ -1455,8 +1457,8 @@
"cost_dms_acctnumber": "",
"dms_make": "",
"dms_model": "",
"dms_wip_acctnumber": "",
"dms_unsold": "",
"dms_wip_acctnumber": "",
"id": "",
"inservicedate": "",
"journal": "",
@@ -2590,8 +2592,8 @@
"job_costing_ro_ins_co": "",
"jobs_completed_not_invoiced": "",
"jobs_invoiced_not_exported": "",
"jobs_scheduled_completion": "",
"jobs_reconcile": "",
"jobs_scheduled_completion": "",
"lag_time": "",
"open_orders": "",
"open_orders_csr": "",

View File

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

View File

@@ -1822,3 +1822,92 @@ 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
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
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
}
}
}`;

View File

@@ -0,0 +1,59 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const GraphQLClient = require("graphql-request").GraphQLClient;
const logger = require("../utils/logger");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
exports.calculateLaborTotals = async function (req, res) {
const BearerToken = req.headers.authorization;
const { jobid } = req.body;
logger.log("job-payroll-labor-totals", "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 assignmentHash = { unassigned: 0 };
job.joblines.forEach((jobline) => {
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[jobline.mod_lbr_ty] =
assignmentHash.unassigned[jobline.mod_lbr_ty] + 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;
}
}
});
res.json(assignmentHash);
} catch (error) {
logger.log(
"job-payroll-labor-totals-error",
"ERROR",
req.user.email,
jobid,
{
jobid: jobid,
error,
}
);
res.status(503).send();
}
};

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

@@ -0,0 +1,249 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const GraphQLClient = require("graphql-request").GraphQLClient;
const _ = require("lodash");
const logger = require("../utils/logger");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
exports.payall = async function (req, res) {
const BearerToken = req.headers.authorization;
const { jobid } = 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 assignmentHash = { unassigned: 0 };
const employeeHash = {}; // employeeid => Cieca labor type => rate => hours. Contains how many hours each person should be paid.
job.joblines.forEach((jobline) => {
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.
console.log(tm);
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 / 100) * jobline.mod_lb_hrs;
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;
});
}
}
});
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;
//Add the rate
});
if (assignmentHash.unassigned > 0) {
res.json({ success: false, error: "Unassigned hours." });
return;
}
//Calculate how much time each tech should have by labor type.
const comparison = compare(employeeHash, ticketHash);
const ticketsToInsert = [];
//Check the ones that are different first. Source of truth will be the employee hash.
comparison.different.forEach((differentKey) => {
const empVal = employeeHash[differentKey];
const ticketVal = ticketHash[differentKey];
ticketsToInsert.push({
jobid: job.id,
employeeid: differentKey.split(".")[0],
productivehrs: empVal - ticketVal,
rate: differentKey.split(".")[2],
memo: "Adjustment between expected and entered values. ",
});
});
comparison.missing_from_first
.filter((differentKey) => differentKey.split(".").length == 3)
.forEach((differentKey) => {
const empVal = employeeHash[differentKey];
const ticketVal = ticketHash[differentKey];
ticketsToInsert.push({
jobid: job.id,
employeeid: differentKey.split(".")[0],
productivehrs: empVal - ticketVal * -1,
rate: differentKey.split(".")[2],
memo: "Entered ticket reversed to match system payroll.",
});
});
comparison.missing_from_second
.filter((differentKey) => differentKey.split(".").length == 3)
.forEach((differentKey) => {
const empVal = employeeHash[differentKey];
const ticketVal = ticketHash[differentKey];
ticketsToInsert.push({
jobid: job.id,
employeeid: differentKey.split(".")[0],
productivehrs: empVal - ticketVal * -1,
rate: differentKey.split(".")[2],
memo: "Entered ticket reversed to match system payroll.",
});
});
res.json({
assignmentHash,
employeeHash,
diff: getObjectDiff(employeeHash, ticketHash),
compare: compare(employeeHash, ticketHash),
});
} catch (error) {
logger.log(
"job-payroll-labor-totals-error",
"ERROR",
req.user.email,
jobid,
{
jobid: jobid,
error,
}
);
res.status(503).send();
}
};
function getObjectDiff(obj1, obj2) {
const diff = Object.keys(obj1).reduce((result, key) => {
if (!obj2.hasOwnProperty(key)) {
result.push(key);
} else if (_.isEqual(obj1[key], obj2[key])) {
const resultKeyIndex = result.indexOf(key);
result.splice(resultKeyIndex, 1);
}
return result;
}, Object.keys(obj2));
return diff;
}
var compare = function (a, b) {
var result = {
different: [],
missing_from_first: [],
missing_from_second: [],
};
_.reduce(
a,
function (result, value, key) {
if (b.hasOwnProperty(key)) {
if (_.isEqual(value, b[key])) {
return result;
} else {
if (typeof a[key] != typeof {} || typeof b[key] != typeof {}) {
//dead end.
result.different.push(key);
return result;
} else {
var deeper = compare(a[key], b[key]);
result.different = result.different.concat(
_.map(deeper.different, (sub_path) => {
return key + "." + sub_path;
})
);
result.missing_from_second = result.missing_from_second.concat(
_.map(deeper.missing_from_second, (sub_path) => {
return key + "." + sub_path;
})
);
result.missing_from_first = result.missing_from_first.concat(
_.map(deeper.missing_from_first, (sub_path) => {
return key + "." + sub_path;
})
);
return result;
}
}
} else {
result.missing_from_second.push(key);
return result;
}
},
result
);
_.reduce(
b,
function (result, value, key) {
if (a.hasOwnProperty(key)) {
return result;
} else {
result.missing_from_first.push(key);
return result;
}
},
result
);
return result;
};

View File

@@ -0,0 +1,2 @@
exports.calculateLaborTotals = require("./calculate-totals").calculateLaborTotals;
exports.payall = require("./pay-all").payall;