diff --git a/bodyshop_translations.babel b/bodyshop_translations.babel index e232e9d85..4c202bbd8 100644 --- a/bodyshop_translations.babel +++ b/bodyshop_translations.babel @@ -1474,6 +1474,27 @@ + + admin_jobuninvoice + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + admin_jobunvoid false @@ -5776,6 +5797,27 @@ + + nextstatus + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + percent false @@ -19482,6 +19524,27 @@ actions + + assign_team + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + converttolabor false @@ -19503,6 +19566,27 @@ + + dispatchparts + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + new false @@ -19618,6 +19702,27 @@ + + assigned_team + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + db_price false @@ -23731,6 +23836,27 @@ + + date_void + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + ded_amt false @@ -33912,6 +34038,27 @@ tech + + claimtask + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + home false @@ -36000,6 +36147,141 @@ + + parts_dispatch + + + errors + + + creating + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + + + fields + + + number + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + percent_accepted + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + + + labels + + + parts_dispatch + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + + + + + parts_dispatch_lines + + + fields + + + accepted_at + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + + + parts_orders @@ -40727,6 +41009,27 @@ + + parts_return_slip + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + sublet_order false @@ -45750,6 +46053,27 @@ + + payall + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + printemployee false @@ -46264,6 +46588,27 @@ + + task_name + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + @@ -46332,6 +46677,27 @@ + + claimtaskpreview + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + clockhours false @@ -46521,6 +46887,27 @@ + + payrollclaimedtasks + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + pmbreak false @@ -46626,6 +47013,27 @@ + + task + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + timetickets false @@ -46647,6 +47055,27 @@ + + unassigned + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + zeroactualnegativeprod false @@ -46846,6 +47275,27 @@ + + unassignedlines + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + diff --git a/client/src/components/form-items-formatted/read-only-form-item.component.jsx b/client/src/components/form-items-formatted/read-only-form-item.component.jsx index fd50b9c94..c27944691 100644 --- a/client/src/components/form-items-formatted/read-only-form-item.component.jsx +++ b/client/src/components/form-items-formatted/read-only-form-item.component.jsx @@ -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
{value}
; case "currency": @@ -14,4 +31,8 @@ const ReadOnlyFormItem = ({ value, type = "text", onChange }, ref) => { return
{value}
; } }; -export default forwardRef(ReadOnlyFormItem); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(forwardRef(ReadOnlyFormItem)); diff --git a/client/src/components/job-detail-lines/job-lines.component.jsx b/client/src/components/job-detail-lines/job-lines.component.jsx index 9eabf11d7..e34d6a446 100644 --- a/client/src/components/job-detail-lines/job-lines.component.jsx +++ b/client/src/components/job-detail-lines/job-lines.component.jsx @@ -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) => ( + + {`${record.oem_partno || ""} ${ + record.alt_partno ? `(${record.alt_partno})` : "" + }`.trim()} + + ), }, { 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) => ( + + ), + }, + ] + : []), + { 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({ {t("joblines.fields.part_types.PAL")} {t("joblines.fields.part_types.PAS")} + {t("joblines.fields.lbr_types.LAB")} + {t("joblines.fields.lbr_types.LAR")} + {t("joblines.fields.lbr_types.LAM")} + {t("general.labels.clear")} ); @@ -427,6 +469,18 @@ export function JobLinesComponent({ )} + + {Enhanced_Payroll.treatment === "on" && ( + + )} + + + + + ); + + return ( + + + + ); +} diff --git a/client/src/components/job-line-dispatch-button/job-line-dispatch-button.component.jsx b/client/src/components/job-line-dispatch-button/job-line-dispatch-button.component.jsx new file mode 100644 index 000000000..04b808932 --- /dev/null +++ b/client/src/components/job-line-dispatch-button/job-line-dispatch-button.component.jsx @@ -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 = ( +
+
+ + + + + + + + +
+
+ ); + + return ( + + + + ); +} diff --git a/client/src/components/job-line-team-assignment/job-line-team-assignmnent.component.jsx b/client/src/components/job-line-team-assignment/job-line-team-assignmnent.component.jsx new file mode 100644 index 000000000..929ec1e5d --- /dev/null +++ b/client/src/components/job-line-team-assignment/job-line-team-assignmnent.component.jsx @@ -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 ( +
+ + + +
+ ); + + const team = bodyshop.employee_teams.find( + (tm) => tm.id === jobline.assigned_team + ); + + return ( +
!disabled && setEditing(true)} + > + {team?.name} +
+ ); +} +export default connect( + mapStateToProps, + mapDispatchToProps +)(JoblineTeamAssignment); diff --git a/client/src/components/jobs-admin-dates/jobs-admin-dates.component.jsx b/client/src/components/jobs-admin-dates/jobs-admin-dates.component.jsx index d497c9618..bfddbfefc 100644 --- a/client/src/components/jobs-admin-dates/jobs-admin-dates.component.jsx +++ b/client/src/components/jobs-admin-dates/jobs-admin-dates.component.jsx @@ -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 }) { - + + + + diff --git a/client/src/components/jobs-admin-mark-reexport/jobs-admin-mark-reexport.component.jsx b/client/src/components/jobs-admin-mark-reexport/jobs-admin-mark-reexport.component.jsx index 137c074aa..c47c30def 100644 --- a/client/src/components/jobs-admin-mark-reexport/jobs-admin-mark-reexport.component.jsx +++ b/client/src/components/jobs-admin-mark-reexport/jobs-admin-mark-reexport.component.jsx @@ -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", { diff --git a/client/src/components/jobs-admin-unvoid/jobs-admin-unvoid.component.jsx b/client/src/components/jobs-admin-unvoid/jobs-admin-unvoid.component.jsx index 51d5d33ed..7963fd05f 100644 --- a/client/src/components/jobs-admin-unvoid/jobs-admin-unvoid.component.jsx +++ b/client/src/components/jobs-admin-unvoid/jobs-admin-unvoid.component.jsx @@ -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 } diff --git a/client/src/components/jobs-detail-dates/jobs-detail-dates.component.jsx b/client/src/components/jobs-detail-dates/jobs-detail-dates.component.jsx index ac501c7e1..05cd1b289 100644 --- a/client/src/components/jobs-detail-dates/jobs-detail-dates.component.jsx +++ b/client/src/components/jobs-detail-dates/jobs-detail-dates.component.jsx @@ -141,6 +141,10 @@ export function JobsDetailDatesComponent({ jobRO, job, bodyshop }) { + + + + ); diff --git a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx index edb3f7dde..154011e33 100644 --- a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx +++ b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx @@ -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: [ { diff --git a/client/src/components/jobs-detail-labor/jobs-detail-labor.component.jsx b/client/src/components/jobs-detail-labor/jobs-detail-labor.component.jsx index d3f4ba651..c03d9d2d4 100644 --- a/client/src/components/jobs-detail-labor/jobs-detail-labor.component.jsx +++ b/client/src/components/jobs-detail-labor/jobs-detail-labor.component.jsx @@ -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 ( @@ -70,14 +81,28 @@ export function JobsDetailLaborContainer({ jobId={jobId} /> - - - + + {Enhanced_Payroll.treatment === "on" ? ( + + + + ) : ( + + + + )} ); } diff --git a/client/src/components/jobs-detail-pli/jobs-detail-pli.component.jsx b/client/src/components/jobs-detail-pli/jobs-detail-pli.component.jsx index e992f6b72..ee73cd488 100644 --- a/client/src/components/jobs-detail-pli/jobs-detail-pli.component.jsx +++ b/client/src/components/jobs-detail-pli/jobs-detail-pli.component.jsx @@ -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 (
@@ -43,6 +45,13 @@ export default function JobsDetailPliComponent({ billsQuery={billsQuery} /> + + +
); diff --git a/client/src/components/jobs-detail-pli/jobs-detail-pli.container.jsx b/client/src/components/jobs-detail-pli/jobs-detail-pli.container.jsx index c2fd78020..99cf8c126 100644 --- a/client/src/components/jobs-detail-pli/jobs-detail-pli.container.jsx +++ b/client/src/components/jobs-detail-pli/jobs-detail-pli.container.jsx @@ -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 ( ); } diff --git a/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.jsx b/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.jsx new file mode 100644 index 000000000..0aa62216e --- /dev/null +++ b/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.jsx @@ -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 ( + + {t("timetickets.labels.unassigned")} + + ); + } + 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 ? ( + + {t("timetickets.labels.unassigned")} + + ) : ( + 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 ( + = 0 ? "green" : "red", + }} + > + {difference} + + ); + }, + }, + ]; + 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) => ( + <> + + {record.db_ref === "900510" || record.db_ref === "900511" + ? record.prt_dsmk_m + : record.act_price} + + {record.prt_dsmk_p && record.prt_dsmk_p !== 0 ? ( + {`(${record.prt_dsmk_p}%)`} + ) : ( + <> + )} + + ), + }, + { + 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 ( + + + + + + + } + > + `${record.employeeid} ${record.mod_lbr_ty}`} + pagination={false} + onChange={handleTableChange} + dataSource={totals} + scroll={{ + x: true, + }} + summary={() => ( + + + + {t("general.labels.totals")} + + + + + {summary.hrs_total.toFixed(5)} + + + {summary.hrs_claimed.toFixed(5)} + + + + {summary.difference.toFixed(5)} + + + )} + /> + + + {convertedLines && convertedLines.length > 0 && ( + + +
+ + + )} + + ); +} +export default connect(mapStateToProps, null)(PayrollLaborAllocationsTable); diff --git a/client/src/components/parts-dispatch-expander/parts-dispatch-expander.component.jsx b/client/src/components/parts-dispatch-expander/parts-dispatch-expander.component.jsx new file mode 100644 index 000000000..ccbb3a02b --- /dev/null +++ b/client/src/components/parts-dispatch-expander/parts-dispatch-expander.component.jsx @@ -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) => ( + {record.accepted_at} + ), + }, + ]; + return ( + + + +
+ + + + ); +} diff --git a/client/src/components/parts-dispatch-table/parts-dispatch-table.component.jsx b/client/src/components/parts-dispatch-table/parts-dispatch-table.component.jsx new file mode 100644 index 000000000..461bad6cd --- /dev/null +++ b/client/src/components/parts-dispatch-table/parts-dispatch-table.component.jsx @@ -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) => ( + + + + ); + 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 ( + + + + { + e.preventDefault(); + setSearchText(e.target.value); + }} + /> + + } + > +
( + + ), + rowExpandable: (record) => true, + + expandIcon: ({ expanded, onExpand, record }) => + expanded ? ( + onExpand(record, e)} /> + ) : ( + onExpand(record, e)} /> + ), + }} + columns={columns} + rowKey="id" + dataSource={billsQuery.data ? billsQuery.data.parts_dispatch : []} + onChange={handleTableChange} + /> + + ); +} +export default connect( + mapStateToProps, + mapDispatchToProps +)(PartDispatchTableComponent); diff --git a/client/src/components/print-wrapper/print-wrapper.component.jsx b/client/src/components/print-wrapper/print-wrapper.component.jsx index 6d5c3438b..0b088adca 100644 --- a/client/src/components/print-wrapper/print-wrapper.component.jsx +++ b/client/src/components/print-wrapper/print-wrapper.component.jsx @@ -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 ( {children || null} - {!emailOnly && handlePrint("p")} />} - handlePrint("e")} /> + {!emailOnly && ( + handlePrint("p")} + style={{ cursor: disabled ? "not-allowed" : null }} + /> + )} + handlePrint("e")} + style={{ cursor: disabled ? "not-allowed" : null }} + /> {loading && } ); diff --git a/client/src/components/production-list-columns/production-list-columns.data.js b/client/src/components/production-list-columns/production-list-columns.data.js index 7d122200d..fe9d8c7bb 100644 --- a/client/src/components/production-list-columns/production-list-columns.data.js +++ b/client/src/components/production-list-columns/production-list-columns.data.js @@ -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 }) => { ), }, + { + title: i18n.t("timetickets.actions.claimtasks"), + dataIndex: "claimtasks", + key: "claimtasks", + ellipsis: true, + render: (text, record) => ( +
{ + store.dispatch( + setModalContext({ + context: { + actions: {}, + context: { jobid: record.id }, + }, + modal: "timeTicketTask", + }) + ); + }} + > + {i18n.t("timetickets.actions.claimtasks")} +
+ ), + }, { title: i18n.t("jobs.fields.ro_number"), dataIndex: "ro_number", diff --git a/client/src/components/shop-info/shop-info.general.component.jsx b/client/src/components/shop-info/shop-info.general.component.jsx index 416d2f658..99245c76e 100644 --- a/client/src/components/shop-info/shop-info.general.component.jsx +++ b/client/src/components/shop-info/shop-info.general.component.jsx @@ -602,6 +602,18 @@ export default function ShopInfoGeneral({ form }) { > + ({ + //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 }) { -
+ + + {t("joblines.fields.lbr_types.LAA")} + + + - + - {t("joblines.fields.lbr_types.LAR")} + {t("joblines.fields.lbr_types.LAD")} - + - {t("joblines.fields.lbr_types.LAM")} + {t("joblines.fields.lbr_types.LAE")} - + - + + + + {t("joblines.fields.lbr_types.LAM")} + + + + + {t("joblines.fields.lbr_types.LAM")} + + + + + {t("joblines.fields.lbr_types.LAR")} + + + + + {t("joblines.fields.lbr_types.LAS")} + + + + + {t("joblines.fields.lbr_types.LAU")} + + + + + {t("joblines.fields.lbr_types.LA1")} + + + + + {t("joblines.fields.lbr_types.LA2")} + + + + + {t("joblines.fields.lbr_types.LA3")} + + + + + {t("joblines.fields.lbr_types.LA4")} + + @@ -128,6 +230,17 @@ export default function ShopInfoTaskPresets({ form }) { > + +
+ + + + + + + + + + + + + + +
{t("bodyshop.fields.md_tasks_presets.percent")}{`${theTaskPreset.percent || 0}%`}
+ {t("bodyshop.fields.md_tasks_presets.hourstype")} + {theTaskPreset.hourstype.join(", ")}
+ {t("bodyshop.fields.md_tasks_presets.nextstatus")} + {theTaskPreset.nextstatus}
+ ); + }} +
- - {() => { - const data = form.getFieldValue("timetickets"); - return ( - { - 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", - // }, - ]} - /> - ); - }} - - - { - //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 ( -
- {errors.map((e, idx) => ( - - ))} -
- {fields.map((field, index) => ( - - - - - - - - - - - - - - - - - - - - - - - - - - ))} -
-
- ); - }} -
+ {loading ? ( + + ) : ( + + {(fields, { add, remove, move }) => { + return ( + <> + + {t("timetickets.labels.claimtaskpreview")} + +
+ + + + + + + + + + {fields.map((field, index) => ( + + + + + + + ))} + +
{t("timetickets.fields.employee")}{t("timetickets.fields.cost_center")}{t("timetickets.fields.ciecacode")}{t("timetickets.fields.productivehrs")}
+ + + + + + + + + + + + + + + +
+ + + ); + }} + + )} + {unassignedHours > 0 && ( + + )} - - {() => { - const jobid = form.getFieldValue("jobid"); - if ( - (!lineTicketCalled && jobid) || - (jobid && - lineTicketData?.jobs_by_pk?.id !== jobid && - !lineTicketLoading) - ) { - queryJobInfo({ variables: { id: jobid } }).then(() => - calculateTimeTickets("") - ); - } - return ( - - ); - }} - {bodyshop?.md_tasks_presets?.use_approvals && ( - + ({ - ..._.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} > diff --git a/client/src/components/time-ticket-task-modal/time-ticket-task-modal.styles.scss b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.styles.scss new file mode 100644 index 000000000..91fd3345e --- /dev/null +++ b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.styles.scss @@ -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; + } +} diff --git a/client/src/components/time-ticket-task-selector/time-ticket-task-selector.component.jsx b/client/src/components/time-ticket-task-selector/time-ticket-task-selector.component.jsx deleted file mode 100644 index c974d3fe3..000000000 --- a/client/src/components/time-ticket-task-selector/time-ticket-task-selector.component.jsx +++ /dev/null @@ -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 ( - - - - ); -} diff --git a/client/src/components/time-ticket-tasks-presets/time-ticket-tasks-presets.component.jsx b/client/src/components/time-ticket-tasks-presets/time-ticket-tasks-presets.component.jsx deleted file mode 100644 index b940ca972..000000000 --- a/client/src/components/time-ticket-tasks-presets/time-ticket-tasks-presets.component.jsx +++ /dev/null @@ -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 ( - ({ - label: p.name, - key: p.name, - })) - : [], - onClick: handleClick, - }} - > - - - ); -} - -// 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 }, -// ]; diff --git a/client/src/graphql/bills.queries.js b/client/src/graphql/bills.queries.js index 6406edfe1..de11c4265 100644 --- a/client/src/graphql/bills.queries.js +++ b/client/src/graphql/bills.queries.js @@ -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 diff --git a/client/src/graphql/bodyshop.queries.js b/client/src/graphql/bodyshop.queries.js index ad004198a..e856fca67 100644 --- a/client/src/graphql/bodyshop.queries.js +++ b/client/src/graphql/bodyshop.queries.js @@ -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 diff --git a/client/src/graphql/jobs-lines.queries.js b/client/src/graphql/jobs-lines.queries.js index ae40dac83..13fd03412 100644 --- a/client/src/graphql/jobs-lines.queries.js +++ b/client/src/graphql/jobs-lines.queries.js @@ -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 + } + } + } +`; diff --git a/client/src/graphql/jobs.queries.js b/client/src/graphql/jobs.queries.js index 31192e268..2a0529320 100644 --- a/client/src/graphql/jobs.queries.js +++ b/client/src/graphql/jobs.queries.js @@ -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 + } + } +`; diff --git a/client/src/graphql/parts-dispatch.queries.js b/client/src/graphql/parts-dispatch.queries.js new file mode 100644 index 000000000..da5584ee0 --- /dev/null +++ b/client/src/graphql/parts-dispatch.queries.js @@ -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 + } + } + } +`; diff --git a/client/src/graphql/timetickets.queries.js b/client/src/graphql/timetickets.queries.js index a62032f40..969b3ec18 100644 --- a/client/src/graphql/timetickets.queries.js +++ b/client/src/graphql/timetickets.queries.js @@ -48,6 +48,7 @@ export const QUERY_TIME_TICKETS_IN_RANGE = gql` flat_rate commited_by committed_at + task_name job { id ro_number diff --git a/client/src/pages/tech/tech.page.component.jsx b/client/src/pages/tech/tech.page.component.jsx index 50219f72d..25b33dc09 100644 --- a/client/src/pages/tech/tech.page.component.jsx +++ b/client/src/pages/tech/tech.page.component.jsx @@ -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 }) { + 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: () => diff --git a/client/src/utils/TemplateConstants.js b/client/src/utils/TemplateConstants.js index 3f87542d2..65dfb3dab 100644 --- a/client/src/utils/TemplateConstants.js +++ b/client/src/utils/TemplateConstants.js @@ -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", }, diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index ee39e05c3..cf189ea3f 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -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: diff --git a/hasura/migrations/1689973479186_alter_table_public_jobs_add_column_void_date/down.sql b/hasura/migrations/1689973479186_alter_table_public_jobs_add_column_void_date/down.sql new file mode 100644 index 000000000..b8c658dd0 --- /dev/null +++ b/hasura/migrations/1689973479186_alter_table_public_jobs_add_column_void_date/down.sql @@ -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; diff --git a/hasura/migrations/1689973479186_alter_table_public_jobs_add_column_void_date/up.sql b/hasura/migrations/1689973479186_alter_table_public_jobs_add_column_void_date/up.sql new file mode 100644 index 000000000..7c41363ed --- /dev/null +++ b/hasura/migrations/1689973479186_alter_table_public_jobs_add_column_void_date/up.sql @@ -0,0 +1,2 @@ +alter table "public"."jobs" add column "void_date" Timestamp + null; diff --git a/hasura/migrations/1689973508700_alter_table_public_jobs_alter_column_void_date/down.sql b/hasura/migrations/1689973508700_alter_table_public_jobs_alter_column_void_date/down.sql new file mode 100644 index 000000000..42a67c083 --- /dev/null +++ b/hasura/migrations/1689973508700_alter_table_public_jobs_alter_column_void_date/down.sql @@ -0,0 +1 @@ +ALTER TABLE "public"."jobs" ALTER COLUMN "void_date" TYPE timestamp without time zone; diff --git a/hasura/migrations/1689973508700_alter_table_public_jobs_alter_column_void_date/up.sql b/hasura/migrations/1689973508700_alter_table_public_jobs_alter_column_void_date/up.sql new file mode 100644 index 000000000..97504d6c8 --- /dev/null +++ b/hasura/migrations/1689973508700_alter_table_public_jobs_alter_column_void_date/up.sql @@ -0,0 +1 @@ +ALTER TABLE "public"."jobs" ALTER COLUMN "void_date" TYPE timestamptz; diff --git a/hasura/migrations/1689978551428_alter_table_public_jobs_alter_column_void_date/down.sql b/hasura/migrations/1689978551428_alter_table_public_jobs_alter_column_void_date/down.sql new file mode 100644 index 000000000..085b24071 --- /dev/null +++ b/hasura/migrations/1689978551428_alter_table_public_jobs_alter_column_void_date/down.sql @@ -0,0 +1 @@ +alter table "public"."jobs" rename column "date_void" to "void_date"; diff --git a/hasura/migrations/1689978551428_alter_table_public_jobs_alter_column_void_date/up.sql b/hasura/migrations/1689978551428_alter_table_public_jobs_alter_column_void_date/up.sql new file mode 100644 index 000000000..4a6b2bfc8 --- /dev/null +++ b/hasura/migrations/1689978551428_alter_table_public_jobs_alter_column_void_date/up.sql @@ -0,0 +1 @@ +alter table "public"."jobs" rename column "void_date" to "date_void"; diff --git a/hasura/migrations/1690482057097_alter_table_public_timetickets_add_column_task_name/down.sql b/hasura/migrations/1690482057097_alter_table_public_timetickets_add_column_task_name/down.sql new file mode 100644 index 000000000..9335d1ce8 --- /dev/null +++ b/hasura/migrations/1690482057097_alter_table_public_timetickets_add_column_task_name/down.sql @@ -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; diff --git a/hasura/migrations/1690482057097_alter_table_public_timetickets_add_column_task_name/up.sql b/hasura/migrations/1690482057097_alter_table_public_timetickets_add_column_task_name/up.sql new file mode 100644 index 000000000..e88d3a79b --- /dev/null +++ b/hasura/migrations/1690482057097_alter_table_public_timetickets_add_column_task_name/up.sql @@ -0,0 +1,2 @@ +alter table "public"."timetickets" add column "task_name" text + null; diff --git a/package.json b/package.json index e2896516b..7fbe5853f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server.js b/server.js index 9b16722d7..ad7e4eaff 100644 --- a/server.js +++ b/server.js @@ -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) => { diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 7d7211066..731d317ff 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -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 + } +} +`; diff --git a/server/payroll/calculate-totals.js b/server/payroll/calculate-totals.js new file mode 100644 index 000000000..e0eb14324 --- /dev/null +++ b/server/payroll/calculate-totals.js @@ -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); +}; diff --git a/server/payroll/claim-task.js b/server/payroll/claim-task.js new file mode 100644 index 000000000..b88c52c34 --- /dev/null +++ b/server/payroll/claim-task.js @@ -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(); + } +}; diff --git a/server/payroll/pay-all.js b/server/payroll/pay-all.js new file mode 100644 index 000000000..99f9f9b98 --- /dev/null +++ b/server/payroll/pay-all.js @@ -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; diff --git a/server/payroll/payroll.js b/server/payroll/payroll.js new file mode 100644 index 000000000..58b92e207 --- /dev/null +++ b/server/payroll/payroll.js @@ -0,0 +1,3 @@ +exports.calculatelabor = require("./calculate-totals").calculatelabor; +exports.payall = require("./pay-all").payall; +exports.claimtask = require("./claim-task").claimtask; diff --git a/yarn.lock b/yarn.lock index d859830cb..2d2d4038d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"