feature/IO-3587-Commision-Cut - Additional test, layout enhancements

This commit is contained in:
Dave
2026-03-17 10:40:14 -04:00
parent 318a3be786
commit 0b772133b8
8 changed files with 317 additions and 99 deletions

View File

@@ -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 <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
case "currency":
return <div>{Dinero({ amount: Math.round(value * 100) }).toFormat()}</div>;
case "currency": {
const numericValue = toFiniteNumber(value);
if (numericValue === null) {
return null;
}
return <div>{Dinero({ amount: Math.round(numericValue * 100) }).toFormat()}</div>;
}
default:
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
}

View File

@@ -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 (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
<Typography.Text strong>{employeeName}</Typography.Text>
<Tag bordered={false} color="geekblue">
{`${t("employee_teams.fields.allocation")}: ${allocation || "--"}`}
</Tag>
<Tag bordered={false} color={getPayoutMethodTagColor(teamMember.payout_method)}>
{payoutMethod}
</Tag>
</div>
);
};
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);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.length} orientation="horizontal" />
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
>
<Form.Item
label={t("employee_teams.fields.employeeid")}
key={`${index}`}
name={[field.name, "employeeid"]}
rules={[
{
required: true
}
]}
>
<EmployeeSearchSelectComponent options={bodyshop.employees} />
</Form.Item>
<Form.Item
label={t("employee_teams.fields.allocation_percentage")}
key={`${index}`}
name={[field.name, "percentage"]}
rules={[
{
required: true
}
]}
>
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
<Form.Item
label={t("employee_teams.fields.payout_method")}
key={`${index}-payout-method`}
name={[field.name, "payout_method"]}
initialValue="hourly"
rules={[
{
required: true
}
]}
>
<Select options={payoutMethodOptions} />
</Form.Item>
<Form.Item noStyle dependencies={[["employee_team_members", field.name, "payout_method"]]}>
{() => {
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) => (
<div>
<Row gutter={[16, 0]}>
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.employee}>
<Form.Item
label={payoutMethod === "commission" ? `${t(`joblines.fields.lbr_types.${laborType}`)} %` : t(`joblines.fields.lbr_types.${laborType}`)}
key={`${index}-${fieldName}-${laborType}`}
name={[field.name, fieldName, laborType]}
label={t("employee_teams.fields.employeeid")}
key={`${index}`}
name={[field.name, "employeeid"]}
rules={[
{
required: true
}
]}
>
{payoutMethod === "commission" ? (
<InputNumber min={0} max={100} precision={2} />
) : (
<CurrencyInput />
)}
<EmployeeSearchSelectComponent options={bodyshop.employees} />
</Form.Item>
));
}}
</Form.Item>
</Col>
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.allocation}>
<Form.Item
label={t("employee_teams.fields.allocation_percentage")}
key={`${index}`}
name={[field.name, "percentage"]}
rules={[
{
required: true
}
]}
>
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
</Col>
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.payoutMethod}>
<Form.Item
label={t("employee_teams.fields.payout_method")}
key={`${index}-payout-method`}
name={[field.name, "payout_method"]}
initialValue="hourly"
rules={[
{
required: true
}
]}
>
<Select options={payoutMethodOptions} />
</Form.Item>
</Col>
</Row>
<Form.Item noStyle dependencies={[["employee_team_members", field.name, "payout_method"]]}>
{() => {
const payoutMethod =
form.getFieldValue(["employee_team_members", field.name, "payout_method"]) || "hourly";
const fieldName = payoutMethod === "commission" ? "commission_rates" : "labor_rates";
return (
<Row gutter={[16, 0]}>
{LABOR_TYPES.map((laborType) => (
<Col {...TEAM_MEMBER_RATE_FIELD_COLS} key={`${index}-${fieldName}-${laborType}`}>
<Form.Item
label={
t(`joblines.fields.lbr_types.${laborType}`)
}
name={[field.name, fieldName, laborType]}
rules={[
{
required: true
}
]}
>
{payoutMethod === "commission" ? (
<InputNumber min={0} max={100} precision={2} />
) : (
<CurrencyInput />
)}
</Form.Item>
</Col>
))}
</Row>
);
}}
</Form.Item>
</div>
</LayoutFormRow>
</Form.Item>
);

View File

@@ -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
<th>{t("timetickets.fields.cost_center")}</th>
<th>{t("timetickets.fields.ciecacode")}</th>
<th>{t("timetickets.fields.productivehrs")}</th>
<th>{t("timetickets.fields.payout_method")}</th>
<th>{t("timetickets.fields.rate")}</th>
<th>{t("timetickets.fields.amount")}</th>
</tr>
</thead>
<tbody>
{fields.map((field, index) => (
<tr key={field.key}>
<td>
<Form.Item key={`${index}employeeid`} name={[field.name, "employeeid"]}>
<ReadOnlyFormItemComponent type="employee" />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}cost_center`} name={[field.name, "cost_center"]}>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}ciecacode`} name={[field.name, "ciecacode"]}>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}productivehrs`} name={[field.name, "productivehrs"]}>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}rate`} name={[field.name, "rate"]}>
<ReadOnlyFormItemComponent type="currency" />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}payoutamount`} name={[field.name, "payoutamount"]}>
<ReadOnlyFormItemComponent type="currency" />
</Form.Item>
</td>
</tr>
))}
{fields.map((field, index) => {
const payoutMethod = form.getFieldValue(["timetickets", field.name, "payout_context", "payout_method"]);
return (
<tr key={field.key}>
<td>
<Form.Item key={`${index}employeeid`} name={[field.name, "employeeid"]}>
<ReadOnlyFormItemComponent type="employee" />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}cost_center`} name={[field.name, "cost_center"]}>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}ciecacode`} name={[field.name, "ciecacode"]}>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}productivehrs`} name={[field.name, "productivehrs"]}>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>{getPayoutMethodLabel(payoutMethod, t)}</td>
<td>
<Form.Item key={`${index}rate`} name={[field.name, "rate"]}>
<ReadOnlyFormItemComponent type="currency" />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}payoutamount`} name={[field.name, "payoutamount"]}>
<ReadOnlyFormItemComponent type="currency" />
</Form.Item>
</td>
</tr>
);
})}
</tbody>
</table>
<Alert type="success" title={t("timetickets.labels.payrollclaimedtasks")} />

View File

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

View File

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

View File

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

View File

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

View File

@@ -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."
});
});
});