Merge branch 'feature/payroll' into feature/america

This commit is contained in:
Patrick Fic
2023-04-18 13:27:02 -07:00
50 changed files with 2812 additions and 78 deletions

View File

@@ -6116,6 +6116,32 @@
</concept_node>
</children>
</folder_node>
<folder_node>
<name>employee_teams</name>
<children>
<concept_node>
<name>page</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
<folder_node>
<name>employees</name>
<children>
@@ -8864,6 +8890,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>tt_enforce_hours_for_tech_console</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>use_fippa</name>
<definition_loaded>false</definition_loaded>
@@ -9404,6 +9451,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>employee_teams</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>employees</name>
<definition_loaded>false</definition_loaded>
@@ -14583,6 +14651,84 @@
</folder_node>
</children>
</folder_node>
<folder_node>
<name>employee_teams</name>
<children>
<folder_node>
<name>actions</name>
<children>
<concept_node>
<name>new</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
<folder_node>
<name>fields</name>
<children>
<concept_node>
<name>active</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>name</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
</children>
</folder_node>
<folder_node>
<name>employees</name>
<children>
@@ -17310,6 +17456,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>total</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>totals</name>
<definition_loaded>false</definition_loaded>
@@ -33848,6 +34015,27 @@
<folder_node>
<name>errors</name>
<children>
<concept_node>
<name>deleting</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>noaccess</name>
<definition_loaded>false</definition_loaded>
@@ -34409,6 +34597,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>deleteconfirm</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>existing_owners</name>
<definition_loaded>false</definition_loaded>
@@ -34519,6 +34728,27 @@
<folder_node>
<name>successes</name>
<children>
<concept_node>
<name>delete</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>save</name>
<definition_loaded>false</definition_loaded>
@@ -43968,6 +44198,27 @@
<folder_node>
<name>actions</name>
<children>
<concept_node>
<name>claimtasks</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>clockin</name>
<definition_loaded>false</definition_loaded>
@@ -45022,6 +45273,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>hoursenteredmorethanavailable</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
</children>
@@ -47334,6 +47606,27 @@
<folder_node>
<name>errors</name>
<children>
<concept_node>
<name>deleting</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>noaccess</name>
<definition_loaded>false</definition_loaded>
@@ -47958,6 +48251,27 @@
<folder_node>
<name>labels</name>
<children>
<concept_node>
<name>deleteconfirm</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>fromvehicle</name>
<definition_loaded>false</definition_loaded>
@@ -48047,6 +48361,27 @@
<folder_node>
<name>successes</name>
<children>
<concept_node>
<name>delete</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>save</name>
<definition_loaded>false</definition_loaded>

View File

@@ -0,0 +1,33 @@
import { useQuery } from "@apollo/client";
import { Select } from "antd";
import React, { forwardRef } from "react";
import { QUERY_TEAMS } from "../../graphql/employee_teams.queries";
import AlertComponent from "../alert/alert.component";
//To be used as a form element only.
const EmployeeTeamSearchSelect = ({ ...props }, ref) => {
const { loading, error, data } = useQuery(QUERY_TEAMS);
if (error) return <AlertComponent message={JSON.stringify(error)} />;
return (
<Select
showSearch
allowClear
loading={loading}
style={{
width: 400,
}}
options={
data
? data.employee_teams.map((e) => ({
value: JSON.stringify(e),
label: e.name,
}))
: []
}
{...props}
/>
);
};
export default forwardRef(EmployeeTeamSearchSelect);

View File

@@ -47,6 +47,8 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(setModalContext({ context: context, modal: "jobCosting" })),
setTimeTicketContext: (context) =>
dispatch(setModalContext({ context: context, modal: "timeTicket" })),
setTimeTicketTaskContext: (context) =>
dispatch(setModalContext({ context: context, modal: "timeTicketTask" })),
setCardPaymentContext: (context) =>
dispatch(setModalContext({ context: context, modal: "cardPayment" })),
});
@@ -62,6 +64,7 @@ export function JobsDetailHeaderActions({
setJobCostingContext,
jobRO,
setTimeTicketContext,
setTimeTicketTaskContext,
setCardPaymentContext,
}) {
const { t } = useTranslation();
@@ -247,6 +250,21 @@ export function JobsDetailHeaderActions({
>
{t("timetickets.actions.enter")}
</Menu.Item>
<Menu.Item
key="claimtimetickettasks"
disabled={
!job.converted ||
(!bodyshop.tt_allow_post_to_invoiced && job.date_invoiced)
}
onClick={() => {
setTimeTicketTaskContext({
actions: {},
context: { jobid: job.id },
});
}}
>
{t("timetickets.actions.claimtasks")}
</Menu.Item>
<Menu.Item
key="enterpayments"
disabled={!job.converted}

View File

@@ -6,10 +6,6 @@ export const CalculateAllocationsTotals = (
timetickets,
adjustments = []
) => {
console.log(
"🚀 ~ file: labor-allocations-table.utility.js ~ line 9 ~ adjustments",
adjustments
);
const responsibilitycenters = bodyshop.md_responsibility_centers;
const jobCodes = joblines.map((item) => item.mod_lbr_ty);
//.filter((value, index, self) => self.indexOf(value) === index && !!value);

View File

@@ -91,11 +91,13 @@ const r = ({ technician, state, activeStatuses, bodyshop }) => {
b.v_make_desc + b.v_model_desc
),
sortOrder:
state.sortedInfo.columnKey === "ownr" && state.sortedInfo.order,
state.sortedInfo.columnKey === "vehicle" && state.sortedInfo.order,
render: (text, record) => (
<span>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${
record.v_model_desc || ""
} ${record.v_color || ""} ${record.plate_no || ""}`}</span>
<Link to={`/manage/vehicles/${record.vehicleid}`}>{`${
record.v_model_yr || ""
} ${record.v_make_desc || ""} ${record.v_model_desc || ""} ${
record.v_color || ""
} ${record.plate_no || ""}`}</Link>
),
},
{

View File

@@ -81,7 +81,7 @@ export function ProductionListTable({
state,
activeStatuses: bodyshop.md_ro_statuses.active_statuses,
}).find((e) => e.key === k.key),
width: k.width,
width: k.width ?? 100,
};
})) ||
[]
@@ -267,6 +267,8 @@ export function ProductionListTable({
sortOrder:
state.sortedInfo.columnKey === c.key && state.sortedInfo.order,
title: headerItem(c),
ellipsis: true,
width: c.width ?? 100,
onHeaderCell: (column) => ({
width: column.width,
onResize: handleResize(index),
@@ -276,11 +278,12 @@ export function ProductionListTable({
rowKey="id"
loading={loading}
dataSource={dataSource}
// scroll={{ x: true }}
scroll={{ x: 1000 }}
onChange={handleTableChange}
/>
</ReactDragListView.DragColumn>
</div>
);
}
export default connect(mapStateToProps, null)(ProductionListTable);

View File

@@ -3,8 +3,26 @@ import { Resizable } from "react-resizable";
export default function ResizableComponent(props) {
const { onResize, width, ...restProps } = props;
if (!width) {
return <th {...restProps} />;
}
return (
<Resizable width={width || 200} height={0} onResize={onResize}>
<Resizable
width={width || 200}
height={0}
onResize={onResize}
draggableOpts={{ enableUserSelectHack: false }}
handle={
<span
className="react-resizable-handle"
onClick={(e) => {
e.stopPropagation();
}}
/>
}
>
<th {...restProps} />
</Resizable>
);

View File

@@ -1,4 +1,4 @@
import { Card, Statistic } from "antd";
import { Card, Divider, Statistic } from "antd";
import moment from "moment";
import React from "react";
import { connect } from "react-redux";
@@ -41,6 +41,9 @@ export function ScoreboardDayStats({ bodyshop, date, entries }) {
label="P"
value={paintHrs.toFixed(1)}
/>
<Divider style={{ margin: 0 }} />
<Statistic value={(bodyHrs + paintHrs).toFixed(1)} />
</Card>
);
}

View File

@@ -1,5 +1,5 @@
import { CalendarOutlined } from "@ant-design/icons";
import { Card, Col, Row, Statistic } from "antd";
import { Card, Col, Divider, Row, Statistic } from "antd";
import _ from "lodash";
import moment from "moment";
import React, { useMemo } from "react";
@@ -177,6 +177,9 @@ export function ScoreboardTargetsTable({ bodyshop, scoreBoardlist }) {
<Statistic value={values.toDatePaint.toFixed(1)} />
</Col>
</Row>
<Row>
<Divider style={{ margin: 5 }} />
</Row>
<Row>
<Col {...statSpans}></Col>
<Col {...statSpans}>
@@ -184,14 +187,53 @@ export function ScoreboardTargetsTable({ bodyshop, scoreBoardlist }) {
value={(values.todayPaint + values.todayBody).toFixed(1)}
/>
</Col>
<Col {...statSpans}></Col>
<Col {...statSpans}>
<Statistic
value={(
Util.WeeklyTargetHrs(
bodyshop.scoreboard_target.dailyBodyTarget,
bodyshop
) +
Util.WeeklyTargetHrs(
bodyshop.scoreboard_target.dailyPaintTarget,
bodyshop
)
).toFixed(1)}
/>
</Col>
<Col {...statSpans}>
<Statistic
value={(values.weeklyPaint + values.weeklyBody).toFixed(1)}
/>
</Col>
<Col {...statSpans}></Col>
<Col {...statSpans}></Col>
<Col {...statSpans}>
<Statistic
value={(
Util.MonthlyTargetHrs(
bodyshop.scoreboard_target.dailyBodyTarget,
bodyshop
) +
Util.MonthlyTargetHrs(
bodyshop.scoreboard_target.dailyPaintTarget,
bodyshop
)
).toFixed(1)}
/>
</Col>
<Col {...statSpans}>
<Statistic
value={(
Util.AsOfTodayTargetHrs(
bodyshop.scoreboard_target.dailyBodyTarget,
bodyshop
) +
Util.AsOfTodayTargetHrs(
bodyshop.scoreboard_target.dailyPaintTarget,
bodyshop
)
).toFixed(1)}
/>
</Col>
<Col {...statSpans}>
<Statistic
value={(values.toDatePaint + values.toDateBody).toFixed(1)}

View File

@@ -81,6 +81,7 @@ export default function ScoreboardTimeTickets() {
totalLastMonth: 0,
totalOverPeriod: 0,
actualTotalOverPeriod: 0,
totalEffieciencyOverPeriod: 0,
employees: {},
};
data.fixedperiod.forEach((ticket) => {
@@ -94,6 +95,7 @@ export default function ScoreboardTimeTickets() {
totalLastMonth: 0,
totalOverPeriod: 0,
actualTotalOverPeriod: 0,
totalEffieciencyOverPeriod: 0,
};
}
@@ -221,6 +223,28 @@ export default function ScoreboardTimeTickets() {
ret2.push(r);
});
// Add total efficiency of employees
const totalActualAndProductive = Object.keys(ret.employees)
.map((key) => {
return { employee_number: key, ...ret.employees[key] };
})
.reduce(
(acc, e) => {
return {
totalOverPeriod: acc.totalOverPeriod + e.totalOverPeriod,
actualTotalOverPeriod:
acc.actualTotalOverPeriod + e.actualTotalOverPeriod,
};
},
{ totalOverPeriod: 0, actualTotalOverPeriod: 0 }
);
ret.totalEffieciencyOverPeriod =
(totalActualAndProductive.totalOverPeriod /
totalActualAndProductive.actualTotalOverPeriod) *
100;
roundObject(ret);
roundObject(totals);
roundObject(ret2);

View File

@@ -62,7 +62,7 @@ export function ScoreboardTicketsStats({ data, bodyshop }) {
key: "efficiencyoverperiod",
render: (text, record) =>
`${(
(record.totalOverPeriod / (record.actualTotalOverPeriod || .1)) *
(record.totalOverPeriod / (record.actualTotalOverPeriod || 0.1)) *
100
).toFixed(1)} %`,
},
@@ -113,6 +113,12 @@ export function ScoreboardTicketsStats({ data, bodyshop }) {
value={data.totalOverPeriod}
/>
</Col>
<Col span={12}>
<Statistic
title={t("scoreboard.labels.efficiencyoverperiod")}
value={`${data.totalEffieciencyOverPeriod}%`}
/>
</Col>
</Row>
<Typography.Text type="secondary">
{t("scoreboard.labels.calendarperiod")}
@@ -121,7 +127,7 @@ export function ScoreboardTicketsStats({ data, bodyshop }) {
<Col md={24} lg={20}>
<Table
columns={columns}
rowKey='employee_number'
rowKey="employee_number"
dataSource={tableData}
id="employee_number"
scroll={{ y: "300px" }}

View File

@@ -11,7 +11,7 @@ import {
Switch,
Table,
} from "antd";
import { useForm } from "antd/es/form/Form";
import moment from "moment";
import querystring from "query-string";
import React, { useEffect } from "react";
@@ -46,7 +46,7 @@ const mapDispatchToProps = (dispatch) => ({
export function ShopEmployeesFormComponent({ bodyshop }) {
const { t } = useTranslation();
const [form] = useForm();
const [form] = Form.useForm();
const history = useHistory();
const search = querystring.parse(useLocation().search);
const [deleteVacation] = useMutation(DELETE_VACATION);

View File

@@ -589,6 +589,13 @@ export default function ShopInfoGeneral({ form }) {
>
<Switch />
</Form.Item>
<Form.Item
name={["tt_enforce_hours_for_tech_console"]}
label={t("bodyshop.fields.tt_enforce_hours_for_tech_console")}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={["bill_allow_post_to_closed"]}
label={t("bodyshop.fields.bill_allow_post_to_closed")}

View File

@@ -388,6 +388,18 @@ export function ShopInfoRbacComponent({ form, bodyshop }) {
>
<InputNumber />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.rbac.employee_teams.page")}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
name={["md_rbac", "employee_teams:page"]}
>
<InputNumber />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.rbac.owners.list")}
rules={[

View File

@@ -0,0 +1,7 @@
import React from 'react'
export default function ShopEmployeeTeamMember({teamMember}) {
return (
<div>ShopEmployeeTeamMember</div>
)
}

View File

@@ -0,0 +1,424 @@
import { DeleteFilled } from "@ant-design/icons";
import { useMutation, useQuery } from "@apollo/client";
import {
Button,
Space,
Card,
Form,
Input,
InputNumber,
Switch,
notification,
} from "antd";
import querystring from "query-string";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useHistory, useLocation } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import {
INSERT_EMPLOYEE_TEAM,
QUERY_EMPLOYEE_TEAM_BY_ID,
UPDATE_EMPLOYEE_TEAM,
} from "../../graphql/employee_teams.queries";
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
const { t } = useTranslation();
const [form] = Form.useForm();
const history = useHistory();
const search = querystring.parse(useLocation().search);
const { error, data } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, {
variables: { id: search.employeeTeamId },
skip: !search.employeeTeamId || search.employeeTeamId === "new",
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
});
useEffect(() => {
if (data && data.employee_teams_by_pk)
form.setFieldsValue(data.employee_teams_by_pk);
else {
form.resetFields();
}
}, [form, data, search.employeeTeamId]);
const [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
const [insertEmployeeTeam] = useMutation(INSERT_EMPLOYEE_TEAM);
const handleFinish = async ({ employee_team_members, ...values }) => {
if (search.employeeTeamId && search.employeeTeamId !== "new") {
//Update a record.
logImEXEvent("shop_employee_update");
const result = await updateEmployeeTeam({
variables: {
employeeTeamId: search.employeeTeamId,
employeeTeam: values,
teamMemberUpdates: employee_team_members
.filter((e) => e.id)
.map((e) => {
delete e.__typename;
return { where: { id: { _eq: e.id } }, _set: e };
}),
teamMemberInserts: employee_team_members
.filter((e) => e.id === null || e.id === undefined)
.map((e) => ({ ...e, teamid: search.employeeTeamId })),
teamMemberDeletes:
data.employee_teams_by_pk.employee_team_members.filter(
(e) => !employee_team_members.find((etm) => etm.id === e.id)
),
},
});
if (!result.errors) {
notification["success"]({
message: t("employees.successes.save"),
});
} else {
notification["error"]({
message: t("employees.errors.save", {
message: JSON.stringify(error),
}),
});
}
} else {
//New record, insert it.
logImEXEvent("shop_employee_insert");
insertEmployeeTeam({
variables: {
employeeTeam: {
...values,
employee_team_members: { data: employee_team_members },
bodyshopid: bodyshop.id,
},
},
refetchQueries: ["QUERY_TEAMS"],
}).then((r) => {
search.employeeTeamId = r.data.insert_employee_teams_one.id;
history.push({ search: querystring.stringify(search) });
notification["success"]({
message: t("employees.successes.save"),
});
});
}
};
if (!search.employeeTeamId) return null;
if (error) return <AlertComponent message={error.message} type="error" />;
return (
<Card
extra={
<Button type="primary" onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
}
>
<Form
onFinish={handleFinish}
autoComplete={"off"}
layout="vertical"
form={form}
>
<LayoutFormRow>
<Form.Item
name="name"
label={t("employee_teams.fields.name")}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("employee_teams.fields.active")}
name="active"
valuePropName="checked"
>
<Switch />
</Form.Item>
</LayoutFormRow>
<Form.List name={["employee_team_members"]}>
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
<Form.Item
label={t("employees.fields.id")}
key={`${index}`}
name={[field.name, "id"]}
hidden
>
<Input />
</Form.Item>
<LayoutFormRow grow>
<Form.Item
label={t("employee_teams.fields.employeeid")}
key={`${index}`}
name={[field.name, "employeeid"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<EmployeeSearchSelectComponent
options={bodyshop.employees}
/>
</Form.Item>
<Form.Item
label={t("employee_teams.fields.percentage")}
key={`${index}`}
name={[field.name, "percentage"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<InputNumber min={0} max={100} />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAA")}
key={`${index}`}
name={[field.name, "labor_rates", "LAA"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAB")}
key={`${index}`}
name={[field.name, "labor_rates", "LAB"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAD")}
key={`${index}`}
name={[field.name, "labor_rates", "LAD"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAE")}
key={`${index}`}
name={[field.name, "labor_rates", "LAE"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAF")}
key={`${index}`}
name={[field.name, "labor_rates", "LAF"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAG")}
key={`${index}`}
name={[field.name, "labor_rates", "LAG"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAM")}
key={`${index}`}
name={[field.name, "labor_rates", "LAM"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAR")}
key={`${index}`}
name={[field.name, "labor_rates", "LAR"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAS")}
key={`${index}`}
name={[field.name, "labor_rates", "LAS"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAU")}
key={`${index}`}
name={[field.name, "labor_rates", "LAU"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA1")}
key={`${index}`}
name={[field.name, "labor_rates", "LA1"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA2")}
key={`${index}`}
name={[field.name, "labor_rates", "LA2"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA3")}
key={`${index}`}
name={[field.name, "labor_rates", "LA3"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA4")}
key={`${index}`}
name={[field.name, "labor_rates", "LA4"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<CurrencyInput />
</Form.Item>
<Space align="center">
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
/>
</Space>
</LayoutFormRow>
</Form.Item>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("employee_teams.actions.newmember")}
</Button>
</Form.Item>
</div>
);
}}
</Form.List>
</Form>
</Card>
);
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(ShopEmployeeTeamsFormComponent);

View File

@@ -0,0 +1,71 @@
import { Button, Table } from "antd";
import queryString from "query-string";
import React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
export default function ShopEmployeeTeamsListComponent({
loading,
employee_teams,
}) {
const { t } = useTranslation();
const history = useHistory();
const search = queryString.parse(useLocation().search);
const handleOnRowClick = (record) => {
if (record) {
search.employeeTeamId = record.id;
history.push({ search: queryString.stringify(search) });
} else {
delete search.employeeTeamId;
history.push({ search: queryString.stringify(search) });
}
};
const columns = [
{
title: t("employee_teams.fields.name"),
dataIndex: "name",
key: "name",
},
];
return (
<div>
<Table
title={() => {
return (
<Button
type="primary"
onClick={() => {
search.employeeTeamId = "new";
history.push({ search: queryString.stringify(search) });
}}
>
{t("employee_teams.actions.new")}
</Button>
);
}}
loading={loading}
pagination={{ position: "top" }}
columns={columns}
rowKey="id"
dataSource={employee_teams}
rowSelection={{
onSelect: (props) => {
search.employeeTeamId = props.id;
history.push({ search: queryString.stringify(search) });
},
type: "radio",
selectedRowKeys: [search.employeeTeamId],
}}
onRow={(record, rowIndex) => {
return {
onClick: (event) => {
handleOnRowClick(record);
},
};
}}
/>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { useQuery } from "@apollo/client";
import React from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { QUERY_TEAMS } from "../../graphql/employee_teams.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import ShopEmployeeTeamsListComponent from "./shop-employee-teams.list";
import ShopEmployeeTeamsFormComponent from "./shop-employee-teams.form.component";
import { Col, Row } from "antd";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
function ShopTeamsContainer({ bodyshop }) {
const { loading, error, data } = useQuery(QUERY_TEAMS, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
});
if (error) return <AlertComponent message={error.message} type="error" />;
return (
<div>
<RbacWrapper action="employee_teams:page">
<Row gutter={[16, 16]}>
<Col span={6}>
<ShopEmployeeTeamsListComponent
employee_teams={data ? data.employee_teams : []}
loading={loading}
/>
</Col>
<Col span={18}>
<ShopEmployeeTeamsFormComponent />
</Col>
</Row>
</RbacWrapper>
</div>
);
}
export default connect(mapStateToProps, null)(ShopTeamsContainer);

View File

@@ -1,4 +1,4 @@
import { useMutation } from "@apollo/client";
import { useMutation, useQuery } from "@apollo/client";
import {
Button,
Card,
@@ -21,6 +21,8 @@ import { selectTechnician } from "../../redux/tech/tech.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import TechJobClockoutDelete from "../tech-job-clock-out-delete/tech-job-clock-out-delete.component";
import { LaborAllocationContainer } from "../time-ticket-modal/time-ticket-modal.component";
import { GET_LINE_TICKET_BY_PK } from "../../graphql/jobs-lines.queries";
import { CalculateAllocationsTotals } from "../labor-allocations-table/labor-allocations-table.utility";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -39,7 +41,17 @@ export function TechClockOffButton({
const [loading, setLoading] = useState(false);
const [updateTimeticket] = useMutation(UPDATE_TIME_TICKET);
const [form] = Form.useForm();
const { queryLoading, data: lineTicketData } = useQuery(
GET_LINE_TICKET_BY_PK,
{
variables: {
id: jobId,
},
skip: !jobId,
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
}
);
const { t } = useTranslation();
const emps = bodyshop.employees.filter(
(e) => e.id === (technician && technician.id)
@@ -129,6 +141,54 @@ export function TechClockOffButton({
required: true,
//message: t("general.validation.required"),
},
({ getFieldValue }) => ({
validator(rule, value) {
console.log(
bodyshop.tt_enforce_hours_for_tech_console
);
if (!bodyshop.tt_enforce_hours_for_tech_console) {
return Promise.resolve();
}
if (
!value ||
getFieldValue("cost_center") === null ||
!lineTicketData
)
return Promise.resolve();
//Check the cost center,
const totals = CalculateAllocationsTotals(
bodyshop,
lineTicketData.joblines,
lineTicketData.timetickets,
lineTicketData.jobs_by_pk.lbr_adjustments
);
const fieldTypeToCheck =
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber
? "mod_lbr_ty"
: "cost_center";
const costCenterDiff =
Math.round(
totals.find(
(total) =>
total[fieldTypeToCheck] ===
getFieldValue("cost_center")
)?.difference * 10
) / 10;
if (value > costCenterDiff)
return Promise.reject(
t(
"timetickets.validation.hoursenteredmorethanavailable"
)
);
else {
return Promise.resolve();
}
},
}),
]}
>
<InputNumber min={0} precision={1} />
@@ -178,7 +238,11 @@ export function TechClockOffButton({
</Col>
{!isShiftTicket && (
<Col span={16}>
<LaborAllocationContainer jobid={jobId} />
<LaborAllocationContainer
jobid={jobId || null}
loading={queryLoading}
lineTicketData={lineTicketData}
/>
</Col>
)}
</Row>

View File

@@ -0,0 +1,142 @@
import { DownOutlined } from "@ant-design/icons";
import {
Button,
Checkbox,
Col,
Form,
InputNumber,
Popover,
Radio,
Row,
Space,
Spin,
} from "antd";
import React, { useState } from "react";
import { GET_JOB_INFO_DRAW_CALCULATIONS } from "../../graphql/jobs-lines.queries";
import { useQuery } from "@apollo/client";
export default function TimeTicketCalculatorComponent({
setProductiveHours,
jobid,
}) {
const { loading, data: lineTicketData } = useQuery(GET_JOB_INFO_DRAW_CALCULATIONS, {
variables: { id: jobid },
skip: !jobid,
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
});
const [visible, setVisible] = useState(false);
const handleOpenChange = (flag) => setVisible(flag);
const handleFinish = ({ type, hourstype, percent }) => {
//setProductiveHours(values);
//setVisible(false);
const eligibleHours = Array.isArray(hourstype)
? lineTicketData.joblines.reduce(
(acc, val) =>
acc + (hourstype.includes(val.mod_lbr_ty) ? val.mod_lb_hrs : 0),
0
)
: lineTicketData.joblines.reduce(
(acc, val) =>
acc + (hourstype === val.mod_lbr_ty ? val.mod_lb_hrs : 0),
0
);
if (type === "draw") {
setProductiveHours(eligibleHours * (percent / 100));
} else if (type === "cut") {
setProductiveHours(eligibleHours * (percent / 100));
console.log(
"Cut selected, rate set to: ",
lineTicketData.jobs_by_pk[`rate_${hourstype.toLowerCase()}`]
);
}
};
const popContent = (
<Spin spinning={loading}>
<Form onFinish={handleFinish}>
<Form.Item name="type">
<Radio.Group>
<Radio.Button value="draw">Draw</Radio.Button>
<Radio.Button value="cut">Cut of Sale</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) => (
<Form.Item name="hourstype">
{getFieldValue("type") === "draw" ? (
<Checkbox.Group>
<Row>
<Col span={8}>
<Checkbox value="LAB" style={{ lineHeight: "32px" }}>
Body
</Checkbox>
</Col>
<Col span={8}>
<Checkbox value="LAR" style={{ lineHeight: "32px" }}>
Refinish
</Checkbox>
</Col>
<Col span={8}>
<Checkbox value="LAM" style={{ lineHeight: "32px" }}>
Mechanical
</Checkbox>
</Col>
<Col span={8}>
<Checkbox value="LAF" style={{ lineHeight: "32px" }}>
Frame
</Checkbox>
</Col>
<Col span={8}>
<Checkbox value="LAG" style={{ lineHeight: "32px" }}>
Glass
</Checkbox>
</Col>
</Row>
</Checkbox.Group>
) : (
<Radio.Group>
<Radio value="LAB">Body</Radio>
<Radio value="LAR">Refinish</Radio>
<Radio value="LAM">Mechanical</Radio>
<Radio value="LAF">Frame</Radio>
<Radio value="LAG">Glass</Radio>
</Radio.Group>
)}
</Form.Item>
)}
</Form.Item>
<Form.Item name="percent">
<InputNumber min={0} max={100} precision={1} addonAfter="%" />
</Form.Item>
<Button htmlType="submit">Calculate</Button>
</Form>
</Spin>
);
return (
<Popover
content={popContent}
trigger={["click"]}
open={visible}
onOpenChange={handleOpenChange}
placement="right"
destroyTooltipOnHide
>
<Button onClick={(e) => e.preventDefault()}>
<Space>
Draw Calculator
<DownOutlined />
</Space>
</Button>
</Popover>
);
}

View File

@@ -0,0 +1,277 @@
import { useQuery } from "@apollo/client";
import {
Button,
Form,
InputNumber,
Modal,
Radio,
Select,
Space,
Table,
Typography,
} from "antd";
import Dinero from "dinero.js";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { GET_JOB_INFO_DRAW_CALCULATIONS } from "../../graphql/jobs-lines.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import FormDatePicker from "../form-date-picker/form-date-picker.component";
import JobSearchSelectComponent from "../job-search-select/job-search-select.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({});
export default connect(
mapStateToProps,
mapDispatchToProps
)(TimeTicketListTeamPay);
export function TimeTicketListTeamPay({ bodyshop, context, actions }) {
//const { refetch } = actions;
const { jobId } = context;
const [visible, setVisible] = useState(false);
const [form] = Form.useForm();
const { t } = useTranslation();
const {
//loading,
data: lineTicketData,
} = useQuery(GET_JOB_INFO_DRAW_CALCULATIONS, {
variables: { id: jobId },
skip: !jobId,
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
});
const handleOk = () => {
setVisible(false);
};
return (
<>
<Modal
width={"80%"}
open={visible}
destroyOnClose
onOk={handleOk}
onCancel={() => setVisible(false)}
>
<Form layout="vertical" form={form} initialValues={{ jobid: jobId }}>
<LayoutFormRow grow noDivider>
<Form.Item shouldUpdate>
{() => (
<Form.Item
name="jobid"
label={t("timetickets.fields.ro_number")}
rules={[
{
required: !(
form.getFieldValue("cost_center") ===
"timetickets.labels.shift"
),
//message: t("general.validation.required"),
},
]}
>
<JobSearchSelectComponent
convertedOnly={!bodyshop.tt_allow_post_to_invoiced}
notExported={!bodyshop.tt_allow_post_to_invoiced}
/>
</Form.Item>
)}
</Form.Item>
<Form.Item
label={t("timetickets.fields.date")}
name="date"
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<FormDatePicker />
</Form.Item>
</LayoutFormRow>
<LayoutFormRow grow noDivider>
<Form.Item name="team" label="Team">
<Select
options={Teams.map((team) => ({
value: team.name,
label: team.name,
}))}
/>
</Form.Item>
<Form.Item name="hourstype">
<Radio.Group>
<Radio value="LAB">Body</Radio>
<Radio value="LAR">Refinish</Radio>
<Radio value="LAM">Mechanical</Radio>
<Radio value="LAF">Frame</Radio>
<Radio value="LAG">Glass</Radio>
</Radio.Group>
</Form.Item>
<Form.Item name="percent">
<InputNumber min={0} max={100} precision={1} addonAfter="%" />
</Form.Item>
</LayoutFormRow>
<Form.Item shouldUpdate noStyle>
{({ getFieldsValue }) => {
const formData = getFieldsValue();
let data = [];
let eligibleHours = 0;
const theTeam = Teams.find((team) => team.name === formData.team);
if (theTeam) {
eligibleHours =
lineTicketData.joblines.reduce(
(acc, val) =>
acc +
(formData.hourstype === val.mod_lbr_ty
? val.mod_lb_hrs
: 0),
0
) * (formData.percent / 100 || 0);
data = theTeam.employees.map((e) => {
return {
employeeid: e.employeeid,
percentage: e.percentage,
rate: e.rates[formData.hourstype],
cost_center:
bodyshop.md_responsibility_centers.defaults.costs[
formData.hourstype
],
productivehrs:
Math.round(eligibleHours * 100 * (e.percentage / 100)) /
100,
pay: Dinero({
amount: Math.round(
(e.rates[formData.hourstype] || 0) * 100
),
})
.multiply(
Math.round(eligibleHours * 100 * (e.percentage / 100)) /
100
)
.toFormat("$0.00"),
};
});
}
return (
<Table
dataSource={data}
rowKey={"employeeid"}
title={() => (
<Space>
<Typography.Title level={4}>
Tickets to be Created
</Typography.Title>
<span>{`(${eligibleHours} hours to split)`}</span>
</Space>
)}
columns={[
{
title: t("timetickets.fields.employee"),
dataIndex: "employee",
key: "employee",
render: (text, record) => {
const emp = bodyshop.employees.find(
(e) => e.id === record.employeeid
);
return `${emp?.first_name} ${emp?.last_name}`;
},
},
{
title: t("timetickets.fields.cost_center"),
dataIndex: "cost_center",
key: "cost_center",
render: (text, record) =>
record.cost_center === "timetickets.labels.shift"
? t(record.cost_center)
: record.cost_center,
},
{
title: t("timetickets.fields.productivehrs"),
dataIndex: "productivehrs",
key: "productivehrs",
},
{
title: "Percentage",
dataIndex: "percentage",
key: "percentage",
},
{
title: "Rate",
dataIndex: "rate",
key: "rate",
},
{
title: "Pay",
dataIndex: "pay",
key: "pay",
},
]}
/>
);
}}
</Form.Item>
</Form>
</Modal>
<Button onClick={() => setVisible(true)}>Assign Team Pay </Button>
</>
);
}
const Teams = [
{
name: "Team A",
employees: [
{
employeeid: "9f1bdc23-8dc2-4b6a-8ca1-5f83a6fdcc22",
percentage: 50,
rates: {
LAB: 10,
LAR: 15,
},
},
{
employeeid: "201db66c-96c7-41ec-bed4-76842ba93087",
percentage: 50,
rates: {
LAB: 20,
LAR: 25,
},
},
],
},
{
name: "Team B",
employees: [
{
employeeid: "9f1bdc23-8dc2-4b6a-8ca1-5f83a6fdcc22",
percentage: 75,
rates: {
LAB: 100,
LAR: 150,
},
},
{
employeeid: "201db66c-96c7-41ec-bed4-76842ba93087",
percentage: 25,
rates: {
LAB: 200,
LAR: 250,
},
},
],
},
];

View File

@@ -1,17 +1,19 @@
import { EditFilled } from "@ant-design/icons";
import { Card, Space, Table } from "antd";
import { Button, Card, Space, Table } from "antd";
import Dinero from "dinero.js";
import moment from "moment";
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { setModalContext } from "../../redux/modals/modals.actions";
import {
selectAuthLevel,
selectBodyshop,
} from "../../redux/user/user.selectors";
import { onlyUnique } from "../../utils/arrayHelper";
import { DateFormatter, DateTimeFormatter } from "../../utils/DateFormatter";
import { onlyUnique } from "../../utils/arrayHelper";
import { alphaSort, dateSort } from "../../utils/sorters";
import RbacWrapper, {
HasRbacAccess,
@@ -22,12 +24,14 @@ const mapStateToProps = createStructuredSelector({
authLevel: selectAuthLevel,
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
setTimeTicketTaskContext: (context) =>
dispatch(setModalContext({ context: context, modal: "timeTicketTask" })),
});
export default connect(mapStateToProps, mapDispatchToProps)(TimeTicketList);
export function TimeTicketList({
bodyshop,
setTimeTicketTaskContext,
authLevel,
disabled,
loading,
@@ -193,6 +197,15 @@ export function TimeTicketList({
}
},
},
{
title: "Pay",
dataIndex: "pay",
key: "pay",
render: (text, record) =>
Dinero({ amount: Math.round(record.rate * 100) })
.multiply(record.flat_rate ? record.productivehrs : record.actualhrs)
.toFormat("$0.00"),
},
{
title: t("general.labels.actions"),
@@ -250,6 +263,23 @@ export function TimeTicketList({
title={t("timetickets.labels.timetickets")}
extra={
<Space wrap>
{
// <TimeTicketListTeamPay
// actions={{ refetch }}
// context={{ jobId: jobId }}
// />
}
<Button
onClick={() => {
setTimeTicketTaskContext();
setTimeTicketTaskContext({
actions: {},
context: { jobid: jobId },
});
}}
>
{t("timetickets.actions.claimtasks")}
</Button>
{jobId &&
(techConsole ? null : (
<TimeTicketEnterButton

View File

@@ -1,4 +1,4 @@
import { useQuery } from "@apollo/client";
import { useLazyQuery } from "@apollo/client";
import { Form, Input, InputNumber, Select, Switch } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
@@ -14,10 +14,12 @@ import FormDatePicker from "../form-date-picker/form-date-picker.component";
import FormDateTimePicker from "../form-date-time-picker/form-date-time-picker.component";
import JobSearchSelect from "../job-search-select/job-search-select.component";
import LaborAllocationsTable from "../labor-allocations-table/labor-allocations-table.component";
import { CalculateAllocationsTotals } from "../labor-allocations-table/labor-allocations-table.utility";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
import TimeTicketList from "../time-ticket-list/time-ticket-list.component";
import TimeTicketCalculatorComponent from "../time-ticket-calculator/time-ticket-calculator.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -38,7 +40,11 @@ export function TimeTicketModalComponent({
employeeSelectDisabled,
}) {
const { t } = useTranslation();
const [loadLineTicketData, { called, loading, data: lineTicketData }] =
useLazyQuery(GET_LINE_TICKET_BY_PK, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
});
const CostCenterSelect = ({ emps, value, ...props }) => {
return (
<Select
@@ -172,20 +178,73 @@ export function TimeTicketModalComponent({
<LayoutFormRow>
<Form.Item shouldUpdate>
{() => (
<Form.Item
label={t("timetickets.fields.productivehrs")}
name="productivehrs"
rules={[
{
required:
form.getFieldValue("cost_center") !==
"timetickets.labels.shift",
//message: t("general.validation.required"),
},
]}
>
<InputNumber precision={1} />
</Form.Item>
<>
<Form.Item
label={t("timetickets.fields.productivehrs")}
name="productivehrs"
rules={[
({ getFieldValue }) => ({
validator(rule, value) {
if (!bodyshop.tt_enforce_hours_for_tech_console) {
return Promise.resolve();
}
if (
!value ||
getFieldValue("cost_center") === null ||
!lineTicketData
)
return Promise.resolve();
//Check the cost center,
const totals = CalculateAllocationsTotals(
bodyshop,
lineTicketData.joblines,
lineTicketData.timetickets,
lineTicketData.jobs_by_pk.lbr_adjustments
);
const fieldTypeToCheck =
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber
? "mod_lbr_ty"
: "cost_center";
const costCenterDiff =
Math.round(
totals.find(
(total) =>
total[fieldTypeToCheck] ===
getFieldValue("cost_center")
)?.difference * 10
) / 10;
if (value > costCenterDiff)
return Promise.reject(
t(
"timetickets.validation.hoursenteredmorethanavailable"
)
);
else {
return Promise.resolve();
}
},
}),
{
required:
form.getFieldValue("cost_center") !==
"timetickets.labels.shift",
//message: t("general.validation.required"),
},
]}
>
<InputNumber precision={1} />
</Form.Item>
<TimeTicketCalculatorComponent
jobid={form.getFieldValue("jobid")}
setProductiveHours={(productivehrs) =>
form.setFieldsValue({ productivehrs })
}
/>
</>
)}
</Form.Item>
<Form.Item
@@ -291,23 +350,33 @@ export function TimeTicketModalComponent({
</Form.Item>
</LayoutFormRow>
<Form.Item dependencies={["jobid"]}>
{() => (
<LaborAllocationContainer
jobid={form.getFieldValue("jobid") || null}
/>
)}
{() => {
const jobid = form.getFieldValue("jobid");
if (
(!called && jobid) ||
(jobid && lineTicketData?.jobs_by_pk?.id !== jobid && !loading)
) {
loadLineTicketData({ variables: { id: jobid } });
}
return (
<LaborAllocationContainer
jobid={jobid || null}
loading={loading}
lineTicketData={lineTicketData}
/>
);
}}
</Form.Item>
</div>
);
}
export function LaborAllocationContainer({ jobid }) {
const { loading, data: lineTicketData } = useQuery(GET_LINE_TICKET_BY_PK, {
variables: { id: jobid },
skip: !jobid,
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
});
export function LaborAllocationContainer({
jobid,
loading,
lineTicketData,
hideTimeTickets = false,
}) {
if (loading) return <LoadingSkeleton />;
if (!lineTicketData) return null;
return (
@@ -318,12 +387,13 @@ export function LaborAllocationContainer({ jobid }) {
timetickets={lineTicketData.timetickets}
adjustments={lineTicketData.jobs_by_pk.lbr_adjustments}
/>
<TimeTicketList
loading={loading}
timetickets={lineTicketData.timetickets}
techConsole
/>
{!hideTimeTickets && (
<TimeTicketList
loading={loading}
timetickets={lineTicketData.timetickets}
techConsole
/>
)}
</div>
);
}

View File

@@ -0,0 +1,376 @@
import {
Alert,
Button,
Checkbox,
Col,
Form,
Input,
InputNumber,
Row,
Space,
Table,
} from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component";
import EmployeeTeamSearchSelectComponent from "../employee-team-search-select/employee-team-search-select.component";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
import JobSearchSelectComponent from "../job-search-select/job-search-select.component";
import { LaborAllocationContainer } from "../time-ticket-modal/time-ticket-modal.component";
import TimeTicketsTasksPresets from "../time-ticket-tasks-presets/time-ticket-tasks-presets.component";
import { CalculateAllocationsTotals } from "../labor-allocations-table/labor-allocations-table.utility";
import _ from "lodash";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(TimeTicketTaskModalComponent);
export function TimeTicketTaskModalComponent({
bodyshop,
form,
employeeAutoCompleteOptions,
lineTicketCalled,
calculateTimeTickets,
lineTicketLoading,
lineTicketData,
queryJobInfo,
}) {
const { t } = useTranslation();
return (
<div>
<TimeTicketsTasksPresets
form={form}
calculateTimeTickets={calculateTimeTickets}
/>
<Row gutter={[16, 16]}>
<Col lg={12} md={24}>
<Form.Item
name="jobid"
label={t("timetickets.fields.ro_number")}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<JobSearchSelectComponent
convertedOnly={!bodyshop.tt_allow_post_to_invoiced}
notExported={!bodyshop.tt_allow_post_to_invoiced}
/>
</Form.Item>
<Form.Item name="employeeid" label={t("timetickets.fields.employee")}>
<EmployeeSearchSelectComponent
options={employeeAutoCompleteOptions}
allowClear
onSelect={(value) => {
const emps =
employeeAutoCompleteOptions &&
employeeAutoCompleteOptions.filter((e) => e.id === value)[0];
form.setFieldsValue({ flat_rate: emps && emps.flat_rate });
}}
/>
</Form.Item>
<Form.Item
name="employeeteamid"
label={t("timetickets.fields.employee_team")}
>
<EmployeeTeamSearchSelectComponent />
</Form.Item>
<Space wrap>
<Form.Item
name="hourstype"
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<Checkbox.Group>
<Space wrap>
<Checkbox value="LAB" style={{ display: "flex" }}>
Body
</Checkbox>
<Checkbox value="LAR" style={{ display: "flex" }}>
Refinish
</Checkbox>
<Checkbox value="LAM" style={{ display: "flex" }}>
Mechanical
</Checkbox>
<Checkbox value="LAF" style={{ display: "flex" }}>
Frame
</Checkbox>
<Checkbox value="LAG" style={{ display: "flex" }}>
Glass
</Checkbox>
</Space>
</Checkbox.Group>
</Form.Item>
<Form.Item
name="percent"
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<InputNumber min={0} max={100} precision={1} addonAfter="%" />
</Form.Item>
</Space>
<Button onClick={calculateTimeTickets}>Calculate</Button>
</Col>
<Col lg={12} md={24}>
<Form.Item shouldUpdate>
{() => {
const data = form.getFieldValue("timetickets");
return (
<Table
dataSource={data}
rowKey={"employeeid"}
columns={[
{
title: t("timetickets.fields.employee"),
dataIndex: "employee",
key: "employee",
render: (text, record) => {
const emp = bodyshop.employees.find(
(e) => e.id === record.employeeid
);
return `${emp?.first_name} ${emp?.last_name}`;
},
},
{
title: t("timetickets.fields.cost_center"),
dataIndex: "cost_center",
key: "cost_center",
render: (text, record) =>
record.cost_center === "timetickets.labels.shift"
? t(record.cost_center)
: record.cost_center,
},
{
title: t("timetickets.fields.productivehrs"),
dataIndex: "productivehrs",
key: "productivehrs",
},
{
title: "Percentage",
dataIndex: "percentage",
key: "percentage",
},
{
title: "Rate",
dataIndex: "rate",
key: "rate",
},
{
title: "Pay",
dataIndex: "pay",
key: "pay",
},
]}
/>
);
}}
</Form.Item>
<Form.List
name={["timetickets"]}
rules={[
{
validator: (rule, value) => {
//Check the cost center,
const totals = CalculateAllocationsTotals(
bodyshop,
lineTicketData.joblines,
lineTicketData.timetickets,
lineTicketData.jobs_by_pk.lbr_adjustments
);
const grouped = _.groupBy(value, "cost_center");
let error = false;
Object.keys(grouped).forEach((key) => {
const totalProdTicketHours = grouped[key].reduce(
(acc, val) => acc + val.productivehrs,
0
);
const fieldTypeToCheck = "cost_center";
// bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber
// ? "mod_lbr_ty"
// : "cost_center";
const costCenterDiff =
Math.round(
totals.find((total) => total[fieldTypeToCheck] === key)
?.difference * 10
) / 10;
if (totalProdTicketHours > costCenterDiff) error = true;
else {
// return Promise.resolve();
}
});
if (!error) return Promise.resolve();
return Promise.reject(
"Too many hours are being claimed as a part of this task"
);
},
},
]}
>
{(fields, { add, remove, move }, { errors }) => {
return (
<div>
{errors.map((e, idx) => (
<Alert key={idx} message={e} />
))}
<div style={{ display: "none" }}>
{fields.map((field, index) => (
<Form.Item
key={field.key}
style={{ padding: 0, margin: 2 }}
>
<Space wrap>
<Form.Item
label={t("timetickets.fields.employeeid")}
key={`${index}employeeid`}
name={[field.name, "employeeid"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<EmployeeSearchSelectComponent
options={bodyshop.employees}
/>
</Form.Item>
<Form.Item
label={t("timetickets.fields.date")}
key={`${index}date`}
name={[field.name, "date"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<FormDateTimePickerComponent />
</Form.Item>
<Form.Item
label={t("timetickets.fields.productivehrs")}
key={`${index}productivehrs`}
name={[field.name, "productivehrs"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t("timetickets.fields.actualhrs")}
key={`${index}actualhrs`}
name={[field.name, "actualhrs"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t("timetickets.fields.rate")}
key={`${index}rate`}
name={[field.name, "rate"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<InputNumber />
</Form.Item>
<Form.Item
label={t("timetickets.fields.cost_center")}
key={`${index}cost_center`}
name={[field.name, "cost_center"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("timetickets.fields.memo")}
key={`${index}memo`}
name={[field.name, "memo"]}
>
<Input />
</Form.Item>
</Space>
</Form.Item>
))}
</div>
</div>
);
}}
</Form.List>
</Col>
</Row>
<Form.Item dependencies={["jobid"]}>
{() => {
const jobid = form.getFieldValue("jobid");
if (
(!lineTicketCalled && jobid) ||
(jobid &&
lineTicketData?.jobs_by_pk?.id !== jobid &&
!lineTicketLoading)
) {
queryJobInfo({ variables: { id: jobid } }).then(() =>
calculateTimeTickets()
);
}
return (
<LaborAllocationContainer
jobid={jobid || null}
loading={lineTicketLoading}
lineTicketData={lineTicketData}
hideTimeTickets
/>
);
}}
</Form.Item>
</div>
);
}

View File

@@ -0,0 +1,181 @@
import React, { useEffect } from "react";
import { useLazyQuery, useMutation, useQuery } from "@apollo/client";
import { Form, Modal, notification } from "antd";
import Dinero from "dinero.js";
import _ from "lodash";
import moment from "moment";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { QUERY_ACTIVE_EMPLOYEES } from "../../graphql/employees.queries";
import { GET_JOB_INFO_DRAW_CALCULATIONS } from "../../graphql/jobs-lines.queries";
import { INSERT_NEW_TIME_TICKET } from "../../graphql/timetickets.queries";
import { toggleModalVisible } from "../../redux/modals/modals.actions";
import { selectTimeTicketTasks } from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import TimeTicketTaskModalComponent from "./time-ticket-task-modal.component";
const mapStateToProps = createStructuredSelector({
timeTicketTasksModal: selectTimeTicketTasks,
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("timeTicketTask")),
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(TimeTickeTaskModalContainer);
export function TimeTickeTaskModalContainer({
bodyshop,
timeTicketTasksModal,
toggleModalVisible,
}) {
const [form] = Form.useForm();
const { context, visible } = timeTicketTasksModal;
const { data: EmployeeAutoCompleteData } = useQuery(QUERY_ACTIVE_EMPLOYEES, {
skip: !visible,
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
});
const { t } = useTranslation();
const [insertTimeTickets] = useMutation(INSERT_NEW_TIME_TICKET);
const [queryJobInfo, { called, loading, data: lineTicketData }] =
useLazyQuery(GET_JOB_INFO_DRAW_CALCULATIONS, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
});
async function handleFinish(values) {
console.log(
"🚀 ~ file: time-ticket-task-modal.container.jsx:52 ~ handleFinish ~ values:",
values
);
try {
const result = await insertTimeTickets({
variables: {
timeTicketInput: values.timetickets.map((ticket) =>
_.omit(ticket, "pay")
),
},
});
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) {
} finally {
}
}
useEffect(() => {
if (context.jobid) {
console.log("UE Fired.");
queryJobInfo({ variables: { id: context.jobid } });
}
}, [context.jobid, queryJobInfo]);
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) {
data = [];
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: presetMemo,
flat_rate: true,
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);
});
});
form.setFieldsValue({
timetickets: data.filter((d) => d.productivehrs > 0),
});
form.validateFields();
}
};
return (
<Modal
destroyOnClose
open={visible}
onCancel={() => toggleModalVisible()}
width="80%"
onOk={() => form.submit()}
>
<Form
autoComplete={"off"}
form={form}
layout="vertical"
onFinish={handleFinish}
// onFieldsChange={handleFieldsChange}
initialValues={context}
>
<TimeTicketTaskModalComponent
form={form}
employeeAutoCompleteOptions={
EmployeeAutoCompleteData && EmployeeAutoCompleteData.employees
}
lineTicketData={lineTicketData}
lineTicketLoading={loading}
lineTicketCalled={called}
calculateTimeTickets={calculateTimeTickets}
queryJobInfo={queryJobInfo}
/>
</Form>
</Modal>
);
}

View File

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

View File

@@ -0,0 +1,50 @@
import { Button, Dropdown } from "antd";
import React from "react";
export default function TimeTicketsTasksPresets({
form,
calculateTimeTickets,
}) {
const handleClick = (props) => {
const preset = samplePresets.find((p) => {
return p.name === props.key;
});
if (preset) {
form.setFieldsValue({
percent: preset.percent,
hourstype: preset.hourstype,
});
calculateTimeTickets(preset.memo);
}
};
return (
<Dropdown
trigger="click"
menu={{
items: samplePresets.map((p) => ({ label: p.name, key: p.name })),
onClick: handleClick,
}}
>
<Button>Presets</Button>
</Dropdown>
);
}
const samplePresets = [
{
name: "Teardown",
hourstype: ["LAB", "LAM"],
percent: 10,
memo: "Teardown Preset Task",
},
{
name: "Disassembly",
hourstype: ["LAB", "LAD"],
percent: 20,
memo: "Disassy Preset Claim",
},
{ name: "Body", hourstype: ["LAB", "LAD"], percent: 20 },
{ name: "Prep", hourstype: ["LAR"], percent: 20 },
];

View File

@@ -10,7 +10,7 @@ import { onlyUnique } from "../../utils/arrayHelper";
import { alphaSort } from "../../utils/sorters";
import { TemplateList } from "../../utils/TemplateConstants";
import PrintWrapperComponent from "../print-wrapper/print-wrapper.component";
import Dinero from "dinero.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
@@ -129,6 +129,16 @@ const JobRelatedTicketsTable = ({
return acc;
}, 0);
const pay = item.tickets
.filter((ticket) => ticket.cost_center === costCenter)
.reduce((acc, val) => {
return acc.add(
Dinero({ amount: Math.round(val.rate * 100) }).multiply(
val.flat_rate ? val.productivehrs : val.actualhrs
)
);
}, Dinero());
return {
id: `${item.jobKey}${costCenter}`,
costCenter,
@@ -136,6 +146,7 @@ const JobRelatedTicketsTable = ({
actHrs: actHrs.toFixed(1),
prodHrs: prodHrs.toFixed(1),
clockHrs,
pay,
};
});
})
@@ -195,6 +206,15 @@ const JobRelatedTicketsTable = ({
state.sortedInfo.columnKey === "clockHrs" && state.sortedInfo.order,
render: (text, record) => record.clockHrs.toFixed(2),
},
{
title: "Pay",
dataIndex: "Pay",
key: "Pay",
sorter: (a, b) => a.clockHrs - b.clockHrs,
sortOrder:
state.sortedInfo.columnKey === "clockHrs" && state.sortedInfo.order,
render: (text, record) => record.pay.toFormat("$0.00"),
},
{
title: t("general.labels.actions"),
dataIndex: "actions",

View File

@@ -116,6 +116,7 @@ export const QUERY_BODYSHOP = gql`
md_lost_sale_reasons
md_parts_scan
enforce_conversion_category
tt_enforce_hours_for_tech_console
employees {
user_email
id
@@ -230,6 +231,7 @@ export const UPDATE_SHOP = gql`
md_lost_sale_reasons
md_parts_scan
enforce_conversion_category
tt_enforce_hours_for_tech_console
employees {
id
first_name

View File

@@ -0,0 +1,91 @@
import { gql } from "@apollo/client";
export const QUERY_TEAMS = gql`
query QUERY_TEAMS {
employee_teams(order_by: { name: asc }) {
id
name
employee_team_members {
id
employeeid
labor_rates
percentage
}
}
}
`;
export const UPDATE_EMPLOYEE_TEAM = gql`
mutation UPDATE_EMPLOYEE_TEAM(
$employeeTeamId: uuid!
$employeeTeam: employee_teams_set_input
$teamMemberDeletes: [uuid!]
$teamMemberUpdates: [employee_team_members_updates!]!
$teamMemberInserts: [employee_team_members_insert_input!]!
) {
update_employee_team_members_many(updates: $teamMemberUpdates) {
returning {
id
labor_rates
employeeid
percentage
}
}
delete_employee_team_members(where: { id: { _in: $teamMemberDeletes } }) {
affected_rows
}
insert_employee_team_members(objects: $teamMemberInserts) {
returning {
id
labor_rates
employeeid
percentage
}
}
update_employee_teams_by_pk(
pk_columns: { id: $employeeTeamId }
_set: $employeeTeam
) {
active
name
id
employee_team_members {
percentage
labor_rates
id
}
}
}
`;
export const INSERT_EMPLOYEE_TEAM = gql`
mutation INSERT_EMPLOYEE_TEAM($employeeTeam: employee_teams_insert_input!) {
insert_employee_teams_one(object: $employeeTeam) {
active
name
id
employee_team_members {
employeeid
percentage
labor_rates
id
}
}
}
`;
export const QUERY_EMPLOYEE_TEAM_BY_ID = gql`
query QUERY_EMPLOYEE_TEAM_BY_ID($id: uuid!) {
employee_teams_by_pk(id: $id) {
id
name
active
employee_team_members {
employeeid
percentage
labor_rates
id
}
}
}
`;

View File

@@ -76,6 +76,65 @@ export const GET_LINE_TICKET_BY_PK = gql`
}
`;
export const GET_JOB_INFO_DRAW_CALCULATIONS = gql`
query GET_JOB_INFO_DRAW_CALCULATIONS($id: uuid!) {
jobs_by_pk(id: $id) {
id
lbr_adjustments
converted
rate_lab
rate_lad
rate_laa
rate_la1
rate_la2
rate_la3
rate_la4
rate_lau
rate_lar
rate_lag
rate_laf
rate_lam
}
timetickets(where: { jobid: { _eq: $id } }) {
actualhrs
ciecacode
cost_center
date
id
jobid
employeeid
memo
flat_rate
clockon
clockoff
rate
employee {
id
first_name
last_name
employee_number
}
productivehrs
}
joblines(where: { jobid: { _eq: $id }, removed: { _eq: false } }) {
id
line_desc
part_type
oem_partno
db_price
act_price
part_qty
mod_lbr_ty
db_hrs
mod_lb_hrs
lbr_op
lbr_amt
op_code_desc
convertedtolbr
convertedtolbr_data
}
}
`;
export const UPDATE_JOB_LINE_STATUS = gql`
mutation UPDATE_JOB_LINE_STATUS(
$ids: [uuid!]!

View File

@@ -284,6 +284,7 @@ export const QUERY_JOBS_IN_PRODUCTION = gql`
clm_no
v_make_desc
v_color
vehicleid
plate_no
actual_in
scheduled_completion

View File

@@ -98,6 +98,11 @@ const BillEnterModalContainer = lazy(() =>
const TimeTicketModalContainer = lazy(() =>
import("../../components/time-ticket-modal/time-ticket-modal.container")
);
const TimeTicketModalTask = lazy(() =>
import(
"../../components/time-ticket-task-modal/time-ticket-task-modal.container"
)
);
const PaymentModalContainer = lazy(() =>
import("../../components/payment-modal/payment-modal.container")
);
@@ -207,6 +212,7 @@ export function Manage({ match, conflict, bodyshop }) {
<ReportCenterModal />
<EmailOverlayContainer />
<TimeTicketModalContainer />
<TimeTicketModalTask />
<PrintCenterModalContainer />
<Route exact path={`${match.path}/_test`} component={TestComponent} />
<Route exact path={`${match.path}`} component={ManageRootPage} />

View File

@@ -14,6 +14,8 @@ import {
} from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import ShopInfoUsersComponent from "../../components/shop-users/shop-users.component";
import ShopTeamsContainer from "../../components/shop-teams/shop-teams.container";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
@@ -44,6 +46,9 @@ export function ShopPage({ bodyshop, setSelectedHeader, setBreadcrumbs }) {
<Tabs.TabPane tab={t("bodyshop.labels.employees")} key="employees">
<ShopEmployeesContainer />
</Tabs.TabPane>
<Tabs.TabPane tab={t("bodyshop.labels.employee_teams")} key="teams">
<ShopTeamsContainer />
</Tabs.TabPane>
<Tabs.TabPane tab={t("bodyshop.labels.licensing")} key="licensing">
<ShopInfoUsersComponent />
</Tabs.TabPane>

View File

@@ -16,6 +16,7 @@ const INITIAL_STATE = {
schedule: { ...baseModal },
partsOrder: { ...baseModal },
timeTicket: { ...baseModal },
timeTicketTask: { ...baseModal },
printCenter: { ...baseModal },
reconciliation: { ...baseModal },
payment: { ...baseModal },

View File

@@ -36,6 +36,10 @@ export const selectTimeTicket = createSelector(
[selectModals],
(modals) => modals.timeTicket
);
export const selectTimeTicketTasks = createSelector(
[selectModals],
(modals) => modals.timeTicketTask
);
export const selectPrintCenter = createSelector(
[selectModals],

View File

@@ -374,6 +374,9 @@
"export": "CSI -> Export",
"page": "CSI -> Page"
},
"employee_teams": {
"page": "Employee Teams -> List"
},
"employees": {
"page": "Employees -> List"
},
@@ -541,6 +544,7 @@
"target_touchtime": "Target Touch Time",
"timezone": "Timezone",
"tt_allow_post_to_invoiced": "Allow Time Tickets to be posted to Invoiced & Exported Jobs",
"tt_enforce_hours_for_tech_console": "Restrict Claimable hours from Tech Console",
"use_fippa": "Use FIPPA for Names on Generated Documents?",
"uselocalmediaserver": "Use Local Media Server?",
"website": "Website",
@@ -572,6 +576,7 @@
"title": "DMS"
},
"emaillater": "Email Later",
"employee_teams": "Employee Teams",
"employees": "Employees",
"estimators": "Estimators",
"filehandlers": "File Handlers",
@@ -905,6 +910,15 @@
"sent": "Email sent successfully."
}
},
"employee_teams": {
"actions": {
"new": "New Team"
},
"fields": {
"active": "Active",
"name": "Team Name"
}
},
"employees": {
"actions": {
"addvacation": "Add Vacation",
@@ -1063,6 +1077,7 @@
"sunday": "Sunday",
"text": "Text",
"thursday": "Thursday",
"total": "Total",
"totals": "Totals",
"tuesday": "Tuesday",
"unknown": "Unknown",
@@ -2605,6 +2620,7 @@
},
"timetickets": {
"actions": {
"claimtasks": "Claim Tasks",
"clockin": "Clock In",
"clockout": "Clock Out",
"enter": "Enter New Time Ticket",
@@ -2663,7 +2679,8 @@
},
"validation": {
"clockoffmustbeafterclockon": "Clock off time must be the same or after clock in time.",
"clockoffwithoutclockon": "Clock off time cannot be set without a clock in time."
"clockoffwithoutclockon": "Clock off time cannot be set without a clock in time.",
"hoursenteredmorethanavailable": "The number of hours entered is more than what is available for this cost center."
}
},
"titles": {

View File

@@ -374,6 +374,9 @@
"export": "",
"page": ""
},
"employee_teams": {
"page": ""
},
"employees": {
"page": ""
},
@@ -541,6 +544,7 @@
"target_touchtime": "",
"timezone": "",
"tt_allow_post_to_invoiced": "",
"tt_enforce_hours_for_tech_console": "",
"use_fippa": "",
"uselocalmediaserver": "",
"website": "",
@@ -572,6 +576,7 @@
"title": ""
},
"emaillater": "",
"employee_teams": "",
"employees": "",
"estimators": "",
"filehandlers": "",
@@ -905,6 +910,15 @@
"sent": "Correo electrónico enviado con éxito."
}
},
"employee_teams": {
"actions": {
"new": ""
},
"fields": {
"active": "",
"name": ""
}
},
"employees": {
"actions": {
"addvacation": "",
@@ -1063,6 +1077,7 @@
"sunday": "",
"text": "",
"thursday": "",
"total": "",
"totals": "",
"tuesday": "",
"unknown": "Desconocido",
@@ -2605,6 +2620,7 @@
},
"timetickets": {
"actions": {
"claimtasks": "",
"clockin": "",
"clockout": "",
"enter": "",
@@ -2663,7 +2679,8 @@
},
"validation": {
"clockoffmustbeafterclockon": "",
"clockoffwithoutclockon": ""
"clockoffwithoutclockon": "",
"hoursenteredmorethanavailable": ""
}
},
"titles": {

View File

@@ -374,6 +374,9 @@
"export": "",
"page": ""
},
"employee_teams": {
"page": ""
},
"employees": {
"page": ""
},
@@ -541,6 +544,7 @@
"target_touchtime": "",
"timezone": "",
"tt_allow_post_to_invoiced": "",
"tt_enforce_hours_for_tech_console": "",
"use_fippa": "",
"uselocalmediaserver": "",
"website": "",
@@ -572,6 +576,7 @@
"title": ""
},
"emaillater": "",
"employee_teams": "",
"employees": "",
"estimators": "",
"filehandlers": "",
@@ -905,6 +910,15 @@
"sent": "E-mail envoyé avec succès."
}
},
"employee_teams": {
"actions": {
"new": ""
},
"fields": {
"active": "",
"name": ""
}
},
"employees": {
"actions": {
"addvacation": "",
@@ -1063,6 +1077,7 @@
"sunday": "",
"text": "",
"thursday": "",
"total": "",
"totals": "",
"tuesday": "",
"unknown": "Inconnu",
@@ -2605,6 +2620,7 @@
},
"timetickets": {
"actions": {
"claimtasks": "",
"clockin": "",
"clockout": "",
"enter": "",
@@ -2663,7 +2679,8 @@
},
"validation": {
"clockoffmustbeafterclockon": "",
"clockoffwithoutclockon": ""
"clockoffwithoutclockon": "",
"hoursenteredmorethanavailable": ""
}
},
"titles": {

View File

@@ -761,6 +761,13 @@
table:
name: email_audit_trail
schema: public
- name: employee_teams
using:
foreign_key_constraint_on:
column: bodyshopid
table:
name: employee_teams
schema: public
- name: employees
using:
foreign_key_constraint_on:
@@ -928,6 +935,7 @@
- textid
- timezone
- tt_allow_post_to_invoiced
- tt_enforce_hours_for_tech_console
- updated_at
- use_fippa
- uselocalmediaserver
@@ -1016,6 +1024,7 @@
- target_touchtime
- timezone
- tt_allow_post_to_invoiced
- tt_enforce_hours_for_tech_console
- updated_at
- use_fippa
- uselocalmediaserver
@@ -1945,6 +1954,165 @@
- active:
_eq: true
check: null
- table:
name: employee_team_members
schema: public
object_relationships:
- name: employee
using:
foreign_key_constraint_on: employeeid
- name: employee_team
using:
foreign_key_constraint_on: teamid
insert_permissions:
- role: user
permission:
check:
employee_team:
bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
columns:
- labor_rates
- percentage
- created_at
- updated_at
- employeeid
- id
- teamid
select_permissions:
- role: user
permission:
columns:
- labor_rates
- percentage
- created_at
- updated_at
- employeeid
- id
- teamid
filter:
employee_team:
bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
update_permissions:
- role: user
permission:
columns:
- labor_rates
- percentage
- created_at
- updated_at
- employeeid
- id
- teamid
filter:
employee_team:
bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
check: null
delete_permissions:
- role: user
permission:
backend_only: false
filter:
employee_team:
bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
- table:
name: employee_teams
schema: public
object_relationships:
- name: bodyshop
using:
foreign_key_constraint_on: bodyshopid
array_relationships:
- name: employee_team_members
using:
foreign_key_constraint_on:
column: teamid
table:
name: employee_team_members
schema: public
insert_permissions:
- role: user
permission:
check:
bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
columns:
- active
- name
- created_at
- updated_at
- bodyshopid
- id
select_permissions:
- role: user
permission:
columns:
- active
- name
- created_at
- updated_at
- bodyshopid
- id
filter:
bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
update_permissions:
- role: user
permission:
columns:
- active
- bodyshopid
- name
- updated_at
filter:
bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
check: null
- table:
name: employee_vacation
schema: public
@@ -2045,6 +2213,13 @@
table:
name: allocations
schema: public
- name: employee_team_members
using:
foreign_key_constraint_on:
column: employeeid
table:
name: employee_team_members
schema: public
- name: employee_vacations
using:
foreign_key_constraint_on:
@@ -2218,30 +2393,32 @@
- active:
_eq: true
columns:
- id
- created_at
- updated_at
- jobid
- billid
- bodyshopid
- created_at
- id
- jobid
- message
- metadata
- paymentid
- successful
- message
- bodyshopid
- updated_at
- useremail
select_permissions:
- role: user
permission:
columns:
- successful
- message
- useremail
- created_at
- updated_at
- billid
- bodyshopid
- created_at
- id
- jobid
- message
- metadata
- paymentid
- successful
- updated_at
- useremail
filter:
bodyshop:
associations:

View File

@@ -0,0 +1 @@
DROP TABLE "public"."employee_teams";

View File

@@ -0,0 +1,18 @@
CREATE TABLE "public"."employee_teams" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), "bodyshopid" uuid NOT NULL, "name" text NOT NULL, "active" boolean NOT NULL DEFAULT true, PRIMARY KEY ("id") , FOREIGN KEY ("bodyshopid") REFERENCES "public"."bodyshops"("id") ON UPDATE cascade ON DELETE cascade);
CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"()
RETURNS TRIGGER AS $$
DECLARE
_new record;
BEGIN
_new := NEW;
_new."updated_at" = NOW();
RETURN _new;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "set_public_employee_teams_updated_at"
BEFORE UPDATE ON "public"."employee_teams"
FOR EACH ROW
EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"();
COMMENT ON TRIGGER "set_public_employee_teams_updated_at" ON "public"."employee_teams"
IS 'trigger to set value of column "updated_at" to current timestamp on row update';
CREATE EXTENSION IF NOT EXISTS pgcrypto;

View File

@@ -0,0 +1 @@
DROP TABLE "public"."employee_team_members";

View File

@@ -0,0 +1,18 @@
CREATE TABLE "public"."employee_team_members" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), "teamid" uuid NOT NULL, "employeeid" uuid NOT NULL, "labor_rates" jsonb NOT NULL DEFAULT jsonb_build_object(), "percentage" numeric NOT NULL DEFAULT 0, PRIMARY KEY ("id") , FOREIGN KEY ("teamid") REFERENCES "public"."employee_teams"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("employeeid") REFERENCES "public"."employees"("id") ON UPDATE cascade ON DELETE cascade);
CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"()
RETURNS TRIGGER AS $$
DECLARE
_new record;
BEGIN
_new := NEW;
_new."updated_at" = NOW();
RETURN _new;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "set_public_employee_team_members_updated_at"
BEFORE UPDATE ON "public"."employee_team_members"
FOR EACH ROW
EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"();
COMMENT ON TRIGGER "set_public_employee_team_members_updated_at" ON "public"."employee_team_members"
IS 'trigger to set value of column "updated_at" to current timestamp on row update';
CREATE EXTENSION IF NOT EXISTS pgcrypto;

View File

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

View File

@@ -0,0 +1,2 @@
alter table "public"."exportlog" add column "metadata" jsonb
null;

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."bodyshops" add column "tt_enforce_hours_for_tech_console" boolean
-- not null default 'false';

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "tt_enforce_hours_for_tech_console" boolean
not null default 'false';

View File

@@ -249,10 +249,10 @@ async function QueryCustomersFromDms(socket) {
SerialNumber: socket.JobData.bodyshop.pbs_serialnumber,
//ContactId: "00000000000000000000000000000000",
// ContactCode: socket.JobData.owner.accountingid,
FirstName: socket.JobData.ownr_co_nm
FirstName: socket.JobData.ownr_fn,
LastName: socket.JobData.ownr_co_nm
? socket.JobData.ownr_co_nm
: socket.JobData.ownr_fn,
LastName: socket.JobData.ownr_ln,
: socket.JobData.ownr_ln,
PhoneNumber: socket.JobData.ownr_ph1,
EmailAddress: socket.JobData.ownr_ea,
// ModifiedSince: "0001-01-01T00:00:00.0000000Z",

View File

@@ -224,6 +224,7 @@ async function CdkSelectedCustomer(socket, selectedCustomerId) {
} finally {
//Ensure we always insert logEvents
//GQL to insert logevents.
CdkBase.createLogEvent(
socket,
"DEBUG",
@@ -1213,6 +1214,7 @@ async function GenerateTransWips(socket) {
wips.push(item);
});
socket.transWips = wips;
return wips;
}
@@ -1388,6 +1390,7 @@ async function MarkJobExported(socket, jobid) {
jobid: jobid,
successful: true,
useremail: socket.user.email,
metadata: socket.transWips,
},
bill: {
exported: true,