feature/IO-3587-Commision-Cut - Additional Testing / Test Harness improvements

This commit is contained in:
Dave
2026-03-18 16:50:57 -04:00
parent 782fa8a1c7
commit 3f03157834
13 changed files with 2227 additions and 45 deletions

View File

@@ -0,0 +1,424 @@
# Commission-Based Cut Feature Manual Test Plan
## Purpose
Use this guide to manually test the commission-based cut feature from an end-user point of view.
This plan is written for a non-technical tester. Follow the steps exactly as written and mark each scenario as Pass or Fail.
## What You Need Before You Start
- A login that can open `Manage my Shop`, `Jobs`, and `Time Tickets`.
- At least 2 active employees in the shop.
- At least 1 converted repair order that already has labor lines on it.
- If possible, use a simple test job where the labor sale rates are easy to calculate.
- A notebook, spreadsheet, or screenshot folder to record what happened.
## Recommended Easy-Math Test Data
If you can choose your own test job, use something simple like this:
- Body sale rate: `$100.00`
- Refinish sale rate: `$120.00`
- Mechanical sale rate: `$80.00`
- 1 Body labor line with `10.0` hours
- 1 Refinish labor line with `4.0` hours
This makes the expected payout easy to check:
- `40%` of `$100.00` = `$40.00`
- `30%` of `$120.00` = `$36.00`
## Important Navigation Notes
- Team setup is under `Manage my Shop` > `Employee Teams`.
- Team assignment happens on the job line grid in the `Team` column.
- Automatic payout happens from the job's `Labor Allocations` card using the `Pay All` button.
- If your shop uses task presets, the `Flag Hours` button can preview the payout method before committing tickets.
---
## Scenario 1: Create a Simple Commission Team
### Goal
Confirm a team member can be set to commission and saved successfully.
### Steps
1. Sign in.
2. Click `Manage my Shop`.
3. Click the `Employee Teams` tab.
4. Click `New Team`.
5. In `Team Name`, type `Commission Team Test`.
6. Make sure `Active` is turned on.
7. In `Max Load`, enter `10`.
8. Click `New Team Member`.
9. In `Employee`, choose an active employee.
10. In `Allocation %`, enter `100`.
11. In `Payout Method`, choose `Commission %`.
12. In each commission field that appears, enter a value.
13. For the main labor types you plan to test, use these values:
14. Enter `40` for Body.
15. Enter `30` for Refinish.
16. Enter `25` for Mechanical.
17. Enter `20` for Frame.
18. Enter `15` for Glass.
19. Fill in the remaining commission boxes with any valid number from `0` to `100`.
20. Click `Save`.
### Expected Result
- The team saves successfully.
- The team stays visible in the Employee Teams list.
- The team member card shows a `Commission` tag.
- The `Allocation Total` shows `100%`.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 2: Allocation Total Must Equal 100%
### Goal
Confirm the system blocks a team that does not total exactly 100%.
### Steps
1. Stay on the same team.
2. Change `Allocation %` from `100` to `90`.
3. Click `Save`.
4. Change `Allocation %` from `90` to `110`.
5. Click `Save`.
6. Change `Allocation %` back to `100`.
7. Click `Save` again.
### Expected Result
- When the total is `90%`, the system should not save.
- When the total is `110%`, the system should not save.
- The page should show that the allocation total is not correct.
- When the total is set back to `100%`, the save should succeed.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 3: The Same Employee Cannot Be Added Twice
### Goal
Confirm the same employee cannot appear twice on one team.
### Steps
1. Open the same team again.
2. Click `New Team Member`.
3. Choose the same employee already used on the team.
4. Enter any valid allocation amount.
5. Choose `Commission %`.
6. Fill in all required commission fields.
7. Click `Save`.
### Expected Result
- The system should block the save.
- The team should not save with the same employee listed twice.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 4: Switching Between Hourly and Commission Changes the Input Style
### Goal
Confirm the rate section changes correctly when the payout method changes.
### Steps
1. Open the same team again.
2. On the team member row, change `Payout Method` from `Commission %` to `Hourly`.
3. Look at the rate fields that appear.
4. Change `Payout Method` back to `Commission %`.
5. Look at the rate fields again.
### Expected Result
- In `Hourly` mode, the rate boxes should behave like money/rate fields.
- In `Commission %` mode, the rate boxes should behave like percentage fields.
- The screen should clearly show you are editing the correct type of value.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 5: Boundary Values for Commission %
### Goal
Confirm the feature accepts valid boundary values and blocks invalid ones.
### Steps
1. Open the team again.
2. In one commission box, enter `0`.
3. In another commission box, enter `100`.
4. Click `Save`.
5. Try to type a value above `100` in one of the commission boxes.
6. Try to type a negative value in one of the commission boxes.
### Expected Result
- `0` should be accepted.
- `100` should be accepted.
- Values above `100` should not be allowed or should fail validation.
- Negative values should not be allowed or should fail validation.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 6: Inactive Teams Should Not Be Offered for New Assignment
### Goal
Confirm inactive teams do not appear as normal team choices.
### Steps
1. Open the team again.
2. Turn `Active` off.
3. Click `Save`.
4. Open a converted repair order.
5. Go to the job lines area where the `Team` column is visible.
6. Click inside the `Team` field on any labor line.
7. Open the team drop-down list.
8. Look for `Commission Team Test`.
9. Go back to `Manage my Shop` > `Employee Teams`.
10. Turn `Active` back on.
11. Click `Save`.
12. Return to the same job line and open the `Team` drop-down again.
### Expected Result
- When the team is inactive, it should not appear as a normal assignment choice.
- After turning it back on, it should appear again.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 7: Assign the Commission Team to a Labor Line
### Goal
Confirm the team can be assigned to a job line from the job screen.
### Steps
1. Open a converted repair order that has labor lines.
2. Find a labor line in the job line grid.
3. In the `Team` column, click the blank area or the current team name.
4. From the drop-down list, choose `Commission Team Test`.
5. Click outside the field so it saves.
6. Repeat for at least 1 Body line and 1 Refinish line if both exist.
### Expected Result
- The selected team name should appear in the `Team` column.
- The assignment should stay in place after the screen refreshes.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 8: Pay All Creates Commission-Based Tickets
### Goal
Confirm `Pay All` creates time tickets using the commission rate, not a flat hourly rate.
### Steps
1. Use a converted repair order that has:
2. At least 1 labor line assigned to `Commission Team Test`.
3. Known labor sale rates on the job.
4. No existing time tickets for the same employee and labor type.
5. Open that repair order.
6. Go to the labor/payroll area where the `Labor Allocations` card is visible.
7. Write down the following before you click anything:
8. The labor type on the line.
9. The sold labor rate for that labor type.
10. The hours on that line.
11. The commission % you entered for that labor type on the team.
12. Click `Pay All`.
13. Wait for the success message.
14. Look at the `Time Tickets` list on the same screen.
15. Find the new ticket created for that employee.
### Expected Result
- The system should show `All hours paid out successfully.`
- A new time ticket should appear.
- The ticket rate should equal:
- `sale rate x commission %`
- Example: if Body sale rate is `$100.00` and commission is `40%`, the ticket rate should be `$40.00`.
- The productive hours should match the assigned labor hours for that employee.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 9: Different Labor Types Use Different Commission Rates
### Goal
Confirm the feature uses the correct commission % for each labor type.
### Steps
1. Use a job that has at least:
2. One Body labor line.
3. One Refinish labor line.
4. Make sure both lines are assigned to `Commission Team Test`.
5. Confirm your team is set up like this:
6. Body = `40%`
7. Refinish = `30%`
8. Open the job's `Labor Allocations` area.
9. Click `Pay All`.
10. Review the new time tickets that are created.
### Expected Result
- The Body ticket should use the Body commission %.
- The Refinish ticket should use the Refinish commission %.
- Example:
- If Body sale rate is `$100.00`, Body payout rate should be `$40.00`.
- If Refinish sale rate is `$120.00`, Refinish payout rate should be `$36.00`.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 10: Mixed Team With Commission and Hourly Members
### Goal
Confirm one team can contain both commission and hourly members, and each person is paid correctly.
### Steps
1. Open `Manage my Shop` > `Employee Teams`.
2. Open `Commission Team Test`.
3. Edit the first team member:
4. Keep Employee 1 as `Commission %`.
5. Change `Allocation %` to `60`.
6. Make sure Body commission is still `40`.
7. Add a second team member.
8. Choose a different active employee.
9. Set `Allocation %` to `40`.
10. Set `Payout Method` to `Hourly`.
11. Enter an hourly rate for each required labor type.
12. For Body, use `$25.00`.
13. Fill in the other required hourly boxes with valid values.
14. Make sure the total allocation shows `100%`.
15. Click `Save`.
16. Assign this team to a Body line with `10.0` hours.
17. Click `Pay All`.
18. Review the new time tickets.
### Expected Result
- Employee 1 should receive `60%` of the hours at the commission-derived rate.
- Employee 2 should receive `40%` of the hours at the hourly rate you entered.
- Example with a 10-hour Body line and `$100.00` sale rate:
- Employee 1 should get `6.0` hours at `$40.00`.
- Employee 2 should get `4.0` hours at `$25.00`.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 11: Pay All Only Adds the Remaining Hours
### Goal
Confirm `Pay All` does not duplicate hours that were already paid.
### Steps
1. Use a job with one Body line assigned to `Commission Team Test`.
2. Make sure the line has `10.0` hours.
3. In the `Time Tickets` card, click `Enter New Time Ticket`.
4. Create a manual time ticket for the same employee and the same labor type.
5. Enter `4.0` productive hours.
6. Save the manual time ticket.
7. Go back to the `Labor Allocations` card.
8. Click `Pay All`.
9. Review the new ticket that is created.
### Expected Result
- The system should only create the remaining unpaid hours.
- In this example, it should add `6.0` hours, not `10.0`.
- The payout rate should still use the current commission-based rate.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 12: Unassigned Labor Lines Should Block Automatic Payout
### Goal
Confirm `Pay All` does not silently pay lines that do not have a team assigned.
### Steps
1. Open a converted repair order with at least 2 labor lines.
2. Assign `Commission Team Test` to one line.
3. Leave the second labor line with no team assigned.
4. Go to the `Labor Allocations` card.
5. Click `Pay All`.
### Expected Result
- The system should not quietly pay everything.
- You should see an error telling you that not all hours have been assigned.
- The unassigned line should still need manual attention.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 13: Flag Hours Preview Shows the Correct Payout Method
### Goal
If your shop uses task presets, confirm the preview shows `Commission` for commission-based tickets.
### Steps
1. Open a converted repair order.
2. Go to the `Time Tickets` card.
3. Click `Flag Hours`.
4. Choose a task preset.
5. Wait for the preview table to load.
6. Review the `Payout Method` column in the preview.
7. If the preview includes more than one employee, review each row.
### Expected Result
- The preview table should load without error.
- Rows for commission-based employees should show `Commission`.
- Rows for hourly employees should show `Hourly`.
- If there are unassigned hours, a warning should appear.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Quick Regression Checklist
- [ ] I can create a commission-based team.
- [ ] Allocation must total exactly 100%.
- [ ] The same employee cannot be added twice to one team.
- [ ] Inactive teams do not appear for normal assignment.
- [ ] A team can be assigned to job lines from the `Team` column.
- [ ] `Pay All` creates commission-based tickets correctly.
- [ ] Different labor types use different commission percentages.
- [ ] Mixed commission and hourly teams calculate correctly.
- [ ] `Pay All` only creates the remaining unpaid hours.
- [ ] Unassigned labor lines stop automatic payout.
- [ ] `Flag Hours` preview shows the correct payout method.
## Tester Sign-Off
- Tester name:
- Test date:
- Environment:
- Overall result:
- Follow-up issues found:

View File

@@ -1,7 +1,11 @@
import { Button } from "antd";
import { Button, Card, Divider, Form, Space, Typography } from "antd";
import { connect } from "react-redux";
import queryString from "query-string";
import { useLocation } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { setModalContext } from "../../redux/modals/modals.actions";
import { PayrollLaborAllocationsTable } from "../labor-allocations-table/labor-allocations-table.payroll.component.jsx";
import { TimeTicketTaskModalComponent } from "../time-ticket-task-modal/time-ticket-task-modal.component.jsx";
const mapStateToProps = createStructuredSelector({});
@@ -9,8 +13,109 @@ const mapDispatchToProps = (dispatch) => ({
setRefundPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "refund_payment" }))
});
const commissionCutFixture = {
bodyshop: {
features: {
timetickets: true
},
employees: [
{ id: "emp-1", first_name: "Avery", last_name: "Johnson" },
{ id: "emp-2", first_name: "Morgan", last_name: "Lee" }
],
md_tasks_presets: {
presets: [
{
name: "Body Prep",
percent: 50,
hourstype: ["LAA", "LAB"],
nextstatus: "In Progress"
}
]
}
},
jobId: "fixture-job-1",
joblines: [
{
id: "line-1",
mod_lbr_ty: "LAA",
mod_lb_hrs: 4,
assigned_team: "team-1",
convertedtolbr: false
}
],
previewValues: {
task: "Body Prep",
timetickets: [
{
employeeid: "emp-1",
cost_center: "Body",
ciecacode: "LAA",
productivehrs: 2,
rate: 40,
payoutamount: 80,
payout_context: {
payout_method: "commission"
}
},
{
employeeid: "emp-2",
cost_center: "Refinish",
ciecacode: "LAB",
productivehrs: 1,
rate: 28,
payoutamount: 28,
payout_context: {
payout_method: "hourly"
}
}
]
}
};
function CommissionCutHarness() {
const [form] = Form.useForm();
return (
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<Typography.Title level={2}>Commission Cut Test Harness</Typography.Title>
<Typography.Paragraph>
This fixture keeps commission-cut browser checks stable by rendering representative payroll and preview UI with
local data.
</Typography.Paragraph>
<Card title="Payroll Labor Allocations">
<PayrollLaborAllocationsTable
jobId={commissionCutFixture.jobId}
joblines={commissionCutFixture.joblines}
timetickets={[]}
bodyshop={commissionCutFixture.bodyshop}
adjustments={[]}
refetch={() => {}}
/>
</Card>
<Divider />
<Card title="Claim Task Preview">
<Form form={form} initialValues={commissionCutFixture.previewValues} layout="vertical">
<TimeTicketTaskModalComponent
bodyshop={commissionCutFixture.bodyshop}
form={form}
loading={false}
completedTasks={[]}
unassignedHours={1.25}
/>
</Form>
</Card>
</Space>
);
}
function Test({ setRefundPaymentContext, refundPaymentModal }) {
const search = queryString.parse(useLocation().search);
console.log("refundPaymentModal", refundPaymentModal);
if (search.fixture === "commission-cut") {
return <CommissionCutHarness />;
}
return (
<div>
<Button

View File

@@ -0,0 +1,112 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UPDATE_JOB } from "../../graphql/jobs.queries";
import { LaborAllocationsAdjustmentEdit } from "./labor-allocations-adjustment-edit.component.jsx";
const updateAdjustmentsMock = vi.fn();
const useMutationMock = vi.fn();
const notification = {
success: vi.fn(),
error: vi.fn()
};
const insertAuditTrailMock = vi.fn();
const jobmodifylbradjMock = vi.fn(() => "audit-entry");
vi.mock("@apollo/client/react", () => ({
useMutation: (...args) => useMutationMock(...args)
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key) => {
const translations = {
"joblines.fields.mod_lbr_ty": "Labor Type",
"joblines.fields.lbr_types.LAA": "LAA",
"jobs.fields.adjustmenthours": "Adjustment Hours",
"general.actions.save": "Save",
"jobs.successes.save": "Saved"
};
return translations[key] || key;
}
})
}));
vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({
useNotification: () => notification
}));
vi.mock("../../utils/AuditTrailMappings", () => ({
default: {
jobmodifylbradj: (...args) => jobmodifylbradjMock(...args)
}
}));
describe("LaborAllocationsAdjustmentEdit", () => {
beforeEach(() => {
vi.clearAllMocks();
useMutationMock.mockImplementation((mutation) => {
if (mutation === UPDATE_JOB) {
return [updateAdjustmentsMock];
}
return [vi.fn()];
});
updateAdjustmentsMock.mockResolvedValue({});
});
it("saves merged labor adjustments and records the adjustment delta", async () => {
render(
<LaborAllocationsAdjustmentEdit
insertAuditTrail={insertAuditTrailMock}
jobId="job-1"
mod_lbr_ty="LAA"
adjustments={{
LAA: 1.2,
LAB: 0.5
}}
refetchQueryNames={["QUERY_JOB"]}
>
<button type="button">Edit Adjustment</button>
</LaborAllocationsAdjustmentEdit>
);
fireEvent.click(screen.getByRole("button", { name: "Edit Adjustment" }));
fireEvent.change(screen.getByRole("spinbutton"), {
target: { value: "3.7" }
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(updateAdjustmentsMock).toHaveBeenCalledWith({
variables: {
jobId: "job-1",
job: {
lbr_adjustments: {
LAA: 3.7,
LAB: 0.5
}
}
},
refetchQueries: ["QUERY_JOB"]
});
});
expect(jobmodifylbradjMock).toHaveBeenCalledWith({
mod_lbr_ty: "LAA",
hours: 2.5
});
expect(insertAuditTrailMock).toHaveBeenCalledWith({
jobid: "job-1",
operation: "audit-entry",
type: "jobmodifylbradj"
});
expect(notification.success).toHaveBeenCalledWith({
title: "Saved"
});
});
});

View File

@@ -0,0 +1,190 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import axios from "axios";
import { describe, expect, it, beforeEach, vi } from "vitest";
import { PayrollLaborAllocationsTable } from "./labor-allocations-table.payroll.component.jsx";
const notification = {
success: vi.fn(),
error: vi.fn()
};
vi.mock("axios", () => ({
default: {
post: vi.fn()
}
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key, values = {}) => {
const translations = {
"jobs.labels.laborallocations": "Labor Allocations",
"timetickets.actions.payall": "Pay All",
"general.labels.totals": "Totals",
"jobs.labels.outstandinghours": "Outstanding hours remain."
};
if (key === "timetickets.successes.payall") {
return "All hours paid out successfully.";
}
if (key === "timetickets.errors.payall") {
return `Error flagging hours. ${values.error || ""}`.trim();
}
return translations[key] || key;
}
})
}));
vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({
useNotification: () => notification
}));
vi.mock("../responsive-table/responsive-table.component", () => {
function ResponsiveTable({ dataSource = [], summary }) {
return (
<div data-testid="responsive-table">
<div>{`rows:${dataSource.length}`}</div>
{summary ? <div>{summary()}</div> : null}
</div>
);
}
ResponsiveTable.Summary = {
Row: ({ children }) => <div>{children}</div>,
Cell: ({ children }) => <div>{children}</div>
};
return {
default: ResponsiveTable
};
});
vi.mock("../feature-wrapper/feature-wrapper.component", () => ({
HasFeatureAccess: () => true
}));
vi.mock("../upsell/upsell.component", () => ({
default: () => <div>Upsell</div>,
upsellEnum: () => ({
timetickets: {
allocations: "allocations"
}
})
}));
vi.mock("../lock-wrapper/lock-wrapper.component", () => ({
default: ({ children }) => children
}));
describe("PayrollLaborAllocationsTable", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows a success notification after Pay All completes", async () => {
axios.post
.mockResolvedValueOnce({
data: [
{
employeeid: "emp-1",
mod_lbr_ty: "LAA",
expectedHours: 4,
claimedHours: 1
}
]
})
.mockResolvedValueOnce({
status: 200,
data: [{ id: "ticket-1" }]
});
const refetch = vi.fn();
render(
<PayrollLaborAllocationsTable
jobId="job-1"
joblines={[{ id: "line-1", convertedtolbr: false }]}
timetickets={[]}
bodyshop={{
features: {
timetickets: true
},
employees: [{ id: "emp-1", first_name: "Avery", last_name: "Johnson" }]
}}
adjustments={[]}
refetch={refetch}
/>
);
await waitFor(() => {
expect(axios.post).toHaveBeenNthCalledWith(1, "/payroll/calculatelabor", {
jobid: "job-1"
});
});
fireEvent.click(screen.getByRole("button", { name: "Pay All" }));
await waitFor(() => {
expect(axios.post).toHaveBeenNthCalledWith(2, "/payroll/payall", {
jobid: "job-1"
});
});
expect(notification.success).toHaveBeenCalledWith({
title: "All hours paid out successfully."
});
expect(refetch).toHaveBeenCalled();
});
it("shows the returned pay-all error message when payroll rejects the request", async () => {
axios.post
.mockResolvedValueOnce({
data: [
{
employeeid: "emp-1",
mod_lbr_ty: "LAA",
expectedHours: 4,
claimedHours: 1
}
]
})
.mockResolvedValueOnce({
status: 200,
data: {
success: false,
error: "Not all hours have been assigned."
}
});
render(
<PayrollLaborAllocationsTable
jobId="job-1"
joblines={[{ id: "line-1", convertedtolbr: false }]}
timetickets={[]}
bodyshop={{
features: {
timetickets: true
},
employees: [{ id: "emp-1", first_name: "Avery", last_name: "Johnson" }]
}}
adjustments={[]}
/>
);
await waitFor(() => {
expect(axios.post).toHaveBeenNthCalledWith(1, "/payroll/calculatelabor", {
jobid: "job-1"
});
});
fireEvent.click(screen.getByRole("button", { name: "Pay All" }));
await waitFor(() => {
expect(notification.error).toHaveBeenCalledWith({
title: "Error flagging hours. Not all hours have been assigned."
});
});
});
});

View File

@@ -36,14 +36,20 @@ import {
} from "../../graphql/employee_teams.queries";
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import {
LABOR_TYPES,
getSplitTotal,
hasExactSplitTotal,
normalizeEmployeeTeam,
normalizeTeamMember,
validateEmployeeTeamMembers
} from "./shop-employee-teams.form.utils.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
const LABOR_TYPES = ["LAA", "LAB", "LAD", "LAE", "LAF", "LAG", "LAM", "LAR", "LAS", "LAU", "LA1", "LA2", "LA3", "LA4"];
const PAYOUT_METHOD_OPTIONS = [
{ labelKey: "employee_teams.options.hourly", value: "hourly" },
{ labelKey: "employee_teams.options.commission_percentage", value: "commission" }
@@ -57,23 +63,6 @@ const TEAM_MEMBER_PRIMARY_FIELD_COLS = {
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",
labor_rates: teamMember.labor_rates || {},
commission_rates: teamMember.commission_rates || {}
});
const normalizeEmployeeTeam = (employeeTeam = {}) => ({
...employeeTeam,
employee_team_members: (employeeTeam.employee_team_members || []).map(normalizeTeamMember)
});
const getSplitTotal = (teamMembers = []) =>
teamMembers.reduce((sum, member) => sum + Number(member?.percentage || 0), 0);
const hasExactSplitTotal = (teamMembers = []) => Math.abs(getSplitTotal(teamMembers) - 100) < 0.00001;
const getPayoutMethodTagColor = (payoutMethod) => (payoutMethod === "commission" ? "gold" : "blue");
const getEmployeeDisplayName = (employees = [], employeeId) => {
@@ -170,32 +159,11 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
};
const handleFinish = async ({ employee_team_members = [], ...values }) => {
const normalizedTeamMembers = employee_team_members.map((teamMember) => {
const nextTeamMember = normalizeTeamMember({ ...teamMember });
delete nextTeamMember.__typename;
return nextTeamMember;
});
const { normalizedTeamMembers, errorKey } = validateEmployeeTeamMembers(employee_team_members);
if (normalizedTeamMembers.length === 0) {
if (errorKey) {
notification.error({
title: t("employee_teams.errors.minimum_one_member")
});
return;
}
const employeeIds = normalizedTeamMembers.map((teamMember) => teamMember.employeeid).filter(Boolean);
const duplicateEmployeeIds = employeeIds.filter((employeeId, index) => employeeIds.indexOf(employeeId) !== index);
if (duplicateEmployeeIds.length > 0) {
notification.error({
title: t("employee_teams.errors.duplicate_member")
});
return;
}
if (!hasExactSplitTotal(normalizedTeamMembers)) {
notification.error({
title: t("employee_teams.errors.allocation_total_exact")
title: t(errorKey)
});
return;
}

View File

@@ -0,0 +1,247 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { INSERT_EMPLOYEE_TEAM, UPDATE_EMPLOYEE_TEAM } from "../../graphql/employee_teams.queries";
import { LABOR_TYPES } from "./shop-employee-teams.form.utils.js";
import { ShopEmployeeTeamsFormComponent } from "./shop-employee-teams.form.component.jsx";
const insertEmployeeTeamMock = vi.fn();
const updateEmployeeTeamMock = vi.fn();
const useQueryMock = vi.fn();
const useMutationMock = vi.fn();
const navigateMock = vi.fn();
const notification = {
error: vi.fn(),
success: vi.fn()
};
vi.mock("@apollo/client/react", () => ({
useQuery: (...args) => useQueryMock(...args),
useMutation: (...args) => useMutationMock(...args)
}));
vi.mock("react-router-dom", () => ({
useLocation: () => ({
search: "?employeeTeamId=new"
}),
useNavigate: () => navigateMock
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key, values = {}) => {
const translations = {
"employee_teams.fields.name": "Team Name",
"employee_teams.fields.active": "Active",
"employee_teams.fields.max_load": "Max Load",
"employee_teams.fields.employeeid": "Employee",
"employee_teams.fields.allocation_percentage": "Allocation %",
"employee_teams.fields.payout_method": "Payout Method",
"employee_teams.fields.allocation": "Allocation",
"employee_teams.fields.employeeid_label": "Employee",
"employee_teams.options.hourly": "Hourly",
"employee_teams.options.commission": "Commission",
"employee_teams.options.commission_percentage": "Commission",
"employee_teams.actions.newmember": "New Team Member",
"employee_teams.errors.minimum_one_member": "Add at least one team member.",
"employee_teams.errors.duplicate_member": "Team members must be unique.",
"employee_teams.errors.allocation_total_exact": "Allocation must total exactly 100%.",
"general.actions.save": "Save",
"employees.successes.save": "Saved"
};
if (key === "employee_teams.labels.allocation_total") {
return `Allocation Total: ${values.total}%`;
}
if (key.startsWith("joblines.fields.lbr_types.")) {
return key.split(".").pop();
}
return translations[key] || key;
}
})
}));
vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({
useNotification: () => notification
}));
vi.mock("../../firebase/firebase.utils", () => ({
logImEXEvent: vi.fn()
}));
vi.mock("../employee-search-select/employee-search-select.component", () => ({
default: ({ id, value, onChange, options = [] }) => (
<select
aria-label="Employee"
id={id}
value={value ?? ""}
onChange={(event) => onChange?.(event.target.value || undefined)}
>
<option value="">Select Employee</option>
{options.map((option) => (
<option key={option.id} value={option.id}>
{[option.first_name, option.last_name].filter(Boolean).join(" ")}
</option>
))}
</select>
)
}));
vi.mock("../form-items-formatted/currency-form-item.component", () => ({
default: ({ id, value, onChange }) => (
<input
data-testid="currency-input"
id={id}
type="text"
value={value ?? ""}
onChange={(event) => onChange?.(event.target.value === "" ? null : Number(event.target.value))}
/>
)
}));
vi.mock("../layout-form-row/layout-form-row.component", () => ({
default: ({ title, extra, children }) => (
<div>
{title}
{extra}
{children}
</div>
)
}));
vi.mock("../form-list-move-arrows/form-list-move-arrows.component", () => ({
default: () => null
}));
const bodyshop = {
id: "shop-1",
employees: [
{
id: "emp-1",
first_name: "Avery",
last_name: "Johnson"
},
{
id: "emp-2",
first_name: "Morgan",
last_name: "Lee"
}
]
};
const fillHourlyRates = (value) => {
LABOR_TYPES.forEach((laborType) => {
fireEvent.change(screen.getByLabelText(laborType), {
target: { value: String(value) }
});
});
};
const addBaseTeamMember = ({ employeeId = "emp-1", percentage = 100, rate = 25 } = {}) => {
fireEvent.click(screen.getByRole("button", { name: "New Team Member" }));
fireEvent.change(screen.getByLabelText("Employee"), {
target: { value: employeeId }
});
fireEvent.change(screen.getByRole("spinbutton", { name: "Allocation %" }), {
target: { value: String(percentage) }
});
fillHourlyRates(rate);
};
describe("ShopEmployeeTeamsFormComponent", () => {
beforeEach(() => {
vi.clearAllMocks();
useQueryMock.mockReturnValue({
error: null,
data: null,
loading: false
});
useMutationMock.mockImplementation((mutation) => {
if (mutation === UPDATE_EMPLOYEE_TEAM) {
return [updateEmployeeTeamMock];
}
if (mutation === INSERT_EMPLOYEE_TEAM) {
return [insertEmployeeTeamMock];
}
return [vi.fn()];
});
insertEmployeeTeamMock.mockResolvedValue({
data: {
insert_employee_teams_one: {
id: "team-1"
}
}
});
});
it("switches a new team member from hourly rates to commission percentages", async () => {
render(<ShopEmployeeTeamsFormComponent bodyshop={bodyshop} />);
addBaseTeamMember();
expect(screen.getAllByTestId("currency-input")).toHaveLength(LABOR_TYPES.length);
fireEvent.mouseDown(screen.getByRole("combobox", { name: "Payout Method" }));
fireEvent.click(screen.getByText("Commission"));
await waitFor(() => {
expect(screen.queryAllByTestId("currency-input")).toHaveLength(0);
});
});
it("submits a valid new hourly team with normalized member data", async () => {
render(<ShopEmployeeTeamsFormComponent bodyshop={bodyshop} />);
fireEvent.change(screen.getByRole("textbox", { name: "Team Name" }), {
target: { value: "Commission Crew" }
});
fireEvent.change(screen.getByRole("spinbutton", { name: "Max Load" }), {
target: { value: "8" }
});
addBaseTeamMember({
employeeId: "emp-1",
percentage: 100,
rate: 27.5
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(insertEmployeeTeamMock).toHaveBeenCalledWith({
variables: {
employeeTeam: {
name: "Commission Crew",
max_load: 8,
employee_team_members: {
data: [
{
employeeid: "emp-1",
percentage: 100,
payout_method: "hourly",
labor_rates: Object.fromEntries(LABOR_TYPES.map((laborType) => [laborType, 27.5])),
commission_rates: {}
}
]
},
bodyshopid: "shop-1"
}
},
refetchQueries: ["QUERY_TEAMS"]
});
});
expect(notification.success).toHaveBeenCalledWith({
title: "Saved"
});
expect(navigateMock).toHaveBeenCalledWith({
search: "employeeTeamId=team-1"
});
});
});

View File

@@ -0,0 +1,70 @@
export const LABOR_TYPES = [
"LAA",
"LAB",
"LAD",
"LAE",
"LAF",
"LAG",
"LAM",
"LAR",
"LAS",
"LAU",
"LA1",
"LA2",
"LA3",
"LA4"
];
export const normalizeTeamMember = (teamMember = {}) => ({
...teamMember,
payout_method: teamMember.payout_method || "hourly",
labor_rates: teamMember.labor_rates || {},
commission_rates: teamMember.commission_rates || {}
});
export const normalizeEmployeeTeam = (employeeTeam = {}) => ({
...employeeTeam,
employee_team_members: (employeeTeam.employee_team_members || []).map(normalizeTeamMember)
});
export const getSplitTotal = (teamMembers = []) =>
teamMembers.reduce((sum, member) => sum + Number(member?.percentage || 0), 0);
export const hasExactSplitTotal = (teamMembers = []) => Math.abs(getSplitTotal(teamMembers) - 100) < 0.00001;
export const validateEmployeeTeamMembers = (employeeTeamMembers = []) => {
const normalizedTeamMembers = employeeTeamMembers.map((teamMember) => {
const nextTeamMember = normalizeTeamMember({ ...teamMember });
delete nextTeamMember.__typename;
return nextTeamMember;
});
if (normalizedTeamMembers.length === 0) {
return {
normalizedTeamMembers,
errorKey: "employee_teams.errors.minimum_one_member"
};
}
const employeeIds = normalizedTeamMembers.map((teamMember) => teamMember.employeeid).filter(Boolean);
const duplicateEmployeeIds = employeeIds.filter((employeeId, index) => employeeIds.indexOf(employeeId) !== index);
if (duplicateEmployeeIds.length > 0) {
return {
normalizedTeamMembers,
errorKey: "employee_teams.errors.duplicate_member"
};
}
if (!hasExactSplitTotal(normalizedTeamMembers)) {
return {
normalizedTeamMembers,
errorKey: "employee_teams.errors.allocation_total_exact"
};
}
return {
normalizedTeamMembers,
errorKey: null
};
};

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import {
getSplitTotal,
hasExactSplitTotal,
normalizeTeamMember,
validateEmployeeTeamMembers
} from "./shop-employee-teams.form.utils.js";
describe("shop employee team form utilities", () => {
it("normalizes missing payout defaults for a team member", () => {
expect(
normalizeTeamMember({
employeeid: "emp-1",
percentage: 100
})
).toEqual({
employeeid: "emp-1",
percentage: 100,
payout_method: "hourly",
labor_rates: {},
commission_rates: {}
});
});
it("returns a minimum-member validation error when no team members are provided", () => {
expect(validateEmployeeTeamMembers([])).toEqual({
normalizedTeamMembers: [],
errorKey: "employee_teams.errors.minimum_one_member"
});
});
it("rejects duplicate employees in the same team", () => {
const result = validateEmployeeTeamMembers([
{ employeeid: "emp-1", percentage: 50, labor_rates: { LAA: 25 } },
{ employeeid: "emp-1", percentage: 50, labor_rates: { LAA: 30 } }
]);
expect(result.errorKey).toBe("employee_teams.errors.duplicate_member");
});
it("rejects team allocations that do not add up to exactly one hundred percent", () => {
const result = validateEmployeeTeamMembers([
{ employeeid: "emp-1", percentage: 60, labor_rates: { LAA: 25 } },
{ employeeid: "emp-2", percentage: 30, labor_rates: { LAA: 30 } }
]);
expect(getSplitTotal(result.normalizedTeamMembers)).toBe(90);
expect(hasExactSplitTotal(result.normalizedTeamMembers)).toBe(false);
expect(result.errorKey).toBe("employee_teams.errors.allocation_total_exact");
});
it("accepts a valid mixed hourly and commission team and strips graph metadata", () => {
const result = validateEmployeeTeamMembers([
{
__typename: "employee_team_members",
employeeid: "emp-1",
percentage: 40,
labor_rates: { LAA: 28.5 }
},
{
employeeid: "emp-2",
percentage: 60,
payout_method: "commission",
commission_rates: { LAA: 35 }
}
]);
expect(result.errorKey).toBeNull();
expect(result.normalizedTeamMembers).toEqual([
{
employeeid: "emp-1",
percentage: 40,
payout_method: "hourly",
labor_rates: { LAA: 28.5 },
commission_rates: {}
},
{
employeeid: "emp-2",
percentage: 60,
payout_method: "commission",
labor_rates: {},
commission_rates: { LAA: 35 }
}
]);
});
});

View File

@@ -0,0 +1,117 @@
import { render, screen } from "@testing-library/react";
import { Form } from "antd";
import { describe, expect, it, vi } from "vitest";
import { TimeTicketTaskModalComponent } from "./time-ticket-task-modal.component.jsx";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key, values = {}) => {
const translations = {
"timetickets.fields.ro_number": "RO Number",
"timetickets.labels.task": "Task",
"bodyshop.fields.md_tasks_presets.percent": "Percent",
"bodyshop.fields.md_tasks_presets.hourstype": "Labor Types",
"bodyshop.fields.md_tasks_presets.nextstatus": "Next Status",
"timetickets.labels.claimtaskpreview": "Claim Task Preview",
"timetickets.fields.employee": "Employee",
"timetickets.fields.cost_center": "Cost Center",
"timetickets.fields.ciecacode": "Labor Type",
"timetickets.fields.productivehrs": "Hours",
"timetickets.fields.payout_method": "Payout Method",
"timetickets.fields.rate": "Rate",
"timetickets.fields.amount": "Amount",
"timetickets.labels.payout_methods.commission": "Commission",
"timetickets.labels.payout_methods.hourly": "Hourly",
"timetickets.labels.payrollclaimedtasks": "Payroll claimed tasks are ready.",
"tt_approvals.labels.approval_queue_in_use": "Approval queue is enabled."
};
if (key === "timetickets.validation.unassignedlines") {
return `${values.unassignedHours} hours remain unassigned.`;
}
return translations[key] || key;
}
})
}));
vi.mock("../form-items-formatted/read-only-form-item.component", () => ({
default: ({ value }) => <span>{value}</span>
}));
vi.mock("../job-search-select/job-search-select.component", () => ({
default: () => <div>Job Search</div>
}));
function TestHarness({ unassignedHours = 0 }) {
const [form] = Form.useForm();
return (
<Form
form={form}
initialValues={{
task: "Body Prep",
timetickets: [
{
employeeid: "emp-1",
cost_center: "Body",
ciecacode: "LAA",
productivehrs: 2,
rate: 40,
payoutamount: 80,
payout_context: {
payout_method: "commission"
}
},
{
employeeid: "emp-2",
cost_center: "Refinish",
ciecacode: "LAB",
productivehrs: 1,
rate: 28,
payoutamount: 28,
payout_context: {
payout_method: "hourly"
}
}
]
}}
>
<TimeTicketTaskModalComponent
bodyshop={{
md_tasks_presets: {
presets: [
{
name: "Body Prep",
percent: 50,
hourstype: ["LAA", "LAB"],
nextstatus: "In Progress"
}
]
}
}}
form={form}
loading={false}
completedTasks={[]}
unassignedHours={unassignedHours}
/>
</Form>
);
}
describe("TimeTicketTaskModalComponent", () => {
it("shows preview payout methods for both commission and hourly tickets", () => {
render(<TestHarness />);
expect(screen.getByText("Claim Task Preview")).toBeInTheDocument();
expect(screen.getByText("Commission")).toBeInTheDocument();
expect(screen.getByText("Hourly")).toBeInTheDocument();
expect(screen.getByText("Payroll claimed tasks are ready.")).toBeInTheDocument();
});
it("shows the unassigned-hours alert when payroll assignments are incomplete", () => {
render(<TestHarness unassignedHours={1.25} />);
expect(screen.getByText("1.25 hours remain unassigned.")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,140 @@
/* eslint-disable */
import { expect, test } from "@playwright/test";
import { acceptEulaIfPresent, login } from "./utils/login";
async function openCommissionCutHarness(page) {
await page.goto("/manage/_test?fixture=commission-cut");
await acceptEulaIfPresent(page);
await expect(page.getByRole("heading", { name: "Commission Cut Test Harness" })).toBeVisible();
}
test.describe("Commission-based cut", () => {
test.skip(!process.env.TEST_USERNAME || !process.env.TEST_PASSWORD, "Requires TEST_USERNAME and TEST_PASSWORD.");
test("renders payout previews and completes Pay All from the commission-cut harness", async ({ page }) => {
let calculateLaborCalls = 0;
let payAllCalls = 0;
await login(page, {
email: process.env.TEST_USERNAME,
password: process.env.TEST_PASSWORD
});
await page.route("**/payroll/calculatelabor", async (route) => {
calculateLaborCalls += 1;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
employeeid: "emp-1",
mod_lbr_ty: "LAA",
expectedHours: 4,
claimedHours: 1
},
{
employeeid: "emp-2",
mod_lbr_ty: "LAB",
expectedHours: 2,
claimedHours: 1
}
])
});
});
await page.route("**/payroll/payall", async (route) => {
payAllCalls += 1;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: "tt-1" }])
});
});
await openCommissionCutHarness(page);
await expect(page.getByText("Claim Task Preview")).toBeVisible();
await expect(page.getByRole("cell", { name: "Commission" })).toBeVisible();
await expect(page.getByRole("cell", { name: "Hourly" })).toBeVisible();
await expect(
page.getByText(
"There are currently 1.25 hours of repair lines that are unassigned. These hours are not including in the above calculations and must be paid manually."
)
).toBeVisible();
await expect(page.getByRole("button", { name: "Pay All" })).toBeVisible();
await page.getByRole("button", { name: "Pay All" }).click();
await expect.poll(() => calculateLaborCalls).toBeGreaterThan(0);
await expect.poll(() => payAllCalls).toBe(1);
await expect(page.getByText("All hours paid out successfully.")).toBeVisible();
});
test("shows the backend error when Pay All is rejected", async ({ page }) => {
await login(page, {
email: process.env.TEST_USERNAME,
password: process.env.TEST_PASSWORD
});
await page.route("**/payroll/calculatelabor", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
employeeid: "emp-1",
mod_lbr_ty: "LAA",
expectedHours: 4,
claimedHours: 1
}
])
});
});
await page.route("**/payroll/payall", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: false,
error: "Not all hours have been assigned."
})
});
});
await openCommissionCutHarness(page);
await page.getByRole("button", { name: "Pay All" }).click();
await expect(page.getByText("Error flagging hours. Not all hours have been assigned.")).toBeVisible();
});
test("shows a negative labor difference when previously claimed hours exceed the current expected hours", async ({
page
}) => {
await login(page, {
email: process.env.TEST_USERNAME,
password: process.env.TEST_PASSWORD
});
await page.route("**/payroll/calculatelabor", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
employeeid: "emp-1",
mod_lbr_ty: "LAA",
expectedHours: 2,
claimedHours: 5
}
])
});
});
await openCommissionCutHarness(page);
await expect(page.locator("strong").filter({ hasText: "-3" }).first()).toBeVisible();
});
});

View File

@@ -1,5 +1,48 @@
import { expect } from "@playwright/test";
const formatToday = () => {
const today = new Date();
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
const year = today.getFullYear();
return `${month}/${day}/${year}`;
};
export async function acceptEulaIfPresent(page) {
const eulaDialog = page.getByRole("dialog", { name: "Terms and Conditions" });
const eulaVisible =
(await eulaDialog.isVisible().catch(() => false)) ||
(await eulaDialog
.waitFor({
state: "visible",
timeout: 5000
})
.then(() => true)
.catch(() => false));
if (!eulaVisible) {
return;
}
const markdownCard = page.locator(".eula-markdown-card");
await markdownCard.evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await page.getByRole("textbox", { name: "First Name" }).fill("Codex");
await page.getByRole("textbox", { name: "Last Name" }).fill("Tester");
await page.getByRole("textbox", { name: "Legal Business Name" }).fill("Codex QA");
await page.getByRole("textbox", { name: "Date Accepted" }).fill(formatToday());
await page.getByRole("checkbox", { name: "I accept the terms and conditions of this agreement." }).check();
const acceptButton = page.getByRole("button", { name: "Accept EULA" });
await expect(acceptButton).toBeEnabled({ timeout: 10000 });
await acceptButton.click();
await expect(eulaDialog).not.toBeVisible({ timeout: 10000 });
}
export async function login(page, { email, password }) {
// Navigate to the login page
await page.goto("/"); // Adjust if your login route differs (e.g., '/login')
@@ -16,6 +59,8 @@ export async function login(page, { email, password }) {
// Wait for navigation or success indicator (e.g., redirect to /manage/)
await page.waitForURL(/\/manage\//, { timeout: 10000 }); // Adjust based on redirect
await acceptEulaIfPresent(page);
// Verify successful login (e.g., check for a dashboard element)
await expect(page.locator("text=Manage")).toBeVisible(); // Adjust to your apps post-login UI
}

View File

@@ -1,5 +1,45 @@
import { afterEach } from "vitest";
import { afterEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom";
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
});
}
if (!window.ResizeObserver) {
window.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
}
if (!window.IntersectionObserver) {
window.IntersectionObserver = class IntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
};
}
if (!window.scrollTo) {
window.scrollTo = vi.fn();
}
if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = vi.fn();
}
afterEach(() => cleanup());

View File

@@ -5,6 +5,7 @@ const logMock = vi.fn();
let payAllModule;
let claimTaskModule;
let calculateTotalsModule;
const buildBaseJob = (overrides = {}) => ({
id: "job-1",
@@ -62,6 +63,7 @@ beforeEach(() => {
mockRequire("../utils/logger", { log: logMock });
payAllModule = require("./pay-all");
claimTaskModule = require("./claim-task");
calculateTotalsModule = require("./calculate-totals");
});
describe("payroll payout helpers", () => {
@@ -174,6 +176,104 @@ describe("payroll payout helpers", () => {
)
).toThrow("Missing hourly payout rate for John Smith on labor type LAB.");
});
it("supports commission boundary values of zero and one hundred percent", () => {
const zeroPercent = payAllModule.BuildPayoutDetails(
{
rate_laa: 123.45
},
{
payout_method: "commission",
commission_rates: {
LAA: 0
},
employee: {
id: "emp-1"
}
},
"LAA"
);
const fullPercent = payAllModule.BuildPayoutDetails(
{
rate_laa: 123.45
},
{
payout_method: "commission",
commission_rates: {
LAA: 100
},
employee: {
id: "emp-1"
}
},
"LAA"
);
expect(zeroPercent.effectiveRate).toBe(0);
expect(zeroPercent.payoutContext.cut_percent_applied).toBe(0);
expect(fullPercent.effectiveRate).toBe(123.45);
expect(fullPercent.payoutContext.cut_percent_applied).toBe(100);
});
it("throws a useful error when the sale rate for a commission labor type is missing", () => {
expect(() =>
payAllModule.BuildPayoutDetails(
{},
{
payout_method: "commission",
commission_rates: {
LAA: 35
},
employee: {
first_name: "Sam",
last_name: "Painter"
}
},
"LAA"
)
).toThrow("Missing sale rate rate_laa for labor type LAA.");
});
it("rejects commission percentages outside the allowed zero-to-one-hundred range", () => {
expect(() =>
payAllModule.BuildPayoutDetails(
{
rate_laa: 100
},
{
payout_method: "commission",
commission_rates: {
LAA: -5
},
employee: {
first_name: "Alex",
last_name: "Painter"
}
},
"LAA"
)
).toThrow("Commission percent for Alex Painter on labor type LAA must be between 0 and 100.");
expect(() =>
payAllModule.BuildPayoutDetails(
{
rate_laa: 100
},
{
payout_method: "commission",
commission_rates: {
LAA: 105
},
employee: {
first_name: "Alex",
last_name: "Painter"
}
},
"LAA"
)
).toThrow("Commission percent for Alex Painter on labor type LAA must be between 0 and 100.");
});
});
describe("payroll routes", () => {
@@ -277,6 +377,344 @@ describe("payroll routes", () => {
expect(res.json).toHaveBeenCalledWith(insertedTickets);
});
it("returns a validation failure when job lines still have unassigned hours", async () => {
const job = buildBaseJob({
joblines: [
{
mod_lbr_ty: "LAA",
mod_lb_hrs: 3.5,
assigned_team: null,
convertedtolbr: false
}
]
});
const { client, req, res } = buildReqRes({ job });
await payAllModule.payall(req, res);
expect(client.request).toHaveBeenCalledTimes(1);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Not all hours have been assigned."
});
});
it("creates separate pay-all tickets for mixed hourly and commission team members across labor types", async () => {
const job = buildBaseJob({
rate_laa: 100,
rate_lab: 80,
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body",
LAB: "Refinish"
}
}
},
md_tasks_presets: {
presets: []
},
employee_teams: [
{
id: "team-1",
employee_team_members: [
{
percentage: 50,
payout_method: "hourly",
labor_rates: {
LAA: 30,
LAB: 25
},
employee: {
id: "emp-hourly",
first_name: "Hourly",
last_name: "Tech"
}
},
{
percentage: 50,
payout_method: "commission",
commission_rates: {
LAA: 40,
LAB: 50
},
labor_rates: {
LAA: 0,
LAB: 0
},
employee: {
id: "emp-commission",
first_name: "Commission",
last_name: "Tech"
}
}
]
}
]
},
joblines: [
{
mod_lbr_ty: "LAA",
mod_lb_hrs: 4,
assigned_team: "team-1",
convertedtolbr: false
},
{
mod_lbr_ty: "LAB",
mod_lb_hrs: 2,
assigned_team: "team-1",
convertedtolbr: false
}
]
});
const { client, req, res } = buildReqRes({ job });
client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 4 } });
await payAllModule.payall(req, res);
expect(client.request).toHaveBeenCalledTimes(2);
const insertedTickets = client.request.mock.calls[1][1].timetickets;
expect(insertedTickets).toHaveLength(4);
expect(insertedTickets).toEqual(
expect.arrayContaining([
expect.objectContaining({
employeeid: "emp-hourly",
ciecacode: "LAA",
productivehrs: 2,
rate: 30,
cost_center: "Body",
payout_context: expect.objectContaining({
payout_method: "hourly",
payout_type: "hourly",
effective_rate: 30
})
}),
expect.objectContaining({
employeeid: "emp-hourly",
ciecacode: "LAB",
productivehrs: 1,
rate: 25,
cost_center: "Refinish",
payout_context: expect.objectContaining({
payout_method: "hourly",
payout_type: "hourly",
effective_rate: 25
})
}),
expect.objectContaining({
employeeid: "emp-commission",
ciecacode: "LAA",
productivehrs: 2,
rate: 40,
cost_center: "Body",
payout_context: expect.objectContaining({
payout_method: "commission",
payout_type: "cut",
cut_percent_applied: 40,
source_labor_rate: 100,
effective_rate: 40
})
}),
expect.objectContaining({
employeeid: "emp-commission",
ciecacode: "LAB",
productivehrs: 1,
rate: 40,
cost_center: "Refinish",
payout_context: expect.objectContaining({
payout_method: "commission",
payout_type: "cut",
cut_percent_applied: 50,
source_labor_rate: 80,
effective_rate: 40
})
})
])
);
expect(res.json).toHaveBeenCalledWith(insertedTickets);
});
it("creates a negative pay-all adjustment at the current commission rate when the remaining expected hours drop below prior claimed hours", async () => {
const job = buildBaseJob({
rate_laa: 120,
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: []
},
employee_teams: [
{
id: "team-1",
employee_team_members: [
{
percentage: 100,
payout_method: "commission",
commission_rates: {
LAA: 40
},
employee: {
id: "emp-1",
first_name: "Current",
last_name: "Tech"
}
}
]
}
]
},
joblines: [
{
mod_lbr_ty: "LAA",
mod_lb_hrs: 4,
assigned_team: "team-1",
convertedtolbr: false
}
],
timetickets: [
{
employeeid: "emp-1",
ciecacode: "LAA",
productivehrs: 6,
rate: 30,
payout_context: {
payout_method: "hourly",
payout_type: "hourly",
effective_rate: 30
}
}
]
});
const { client, req, res } = buildReqRes({ job });
client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 1 } });
await payAllModule.payall(req, res);
const insertedTickets = client.request.mock.calls[1][1].timetickets;
expect(insertedTickets).toHaveLength(1);
expect(insertedTickets[0]).toEqual(
expect.objectContaining({
employeeid: "emp-1",
productivehrs: -2,
rate: 48,
ciecacode: "LAA",
memo: "Adjust flagged hours per assignment. (payroll@example.com)"
})
);
expect(insertedTickets[0].payout_context).toEqual(
expect.objectContaining({
payout_method: "commission",
payout_type: "cut",
cut_percent_applied: 40,
source_labor_rate: 120,
effective_rate: 48,
generated_from: "payall",
used_ticket_fallback: false
})
);
expect(res.json).toHaveBeenCalledWith(insertedTickets);
});
it("uses the current commission sale rate for remaining hours when older commission tickets were created from a lower sale rate", async () => {
const job = buildBaseJob({
rate_laa: 120,
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: []
},
employee_teams: [
{
id: "team-1",
employee_team_members: [
{
percentage: 100,
payout_method: "commission",
commission_rates: {
LAA: 40
},
employee: {
id: "emp-1",
first_name: "Current",
last_name: "Tech"
}
}
]
}
]
},
joblines: [
{
mod_lbr_ty: "LAA",
mod_lb_hrs: 5,
assigned_team: "team-1",
convertedtolbr: false
}
],
timetickets: [
{
employeeid: "emp-1",
ciecacode: "LAA",
productivehrs: 2,
rate: 40,
payout_context: {
payout_method: "commission",
payout_type: "cut",
cut_percent_applied: 40,
source_labor_rate: 100,
effective_rate: 40
}
}
]
});
const { client, req, res } = buildReqRes({ job });
client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 1 } });
await payAllModule.payall(req, res);
const insertedTickets = client.request.mock.calls[1][1].timetickets;
expect(insertedTickets).toHaveLength(1);
expect(insertedTickets[0]).toEqual(
expect.objectContaining({
employeeid: "emp-1",
productivehrs: 3,
rate: 48,
ciecacode: "LAA"
})
);
expect(insertedTickets[0].payout_context).toEqual(
expect.objectContaining({
payout_method: "commission",
cut_percent_applied: 40,
source_labor_rate: 120,
effective_rate: 48,
generated_from: "payall",
used_ticket_fallback: false
})
);
expect(res.json).toHaveBeenCalledWith(insertedTickets);
});
it("rejects duplicate claim-task submissions for completed presets", async () => {
const job = buildBaseJob({
completed_tasks: [{ name: "Disassembly" }],
@@ -381,6 +819,132 @@ describe("payroll routes", () => {
});
});
it("returns commission-aware claim-task previews and reports unassigned hours", async () => {
const job = buildBaseJob({
rate_laa: 120,
rate_lab: 80,
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body",
LAB: "Refinish"
}
}
},
md_tasks_presets: {
presets: [
{
name: "Body Prep",
hourstype: ["LAA", "LAB"],
percent: 50,
nextstatus: "Prep",
memo: "Prep body work"
}
]
},
employee_teams: [
{
id: "team-1",
employee_team_members: [
{
percentage: 50,
payout_method: "hourly",
labor_rates: {
LAA: 30,
LAB: 25
},
employee: {
id: "emp-hourly",
first_name: "Hourly",
last_name: "Tech"
}
},
{
percentage: 50,
payout_method: "commission",
commission_rates: {
LAA: 40,
LAB: 50
},
employee: {
id: "emp-commission",
first_name: "Commission",
last_name: "Tech"
}
}
]
}
]
},
joblines: [
{
mod_lbr_ty: "LAA",
mod_lb_hrs: 4,
assigned_team: "team-1",
convertedtolbr: false
},
{
mod_lbr_ty: "LAB",
mod_lb_hrs: 1.5,
assigned_team: null,
convertedtolbr: false
}
]
});
const { client, req, res } = buildReqRes({
job,
body: {
task: "Body Prep",
calculateOnly: true,
employee: {
name: "Payroll Manager"
}
}
});
await claimTaskModule.claimtask(req, res);
expect(client.request).toHaveBeenCalledTimes(1);
expect(res.json).toHaveBeenCalledWith({
unassignedHours: 1.5,
ticketsToInsert: expect.arrayContaining([
expect.objectContaining({
task_name: "Body Prep",
employeeid: "emp-hourly",
productivehrs: 1,
rate: 30,
ciecacode: "LAA",
created_by: "Payroll Manager",
payout_context: expect.objectContaining({
payout_method: "hourly",
payout_type: "hourly",
generated_from: "claimtask",
task_name: "Body Prep"
})
}),
expect.objectContaining({
task_name: "Body Prep",
employeeid: "emp-commission",
productivehrs: 1,
rate: 48,
ciecacode: "LAA",
created_by: "Payroll Manager",
payout_context: expect.objectContaining({
payout_method: "commission",
payout_type: "cut",
cut_percent_applied: 40,
source_labor_rate: 120,
generated_from: "claimtask",
task_name: "Body Prep"
})
})
])
});
});
it("rejects claim-task when an assigned team member is missing the hourly rate for the selected labor type", async () => {
const job = buildBaseJob({
bodyshop: {
@@ -462,4 +1026,78 @@ describe("payroll routes", () => {
error: "Missing hourly payout rate for Missing Rate on labor type LAB."
});
});
it("locks in the current enhanced-payroll behavior of ignoring lbr_adjustments when calculating labor totals", async () => {
const job = buildBaseJob({
lbr_adjustments: {
LAA: 2.5
},
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: []
},
employee_teams: [
{
id: "team-1",
employee_team_members: [
{
percentage: 100,
payout_method: "commission",
commission_rates: {
LAA: 40
},
employee: {
id: "emp-1",
first_name: "Current",
last_name: "Tech"
}
}
]
}
]
},
joblines: [
{
mod_lbr_ty: "LAA",
mod_lb_hrs: 4,
assigned_team: "team-1",
convertedtolbr: false
}
],
timetickets: [
{
employeeid: "emp-1",
ciecacode: "LAA",
productivehrs: 1,
rate: 48,
payout_context: {
payout_method: "commission"
}
}
]
});
const { client, req, res } = buildReqRes({ job });
await calculateTotalsModule.calculatelabor(req, res);
expect(client.request).toHaveBeenCalledTimes(1);
expect(res.json).toHaveBeenCalledWith([
{
employeeid: "emp-1",
rate: 40,
mod_lbr_ty: "LAA",
expectedHours: 4,
claimedHours: 1
}
]);
});
});