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)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
const toFiniteNumber = (value) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => { const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
if (value === null || value === undefined || value === "") return null; if (value === null || value === undefined || value === "") return null;
switch (type) { switch (type) {
@@ -20,8 +25,15 @@ const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
case "text": case "text":
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>; return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
case "currency": case "currency": {
return <div>{Dinero({ amount: Math.round(value * 100) }).toFormat()}</div>; const numericValue = toFiniteNumber(value);
if (numericValue === null) {
return null;
}
return <div>{Dinero({ amount: Math.round(numericValue * 100) }).toFormat()}</div>;
}
default: default:
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>; return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
} }

View File

@@ -1,6 +1,6 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { useMutation, useQuery } from "@apollo/client/react"; 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 querystring from "query-string";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -35,6 +35,14 @@ const PAYOUT_METHOD_OPTIONS = [
{ labelKey: "employee_teams.options.commission_percentage", value: "commission" } { 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 = {}) => ({ const normalizeTeamMember = (teamMember = {}) => ({
...teamMember, ...teamMember,
payout_method: teamMember.payout_method || "hourly", payout_method: teamMember.payout_method || "hourly",
@@ -52,6 +60,8 @@ const getSplitTotal = (teamMembers = []) =>
const hasExactSplitTotal = (teamMembers = []) => Math.abs(getSplitTotal(teamMembers) - 100) < 0.00001; const hasExactSplitTotal = (teamMembers = []) => Math.abs(getSplitTotal(teamMembers) - 100) < 0.00001;
const getPayoutMethodTagColor = (payoutMethod) => (payoutMethod === "commission" ? "gold" : "blue");
const getEmployeeDisplayName = (employees = [], employeeId) => { const getEmployeeDisplayName = (employees = [], employeeId) => {
const employee = employees.find((currentEmployee) => currentEmployee.id === employeeId); const employee = employees.find((currentEmployee) => currentEmployee.id === employeeId);
if (!employee) return null; if (!employee) return null;
@@ -102,12 +112,25 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
const teamCardTitle = teamName?.trim() || t("employee_teams.fields.name"); const teamCardTitle = teamName?.trim() || t("employee_teams.fields.name");
const getTeamMemberTitle = (teamMember = {}) => { const getTeamMemberTitle = (teamMember = {}) => {
const employeeName = getEmployeeDisplayName(bodyshop.employees, teamMember.employeeid) || t("employee_teams.fields.employeeid"); const employeeName =
const allocation = formatAllocationPercentage(teamMember.percentage) || t("employee_teams.fields.allocation_percentage"); getEmployeeDisplayName(bodyshop.employees, teamMember.employeeid) || t("employee_teams.fields.employeeid");
const allocation = formatAllocationPercentage(teamMember.percentage);
const payoutMethod = 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 }) => { const handleFinish = async ({ employee_team_members = [], ...values }) => {
@@ -158,7 +181,9 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
.filter((teamMember) => teamMember.id === null || teamMember.id === undefined) .filter((teamMember) => teamMember.id === null || teamMember.id === undefined)
.map((teamMember) => ({ ...teamMember, teamid: search.employeeTeamId })), .map((teamMember) => ({ ...teamMember, teamid: search.employeeTeamId })),
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members 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) .map((teamMember) => teamMember.id)
} }
}); });
@@ -260,73 +285,95 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
remove(field.name); remove(field.name);
}} }}
/> />
<FormListMoveArrows move={move} index={index} total={fields.length} orientation="horizontal" /> <FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space> </Space>
} }
> >
<Form.Item <div>
label={t("employee_teams.fields.employeeid")} <Row gutter={[16, 0]}>
key={`${index}`} <Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.employee}>
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) => (
<Form.Item <Form.Item
label={payoutMethod === "commission" ? `${t(`joblines.fields.lbr_types.${laborType}`)} %` : t(`joblines.fields.lbr_types.${laborType}`)} label={t("employee_teams.fields.employeeid")}
key={`${index}-${fieldName}-${laborType}`} key={`${index}`}
name={[field.name, fieldName, laborType]} name={[field.name, "employeeid"]}
rules={[ rules={[
{ {
required: true required: true
} }
]} ]}
> >
{payoutMethod === "commission" ? ( <EmployeeSearchSelectComponent options={bodyshop.employees} />
<InputNumber min={0} max={100} precision={2} />
) : (
<CurrencyInput />
)}
</Form.Item> </Form.Item>
)); </Col>
}} <Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.allocation}>
</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>
</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> </LayoutFormRow>
</Form.Item> </Form.Item>
); );

View File

@@ -15,6 +15,18 @@ const mapDispatchToProps = () => ({
}); });
export default connect(mapStateToProps, mapDispatchToProps)(TimeTicketTaskModalComponent); 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 }) { export function TimeTicketTaskModalComponent({ bodyshop, form, loading, completedTasks, unassignedHours }) {
const { t } = useTranslation(); 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.cost_center")}</th>
<th>{t("timetickets.fields.ciecacode")}</th> <th>{t("timetickets.fields.ciecacode")}</th>
<th>{t("timetickets.fields.productivehrs")}</th> <th>{t("timetickets.fields.productivehrs")}</th>
<th>{t("timetickets.fields.payout_method")}</th>
<th>{t("timetickets.fields.rate")}</th> <th>{t("timetickets.fields.rate")}</th>
<th>{t("timetickets.fields.amount")}</th> <th>{t("timetickets.fields.amount")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{fields.map((field, index) => ( {fields.map((field, index) => {
<tr key={field.key}> const payoutMethod = form.getFieldValue(["timetickets", field.name, "payout_context", "payout_method"]);
<td>
<Form.Item key={`${index}employeeid`} name={[field.name, "employeeid"]}> return (
<ReadOnlyFormItemComponent type="employee" /> <tr key={field.key}>
</Form.Item> <td>
</td> <Form.Item key={`${index}employeeid`} name={[field.name, "employeeid"]}>
<td> <ReadOnlyFormItemComponent type="employee" />
<Form.Item key={`${index}cost_center`} name={[field.name, "cost_center"]}> </Form.Item>
<ReadOnlyFormItemComponent /> </td>
</Form.Item> <td>
</td> <Form.Item key={`${index}cost_center`} name={[field.name, "cost_center"]}>
<td> <ReadOnlyFormItemComponent />
<Form.Item key={`${index}ciecacode`} name={[field.name, "ciecacode"]}> </Form.Item>
<ReadOnlyFormItemComponent /> </td>
</Form.Item> <td>
</td> <Form.Item key={`${index}ciecacode`} name={[field.name, "ciecacode"]}>
<td> <ReadOnlyFormItemComponent />
<Form.Item key={`${index}productivehrs`} name={[field.name, "productivehrs"]}> </Form.Item>
<ReadOnlyFormItemComponent /> </td>
</Form.Item> <td>
</td> <Form.Item key={`${index}productivehrs`} name={[field.name, "productivehrs"]}>
<td> <ReadOnlyFormItemComponent />
<Form.Item key={`${index}rate`} name={[field.name, "rate"]}> </Form.Item>
<ReadOnlyFormItemComponent type="currency" /> </td>
</Form.Item> <td>{getPayoutMethodLabel(payoutMethod, t)}</td>
</td> <td>
<td> <Form.Item key={`${index}rate`} name={[field.name, "rate"]}>
<Form.Item key={`${index}payoutamount`} name={[field.name, "payoutamount"]}> <ReadOnlyFormItemComponent type="currency" />
<ReadOnlyFormItemComponent type="currency" /> </Form.Item>
</Form.Item> </td>
</td> <td>
</tr> <Form.Item key={`${index}payoutamount`} name={[field.name, "payoutamount"]}>
))} <ReadOnlyFormItemComponent type="currency" />
</Form.Item>
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
<Alert type="success" title={t("timetickets.labels.payrollclaimedtasks")} /> <Alert type="success" title={t("timetickets.labels.payrollclaimedtasks")} />

View File

@@ -25,6 +25,22 @@ const mapDispatchToProps = (dispatch) => ({
}); });
export default connect(mapStateToProps, mapDispatchToProps)(TimeTickeTaskModalContainer); 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 }) { export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicketTasksModal, toggleModalVisible }) {
const [form] = Form.useForm(); const [form] = Form.useForm();
const { context, open, actions } = timeTicketTasksModal; const { context, open, actions } = timeTicketTasksModal;
@@ -93,7 +109,7 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke
form.setFieldsValue({ form.setFieldsValue({
timetickets: (data.ticketsToInsert || []).map((ticket) => ({ timetickets: (data.ticketsToInsert || []).map((ticket) => ({
...ticket, ...ticket,
payoutamount: Number(ticket.productivehrs || 0) * Number(ticket.rate || 0) payoutamount: getPreviewPayoutAmount(ticket)
})) }))
}); });
setUnassignedHours(data.unassignedHours); setUnassignedHours(data.unassignedHours);

View File

@@ -1183,6 +1183,7 @@
}, },
"fields": { "fields": {
"active": "Active", "active": "Active",
"allocation": "Allocation",
"allocation_percentage": "Allocation %", "allocation_percentage": "Allocation %",
"employeeid": "Employee", "employeeid": "Employee",
"max_load": "Max Load", "max_load": "Max Load",
@@ -1194,6 +1195,7 @@
"allocation_total": "Allocation Total: {{total}}%" "allocation_total": "Allocation Total: {{total}}%"
}, },
"options": { "options": {
"commission": "Commission",
"commission_percentage": "Commission %", "commission_percentage": "Commission %",
"hourly": "Hourly" "hourly": "Hourly"
} }
@@ -3610,6 +3612,7 @@
}, },
"fields": { "fields": {
"actualhrs": "Actual Hours", "actualhrs": "Actual Hours",
"amount": "Amount",
"ciecacode": "CIECA Code", "ciecacode": "CIECA Code",
"clockhours": "Clock Hours", "clockhours": "Clock Hours",
"clockoff": "Clock Off", "clockoff": "Clock Off",
@@ -3625,7 +3628,9 @@
"flat_rate": "Flat Rate?", "flat_rate": "Flat Rate?",
"memo": "Memo", "memo": "Memo",
"pay": "Pay", "pay": "Pay",
"payout_method": "Payout Method",
"productivehrs": "Productive Hours", "productivehrs": "Productive Hours",
"rate": "Rate",
"ro_number": "Job to Post Against", "ro_number": "Job to Post Against",
"task_name": "Task" "task_name": "Task"
}, },
@@ -3644,6 +3649,10 @@
"lunch": "Lunch", "lunch": "Lunch",
"new": "New Time Ticket", "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.", "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", "pmbreak": "PM Break",
"pmshift": "PM Shift", "pmshift": "PM Shift",
"shift": "Shift", "shift": "Shift",

View File

@@ -1183,6 +1183,7 @@
}, },
"fields": { "fields": {
"active": "", "active": "",
"allocation": "",
"allocation_percentage": "", "allocation_percentage": "",
"employeeid": "", "employeeid": "",
"max_load": "", "max_load": "",
@@ -1194,6 +1195,7 @@
"allocation_total": "" "allocation_total": ""
}, },
"options": { "options": {
"commission": "",
"commission_percentage": "", "commission_percentage": "",
"hourly": "" "hourly": ""
} }
@@ -3610,6 +3612,7 @@
}, },
"fields": { "fields": {
"actualhrs": "", "actualhrs": "",
"amount": "",
"ciecacode": "", "ciecacode": "",
"clockhours": "", "clockhours": "",
"clockoff": "", "clockoff": "",
@@ -3625,7 +3628,9 @@
"flat_rate": "", "flat_rate": "",
"memo": "", "memo": "",
"pay": "", "pay": "",
"payout_method": "",
"productivehrs": "", "productivehrs": "",
"rate": "",
"ro_number": "", "ro_number": "",
"task_name": "" "task_name": ""
}, },
@@ -3644,6 +3649,10 @@
"lunch": "", "lunch": "",
"new": "", "new": "",
"payrollclaimedtasks": "", "payrollclaimedtasks": "",
"payout_methods": {
"commission": "",
"hourly": ""
},
"pmbreak": "", "pmbreak": "",
"pmshift": "", "pmshift": "",
"shift": "", "shift": "",

View File

@@ -1183,6 +1183,7 @@
}, },
"fields": { "fields": {
"active": "", "active": "",
"allocation": "",
"allocation_percentage": "", "allocation_percentage": "",
"employeeid": "", "employeeid": "",
"max_load": "", "max_load": "",
@@ -1194,6 +1195,7 @@
"allocation_total": "" "allocation_total": ""
}, },
"options": { "options": {
"commission": "",
"commission_percentage": "", "commission_percentage": "",
"hourly": "" "hourly": ""
} }
@@ -3610,6 +3612,7 @@
}, },
"fields": { "fields": {
"actualhrs": "", "actualhrs": "",
"amount": "",
"ciecacode": "", "ciecacode": "",
"clockhours": "", "clockhours": "",
"clockoff": "", "clockoff": "",
@@ -3625,7 +3628,9 @@
"flat_rate": "", "flat_rate": "",
"memo": "", "memo": "",
"pay": "", "pay": "",
"payout_method": "",
"productivehrs": "", "productivehrs": "",
"rate": "",
"ro_number": "", "ro_number": "",
"task_name": "" "task_name": ""
}, },
@@ -3644,6 +3649,10 @@
"lunch": "", "lunch": "",
"new": "", "new": "",
"payrollclaimedtasks": "", "payrollclaimedtasks": "",
"payout_methods": {
"commission": "",
"hourly": ""
},
"pmbreak": "", "pmbreak": "",
"pmshift": "", "pmshift": "",
"shift": "", "shift": "",

View File

@@ -158,6 +158,22 @@ describe("payroll payout helpers", () => {
) )
).toThrow("Missing commission percent for Jane Doe on labor type LAA."); ).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", () => { describe("payroll routes", () => {
@@ -364,4 +380,86 @@ describe("payroll routes", () => {
error: "Task preset percentages for labor type LAA total 110% and cannot exceed 100%." 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."
});
});
}); });