From 0b772133b8a83deb0b0bf3cf624c6692e13fd40c Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 17 Mar 2026 10:40:14 -0400 Subject: [PATCH] feature/IO-3587-Commision-Cut - Additional test, layout enhancements --- .../read-only-form-item.component.jsx | 16 +- .../shop-employee-teams.form.component.jsx | 171 +++++++++++------- .../time-ticket-task-modal.component.jsx | 86 +++++---- .../time-ticket-task-modal.container.jsx | 18 +- client/src/translations/en_us/common.json | 9 + client/src/translations/es/common.json | 9 + client/src/translations/fr/common.json | 9 + server/payroll/payroll.test.js | 98 ++++++++++ 8 files changed, 317 insertions(+), 99 deletions(-) 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" ? ( + + ) : ( + + )} + + + ))} + + ); + }} + + ); 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." + }); + }); });