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

@@ -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": "",