diff --git a/client/src/components/form-items-formatted/read-only-form-item.component.jsx b/client/src/components/form-items-formatted/read-only-form-item.component.jsx
index fc9a4df56..fc4490528 100644
--- a/client/src/components/form-items-formatted/read-only-form-item.component.jsx
+++ b/client/src/components/form-items-formatted/read-only-form-item.component.jsx
@@ -10,6 +10,11 @@ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
+const toFiniteNumber = (value) => {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : null;
+};
+
const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
if (value === null || value === undefined || value === "") return null;
switch (type) {
@@ -20,8 +25,15 @@ const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
case "text":
return
{value}
;
- case "currency":
- return {Dinero({ amount: Math.round(value * 100) }).toFormat()}
;
+ case "currency": {
+ const numericValue = toFiniteNumber(value);
+
+ if (numericValue === null) {
+ return null;
+ }
+
+ return {Dinero({ amount: Math.round(numericValue * 100) }).toFormat()}
;
+ }
default:
return {value}
;
}
diff --git a/client/src/components/shop-teams/shop-employee-teams.form.component.jsx b/client/src/components/shop-teams/shop-employee-teams.form.component.jsx
index c6557a9de..87cd275e3 100644
--- a/client/src/components/shop-teams/shop-employee-teams.form.component.jsx
+++ b/client/src/components/shop-teams/shop-employee-teams.form.component.jsx
@@ -1,6 +1,6 @@
import { DeleteFilled } from "@ant-design/icons";
import { useMutation, useQuery } from "@apollo/client/react";
-import { Button, Card, Form, Input, InputNumber, Select, Space, Switch, Typography } from "antd";
+import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch, Tag, Typography } from "antd";
import querystring from "query-string";
import { useEffect } from "react";
@@ -35,6 +35,14 @@ const PAYOUT_METHOD_OPTIONS = [
{ labelKey: "employee_teams.options.commission_percentage", value: "commission" }
];
+const TEAM_MEMBER_PRIMARY_FIELD_COLS = {
+ employee: { xs: 24, lg: 13, xxl: 14 },
+ allocation: { xs: 24, sm: 12, lg: 4, xxl: 4 },
+ payoutMethod: { xs: 24, sm: 12, lg: 7, xxl: 6 }
+};
+
+const TEAM_MEMBER_RATE_FIELD_COLS = { xs: 24, sm: 12, md: 8, lg: 6, xxl: 4 };
+
const normalizeTeamMember = (teamMember = {}) => ({
...teamMember,
payout_method: teamMember.payout_method || "hourly",
@@ -52,6 +60,8 @@ const getSplitTotal = (teamMembers = []) =>
const hasExactSplitTotal = (teamMembers = []) => Math.abs(getSplitTotal(teamMembers) - 100) < 0.00001;
+const getPayoutMethodTagColor = (payoutMethod) => (payoutMethod === "commission" ? "gold" : "blue");
+
const getEmployeeDisplayName = (employees = [], employeeId) => {
const employee = employees.find((currentEmployee) => currentEmployee.id === employeeId);
if (!employee) return null;
@@ -102,12 +112,25 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
const teamCardTitle = teamName?.trim() || t("employee_teams.fields.name");
const getTeamMemberTitle = (teamMember = {}) => {
- const employeeName = getEmployeeDisplayName(bodyshop.employees, teamMember.employeeid) || t("employee_teams.fields.employeeid");
- const allocation = formatAllocationPercentage(teamMember.percentage) || t("employee_teams.fields.allocation_percentage");
+ const employeeName =
+ getEmployeeDisplayName(bodyshop.employees, teamMember.employeeid) || t("employee_teams.fields.employeeid");
+ const allocation = formatAllocationPercentage(teamMember.percentage);
const payoutMethod =
- payoutMethodOptions.find((option) => option.value === teamMember.payout_method)?.label || t("employee_teams.fields.payout_method");
+ teamMember.payout_method === "commission"
+ ? t("employee_teams.options.commission")
+ : t("employee_teams.options.hourly");
- return `${employeeName} | ${allocation} | ${payoutMethod}`;
+ return (
+
+ {employeeName}
+
+ {`${t("employee_teams.fields.allocation")}: ${allocation || "--"}`}
+
+
+ {payoutMethod}
+
+
+ );
};
const handleFinish = async ({ employee_team_members = [], ...values }) => {
@@ -158,7 +181,9 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
.filter((teamMember) => teamMember.id === null || teamMember.id === undefined)
.map((teamMember) => ({ ...teamMember, teamid: search.employeeTeamId })),
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members
- .filter((teamMember) => !normalizedTeamMembers.find((currentTeamMember) => currentTeamMember.id === teamMember.id))
+ .filter(
+ (teamMember) => !normalizedTeamMembers.find((currentTeamMember) => currentTeamMember.id === teamMember.id)
+ )
.map((teamMember) => teamMember.id)
}
});
@@ -260,73 +285,95 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
remove(field.name);
}}
/>
-
+
}
>
-
-
-
-
-
-
-
-
-
-
- {() => {
- const payoutMethod =
- form.getFieldValue(["employee_team_members", field.name, "payout_method"]) || "hourly";
- const fieldName = payoutMethod === "commission" ? "commission_rates" : "labor_rates";
-
- return LABOR_TYPES.map((laborType) => (
+
+
+
- {payoutMethod === "commission" ? (
-
- ) : (
-
- )}
+
- ));
- }}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {() => {
+ const payoutMethod =
+ form.getFieldValue(["employee_team_members", field.name, "payout_method"]) || "hourly";
+ const fieldName = payoutMethod === "commission" ? "commission_rates" : "labor_rates";
+
+ return (
+
+ {LABOR_TYPES.map((laborType) => (
+
+
+ {payoutMethod === "commission" ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+ );
+ }}
+
+
);
diff --git a/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.jsx b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.jsx
index d06e2fed6..ab95ce9d1 100644
--- a/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.jsx
+++ b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.jsx
@@ -15,6 +15,18 @@ const mapDispatchToProps = () => ({
});
export default connect(mapStateToProps, mapDispatchToProps)(TimeTicketTaskModalComponent);
+const getPayoutMethodLabel = (payoutMethod, t) => {
+ if (!payoutMethod) {
+ return "";
+ }
+
+ if (payoutMethod === "hourly" || payoutMethod === "commission") {
+ return t(`timetickets.labels.payout_methods.${payoutMethod}`);
+ }
+
+ return payoutMethod;
+};
+
export function TimeTicketTaskModalComponent({ bodyshop, form, loading, completedTasks, unassignedHours }) {
const { t } = useTranslation();
@@ -101,45 +113,51 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
{t("timetickets.fields.cost_center")} |
{t("timetickets.fields.ciecacode")} |
{t("timetickets.fields.productivehrs")} |
+ {t("timetickets.fields.payout_method")} |
{t("timetickets.fields.rate")} |
{t("timetickets.fields.amount")} |
- {fields.map((field, index) => (
-
- |
-
-
-
- |
-
-
-
-
- |
-
-
-
-
- |
-
-
-
-
- |
-
-
-
-
- |
-
-
-
-
- |
-
- ))}
+ {fields.map((field, index) => {
+ const payoutMethod = form.getFieldValue(["timetickets", field.name, "payout_context", "payout_method"]);
+
+ return (
+
+ |
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+ |
+ {getPayoutMethodLabel(payoutMethod, t)} |
+
+
+
+
+ |
+
+
+
+
+ |
+
+ );
+ })}
diff --git a/client/src/components/time-ticket-task-modal/time-ticket-task-modal.container.jsx b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.container.jsx
index f69d68523..3e4b62ace 100644
--- a/client/src/components/time-ticket-task-modal/time-ticket-task-modal.container.jsx
+++ b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.container.jsx
@@ -25,6 +25,22 @@ const mapDispatchToProps = (dispatch) => ({
});
export default connect(mapStateToProps, mapDispatchToProps)(TimeTickeTaskModalContainer);
+const toFiniteNumber = (value) => {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : null;
+};
+
+const getPreviewPayoutAmount = (ticket) => {
+ const productiveHours = toFiniteNumber(ticket?.productivehrs);
+ const rate = toFiniteNumber(ticket?.rate);
+
+ if (productiveHours === null || rate === null) {
+ return null;
+ }
+
+ return productiveHours * rate;
+};
+
export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicketTasksModal, toggleModalVisible }) {
const [form] = Form.useForm();
const { context, open, actions } = timeTicketTasksModal;
@@ -93,7 +109,7 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke
form.setFieldsValue({
timetickets: (data.ticketsToInsert || []).map((ticket) => ({
...ticket,
- payoutamount: Number(ticket.productivehrs || 0) * Number(ticket.rate || 0)
+ payoutamount: getPreviewPayoutAmount(ticket)
}))
});
setUnassignedHours(data.unassignedHours);
diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json
index e1deb78f5..96a415f8a 100644
--- a/client/src/translations/en_us/common.json
+++ b/client/src/translations/en_us/common.json
@@ -1183,6 +1183,7 @@
},
"fields": {
"active": "Active",
+ "allocation": "Allocation",
"allocation_percentage": "Allocation %",
"employeeid": "Employee",
"max_load": "Max Load",
@@ -1194,6 +1195,7 @@
"allocation_total": "Allocation Total: {{total}}%"
},
"options": {
+ "commission": "Commission",
"commission_percentage": "Commission %",
"hourly": "Hourly"
}
@@ -3610,6 +3612,7 @@
},
"fields": {
"actualhrs": "Actual Hours",
+ "amount": "Amount",
"ciecacode": "CIECA Code",
"clockhours": "Clock Hours",
"clockoff": "Clock Off",
@@ -3625,7 +3628,9 @@
"flat_rate": "Flat Rate?",
"memo": "Memo",
"pay": "Pay",
+ "payout_method": "Payout Method",
"productivehrs": "Productive Hours",
+ "rate": "Rate",
"ro_number": "Job to Post Against",
"task_name": "Task"
},
@@ -3644,6 +3649,10 @@
"lunch": "Lunch",
"new": "New Time Ticket",
"payrollclaimedtasks": "These time tickets will be automatically entered to the system as a part of claiming this task. These numbers are calculated using the jobs assigned lines. If lines are unassigned, they will be excluded from created tickets.",
+ "payout_methods": {
+ "commission": "Commission",
+ "hourly": "Hourly"
+ },
"pmbreak": "PM Break",
"pmshift": "PM Shift",
"shift": "Shift",
diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json
index e31108ab0..2cc78314f 100644
--- a/client/src/translations/es/common.json
+++ b/client/src/translations/es/common.json
@@ -1183,6 +1183,7 @@
},
"fields": {
"active": "",
+ "allocation": "",
"allocation_percentage": "",
"employeeid": "",
"max_load": "",
@@ -1194,6 +1195,7 @@
"allocation_total": ""
},
"options": {
+ "commission": "",
"commission_percentage": "",
"hourly": ""
}
@@ -3610,6 +3612,7 @@
},
"fields": {
"actualhrs": "",
+ "amount": "",
"ciecacode": "",
"clockhours": "",
"clockoff": "",
@@ -3625,7 +3628,9 @@
"flat_rate": "",
"memo": "",
"pay": "",
+ "payout_method": "",
"productivehrs": "",
+ "rate": "",
"ro_number": "",
"task_name": ""
},
@@ -3644,6 +3649,10 @@
"lunch": "",
"new": "",
"payrollclaimedtasks": "",
+ "payout_methods": {
+ "commission": "",
+ "hourly": ""
+ },
"pmbreak": "",
"pmshift": "",
"shift": "",
diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json
index 142c0bf5a..6d8c56c13 100644
--- a/client/src/translations/fr/common.json
+++ b/client/src/translations/fr/common.json
@@ -1183,6 +1183,7 @@
},
"fields": {
"active": "",
+ "allocation": "",
"allocation_percentage": "",
"employeeid": "",
"max_load": "",
@@ -1194,6 +1195,7 @@
"allocation_total": ""
},
"options": {
+ "commission": "",
"commission_percentage": "",
"hourly": ""
}
@@ -3610,6 +3612,7 @@
},
"fields": {
"actualhrs": "",
+ "amount": "",
"ciecacode": "",
"clockhours": "",
"clockoff": "",
@@ -3625,7 +3628,9 @@
"flat_rate": "",
"memo": "",
"pay": "",
+ "payout_method": "",
"productivehrs": "",
+ "rate": "",
"ro_number": "",
"task_name": ""
},
@@ -3644,6 +3649,10 @@
"lunch": "",
"new": "",
"payrollclaimedtasks": "",
+ "payout_methods": {
+ "commission": "",
+ "hourly": ""
+ },
"pmbreak": "",
"pmshift": "",
"shift": "",
diff --git a/server/payroll/payroll.test.js b/server/payroll/payroll.test.js
index 1e3735df2..3a0a105e8 100644
--- a/server/payroll/payroll.test.js
+++ b/server/payroll/payroll.test.js
@@ -158,6 +158,22 @@ describe("payroll payout helpers", () => {
)
).toThrow("Missing commission percent for Jane Doe on labor type LAA.");
});
+
+ it("throws a useful error when an hourly payout rate is missing", () => {
+ expect(() =>
+ payAllModule.BuildPayoutDetails(
+ {},
+ {
+ labor_rates: {},
+ employee: {
+ first_name: "John",
+ last_name: "Smith"
+ }
+ },
+ "LAB"
+ )
+ ).toThrow("Missing hourly payout rate for John Smith on labor type LAB.");
+ });
});
describe("payroll routes", () => {
@@ -364,4 +380,86 @@ describe("payroll routes", () => {
error: "Task preset percentages for labor type LAA total 110% and cannot exceed 100%."
});
});
+
+ it("rejects claim-task when an assigned team member is missing the hourly rate for the selected labor type", async () => {
+ const job = buildBaseJob({
+ bodyshop: {
+ id: "shop-1",
+ md_responsibility_centers: {
+ defaults: {
+ costs: {
+ LAB: "Body"
+ }
+ }
+ },
+ md_tasks_presets: {
+ presets: [
+ {
+ name: "Teardown",
+ hourstype: ["LAB"],
+ percent: 100,
+ nextstatus: "In Progress",
+ memo: "Teardown"
+ }
+ ]
+ },
+ employee_teams: [
+ {
+ id: "team-1",
+ employee_team_members: [
+ {
+ percentage: 50,
+ labor_rates: {
+ LAB: 45
+ },
+ employee: {
+ id: "emp-1",
+ first_name: "Configured",
+ last_name: "Tech"
+ }
+ },
+ {
+ percentage: 50,
+ labor_rates: {},
+ employee: {
+ id: "emp-2",
+ first_name: "Missing",
+ last_name: "Rate"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ joblines: [
+ {
+ mod_lbr_ty: "LAB",
+ mod_lb_hrs: 4.4,
+ assigned_team: "team-1",
+ convertedtolbr: false
+ }
+ ]
+ });
+
+ const { client, req, res } = buildReqRes({
+ job,
+ body: {
+ task: "Teardown",
+ calculateOnly: true,
+ employee: {
+ name: "Dave",
+ email: "dave@rome.test"
+ }
+ }
+ });
+
+ await claimTaskModule.claimtask(req, res);
+
+ expect(client.request).toHaveBeenCalledTimes(1);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ success: false,
+ error: "Missing hourly payout rate for Missing Rate on labor type LAB."
+ });
+ });
});